feat: add seasons from request details
Magent CI/CD / verify (push) Successful in 1m51s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped

This commit is contained in:
2026-09-16 17:15:40 +12:00
parent 05a540ecbb
commit aed1bf9256
6 changed files with 532 additions and 5 deletions
+138 -1
View File
@@ -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,
+34
View File
@@ -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)
+126
View File
@@ -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",
+30
View File
@@ -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; }
}
+103 -4
View File
@@ -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<string, number[]>
actionIds?: string[]
visible?: boolean
@@ -327,6 +329,7 @@ export default function RequestTimelinePage() {
const [operationProgress, setOperationProgress] = useState<OperationProgress | null>(null)
const [isAdmin, setIsAdmin] = useState(false)
const [canReportIssues, setCanReportIssues] = useState(false)
const [selectedAdditionalSeasons, setSelectedAdditionalSeasons] = useState<number[]>([])
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<string, string> = {
@@ -867,6 +907,65 @@ export default function RequestTimelinePage() {
)}
</section>
{snapshot.request_type === 'tv' && unmonitoredSeasons.length > 0 && (
<section className="request-add-seasons" aria-labelledby="request-add-seasons-heading">
<div className="request-add-seasons-heading">
<div>
<span className="section-kicker">Collection expansion</span>
<h2 id="request-add-seasons-heading">Add more seasons</h2>
<p>
Sonarr knows about {unmonitoredSeasons.length} additional season{unmonitoredSeasons.length === 1 ? '' : 's'} that {unmonitoredSeasons.length === 1 ? 'is' : 'are'} not currently part of this request.
</p>
</div>
<span className="request-live-indicator"><i />Available to add</span>
</div>
<fieldset className="request-season-picker">
<legend>Choose seasons to monitor and search</legend>
<div className="request-season-actions">
<button type="button" disabled={Boolean(busyAction)} onClick={() => setSelectedAdditionalSeasons(unmonitoredSeasons.map((season) => season.seasonNumber))}>Select all</button>
<button type="button" disabled={Boolean(busyAction) || selectedAdditionalSeasons.length === 0} onClick={() => setSelectedAdditionalSeasons([])}>Clear</button>
</div>
<div className="request-season-grid">
{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 (
<label className={selected ? 'is-selected' : ''} key={season.seasonNumber}>
<input
type="checkbox"
checked={selected}
disabled={Boolean(busyAction) || libraryStage?.canAddSeasons === false}
onChange={() => setSelectedAdditionalSeasons((current) => selected
? current.filter((number) => number !== season.seasonNumber)
: [...current, season.seasonNumber].sort((left, right) => left - right))}
/>
<span>
<strong>Season {season.seasonNumber}</strong>
<small>{episodeLabel}{season.available > 0 ? ` · ${season.available} already collected` : ''}</small>
</span>
</label>
)
})}
</div>
</fieldset>
<div className="request-add-seasons-submit">
<div>
<strong>{selectedAdditionalSeasons.length ? `${selectedAdditionalSeasons.length} season${selectedAdditionalSeasons.length === 1 ? '' : 's'} selected` : 'Choose one or more seasons'}</strong>
<small>Selected seasons will be monitored in Sonarr and released missing episodes will be searched immediately.</small>
</div>
{libraryStage?.canAddSeasons === false ? (
<span className="request-ready-unavailable">Automatic collection searches are not enabled for your account.</span>
) : (
<button type="button" disabled={Boolean(busyAction) || selectedAdditionalSeasons.length === 0} onClick={() => void addSelectedSeasons()}>
{busyAction === 'add_seasons' ? 'Adding seasons…' : 'Add selected seasons'}
</button>
)}
</div>
</section>
)}
{repairActivity?.visible && (
<section className={`request-repair-activity is-${repairActivity.state ?? 'searching'}`} aria-live="polite">
<div className="request-repair-heading">
+101
View File
@@ -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; });