"use client"; import Image from "next/image"; import { useParams, useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useState } from "react"; import { authFetch, clearToken, getApiBase, getToken } from "../../lib/auth"; import { canAccess, type FeatureAccess } from "../../lib/features"; import { lockBodyScroll } from "../../lib/scrollLock"; import { useEffectiveRole } from "../../lib/viewMode"; import PageHeading from "../../ui/PageHeading"; import LatestActivity from "./LatestActivity"; import RequestLanguage from "./RequestLanguage"; type TimelineHop = { service: string; status: string; details?: Record; }; type RequestAction = { id: string; label: string; risk: string; description?: string; requires_confirmation: boolean; }; type PipelineStage = { id: string; label: string; state: "complete" | "active" | "partial" | "attention" | "waiting" | string; stateLabel?: string; searchStatus?: "searching" | "queued" | "idle" | "unavailable"; summary: string; available?: number; 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; torrents?: Array>; link?: string | null; }; type RepairActivityStep = { id: string; label: string; state: "complete" | "active" | "attention" | "waiting" | string; detail: string; }; type RepairActivity = { visible?: boolean; actionId?: string; state?: "searching" | "downloading" | "importing" | "indexing" | "complete" | "attention" | string; headline?: string; message?: string; service?: string; updatedAt?: string | null; steps?: RepairActivityStep[]; }; type Snapshot = { request_id: string; title: string; year?: number; request_type: string; state: string; state_reason?: string; timeline: TimelineHop[]; actions: RequestAction[]; artwork?: { poster_url?: string; backdrop_url?: string }; presentation?: { repairCycle?: string | null; status?: { label?: string; meaning?: string }; download?: { visible?: boolean; state?: string; summary?: string; torrents?: Array>; lastSeenAt?: string | null; }; nextStep?: { title?: string; description?: string; actionIds?: string[] }; pipeline?: PipelineStage[]; repairActivity?: RepairActivity; }; raw?: { jellyfin?: { link?: string | null } }; }; type ReleaseOption = { title?: string; indexer?: string; indexerId?: number; guid?: string; size?: number; seeders?: number; leechers?: number; protocol?: string; publishDate?: string; infoUrl?: string; downloadUrl?: string; magnetUrl?: string; fullSeason?: boolean; seasonNumber?: number; quality?: string; customFormatScore?: number; approved?: boolean; selectionToken?: string; requiresOverride?: boolean; selectable?: boolean; rejections?: string[]; bestPick?: boolean; }; type SnapshotHistory = { request_id: string; state: string; state_reason?: string; created_at: string; }; type ActionHistory = { request_id: string; action_id: string; label: string; status: string; message?: string; created_at: string; }; type LiveDownloadProgress = { repairCycle?: string | null; visible?: boolean; request_id: string; state: string; summary: string; torrents: Array>; updated_at: string; }; type OperationEvent = { id: string; service: string; state: "active" | "complete" | "error" | string; message: string; duration_ms?: number | null; status_code?: number | null; }; type OperationProgress = { summary?: { title: string; message: string; next: string; action?: string }; id: string; label: string; status: "running" | "complete" | "error" | string; duration_ms?: number | null; events: OperationEvent[]; }; const readApiError = async (response: Response, fallback: string) => { try { const contentType = response.headers.get("content-type") ?? ""; if (contentType.includes("application/json")) { const payload = await response.json(); if (typeof payload?.detail === "string" && payload.detail.trim()) return payload.detail; if (typeof payload?.message === "string" && payload.message.trim()) return payload.message; } else { const text = await response.text(); if (text.trim()) return text.trim(); } } catch (error) { console.error(error); } return fallback; }; const isSnapshotPayload = (value: unknown): value is Snapshot => { if (!value || typeof value !== "object") return false; const snapshot = value as Partial; return ( typeof snapshot.request_id === "string" && typeof snapshot.title === "string" && typeof snapshot.request_type === "string" && typeof snapshot.state === "string" && Array.isArray(snapshot.timeline) && Array.isArray(snapshot.actions) ); }; const formatBytes = (value?: number) => { if (!value || Number.isNaN(value)) return "Size unavailable"; const units = ["B", "KB", "MB", "GB", "TB"]; let size = value; let index = 0; while (size >= 1024 && index < units.length - 1) { size /= 1024; index += 1; } return `${size.toFixed(1)} ${units[index]}`; }; const torrentProgress = (torrent: Record) => { const progress = Number(torrent.progress); if (!Number.isNaN(progress) && progress >= 0 && progress <= 1) return Math.round(progress * 1000) / 10; const supplied = Number(torrent.progressPercent); if (!Number.isNaN(supplied) && supplied >= 0 && supplied <= 100) return Math.round(supplied * 10) / 10; return null; }; const formatProgress = (progress: number) => `${progress.toFixed(1).replace(/\.0$/, "")}% complete`; const mergeLiveDownload = (current: Snapshot, live: LiveDownloadProgress): Snapshot => { if (String(current.request_id) !== String(live.request_id)) return current; if ((current.presentation?.repairCycle ?? null) !== (live.repairCycle ?? null)) return current; if (["COMPLETED", "AVAILABLE"].includes(current.state)) return current; const downloadStage = current.presentation?.pipeline?.find((stage) => stage.id === "download"); if (downloadStage?.state === "complete" && downloadStage.visible === false) return current; const visible = live.visible ?? live.state !== "not_started"; const stageState = live.state === "completed" ? "complete" : ["missing", "error"].includes(live.state) ? "attention" : live.state === "not_started" ? "waiting" : "active"; const presentation = current.presentation ?? {}; const pipeline = (presentation.pipeline ?? fallbackPipeline(current)).map((stage) => stage.id === "download" ? { ...stage, state: stageState, stateLabel: undefined, summary: live.summary, visible, torrents: live.torrents } : stage, ); return { ...current, presentation: { ...presentation, download: { ...(presentation.download ?? {}), visible, state: live.state, summary: live.summary, torrents: live.torrents, lastSeenAt: live.updated_at, }, pipeline, }, }; }; const fallbackStatusLabel = (state: string) => { const labels: Record = { REQUESTED: "Waiting for approval", APPROVED: "Approved — preparing collection", NEEDS_ADD: "Approved, but not yet in the library queue", ADDED_TO_ARR: "Added to library queue", SEARCHING: "Searching for a matching release", GRABBED: "Download queued", DOWNLOADING: "Download in progress", IMPORTING: "Preparing the collected media", COMPLETED: "Available to watch", AVAILABLE: "Available to watch", FAILED: "This request needs attention", UNKNOWN: "Checking request status", }; return labels[state] ?? "Checking request status"; }; const fallbackPipeline = (snapshot: Snapshot): PipelineStage[] => { const approved = snapshot.state !== "REQUESTED"; const complete = ["COMPLETED", "AVAILABLE"].includes(snapshot.state); const indexing = snapshot.state === "IMPORTING"; return [ { id: "requested", label: "Requested", state: "complete", summary: "Request received" }, { id: "approved", label: "Approved", state: approved ? "complete" : "active", summary: approved ? "Approved for collection" : "Waiting for approval", }, { id: "library", label: "Library collection", state: complete ? "complete" : approved ? "active" : "waiting", summary: complete ? "Collection complete" : "Waiting for collector information", }, { id: "search", label: "Release search", state: "waiting", summary: "Search state unavailable" }, { id: "download", label: "Download", state: "waiting", summary: "No download attempt yet" }, { id: "available", label: complete ? "Available to watch" : indexing ? "Adding to Grizzlyflix" : "Media server", state: complete ? "complete" : indexing ? "active" : "waiting", stateLabel: complete ? "Ready" : indexing ? "Indexing" : "Waiting", summary: complete ? "This title is ready to watch in Grizzlyflix." : indexing ? "The download is complete. Grizzlyflix is indexing this title now." : "This title has not reached Grizzlyflix yet.", link: snapshot.raw?.jellyfin?.link, }, ]; }; const formatWhen = (value?: string | null) => { if (!value) return "Time unavailable"; const date = new Date(value); if (Number.isNaN(date.valueOf())) return value; return date.toLocaleString(); }; export default function RequestTimelinePage() { const params = useParams<{ id: string | string[] }>(); const requestId = Array.isArray(params?.id) ? params.id[0] : params?.id; const router = useRouter(); const [snapshot, setSnapshot] = useState(null); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(null); const [showDetails, setShowDetails] = useState(false); const [actionMessage, setActionMessage] = useState(null); const [actionError, setActionError] = useState(null); const [busyAction, setBusyAction] = useState(null); const [releaseOptions, setReleaseOptions] = useState([]); const [releasePickerOpen, setReleasePickerOpen] = useState(false); const [canIgnoreProfileLimits, setCanIgnoreProfileLimits] = useState(false); const [ignoreProfileLimits, setIgnoreProfileLimits] = useState(false); const [nextSearchOffset, setNextSearchOffset] = useState(null); const [releaseCollector, setReleaseCollector] = useState(null); const [releaseSearchMessage, setReleaseSearchMessage] = useState(null); const [historySnapshots, setHistorySnapshots] = useState([]); const [historyActions, setHistoryActions] = useState([]); const [operationProgress, setOperationProgress] = useState(null); const [viewer, setViewer] = useState<{ role?: string; features?: Partial } | null>(null); const effectiveRole = useEffectiveRole(viewer?.role); const isAdmin = effectiveRole === "admin"; const canReportIssues = canAccess(viewer ? { ...viewer, role: effectiveRole ?? undefined } : null, "issues"); const [selectedAdditionalSeasons, setSelectedAdditionalSeasons] = useState([]); const awaitingMediaIndex = Boolean( snapshot?.presentation?.pipeline?.some((stage) => stage.id === "available" && stage.state === "active"), ); const repairIsActive = Boolean( snapshot?.presentation?.repairActivity?.visible && !["complete", "attention"].includes(snapshot.presentation.repairActivity.state ?? ""), ); const searchIsActive = Boolean( snapshot?.presentation?.pipeline?.some( (stage) => stage.id === "library" && ["searching", "queued"].includes(stage.searchStatus ?? ""), ), ); const closeReleasePicker = useCallback(() => { if (busyAction?.startsWith("grab:")) return; setReleasePickerOpen(false); setReleaseOptions([]); setReleaseSearchMessage(null); }, [busyAction]); useEffect(() => { if (!releasePickerOpen) return; const unlock = lockBodyScroll(); const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") closeReleasePicker(); }; window.addEventListener("keydown", handleKeyDown); return () => { unlock(); window.removeEventListener("keydown", handleKeyDown); }; }, [closeReleasePicker, releasePickerOpen]); useEffect(() => { if (!requestId) return; const load = async () => { setLoading(true); setLoadError(null); try { if (!getToken()) { router.push("/login"); return; } const baseUrl = getApiBase(); const [meResponse, snapshotResponse] = await Promise.all([ authFetch(`${baseUrl}/auth/me`), authFetch(`${baseUrl}/requests/${requestId}/snapshot`), ]); if ([meResponse, snapshotResponse].some((response) => response.status === 401)) { clearToken(); router.push("/login"); return; } if (!meResponse.ok) { throw new Error("Unable to verify your request access."); } const me = await meResponse.json(); setViewer(me); if (!snapshotResponse.ok) { throw new Error(await readApiError(snapshotResponse, "Unable to load this request.")); } const snapshotData = await snapshotResponse.json(); if (!isSnapshotPayload(snapshotData)) throw new Error("Unable to load this request."); setSnapshot(snapshotData); } catch (error) { console.error(error); setLoadError(error instanceof Error ? error.message : "Unable to load this request."); } finally { setLoading(false); } }; void load(); }, [requestId, router]); useEffect(() => { if (!isAdmin || !requestId) { setShowDetails(false); setHistorySnapshots([]); setHistoryActions([]); return; } const controller = new AbortController(); const loadHistory = async () => { try { const baseUrl = getApiBase(); const [historyResponse, actionsResponse] = await Promise.all([ authFetch(`${baseUrl}/requests/${requestId}/history?limit=10`, { signal: controller.signal }), authFetch(`${baseUrl}/requests/${requestId}/actions?limit=10`, { signal: controller.signal }), ]); if (historyResponse.ok) { const data = await historyResponse.json(); if (!controller.signal.aborted && Array.isArray(data.snapshots)) setHistorySnapshots(data.snapshots); } if (actionsResponse.ok) { const data = await actionsResponse.json(); if (!controller.signal.aborted && Array.isArray(data.actions)) setHistoryActions(data.actions); } } catch (error) { if (!controller.signal.aborted) console.error(error); } }; void loadHistory(); return () => controller.abort(); }, [isAdmin, requestId]); useEffect(() => { if (!getToken() || !requestId) return; let stopped = false; let refreshing = false; const refresh = async () => { if (document.visibilityState === "hidden" || refreshing) return; refreshing = true; try { const response = await authFetch(`${getApiBase()}/requests/${requestId}/snapshot`); if (response.status === 401) { clearToken(); router.push("/login"); return; } if (!response.ok) return; const payload = await response.json(); if (!stopped && isSnapshotPayload(payload)) setSnapshot(payload); } catch (error) { if (!stopped) console.error(error); } finally { refreshing = false; } }; const timer = window.setInterval( () => void refresh(), awaitingMediaIndex || repairIsActive || searchIsActive ? 5_000 : 15_000, ); return () => { stopped = true; window.clearInterval(timer); }; }, [awaitingMediaIndex, repairIsActive, searchIsActive, requestId, router]); const liveDownloadKey = useMemo(() => { const downloadStage = snapshot?.presentation?.pipeline?.find((stage) => stage.id === "download"); if (!downloadStage || downloadStage.state === "complete") return ""; return "discover-and-track"; }, [snapshot]); useEffect(() => { if (!getToken() || !requestId || !liveDownloadKey) return; let stopped = false; let timer: number | undefined; let controller: AbortController | null = null; const schedule = () => { if (!stopped) timer = window.setTimeout(() => void refresh(), 5_000); }; const refresh = async () => { if (document.visibilityState === "hidden") { schedule(); return; } controller = new AbortController(); try { const response = await authFetch(`${getApiBase()}/requests/${requestId}/download-progress`, { signal: controller.signal, cache: "no-store", }); if (response.status === 401) { clearToken(); router.push("/login"); return; } if (response.ok) { const payload = (await response.json()) as LiveDownloadProgress; if (!stopped && Array.isArray(payload.torrents)) { setSnapshot((current) => (current ? mergeLiveDownload(current, payload) : current)); } } } catch (error) { if (!stopped && !(error instanceof DOMException && error.name === "AbortError")) console.error(error); } finally { controller = null; schedule(); } }; timer = window.setTimeout(() => void refresh(), 750); return () => { stopped = true; if (timer !== undefined) window.clearTimeout(timer); controller?.abort(); }; }, [liveDownloadKey, requestId, router]); const actionsById = useMemo( () => new Map((snapshot?.actions ?? []).map((action) => [action.id, action])), [snapshot?.actions], ); if (loading) { return (
); } if (loadError || !snapshot) { return (
); } 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 : null; const requestComplete = ["COMPLETED", "AVAILABLE"].includes(snapshot.state) || availableStage?.state === "complete"; const issueReportParams = new URLSearchParams({ reportRequest: snapshot.request_id, title: snapshot.title, type: snapshot.request_type === "tv" ? "tv" : "movie", }); if (snapshot.year) issueReportParams.set("year", String(snapshot.year)); const issueReportLink = `/portal/issues?${issueReportParams.toString()}`; const statusLabel = presentation.status?.label ?? fallbackStatusLabel(snapshot.state); const statusMeaning = presentation.status?.meaning ?? snapshot.state_reason ?? "Magent is checking this request."; const download = presentation.download; const repairActivity = presentation.repairActivity; const downloadVisible = Boolean(download?.visible); const nextStep = presentation.nextStep ?? { title: snapshot.actions[0]?.label ?? "No action needed right now", description: snapshot.actions.length ? "Choose an option below to continue." : "Magent will keep checking automatically.", actionIds: snapshot.actions.slice(0, 2).map((action) => action.id), }; const recommendedActions = (nextStep.actionIds ?? []) .map((actionId) => actionsById.get(actionId)) .filter((action): action is RequestAction => Boolean(action)); const posterUrl = snapshot.artwork?.poster_url; const resolvedPoster = posterUrl?.startsWith("http") ? posterUrl : posterUrl ? `${getApiBase()}${posterUrl}` : null; const backdropUrl = snapshot.artwork?.backdrop_url?.replace( "https://image.tmdb.org/t/p/w780/", "https://image.tmdb.org/t/p/w1280/", ); const resolvedBackdrop = backdropUrl?.startsWith("http") ? backdropUrl : backdropUrl ? `${getApiBase()}${backdropUrl}` : null; const trackedPost = async (label: string, url: string, init: RequestInit = {}) => { const operationId = typeof crypto?.randomUUID === "function" ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}`; const headers = new Headers(init.headers ?? {}); headers.set("X-Magent-Operation-ID", operationId); headers.set("X-Magent-Operation-Label", label); setOperationProgress({ id: operationId, label, status: "running", duration_ms: null, events: [ { id: "sending", service: "Magent", state: "active", message: "Sending the action to Magent…", }, ], }); let stopped = false; const refreshProgress = async () => { try { const progressResponse = await authFetch(`${getApiBase()}/operations/${operationId}`, { cache: "no-store", }); if (!stopped && progressResponse.ok) { const progress = await progressResponse.json(); if (Array.isArray(progress?.events)) { setOperationProgress(progress); return progress as OperationProgress; } } } catch (error) { if (!stopped) console.error(error); } }; const request = authFetch(url, { ...init, method: "POST", headers }); const timer = window.setInterval(() => void refreshProgress(), 650); try { const response = await request; await refreshProgress(); let result: Record | null = null; try { result = await response.clone().json(); } catch { /* Non-JSON error is handled below. */ } const needsAttention = !response.ok || result?.status === "attention" || result?.outcome === "attention"; const finalState = needsAttention ? "error" : result?.status === "searching" ? "searching" : "complete"; const interactiveSearch = /\/actions\/search(?:\?|$)/.test(url); const items = Array.isArray(result?.releases) ? result.releases : []; const available = items.some((item: ReleaseOption) => item.selectable && !item.requiresOverride); const outside = items.some((item: ReleaseOption) => item.selectable && item.requiresOverride); const canChoose = available || (outside && result?.canIgnoreProfileLimits === true); const summary = interactiveSearch ? !response.ok ? { title: "Search could not finish", message: "We could not complete the search.", next: "Try again shortly. If it keeps happening, contact an admin.", } : canChoose ? { title: available ? "Downloads found" : "Other versions are available", message: available ? "Choose the version you want to download." : "These versions are outside your usual download settings.", next: available ? "Your download starts after you choose a version." : "You can review them and confirm a download outside your profile.", action: "Choose a version", } : { title: items.length ? "No suitable downloads" : "Nothing available yet", message: items.length ? "The versions found cannot be downloaded with your current settings." : "No downloads were found in this search.", next: result?.nextOffset != null ? "You can check the next group of missing episodes." : "You can try again later.", action: result?.nextOffset != null ? "View search results" : undefined, } : response.ok && result?.status === "pending" ? { title: "Waiting for download confirmation", message: "The search was sent. The download queue may still be updating.", next: "Close this window and recheck the request shortly. You do not need to start another search yet.", } : response.ok && result?.status === "downloading" ? { title: "Download queued", message: "The download service has confirmed a download for this title.", next: "Close this window to follow its progress.", } : response.ok && /\/actions\/grab$/.test(url) ? { title: "Waiting to start", message: "Your download has been sent.", next: "Close this box to follow its progress. It may take a moment to start.", } : undefined; setOperationProgress((current) => current?.id === operationId ? { ...current, status: finalState, summary, events: [ ...current.events.map((event) => event.state === "active" ? { ...event, state: "complete" as const } : event, ), { id: "result", service: "Magent", state: needsAttention ? "error" : "complete", message: (typeof result?.message === "string" && result.message) || (typeof result?.detail === "string" ? result.detail : response.ok ? "Action completed. The pipeline will update as the media services report progress." : "The action failed. Recheck the request before trying again."), }, ], } : current, ); return response; } catch (error) { setOperationProgress((current) => current?.id === operationId ? { ...current, status: "error", events: [ ...current.events, { id: "connection-error", service: "Magent", state: "error", message: "The connection was interrupted. Recheck the request before trying the action again—it may already have started.", }, ], } : current, ); throw error; } finally { stopped = true; window.clearInterval(timer); } }; const recheckRequest = async () => { setBusyAction("recheck_pipeline"); setActionError(null); setActionMessage(null); setReleaseOptions([]); setReleasePickerOpen(false); setReleaseSearchMessage(null); try { const response = await trackedPost( "Recheck request status", `${getApiBase()}/requests/${snapshot.request_id}/actions/recheck`, ); if (response.status === 401) { clearToken(); router.push("/login"); return; } if (!response.ok) { throw new Error(await readApiError(response, "The request could not be rechecked.")); } const data = await response.json(); if (!isSnapshotPayload(data?.snapshot)) { throw new Error("The request was checked, but Magent did not return a valid pipeline."); } setSnapshot(data.snapshot); setActionMessage(data?.message ?? "Request status rebuilt from live service data."); } catch (error) { console.error(error); setActionError(error instanceof Error ? error.message : "The request could not be rechecked."); } finally { setBusyAction(null); } }; 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 = { search_releases: "actions/search", search_auto: "actions/search_auto", resume_torrent: "actions/qbit/resume", readd_to_arr: "actions/readd", }; const path = actionPaths[action.id]; if (!path) { setActionError("This action is not connected yet."); return; } if (action.id === "search_releases") { setReleaseOptions([]); setIgnoreProfileLimits(false); setNextSearchOffset(null); setReleaseCollector(snapshot.request_type === "tv" ? "Sonarr" : "Radarr"); setReleaseSearchMessage(null); setReleasePickerOpen(false); } setBusyAction(action.id); setActionError(null); setActionMessage(null); try { const response = await trackedPost( action.label, `${getApiBase()}/requests/${snapshot.request_id}/${path}${action.id === "search_releases" ? `?offset=${searchOffset}` : ""}`, ); if (response.status === 401) { clearToken(); router.push("/login"); return; } if (!response.ok) throw new Error(await readApiError(response, `${action.label} could not be completed.`)); const data = await response.json(); if (action.id === "search_releases") { const releases = Array.isArray(data.releases) ? data.releases : []; setReleaseOptions(releases); setCanIgnoreProfileLimits(data.canIgnoreProfileLimits === true); setNextSearchOffset(typeof data.nextOffset === "number" ? data.nextOffset : null); setReleasePickerOpen(true); setReleaseCollector(data?.collector ?? (snapshot.request_type === "tv" ? "Sonarr" : "Radarr")); setReleaseSearchMessage( data?.message ?? (releases.length ? `Found ${releases.length} approved release${releases.length === 1 ? "" : "s"}.` : "No releases currently meet the assigned quality profile."), ); setActionMessage(null); } else { setActionMessage(data?.message ?? `${action.label} was started successfully.`); } } catch (error) { console.error(error); setActionError(error instanceof Error ? error.message : `${action.label} could not be completed.`); } finally { setBusyAction(null); } }; const downloadRelease = async (release: ReleaseOption) => { if (!release.guid || !release.indexerId) { setActionError("This release is missing the details needed to start it."); return; } if (release.requiresOverride && (!canIgnoreProfileLimits || !ignoreProfileLimits)) return; if ( release.requiresOverride && !window.confirm( `Download this release outside the assigned profile?\n\n${release.title}\n${(release.rejections || []).join("\n")}\n\nThe assigned profile will stay unchanged.`, ) ) return; const collector = snapshot.request_type === "tv" ? "Sonarr" : "Radarr"; setBusyAction(`grab:${release.guid}`); setActionError(null); try { const response = await trackedPost( `Send release through ${collector}`, `${getApiBase()}/requests/${snapshot.request_id}/actions/grab`, { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...release, ignoreProfileLimits: release.requiresOverride === true && ignoreProfileLimits, }), }, ); if (response.status === 401) { clearToken(); router.push("/login"); return; } if (!response.ok) throw new Error(await readApiError(response, "The selected release could not be started.")); const data = await response.json(); setActionMessage(data?.message ?? "The selected release was queued for download."); setReleaseOptions([]); setReleasePickerOpen(false); setReleaseSearchMessage(null); } catch (error) { console.error(error); setActionError(error instanceof Error ? error.message : "The selected release could not be started."); } finally { setBusyAction(null); } }; return (
) } />
{ setBusyAction("language"); try { const response = await trackedPost( "Use original audio and search", `${getApiBase()}/requests/${snapshot.request_id}/actions/language`, { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ acceptOriginalLanguage: true, languageCode: code }), }, ); if (!response.ok) throw new Error(await readApiError(response, "The audio choice could not be saved.")); } finally { setBusyAction(null); } }} />
Status {statusLabel}
What this means

{statusMeaning}

{downloadVisible && (
Current download state {download?.summary ?? "A download attempt has been observed."} {download?.lastSeenAt && !download?.torrents?.length && ( Last observed {formatWhen(download.lastSeenAt)} )}
)} {operationProgress && ( setOperationProgress(null)} /> )}
{requestComplete ? (
Ready to watch Watch this now!

Open {snapshot.title} directly in Grizzlyflix.

{mediaServerLink ? ( Watch on Grizzlyflix ) : ( The Grizzlyflix watch link is not configured. )}
Need help? Is there a problem with this?

Let us know what is wrong and we'll attach the title and request details automatically.

{canReportIssues ? ( Start issue report ) : ( Issue reporting is not enabled for your account. )}
) : ( <>
Next step {nextStep.title}

{nextStep.description}

{recommendedActions.map((action) => ( ))}
)}
{(actionMessage || actionError) && (
{actionError ?? actionMessage}
)}
{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 && (
Repair activity

{repairActivity.headline ?? "Repair in progress"}

{repairActivity.message ?? "Magent is checking the repair with the connected services."}

{repairActivity.state === "complete" ? "Complete" : repairActivity.state === "attention" ? "Attention" : "Live"} {repairActivity.updatedAt && Updated {formatWhen(repairActivity.updatedAt)}}
{(repairActivity.steps ?? []).map((step) => (