diff --git a/backend/app/routers/site.py b/backend/app/routers/site.py index 71724e9..9933e58 100644 --- a/backend/app/routers/site.py +++ b/backend/app/routers/site.py @@ -1,4 +1,5 @@ from typing import Any, Dict +from urllib.parse import urlsplit from fastapi import APIRouter, Depends @@ -36,6 +37,13 @@ def _build_site_info(include_changelog: bool) -> Dict[str, Any]: } if include_changelog: info["changelog"] = (CHANGELOG or "").strip() + playback_url = (runtime.jellyfin_public_url or "").strip() + try: + parsed = urlsplit(playback_url) + valid = parsed.scheme in {"http", "https"} and bool(parsed.hostname) and not parsed.username and not parsed.password + except ValueError: + valid = False + info["mediaServerUrl"] = playback_url if valid else None return info diff --git a/backend/tests/test_welcome_site.py b/backend/tests/test_welcome_site.py new file mode 100644 index 0000000..5ddddf6 --- /dev/null +++ b/backend/tests/test_welcome_site.py @@ -0,0 +1,19 @@ +import unittest +from unittest.mock import patch +from backend.app.config import Settings +from backend.app.routers.site import _build_site_info + + +class WelcomeSiteTests(unittest.TestCase): + def test_public_response_does_not_expose_playback_url(self): + with patch('backend.app.routers.site.get_runtime_settings', return_value=Settings().model_copy(update={'jellyfin_public_url': 'https://watch.example.com'})): + self.assertNotIn('mediaServerUrl', _build_site_info(False)) + + def test_authenticated_response_uses_public_playback_url(self): + with patch('backend.app.routers.site.get_runtime_settings', return_value=Settings().model_copy(update={'jellyfin_public_url': 'https://watch.example.com/web/'})): + self.assertEqual(_build_site_info(True)['mediaServerUrl'], 'https://watch.example.com/web/') + + def test_missing_unsafe_or_credential_urls_have_no_watch_link(self): + for url in ['', 'javascript:alert(1)', '//internal', 'https://user:secret@example.com', 'https://[broken']: + with self.subTest(url=url), patch('backend.app.routers.site.get_runtime_settings', return_value=Settings().model_copy(update={'jellyfin_public_url': url})): + self.assertIsNone(_build_site_info(True)['mediaServerUrl']) diff --git a/frontend/app/how-it-works/page.tsx b/frontend/app/how-it-works/page.tsx index ce1dd95..e36845a 100644 --- a/frontend/app/how-it-works/page.tsx +++ b/frontend/app/how-it-works/page.tsx @@ -1,181 +1,56 @@ -'use client' - import PageHeading from '../ui/PageHeading' +import '../welcome.css' export default function HowItWorksPage() { - return ( -
- - -
-

What Magent is for

-
-
-

Track requests

-

- Search by title, year, or request number to open the request page and see where an - item is up to. -

-
-
-

See live progress

-

- Request status, timeline events, and download progress update live while you are - viewing the page. -

-
-
-

Know when it is ready

-

- When the request is fully imported and available, Magent shows it as ready and links - you through to Jellyfin. -

-
-
-
- -
-

The request pipeline

-
    -
  1. - You request a movie or show through Seerr. -
  2. -
  3. - Magent picks up the request and shows its current state. -
  4. -
  5. - The automation stack searches and downloads it if it can find a valid - release. -
  6. -
  7. - The file is imported into the library. -
  8. -
  9. - Jellyfin serves it once it is ready to watch. -
  10. -
-
- -
-

What the statuses usually mean

-
-
-

Pending

-

The request exists, but it is still waiting for approval or the next step.

-
-
-

Approved / Processing

-

The request has been accepted and the automation tools are working on it.

-
-
-

Downloading

-

Magent can show live progress while the content is still being downloaded.

-
-
-

Ready

-

The item has been imported and should now be available in Jellyfin.

-
-
-

Partial / Waiting

-

- Part of the workflow completed, but the request is still waiting on another service or - on content becoming available. -

-
-
-

Declined

-

The request was rejected or cannot proceed in its current form.

-
-
-
- -
-

Live updates you can expect

-
-
-
1
-

Recent requests refresh automatically

-

- Your request list and landing-page activity update automatically while you are signed - in. -

-
-
-
2
-

Request pages update in real time

-

- State changes, timeline steps, and downloader progress are pushed to the page live. -

-
-
-
3
-

Ready state appears as soon as the import completes

-

- Once the content is actually available, Magent updates the request page without a hard - refresh. -

-
-
-
- -
-

User actions you may see

-
-
-

Open request

-

Jump into the full request page to inspect the current state and activity.

-
-
-

Open in Jellyfin

-

Appears when the request is ready and Magent can link you through for playback.

-
-
-

Search + auto-download

-

- Only appears for accounts that have been granted self-service download access by the - admin team. -

-
-
-

My invites

-

- If your account is allowed to invite others, you can create and manage invite links - from your profile. -

-
-
-
- -
-

Invites and signup

-
    -
  1. - You receive an invite link by email or directly from the person who - invited you. -
  2. -
  3. - You sign up through Magent and your account is linked into the media - stack. -
  4. -
  5. - Your account defaults apply based on the invite or your assigned - profile. -
  6. -
  7. - You sign in and track requests from the landing page and your request - pages. -
  8. -
-
- -
-

If a request looks stuck

-

- A waiting request usually means no usable release has been found yet, the download is - still in progress, or the import has not completed. Magent will keep updating as the - underlying services move forward. -

-
-
- ) + return
+ + +
Request a movie or TV show +
    +
  1. Choose Movie or TV show.

    Open 02 New Requests and pick what you’re looking for.

  2. +
  3. Search and choose the right title.

    For TV, choose the seasons you want. If it’s already requested, open that request to see its progress.

  4. +
  5. Check your choices and send it.

    Choose from the quality options shown. These come from the library’s settings.

  6. +
  7. Follow it in My Requests.

    We’ll show what’s happening and any next step you can take. Some titles need approval or may not have a suitable download yet.

  8. +
+
+
Understand the six progress steps +
    +
  1. Requested: Your request has been received.
  2. +
  3. Approved: It has permission to go ahead.
  4. +
  5. Library collection: The library is tracking what’s collected and what’s missing.
  6. +
  7. Release search: A suitable download is being looked for. Waiting here can mean there isn’t a good match yet.
  8. +
  9. Download: The files are being downloaded. TV requests can include several episodes or a season pack.
  10. +
  11. Available to watch: GrizzlyFlix has added the content. Use the watch button to open it.
  12. +
+

A finished download still needs to be added to the media library. Wait for “Available to watch” before heading over.

+
+
Something looks stuck +
    +
  1. Open the request.

    Read its current status and next step.

  2. +
  3. Choose Recheck request.

    Magent checks the connected services again to refresh where things are up to.

  4. +
  5. Follow the action offered.

    You may be able to restart a search or review suitable releases. Choose “Best pick” when offered if you’re unsure.

  6. +
+

Remote activity explains the latest check. Open it to see the full list. A successful search doesn’t always mean a download was found.

+
+
Report a problem and follow the fix +
    +
  1. Open 03 Issues.

    Choose what’s wrong: missing content, broken picture, wrong download, audio, subtitles, or playback.

  2. +
  3. Choose the affected content.

    Find the movie or show. For TV, select the affected seasons or episodes; you can choose more than one.

  4. +
  5. Read “What will happen”, then submit.

    It tells you whether the selected files will be replaced, missing content searched for, subtitles checked, or playback investigated.

  6. +
  7. Follow the issue’s progress.

    Open your reported issue to see the work recorded and where the fix is up to.

  8. +
  9. Tell us if it worked.

    When a supported repair is detected as ready to check, Magent can email you. Try the content, then choose “Yes” if it’s fixed or “No” if you still need help.

  10. +
+

Add your email in My profile so updates can reach you. Reminder and automatic closure timings depend on the site’s settings.

+
+
Invite someone +
    +
  1. Open 04 Invites.

    If invites are enabled for your account, give your invite a name you’ll recognise.

  2. +
  3. Add a welcome note, or skip it.

    A custom invite code is optional too.

  4. +
  5. Choose how to share it.

    Copy the link yourself, or enter an email address to send it directly.

  6. +
  7. Manage it later.

    You can return to your invites to check them or disable a link. Your account’s invite limits apply automatically.

  8. +
+
+
Update your account

Open the account menu and choose My profile to update your contact email, view your activity, or use the password options available for your account.

Looking for your downloads instead? 01 My Requests is your starting point.

+ +
} diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index 3da35b0..f298c06 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -71,7 +71,7 @@ export default function LoginPage() { if (!data?.authenticated) { setError('Could not sign in. Please try again.'); return } setToken('cookie') const next = new URLSearchParams(window.location.search).get('next') || '' - window.location.assign(/^\/issues\/confirm\/\d+$/.test(next) ? next : '/') + window.location.assign(/^\/issues\/confirm\/\d+$/.test(next) ? next : '/welcome') } catch { setError('Could not reach Magent. Check your connection and try again.') } finally { setLoading(false) } diff --git a/frontend/app/ui/ApplicationChrome.tsx b/frontend/app/ui/ApplicationChrome.tsx index 4696e57..0dc2092 100644 --- a/frontend/app/ui/ApplicationChrome.tsx +++ b/frontend/app/ui/ApplicationChrome.tsx @@ -10,7 +10,7 @@ import WorkspaceNavigation from './WorkspaceNavigation' export default function ApplicationChrome() { const pathname = usePathname() - if (['/coming-soon', '/login', '/forgot-password', '/reset-password', '/signup'].includes(pathname)) return null + if (['/welcome', '/coming-soon', '/login', '/forgot-password', '/reset-password', '/signup'].includes(pathname)) return null return <>
Magent
GrizzlyFlix media operations
diff --git a/frontend/app/ui/HeaderIdentity.tsx b/frontend/app/ui/HeaderIdentity.tsx index 8180dac..fd1206f 100644 --- a/frontend/app/ui/HeaderIdentity.tsx +++ b/frontend/app/ui/HeaderIdentity.tsx @@ -93,6 +93,8 @@ export default function HeaderIdentity() { {viewAsUser ? Previewing user view : null}
+ setOpen(false)}>Welcome page + setOpen(false)}>How it works setOpen(false)}> My profile diff --git a/frontend/app/welcome.css b/frontend/app/welcome.css new file mode 100644 index 0000000..6d62da8 --- /dev/null +++ b/frontend/app/welcome.css @@ -0,0 +1,21 @@ +.welcome-page, .friendly-guide { width: min(100%, 1040px); margin: 40px auto; color: var(--ops-text); } +.welcome-page > header { text-align: center; margin-bottom: 32px; } +.welcome-kicker { color: var(--ops-cyan); font-size: 12px; letter-spacing: .12em; text-transform: uppercase; } +.welcome-page h1 { font-size: clamp(30px, 5vw, 48px); line-height: 1.15; margin: 16px 0; } +.welcome-page p, .friendly-guide p { color: var(--ops-muted); line-height: 1.65; } +.welcome-choices { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; } +.welcome-choice { display: flex; flex-direction: column; align-items: flex-start; padding: 32px; gap: 16px; background: var(--ops-panel); border: 1px solid var(--ops-line); border-radius: 16px; text-decoration: none; color: inherit; } +.welcome-choice h2, .welcome-choice p { margin: 0; } +.welcome-choice strong { color: var(--ops-primary-2); margin-top: auto; padding-top: 16px; } +a.welcome-choice:hover { border-color: var(--ops-cyan); background: var(--ops-panel-2); } +.welcome-icon { color: var(--ops-cyan); font-size: 36px; line-height: 1; } +.welcome-page footer { text-align: center; margin-top: 28px; color: var(--ops-muted); } +.welcome-page a:focus-visible, .friendly-guide a:focus-visible, .friendly-guide summary:focus-visible { outline: 3px solid var(--ops-cyan); outline-offset: 5px; } +.friendly-guide > nav { display: flex; gap: 20px; flex-wrap: wrap; margin-bottom: 24px; } +.friendly-guide details { border: 1px solid var(--ops-line); border-radius: 12px; background: var(--ops-panel); margin: 12px 0; padding: 20px 24px; } +.friendly-guide summary { font-weight: 700; font-size: 19px; cursor: pointer; } +.friendly-guide ol { padding-left: 24px; } +.friendly-guide li { padding: 8px 0 8px 8px; line-height: 1.6; } +.friendly-guide li p { margin: 4px 0; } +.friendly-guide > footer { padding: 20px 0; } +@media (max-width: 640px) { .welcome-choices { grid-template-columns: 1fr; } .welcome-page, .friendly-guide { margin: 24px auto; } .welcome-choice { padding: 24px; } } diff --git a/frontend/app/welcome/page.tsx b/frontend/app/welcome/page.tsx new file mode 100644 index 0000000..0734e52 --- /dev/null +++ b/frontend/app/welcome/page.tsx @@ -0,0 +1,36 @@ +'use client' + +import { useEffect, useState } from 'react' +import { authFetch, clearToken, getApiBase } from '../lib/auth' +import '../welcome.css' + +export default function WelcomePage() { + const [ready, setReady] = useState(false) + const [url, setUrl] = useState(null) + const [error, setError] = useState('') + useEffect(() => { + const controller = new AbortController() + void (async () => { + try { + const response = await authFetch(`${getApiBase()}/site/info`, { signal: controller.signal }) + if (response.status === 401) { clearToken(); window.location.replace('/login'); return } + if (!response.ok) throw new Error('Unavailable') + const data = await response.json() + const candidate = data.mediaServerUrl ? new URL(data.mediaServerUrl) : null + if (candidate && ['https:', 'http:'].includes(candidate.protocol) && !candidate.username && !candidate.password) setUrl(candidate.href) + setReady(true) + } catch { + if (!controller.signal.aborted) setError('We couldn’t load your welcome page. Please try again.') + } + })() + return () => controller.abort() + }, []) + return
+
GrizzlyFlix + Magent

Make yourself at home.

Something to watch, or something to sort out?

+ {error ?

{error}

Back to sign in
: !ready ?

Getting things ready…

:
+ {url ?

Go to GrizzlyFlix

Find your next favourite. Watch movies and TV shows.

Let’s watch
:

Go to GrizzlyFlix

The watch link hasn’t been set up yet. Please ask an admin to add the public playback URL.

} +

Manage your account

Track requests, report a problem, or update your profile.

Open 01 My Requests
+
} + +
+} diff --git a/scripts/review_welcome.cjs b/scripts/review_welcome.cjs new file mode 100644 index 0000000..acd2f07 --- /dev/null +++ b/scripts/review_welcome.cjs @@ -0,0 +1,34 @@ +const { chromium } = require(process.env.REVIEW_PLAYWRIGHT); +const assert = require('node:assert/strict'); +(async () => { + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage(); + let mode = 'ready'; + await page.route('**/api/**', route => { + if (route.request().url().endsWith('/auth/me')) return route.fulfill({ json: { username: 'Tester', role: 'user' } }); + if (route.request().url().endsWith('/site/info')) return route.fulfill({ status: mode === 'unauthorized' ? 401 : 200, json: { mediaServerUrl: mode === 'missing' ? null : 'https://watch.example.com/' } }); + return route.fulfill({ json: {} }); + }); + for (const width of [1440, 390]) { + await page.setViewportSize({ width, height: 900 }); + await page.goto('http://127.0.0.1:3103/welcome'); + await page.getByRole('link', { name: /Go to GrizzlyFlix/ }).waitFor(); + assert.equal(await page.getByRole('link', { name: /Go to GrizzlyFlix/ }).getAttribute('href'), 'https://watch.example.com/'); + assert.equal(await page.getByRole('link', { name: /Manage your account/ }).getAttribute('href'), '/'); + assert.equal(await page.locator('.header').count(), 0); + assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)); + await page.goto('http://127.0.0.1:3103/how-it-works'); + await page.getByText('Understand the six progress steps').click(); + await page.getByText('Your request has been received.').waitFor(); + assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)); + } + mode = 'missing'; + await page.goto('http://127.0.0.1:3103/welcome'); + await page.getByText(/watch link hasn’t been set up/).waitFor(); + mode = 'unauthorized'; + await page.goto('http://127.0.0.1:3103/welcome'); + await page.waitForURL('**/login'); + console.log('Welcome and guide checks passed: desktop, mobile, links, disclosure, missing URL, auth redirect.'); + } finally { await browser.close(); } +})().catch(error => { console.error(error); process.exit(1); });