Files
Magent/frontend/app/requests/[id]/page.tsx
T
Assclaw f852e7c941
Magent CI/CD / verify (push) Failing after 9m34s
Magent CI/CD / deploy-beta (push) Skipped
chore: standardize security and quality foundations
2026-09-17 20:03:47 +12:00

1587 lines
62 KiB
TypeScript

"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 } 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;
status: string;
details?: Record<string, unknown>;
};
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<string, number[]>;
actionIds?: string[];
visible?: boolean;
torrents?: Array<Record<string, unknown>>;
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<Record<string, unknown>>;
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<Record<string, unknown>>;
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<Snapshot>;
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<string, unknown>) => {
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<string, string> = {
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<Snapshot | null>(null);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [showDetails, setShowDetails] = useState(false);
const [actionMessage, setActionMessage] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [busyAction, setBusyAction] = useState<string | null>(null);
const [releaseOptions, setReleaseOptions] = useState<ReleaseOption[]>([]);
const [releasePickerOpen, setReleasePickerOpen] = useState(false);
const [canIgnoreProfileLimits, setCanIgnoreProfileLimits] = useState(false);
const [ignoreProfileLimits, setIgnoreProfileLimits] = useState(false);
const [nextSearchOffset, setNextSearchOffset] = useState<number | null>(null);
const [releaseCollector, setReleaseCollector] = useState<string | null>(null);
const [releaseSearchMessage, setReleaseSearchMessage] = useState<string | null>(null);
const [historySnapshots, setHistorySnapshots] = useState<SnapshotHistory[]>([]);
const [historyActions, setHistoryActions] = useState<ActionHistory[]>([]);
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"),
);
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();
const viewerIsAdmin = me?.role === "admin";
setIsAdmin(viewerIsAdmin);
setCanReportIssues(canAccess(me, "issues"));
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);
if (viewerIsAdmin) {
const [historyResponse, actionsResponse] = await Promise.all([
authFetch(`${baseUrl}/requests/${requestId}/history?limit=10`),
authFetch(`${baseUrl}/requests/${requestId}/actions?limit=10`),
]);
if (historyResponse.ok) {
const historyData = await historyResponse.json();
if (Array.isArray(historyData.snapshots)) setHistorySnapshots(historyData.snapshots);
}
if (actionsResponse.ok) {
const actionsData = await actionsResponse.json();
if (Array.isArray(actionsData.actions)) setHistoryActions(actionsData.actions);
}
} else {
setHistorySnapshots([]);
setHistoryActions([]);
}
} 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 (!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 (
<main className="card request-detail-page">
<div className="loading-center" role="status" aria-live="polite">
<div className="spinner" aria-hidden="true" />
<div className="loading-text">Building a clear request update</div>
</div>
</main>
);
}
if (loadError || !snapshot) {
return (
<main className="card request-detail-page">
<PageHeading
title="We could not load this request"
description={loadError ?? "The request API did not return a valid status."}
/>
<div className="request-error-actions">
<button type="button" onClick={() => window.location.reload()}>
Retry
</button>
<button type="button" className="ghost-button" onClick={() => router.push("/")}>
Back to requests
</button>
</div>
</main>
);
}
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<string, unknown> | 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<string, string> = {
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 (
<main className={`card request-detail-page request-cinematic ${resolvedBackdrop ? "has-backdrop" : ""}`}>
<div className="request-cinematic-art" aria-hidden="true">
{resolvedBackdrop && (
<Image
src={resolvedBackdrop}
alt=""
fill
sizes="100vw"
priority
unoptimized
onError={(event) => {
event.currentTarget.style.opacity = "0";
}}
/>
)}
</div>
<div className="request-cinematic-title">
<PageHeading
title={snapshot.title}
eyebrow={`Request #${snapshot.request_id}`}
description={[snapshot.request_type === "tv" ? "TV show" : "Movie", snapshot.year]
.filter(Boolean)
.join(" · ")}
leading={
resolvedPoster && (
<Image
className="request-poster"
src={resolvedPoster}
alt={`${snapshot.title} poster`}
width={60}
height={90}
sizes="60px"
unoptimized
/>
)
}
/>
</div>
<RequestLanguage
requestId={snapshot.request_id}
disabled={Boolean(busyAction)}
onApply={async (code) => {
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);
}
}}
/>
<section className="request-overview" aria-labelledby="request-status-heading">
<div className="request-overview-block request-overview-status">
<span className="request-overview-label" id="request-status-heading">
Status
</span>
<strong>{statusLabel}</strong>
</div>
<div className="request-overview-block">
<span className="request-overview-label">What this means</span>
<p>{statusMeaning}</p>
</div>
{downloadVisible && (
<div className="request-overview-block">
<span className="request-overview-label">Current download state</span>
<strong>{download?.summary ?? "A download attempt has been observed."}</strong>
{download?.lastSeenAt && !download?.torrents?.length && (
<small>Last observed {formatWhen(download.lastSeenAt)}</small>
)}
</div>
)}
{operationProgress && (
<LatestActivity
operation={operationProgress}
besideDownload={downloadVisible}
onDismiss={() => setOperationProgress(null)}
/>
)}
<div className={`request-overview-block request-next-step ${requestComplete ? "is-ready" : ""}`}>
{requestComplete ? (
<div className="request-ready-actions">
<section>
<span className="request-overview-label">Ready to watch</span>
<strong>Watch this now!</strong>
<p>Open {snapshot.title} directly in Grizzlyflix.</p>
{mediaServerLink ? (
<a className="request-watch-button" href={mediaServerLink} target="_blank" rel="noreferrer">
Watch on Grizzlyflix <span aria-hidden="true">&rarr;</span>
</a>
) : (
<span className="request-ready-unavailable">The Grizzlyflix watch link is not configured.</span>
)}
</section>
<section>
<span className="request-overview-label">Need help?</span>
<strong>Is there a problem with this?</strong>
<p>Let us know what is wrong and we&apos;ll attach the title and request details automatically.</p>
{canReportIssues ? (
<a className="request-problem-button" href={issueReportLink}>
Start issue report <span aria-hidden="true">&rarr;</span>
</a>
) : (
<span className="request-ready-unavailable">Issue reporting is not enabled for your account.</span>
)}
</section>
</div>
) : (
<>
<div className="request-next-step-main">
<div className="request-next-step-copy">
<span className="request-overview-label">Next step</span>
<strong>{nextStep.title}</strong>
<p>{nextStep.description}</p>
</div>
</div>
<div className="request-action-row">
{recommendedActions.map((action) => (
<button
key={action.id}
type="button"
disabled={Boolean(busyAction)}
onClick={() => void runAction(action)}
>
{busyAction === action.id ? "Working…" : action.label}
</button>
))}
<button
type="button"
className="request-recheck-button"
disabled={Boolean(busyAction)}
onClick={() => void recheckRequest()}
title="Recheck Seerr, the library collector, qBittorrent, and the media server"
>
{busyAction === "recheck_pipeline" ? "Rechecking…" : "Recheck request"}
</button>
</div>
</>
)}
</div>
{(actionMessage || actionError) && (
<div className={`request-action-feedback ${actionError ? "is-error" : "is-success"}`} role="status">
{actionError ?? actionMessage}
</div>
)}
</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">
<div>
<span className="section-kicker">Repair activity</span>
<h2>{repairActivity.headline ?? "Repair in progress"}</h2>
<p>{repairActivity.message ?? "Magent is checking the repair with the connected services."}</p>
</div>
<div className="request-repair-meta">
<span
className={`request-operation-status is-${repairActivity.state === "attention" ? "error" : repairActivity.state === "complete" ? "complete" : "running"}`}
>
{repairActivity.state === "complete"
? "Complete"
: repairActivity.state === "attention"
? "Attention"
: "Live"}
</span>
{repairActivity.updatedAt && <small>Updated {formatWhen(repairActivity.updatedAt)}</small>}
</div>
</div>
<div className="request-repair-steps">
{(repairActivity.steps ?? []).map((step) => (
<div className={`request-repair-step is-${step.state}`} key={step.id}>
<i aria-hidden="true" />
<div>
<strong>{step.label}</strong>
<span>{step.detail}</span>
</div>
</div>
))}
</div>
</section>
)}
{!requestComplete && (
<section className="request-journey" aria-labelledby="request-journey-heading">
<div className="request-journey-heading">
<div>
<span className="section-kicker">Live collection path</span>
<h2 id="request-journey-heading">Where your request is now</h2>
</div>
<span className="request-live-indicator">
<i />
Live status
</span>
</div>
<div className="request-stage-grid">
{pipeline.map((stage, index) => {
const stageActions = (stage.actionIds ?? [])
.map((actionId) => actionsById.get(actionId))
.filter((action): action is RequestAction => Boolean(action));
const content = (
<>
<div className="request-stage-topline">
<span className="request-stage-number">{String(index + 1).padStart(2, "0")}</span>
<span className={`request-stage-state state-${stage.state}`}>
{stage.stateLabel ?? stage.state}
</span>
</div>
<h3>{stage.label}</h3>
<p
aria-live={stage.id === "library" ? "polite" : undefined}
aria-atomic={stage.id === "library" ? true : undefined}
>
{stage.summary}
</p>
{stage.id === "library" && Boolean(stage.total) && (
<div className="request-availability-meter">
<div className="request-meter-copy">
<span>{stage.available ?? 0} collected</span>
<span>{stage.missing ?? 0} missing</span>
</div>
<div
className="request-meter-track"
role="progressbar"
aria-label="Collection progress"
aria-valuemin={0}
aria-valuemax={stage.total ?? 0}
aria-valuenow={stage.available ?? 0}
>
<span
style={{
width: `${Math.round(((stage.available ?? 0) / Math.max(stage.total ?? 1, 1)) * 100)}%`,
}}
/>
</div>
{stage.seasons?.map((season) => (
<div className="request-season-row" key={season.seasonNumber}>
<span>Season {season.seasonNumber}</span>
<span>
{season.available} collected · {season.missing} missing
</span>
</div>
))}
</div>
)}
{stage.id === "library" && stage.missingEpisodes && Object.keys(stage.missingEpisodes).length > 0 && (
<div className="request-missing-list">
{Object.entries(stage.missingEpisodes).map(([season, episodes]) => (
<div key={season}>
<span>Missing from season {season}</span>
<strong>{episodes.map((episode) => `E${episode}`).join(", ")}</strong>
</div>
))}
</div>
)}
{stage.id === "download" &&
stage.visible &&
stage.torrents?.map((torrent) => {
const progress = torrentProgress(torrent);
const torrentName = typeof torrent.name === "string" ? torrent.name : "Download";
const torrentHash = typeof torrent.hash === "string" ? torrent.hash : torrentName;
const episodeLabel = typeof torrent.episodeLabel === "string" ? torrent.episodeLabel : null;
return (
<div className="request-torrent" key={torrentHash}>
{episodeLabel && <small>{episodeLabel}</small>}
<div>
<strong>{torrentName}</strong>
<span>{progress === null ? "Progress unavailable" : formatProgress(progress)}</span>
</div>
{progress !== null && (
<div
className="request-meter-track is-live-download"
role="progressbar"
aria-label={`${torrentName} progress`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={progress}
>
<span style={{ width: `${progress}%` }} />
</div>
)}
</div>
);
})}
{stageActions.length > 0 && (
<div className="request-stage-actions">
{stageActions.map((action) => (
<button
key={action.id}
type="button"
disabled={Boolean(busyAction)}
onClick={() => void runAction(action)}
>
{busyAction === action.id ? "Working…" : action.label}
</button>
))}
</div>
)}
</>
);
return stage.id === "available" && stage.link ? (
<a
className={`request-stage stage-${stage.state} is-link`}
href={stage.link}
target="_blank"
rel="noreferrer"
key={stage.id}
>
{content}
<span className="request-stage-link">Open on media server </span>
</a>
) : (
<article className={`request-stage stage-${stage.state}`} key={stage.id}>
{content}
</article>
);
})}
</div>
</section>
)}
{releasePickerOpen && (
<div className="request-release-modal-layer">
<button
type="button"
className="request-release-modal-backdrop"
aria-label="Close available releases"
onClick={closeReleasePicker}
/>
<section
className="request-release-modal"
role="dialog"
aria-modal="true"
aria-labelledby="release-picker-title"
>
<header className="request-release-modal-header">
<div>
<span className="section-kicker">
Results from {releaseCollector ?? (snapshot.request_type === "tv" ? "Sonarr" : "Radarr")}
</span>
<h2 id="release-picker-title">Choose an available download</h2>
<p>Review available releases and any rejection reasons. The assigned profile stays unchanged.</p>
</div>
<button
type="button"
className="ghost-button"
onClick={closeReleasePicker}
disabled={busyAction?.startsWith("grab:")}
>
Close
</button>
</header>
<div className="request-release-modal-body">
{busyAction === "search_releases" && (
<div className="request-release-searching" role="status">
<i aria-hidden="true" />
<div>
<strong>Checking available releases</strong>
<span>
{releaseCollector ?? "The collector"} is applying its quality profile and ranking the results.
</span>
</div>
</div>
)}
{busyAction !== "search_releases" && releaseSearchMessage && (
<div className="request-release-profile-note">
<strong>Search results</strong>
<span>{releaseSearchMessage}</span>
</div>
)}
{busyAction !== "search_releases" && actionError && (
<div className="request-release-empty is-error" role="alert">
<strong>The release search could not be completed</strong>
<span>{actionError}</span>
</div>
)}
{busyAction !== "search_releases" && !actionError && releaseOptions.length === 0 && (
<div className="request-release-empty">
<strong>No suitable downloads are available right now</strong>
<span>No releases were returned for this search. Check the indexers or try again later.</span>
</div>
)}
{canIgnoreProfileLimits && (
<label className="request-release-profile-toggle">
<span>
<strong>Ignore profile limits</strong>
<small>Allow releases outside this title&apos;s profile. You&apos;ll confirm each download.</small>
</span>
<input
type="checkbox"
checked={ignoreProfileLimits}
disabled={Boolean(busyAction)}
onChange={(event) => setIgnoreProfileLimits(event.target.checked)}
/>
</label>
)}
{nextSearchOffset !== null && (
<button
type="button"
className="ghost-button"
disabled={Boolean(busyAction)}
onClick={() =>
void runAction(
{
id: "search_releases",
label: "Search next missing episodes",
risk: "low",
requires_confirmation: false,
},
nextSearchOffset,
)
}
>
Search next missing episodes
</button>
)}
{releaseOptions.length > 0 && (
<div className="request-release-list">
{releaseOptions.map((release) => {
const isBestPick = release.bestPick === true;
return (
<article
className={`request-release ${isBestPick ? "is-best-pick" : ""}`}
key={`${release.indexerId ?? ""}:${release.guid ?? release.title}`}
>
<div className="request-release-copy">
<div className="request-release-badges">
{isBestPick && <span className="request-release-best-badge">Best pick</span>}
{release.requiresOverride && <span>Outside profile</span>}
{release.selectable === false && <span>Unavailable for selection</span>}
{release.quality && <span>{release.quality}</span>}
{release.fullSeason && <span>Season {release.seasonNumber ?? ""} pack</span>}
</div>
<strong>{release.title ?? "Unknown release"}</strong>
<span>
{release.indexer ?? "Unknown indexer"} · {release.seeders ?? 0} seeders ·{" "}
{formatBytes(release.size)}
{typeof release.customFormatScore === "number"
? ` · Score ${release.customFormatScore}`
: ""}
</span>
{!!release.rejections?.length && (
<small className="release-rejection-reasons">{release.rejections.join("; ")}</small>
)}
{isBestPick && (
<small>
This is the highest-ranked release approved by {releaseCollector ?? "the collector"}.
</small>
)}
</div>
<button
type="button"
disabled={
Boolean(busyAction) ||
!release.selectionToken ||
release.selectable === false ||
(release.requiresOverride && (!canIgnoreProfileLimits || !ignoreProfileLimits))
}
onClick={() => void downloadRelease(release)}
>
{busyAction === `grab:${release.guid}`
? "Sending…"
: release.requiresOverride
? "Download outside profile"
: isBestPick
? "Download best pick"
: "Download this release"}
</button>
</article>
);
})}
</div>
)}
</div>
</section>
</div>
)}
{isAdmin && (
<section className="request-advanced">
<button
type="button"
className="request-advanced-toggle"
aria-expanded={showDetails}
onClick={() => setShowDetails((current) => !current)}
>
<span>
<strong>Advanced details</strong>
<small>Service diagnostics, status history and recorded actions</small>
</span>
<span>{showDetails ? "Hide" : "Show"}</span>
</button>
{showDetails && (
<div className="request-advanced-content">
<div className="request-diagnostics-grid">
{snapshot.timeline.map((hop, index) => (
<article className="request-diagnostic" key={`${hop.service}-${index}`}>
<div>
<strong>{hop.service}</strong>
<span>{hop.status}</span>
</div>
{hop.details && <pre>{JSON.stringify(hop.details, null, 2)}</pre>}
</article>
))}
</div>
<div className="history-grid">
<div className="summary-card">
<h3>Status changes</h3>
<ul>
{historySnapshots.length === 0 ? (
<li>No distinct status changes recorded yet.</li>
) : (
historySnapshots.map((entry) => (
<li key={`${entry.created_at}-${entry.state}`}>
<span>{fallbackStatusLabel(entry.state)}</span>
<small>
{entry.state_reason ?? "No additional detail."} · {formatWhen(entry.created_at)}
</small>
</li>
))
)}
</ul>
</div>
<div className="summary-card">
<h3>Recorded actions</h3>
<ul>
{historyActions.length === 0 ? (
<li>No actions have been run for this request.</li>
) : (
historyActions.map((entry) => (
<li key={`${entry.created_at}-${entry.action_id}`}>
<span>{entry.label}</span>
<small>
{entry.message ?? entry.status} · {formatWhen(entry.created_at)}
</small>
</li>
))
)}
</ul>
</div>
</div>
</div>
)}
</section>
)}
</main>
);
}