1587 lines
62 KiB
TypeScript
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">→</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'll attach the title and request details automatically.</p>
|
|
{canReportIssues ? (
|
|
<a className="request-problem-button" href={issueReportLink}>
|
|
Start issue report <span aria-hidden="true">→</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's profile. You'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>
|
|
);
|
|
}
|