From aed1bf9256416ef2f28a4ce23bfb9f1b5579415f Mon Sep 17 00:00:00 2001 From: Zak Bearman Date: Wed, 16 Sep 2026 17:15:40 +1200 Subject: [PATCH] feat: add seasons from request details --- backend/app/routers/requests.py | 139 +++++++++++++++++++++++++- backend/app/services/snapshot.py | 34 +++++++ backend/tests/test_backend_quality.py | 126 +++++++++++++++++++++++ frontend/app/ops-redesign.css | 30 ++++++ frontend/app/requests/[id]/page.tsx | 107 +++++++++++++++++++- scripts/review_add_seasons_ui.cjs | 101 +++++++++++++++++++ 6 files changed, 532 insertions(+), 5 deletions(-) create mode 100644 scripts/review_add_seasons_ui.cjs diff --git a/backend/app/routers/requests.py b/backend/app/routers/requests.py index 9fcf72e..c9198cd 100644 --- a/backend/app/routers/requests.py +++ b/backend/app/routers/requests.py @@ -273,8 +273,14 @@ def _user_can_use_search_auto(user: Dict[str, Any]) -> bool: def _filter_snapshot_for_user(snapshot: Snapshot, user: Dict[str, Any]) -> Snapshot: - if not _user_can_use_search_auto(user): + can_add_seasons = _user_can_use_search_auto(user) + if not can_add_seasons: snapshot.actions = [action for action in snapshot.actions if action.id != "search_auto"] + pipeline = snapshot.presentation.get("pipeline") + if isinstance(pipeline, list): + for stage in pipeline: + if isinstance(stage, dict) and stage.get("id") == "library": + stage["canAddSeasons"] = can_add_seasons if user.get("role") != "admin": # The standard request view is intentionally collaborative, but service payloads can # contain requester identities, internal URLs, download hashes and diagnostic errors. @@ -2474,6 +2480,137 @@ async def action_search_missing_media( return {"status": "ok", "message": message, "episode_ids": searched_ids} +@router.post("/{request_id}/actions/add-seasons") +async def action_add_seasons( + request_id: str, + payload: Dict[str, Any], + user: Dict[str, str] = Depends(get_current_user), +) -> Dict[str, Any]: + if not request_id.isdigit(): + raise HTTPException(status_code=400, detail="Invalid request id") + if not _user_can_use_search_auto(user): + raise HTTPException(status_code=403, detail="Adding seasons is disabled for this user") + season_numbers = _positive_id_list( + payload.get("season_numbers"), field="season_numbers", maximum=100 + ) + if not season_numbers: + raise HTTPException(status_code=400, detail="Choose at least one season") + + runtime = get_runtime_settings() + seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) + if seerr.configured(): + await _ensure_request_access(seerr, int(request_id), user) + snapshot = await build_snapshot(request_id) + if snapshot.request_type != RequestType.tv: + raise HTTPException(status_code=400, detail="Additional seasons are only available for TV requests") + arr_item = snapshot.raw.get("arr", {}).get("item") + if not isinstance(arr_item, dict) or not isinstance(arr_item.get("id"), int): + raise HTTPException(status_code=404, detail="Series not found in Sonarr") + + sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key) + if not sonarr.configured(): + raise HTTPException(status_code=400, detail="Sonarr is not configured") + series_id = int(arr_item["id"]) + label = "Add seasons" + try: + series = await sonarr.get_series(series_id) + if not isinstance(series, dict) or not isinstance(series.get("seasons"), list): + raise HTTPException(status_code=502, detail="Sonarr did not return the series seasons") + known_seasons = { + season.get("seasonNumber") + for season in series["seasons"] + if isinstance(season, dict) + and isinstance(season.get("seasonNumber"), int) + and season.get("seasonNumber") > 0 + } + if any(season_number not in known_seasons for season_number in season_numbers): + raise HTTPException(status_code=409, detail="One or more selected seasons are no longer available in Sonarr") + + updated_seasons = [ + {**season, "monitored": True} + if isinstance(season, dict) and season.get("seasonNumber") in season_numbers + else season + for season in series["seasons"] + ] + if series.get("monitored") is not True or updated_seasons != series["seasons"]: + await sonarr.update_series({**series, "monitored": True, "seasons": updated_seasons}) + + episodes = await sonarr.get_episodes(series_id) + if not isinstance(episodes, list): + raise HTTPException(status_code=502, detail="Sonarr did not return an episode list") + selected_episodes = [ + episode for episode in episodes + if isinstance(episode, dict) + and episode.get("seasonNumber") in season_numbers + and isinstance(episode.get("id"), int) + ] + episode_ids = [int(episode["id"]) for episode in selected_episodes] + if episode_ids: + await sonarr.monitor_episodes(episode_ids, True) + search_ids = [ + int(episode["id"]) + for episode in selected_episodes + if _released_episode(episode) + and episode.get("hasFile") is not True + and not ( + isinstance(episode.get("episodeFileId"), int) + and episode.get("episodeFileId") > 0 + ) + ] + if search_ids: + await sonarr.search_episodes(search_ids) + + verified_series = await sonarr.get_series(series_id) + verified_seasons = verified_series.get("seasons") if isinstance(verified_series, dict) else [] + verified_season_map = { + season.get("seasonNumber"): season.get("monitored") + for season in verified_seasons + if isinstance(season, dict) and isinstance(season.get("seasonNumber"), int) + } + if ( + not isinstance(verified_series, dict) + or verified_series.get("monitored") is not True + or any(verified_season_map.get(number) is not True for number in season_numbers) + ): + raise HTTPException(status_code=502, detail="Sonarr did not enable monitoring for every selected season") + if episode_ids: + verified_episodes = await sonarr.get_episodes(series_id) + if not isinstance(verified_episodes, list) or any( + isinstance(episode, dict) + and episode.get("id") in episode_ids + and episode.get("monitored") is not True + for episode in verified_episodes + ): + raise HTTPException(status_code=502, detail="Sonarr did not enable monitoring for every selected episode") + except HTTPException as exc: + detail = f"The seasons could not be added: {exc.detail}" + await asyncio.to_thread(save_action, request_id, "add_seasons", label, "failed", detail) + raise + except Exception as exc: + logger.exception("add seasons failed request_id=%s", request_id) + detail = "Sonarr could not add the selected seasons." + await asyncio.to_thread(save_action, request_id, "add_seasons", label, "failed", detail) + raise HTTPException(status_code=502, detail=detail) from exc + + season_label = ", ".join(str(number) for number in season_numbers) + message = f"Season{'s' if len(season_numbers) != 1 else ''} {season_label} added to Sonarr." + if search_ids: + message += f" Searching for {len(search_ids)} released missing episode{'s' if len(search_ids) != 1 else ''}." + elif episode_ids: + message += " All known episodes are already collected or have not aired yet." + else: + message += " New episodes will be monitored when Sonarr discovers them." + await asyncio.to_thread(save_action, request_id, "add_seasons", label, "ok", message) + fresh_snapshot = _filter_snapshot_for_user(await build_snapshot(request_id), user) + return { + "status": "ok", + "message": message, + "season_numbers": season_numbers, + "searched_episode_count": len(search_ids), + "snapshot": fresh_snapshot, + } + + @router.post("/{request_id}/actions/repair-subtitles") async def action_repair_subtitles( request_id: str, diff --git a/backend/app/services/snapshot.py b/backend/app/services/snapshot.py index 863f5b1..f5dd1b7 100644 --- a/backend/app/services/snapshot.py +++ b/backend/app/services/snapshot.py @@ -369,6 +369,38 @@ def _episode_availability(episodes: Any) -> Dict[str, Any]: } +def _unmonitored_season_options(series: Any, episodes: Any) -> List[Dict[str, int]]: + """Describe regular Sonarr seasons that can be added to an existing request.""" + if not isinstance(series, dict) or not isinstance(series.get("seasons"), list): + return [] + episode_rows = [episode for episode in episodes if isinstance(episode, dict)] if isinstance(episodes, list) else [] + options: List[Dict[str, int]] = [] + for season in series["seasons"]: + if not isinstance(season, dict) or season.get("monitored") is not False: + continue + season_number = season.get("seasonNumber") + if not isinstance(season_number, int) or season_number <= 0: + continue + matching = [episode for episode in episode_rows if episode.get("seasonNumber") == season_number] + statistics = season.get("statistics") if isinstance(season.get("statistics"), dict) else {} + episode_count = statistics.get("totalEpisodeCount") + if not isinstance(episode_count, int): + episode_count = statistics.get("episodeCount") + if not isinstance(episode_count, int): + episode_count = len(matching) + available = statistics.get("episodeFileCount") + if not isinstance(available, int): + available = sum(1 for episode in matching if episode.get("hasFile") is True) + options.append( + { + "seasonNumber": season_number, + "episodeCount": max(0, episode_count), + "available": max(0, available), + } + ) + return sorted(options, key=lambda item: item["seasonNumber"]) + + def _summarize_qbit(torrents: List[Dict[str, Any]]) -> Dict[str, Any]: if not torrents: return {"state": "idle", "message": "0 active downloads."} @@ -939,6 +971,7 @@ def _build_presentation( "missing": missing, "total": total, "seasons": availability.get("seasons") or [], + "unmonitoredSeasons": arr_details.get("unmonitoredSeasons") or [], "missingEpisodes": arr_details.get("missingEpisodes") or {}, }, { @@ -1240,6 +1273,7 @@ async def build_snapshot(request_id: str) -> Snapshot: "state": await read_search_status(sonarr, RequestType.tv, series_id, episodes) } arr_details["availability"] = _episode_availability(episodes) + arr_details["unmonitoredSeasons"] = _unmonitored_season_options(arr_item, episodes) counts = arr_details["availability"] arr_state = "available" if counts.get("total", 0) > 0 and not counts.get("missing") else "added" missing_by_season = _missing_episode_numbers_by_season(episodes) diff --git a/backend/tests/test_backend_quality.py b/backend/tests/test_backend_quality.py index f1bc0e5..530a054 100644 --- a/backend/tests/test_backend_quality.py +++ b/backend/tests/test_backend_quality.py @@ -39,6 +39,7 @@ from backend.app.services.snapshot import ( _build_repair_activity, _episode_availability, _torrent_progress, + _unmonitored_season_options, ) @@ -567,6 +568,34 @@ class RequestPresentationTests(unittest.TestCase): self.assertEqual(availability["missing"], 1) self.assertEqual(availability["total"], 2) + def test_unmonitored_seasons_are_offered_separately_from_collection_progress(self) -> None: + series = { + "seasons": [ + {"seasonNumber": 0, "monitored": False}, + {"seasonNumber": 7, "monitored": True}, + { + "seasonNumber": 8, + "monitored": False, + "statistics": {"episodeCount": 16, "episodeFileCount": 2}, + }, + {"seasonNumber": 9, "monitored": False}, + ] + } + episodes = [ + {"seasonNumber": 9, "episodeNumber": 1, "hasFile": True}, + {"seasonNumber": 9, "episodeNumber": 2, "hasFile": False}, + ] + + options = _unmonitored_season_options(series, episodes) + + self.assertEqual( + options, + [ + {"seasonNumber": 8, "episodeCount": 16, "available": 2}, + {"seasonNumber": 9, "episodeCount": 2, "available": 1}, + ], + ) + def test_presentation_hides_download_without_download_evidence(self) -> None: snapshot = Snapshot( request_id="3909", @@ -1892,6 +1921,103 @@ class MediaReplacementTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase) sonarr.search_episodes.assert_awaited_once_with([36899]) sonarr.search.assert_not_awaited() + async def test_add_seasons_monitors_series_and_searches_released_missing_episodes(self) -> None: + snapshot = Snapshot( + request_id="3580", + title="Suits", + request_type=RequestType.tv, + state=NormalizedState.available, + raw={"arr": {"item": {"id": 540}}}, + ) + refreshed = Snapshot( + request_id="3580", + title="Suits", + request_type=RequestType.tv, + state=NormalizedState.importing, + presentation={"pipeline": [{"id": "library", "unmonitoredSeasons": []}]}, + ) + original_series = { + "id": 540, + "monitored": True, + "qualityProfileId": 7, + "seasons": [ + {"seasonNumber": 7, "monitored": True}, + {"seasonNumber": 8, "monitored": False}, + {"seasonNumber": 9, "monitored": False}, + ], + } + updated_series = { + **original_series, + "seasons": [ + {"seasonNumber": 7, "monitored": True}, + {"seasonNumber": 8, "monitored": True}, + {"seasonNumber": 9, "monitored": True}, + ], + } + episodes = [ + { + "id": 801, + "seasonNumber": 8, + "episodeNumber": 1, + "monitored": False, + "hasFile": False, + "airDateUtc": "2018-07-18T00:00:00Z", + }, + { + "id": 802, + "seasonNumber": 8, + "episodeNumber": 2, + "monitored": False, + "hasFile": True, + "episodeFileId": 88, + }, + { + "id": 901, + "seasonNumber": 9, + "episodeNumber": 1, + "monitored": False, + "hasFile": False, + "airDateUtc": "2019-07-17T00:00:00Z", + }, + ] + verified_episodes = [{**episode, "monitored": True} for episode in episodes] + sonarr = SimpleNamespace( + configured=lambda: True, + get_series=AsyncMock(side_effect=[original_series, updated_series]), + update_series=AsyncMock(return_value=updated_series), + get_episodes=AsyncMock(side_effect=[episodes, verified_episodes]), + monitor_episodes=AsyncMock(return_value={"monitored": True}), + search_episodes=AsyncMock(return_value={"id": 9001}), + ) + runtime = SimpleNamespace( + jellyseerr_base_url=None, + jellyseerr_api_key=None, + sonarr_base_url="http://sonarr", + sonarr_api_key="secret", + ) + with ( + patch.object(requests_router, "get_runtime_settings", return_value=runtime), + patch.object( + requests_router, + "build_snapshot", + new=AsyncMock(side_effect=[snapshot, refreshed]), + ), + patch.object(requests_router, "SonarrClient", return_value=sonarr), + patch.object(requests_router, "save_action"), + ): + result = await requests_router.action_add_seasons( + "3580", + {"season_numbers": [8, 9]}, + {"username": "viewer", "role": "user", "auto_search_enabled": True}, + ) + + self.assertEqual(result["season_numbers"], [8, 9]) + self.assertEqual(result["searched_episode_count"], 2) + self.assertTrue(result["snapshot"].presentation["pipeline"][0]["canAddSeasons"]) + sonarr.update_series.assert_awaited_once_with(updated_series) + sonarr.monitor_episodes.assert_awaited_once_with([801, 802, 901], True) + sonarr.search_episodes.assert_awaited_once_with([801, 901]) + async def test_missing_movie_search_monitors_movie_before_search(self) -> None: snapshot = Snapshot( request_id="3914", diff --git a/frontend/app/ops-redesign.css b/frontend/app/ops-redesign.css index 5515d8c..e5f93a6 100644 --- a/frontend/app/ops-redesign.css +++ b/frontend/app/ops-redesign.css @@ -2028,6 +2028,7 @@ button:disabled, .request-overview, .request-repair-activity, +.request-add-seasons, .request-journey, .request-advanced { border: 1px solid var(--ops-line); @@ -2203,6 +2204,30 @@ button:disabled, } .request-action-feedback.is-error { color: var(--request-red); background: rgba(255, 86, 113, 0.08); } +.request-add-seasons { + display: grid; + gap: 18px; + padding: 20px; + border-color: rgba(126, 215, 255, 0.32); + background: + radial-gradient(circle at 5% 0%, rgba(14, 165, 233, 0.13), transparent 34%), + rgba(255, 255, 255, 0.018); +} +.request-add-seasons-heading, +.request-add-seasons-submit { display: flex; align-items: center; justify-content: space-between; gap: 20px; } +.request-add-seasons-heading > div, +.request-add-seasons-submit > div { display: grid; gap: 5px; } +.request-add-seasons-heading h2 { margin: 0; font-size: clamp(1.25rem, 2.2vw, 1.8rem); } +.request-add-seasons-heading p { max-width: 82ch; margin: 0; color: var(--ops-muted); line-height: 1.5; } +.request-add-seasons-submit { + padding: 15px; + border: 1px solid var(--ops-line-soft); + border-radius: var(--ops-radius-lg); + background: rgba(255, 255, 255, 0.024); +} +.request-add-seasons-submit small { color: var(--ops-muted); line-height: 1.45; } +.request-add-seasons-submit button { flex: 0 0 auto; min-width: 190px; min-height: 44px; } + .request-operation-progress { grid-column: 1 / -1; display: grid; @@ -3717,6 +3742,11 @@ textarea:focus { .request-ready-actions { grid-template-columns: 1fr; } .request-ready-actions > section + section { border-top: 1px solid var(--ops-line-soft); border-left: 0; } .request-ready-actions > section > p { min-height: 0; } + .request-add-seasons-heading, + .request-add-seasons-submit { align-items: stretch; flex-direction: column; } + .request-add-seasons-heading .request-live-indicator { align-self: flex-start; } + .request-add-seasons .request-season-grid { grid-template-columns: 1fr; } + .request-add-seasons-submit button { width: 100%; } .issue-prefilled-request { align-items: stretch; flex-direction: column; } .issue-prefilled-request > a { text-align: center; } } diff --git a/frontend/app/requests/[id]/page.tsx b/frontend/app/requests/[id]/page.tsx index 4337bd9..c8d4290 100644 --- a/frontend/app/requests/[id]/page.tsx +++ b/frontend/app/requests/[id]/page.tsx @@ -1,15 +1,15 @@ 'use client' -import PageHeading from '../../ui/PageHeading' -import RequestLanguage from './RequestLanguage' -import { lockBodyScroll } from '../../lib/scrollLock' -import LatestActivity from './LatestActivity' import Image from 'next/image' import { useParams, useRouter } from 'next/navigation' import { useEffect, useMemo, useState } from 'react' import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth' import { canAccess } from '../../lib/features' +import { lockBodyScroll } from '../../lib/scrollLock' +import PageHeading from '../../ui/PageHeading' +import LatestActivity from './LatestActivity' +import RequestLanguage from './RequestLanguage' type TimelineHop = { service: string @@ -36,6 +36,8 @@ type PipelineStage = { missing?: number total?: number seasons?: Array<{ seasonNumber: number; available: number; missing: number; total: number }> + unmonitoredSeasons?: Array<{ seasonNumber: number; episodeCount: number; available: number }> + canAddSeasons?: boolean missingEpisodes?: Record actionIds?: string[] visible?: boolean @@ -327,6 +329,7 @@ export default function RequestTimelinePage() { const [operationProgress, setOperationProgress] = useState(null) const [isAdmin, setIsAdmin] = useState(false) const [canReportIssues, setCanReportIssues] = useState(false) + const [selectedAdditionalSeasons, setSelectedAdditionalSeasons] = useState([]) const awaitingMediaIndex = Boolean( snapshot?.presentation?.pipeline?.some( (stage) => stage.id === 'available' && stage.state === 'active' @@ -536,6 +539,8 @@ export default function RequestTimelinePage() { const presentation = snapshot.presentation ?? {} const pipeline = presentation.pipeline?.length ? presentation.pipeline : fallbackPipeline(snapshot) + const libraryStage = pipeline.find((stage) => stage.id === 'library') + const unmonitoredSeasons = libraryStage?.unmonitoredSeasons ?? [] const availableStage = pipeline.find((stage) => stage.id === 'available') const mediaServerLink = availableStage?.state === 'complete' && availableStage.link ? availableStage.link @@ -685,6 +690,41 @@ export default function RequestTimelinePage() { } } + const addSelectedSeasons = async () => { + if (!selectedAdditionalSeasons.length) return + setBusyAction('add_seasons') + setActionError(null) + setActionMessage(null) + try { + const response = await trackedPost( + 'Add seasons to this request', + `${getApiBase()}/requests/${snapshot.request_id}/actions/add-seasons`, + { + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ season_numbers: selectedAdditionalSeasons }), + }, + ) + if (response.status === 401) { + clearToken() + router.push('/login') + return + } + if (!response.ok) throw new Error(await readApiError(response, 'The selected seasons could not be added.')) + const data = await response.json() + if (!isSnapshotPayload(data?.snapshot)) { + throw new Error('The seasons were added, but Magent did not return an updated request.') + } + setSnapshot(data.snapshot) + setSelectedAdditionalSeasons([]) + setActionMessage(data?.message ?? 'The selected seasons were added and will now be monitored.') + } catch (error) { + console.error(error) + setActionError(error instanceof Error ? error.message : 'The selected seasons could not be added.') + } finally { + setBusyAction(null) + } + } + const runAction = async (action: RequestAction, searchOffset = 0) => { if (action.requires_confirmation && !window.confirm(`Run “${action.label}”?`)) return const actionPaths: Record = { @@ -867,6 +907,65 @@ export default function RequestTimelinePage() { )} + {snapshot.request_type === 'tv' && unmonitoredSeasons.length > 0 && ( +
+
+
+ Collection expansion +

Add more seasons

+

+ Sonarr knows about {unmonitoredSeasons.length} additional season{unmonitoredSeasons.length === 1 ? '' : 's'} that {unmonitoredSeasons.length === 1 ? 'is' : 'are'} not currently part of this request. +

+
+ Available to add +
+
+ Choose seasons to monitor and search +
+ + +
+
+ {unmonitoredSeasons.map((season) => { + const selected = selectedAdditionalSeasons.includes(season.seasonNumber) + const episodeLabel = season.episodeCount > 0 + ? `${season.episodeCount} known episode${season.episodeCount === 1 ? '' : 's'}` + : 'Episodes not announced yet' + return ( + + ) + })} +
+
+
+
+ {selectedAdditionalSeasons.length ? `${selectedAdditionalSeasons.length} season${selectedAdditionalSeasons.length === 1 ? '' : 's'} selected` : 'Choose one or more seasons'} + Selected seasons will be monitored in Sonarr and released missing episodes will be searched immediately. +
+ {libraryStage?.canAddSeasons === false ? ( + Automatic collection searches are not enabled for your account. + ) : ( + + )} +
+
+ )} + {repairActivity?.visible && (
diff --git a/scripts/review_add_seasons_ui.cjs b/scripts/review_add_seasons_ui.cjs new file mode 100644 index 0000000..82e6b67 --- /dev/null +++ b/scripts/review_add_seasons_ui.cjs @@ -0,0 +1,101 @@ +const assert = require('node:assert/strict'); +const { chromium } = require(process.env.PLAYWRIGHT_PACKAGE || 'playwright'); +const base = process.env.REVIEW_BASE || 'http://localhost:3114'; + +const snapshot = added => ({ + request_id: '3580', + title: 'Suits', + year: 2011, + request_type: 'tv', + state: added ? 'IMPORTING' : 'AVAILABLE', + timeline: [], + actions: [], + presentation: { + status: { + label: added ? 'Partially collected — 32 episodes still missing' : 'Available to watch', + meaning: added + ? 'Seasons 8 and 9 are now monitored and collection is in progress.' + : 'The originally requested collection is complete and available on the media server.', + }, + nextStep: { title: added ? 'Wait for search results' : 'Ready to watch', description: 'Magent is checking Sonarr.', actionIds: [] }, + pipeline: [ + { + id: 'library', + label: 'Library collection', + state: added ? 'partial' : 'complete', + summary: added ? 'Seasons 8 and 9 are being collected.' : 'Collection complete — no search needed', + available: 124, + missing: added ? 32 : 0, + total: added ? 156 : 124, + seasons: added + ? [ + { seasonNumber: 7, available: 16, missing: 0, total: 16 }, + { seasonNumber: 8, available: 0, missing: 16, total: 16 }, + { seasonNumber: 9, available: 0, missing: 16, total: 16 }, + ] + : [{ seasonNumber: 7, available: 16, missing: 0, total: 16 }], + unmonitoredSeasons: added + ? [] + : [ + { seasonNumber: 8, episodeCount: 16, available: 0 }, + { seasonNumber: 9, episodeCount: 16, available: 0 }, + ], + canAddSeasons: true, + }, + { id: 'available', label: 'Available to watch', state: added ? 'partial' : 'complete', summary: 'Suits is available.', link: 'https://watch.example.test/suits' }, + ], + }, +}); + +(async () => { + const browser = await chromium.launch(); + try { + const calls = []; + const errors = []; + for (const width of [1440, 390]) { + let added = false; + const context = await browser.newContext({ viewport: { width, height: 950 } }); + await context.addCookies([{ name: 'magent_logged_in', value: '1', url: base }]); + await context.route('**/api/**', async route => { + const request = route.request(); + const path = new URL(request.url()).pathname; + const reply = json => route.fulfill({ json }); + if (path === '/api/auth/me') return reply({ username: 'Viewer', role: 'user', auto_search_enabled: true, features: { requests: true, new_requests: true, stats: true, issues: true } }); + if (path === '/api/site/info') return reply({ mediaServerUrl: 'https://watch.example.test/' }); + if (path === '/api/requests/3580/snapshot') return reply(snapshot(added)); + if (path === '/api/requests/3580/language') return reply({ language: null }); + if (path === '/api/requests/3580/actions/add-seasons' && request.method() === 'POST') { + calls.push(request.postDataJSON()); + added = true; + return reply({ status: 'ok', message: 'Seasons 8, 9 added to Sonarr. Searching for 32 released missing episodes.', snapshot: snapshot(true) }); + } + if (path.startsWith('/api/operations/')) return reply({ id: 'fixture', label: 'Add seasons', status: 'complete', events: [] }); + if (path.includes('/events/stream')) return route.fulfill({ contentType: 'text/event-stream', body: ': fixture\n\n' }); + if (path.includes('/branding/')) return route.fulfill({ status: 404 }); + return reply({ navigation: { showRequests: true }, items: [], total: 0, services: [] }); + }); + + const page = await context.newPage(); + page.on('pageerror', error => errors.push(error.message)); + await page.goto(base + '/requests/3580'); + await page.getByRole('heading', { name: 'Add more seasons' }).waitFor(); + await page.getByText('Season 8', { exact: true }).waitFor(); + await page.getByText('Season 9', { exact: true }).waitFor(); + assert.equal(await page.getByRole('checkbox').count(), 2); + await page.getByRole('button', { name: 'Select all' }).click(); + assert(await page.getByText('2 seasons selected', { exact: true }).isVisible()); + await page.getByRole('button', { name: 'Add selected seasons' }).click(); + await page.getByText('Seasons 8, 9 added to Sonarr. Searching for 32 released missing episodes.', { exact: true }).waitFor(); + assert.equal(await page.getByRole('heading', { name: 'Add more seasons' }).count(), 0); + assert(await page.getByRole('heading', { name: 'Where your request is now' }).isVisible()); + assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)); + await context.close(); + } + + assert.deepEqual(calls, [{ season_numbers: [8, 9] }, { season_numbers: [8, 9] }]); + assert.deepEqual(errors, []); + console.log('Passed: completed TV requests show unmonitored seasons and add them through Sonarr on desktop/mobile. APIs intercepted.'); + } finally { + await browser.close(); + } +})().catch(error => { console.error(error); process.exitCode = 1; });