Files
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

741 lines
29 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import PageHeading from "../ui/PageHeading";
import { lockBodyScroll } from "../lib/scrollLock";
import "./request-progress.css";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { authFetch, clearToken, getApiBase, getToken } from "../lib/auth";
type MediaType = "movie" | "tv";
type DiscoveryResult = {
title: string;
year?: number | null;
type: MediaType;
tmdbId: number;
requestId?: number | null;
statusLabel?: string | null;
overview?: string | null;
posterPath?: string | null;
backdropPath?: string | null;
};
type RequestOptions = {
media: DiscoveryResult & {
seasons: Array<{
seasonNumber: number;
name: string;
episodeCount: number;
airDate?: string | null;
}>;
originalLanguage?: { code: string } | null;
existingRequestId?: number | null;
};
destination: {
collector: "Sonarr" | "Radarr";
serverName: string;
defaultProfileId: number;
profiles: Array<{ id: number; name: string }>;
};
};
type OperationEvent = {
id: string;
service: string;
state: "active" | "complete" | "error";
message: string;
duration_ms?: number | null;
status_code?: number | null;
};
type OperationProgress = {
status: "running" | "complete" | "error";
duration_ms?: number | null;
events: OperationEvent[];
};
const mediaChoices: Array<{
type: MediaType;
eyebrow: string;
title: string;
description: string;
collector: "Radarr" | "Sonarr";
icon: string;
}> = [
{
type: "movie",
eyebrow: "Film",
title: "Movie",
description: "Find a film and send it through Seerr to Radarr.",
collector: "Radarr",
icon: "/service-icons/radarr.svg",
},
{
type: "tv",
eyebrow: "Series",
title: "TV show",
description: "Choose a series, the seasons you want, and send it to Sonarr.",
collector: "Sonarr",
icon: "/service-icons/sonarr.svg",
},
];
const artworkUrl = (path?: string | null, size: "w185" | "w342" = "w342") => {
if (!path) return null;
return `https://image.tmdb.org/t/p/${size}${path.startsWith("/") ? path : `/${path}`}`;
};
const apiError = async (response: Response, fallback: string) => {
try {
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;
} catch {
// The upstream response was not JSON. Use the friendly fallback below.
}
return fallback;
};
export default function NewRequestClient() {
const router = useRouter();
const searchSectionRef = useRef<HTMLElement | null>(null);
const resultsSectionRef = useRef<HTMLElement | null>(null);
const configureSectionRef = useRef<HTMLElement | null>(null);
const [mediaType, setMediaType] = useState<MediaType | null>(null);
const [query, setQuery] = useState("");
const [searching, setSearching] = useState(false);
const [searchAttempted, setSearchAttempted] = useState(false);
const [results, setResults] = useState<DiscoveryResult[]>([]);
const [selected, setSelected] = useState<DiscoveryResult | null>(null);
const [options, setOptions] = useState<RequestOptions | null>(null);
const [loadingOptions, setLoadingOptions] = useState(false);
const [selectedSeasons, setSelectedSeasons] = useState<number[]>([]);
const [acceptOriginalLanguage, setAcceptOriginalLanguage] = useState<boolean | null>(null);
const [submitting, setSubmitting] = useState(false);
const [progressOpen, setProgressOpen] = useState(false);
const progressDialog = useRef<HTMLDialogElement>(null);
useEffect(() => {
if (!progressOpen) return;
const previous = document.activeElement as HTMLElement | null;
progressDialog.current?.showModal();
const unlock = lockBodyScroll();
return () => {
progressDialog.current?.close();
unlock();
previous?.focus();
};
}, [progressOpen]);
const [operation, setOperation] = useState<OperationProgress | null>(null);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
useEffect(() => {
if (!getToken()) router.push("/login");
}, [router]);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const requestedType = params.get("type");
const requestedQuery = params.get("query")?.trim();
if ((requestedType === "movie" || requestedType === "tv") && requestedQuery) {
setMediaType(requestedType);
setQuery(requestedQuery);
window.setTimeout(() => searchSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }), 80);
}
}, []);
const selectedTitleId = selected?.tmdbId;
useEffect(() => {
if (selectedTitleId) configureSectionRef.current?.focus();
}, [selectedTitleId]);
const changeTitle = () => {
setSelected(null);
setOptions(null);
setAcceptOriginalLanguage(null);
setSelectedSeasons([]);
setOperation(null);
setError(null);
setSuccess(null);
window.requestAnimationFrame(() => document.getElementById("request-title-search")?.focus());
};
const resetAfterType = (nextType: MediaType) => {
setMediaType(nextType);
setQuery("");
setResults([]);
setSearchAttempted(false);
setSelected(null);
setOptions(null);
setAcceptOriginalLanguage(null);
setSelectedSeasons([]);
setOperation(null);
setError(null);
setSuccess(null);
window.setTimeout(() => searchSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }), 80);
};
const runSearch = async (event: React.FormEvent) => {
event.preventDefault();
if (!mediaType) return;
const term = query.trim();
if (!term) {
setError("Enter a title to search for.");
return;
}
setSearching(true);
setSearchAttempted(true);
setSelected(null);
setOptions(null);
setAcceptOriginalLanguage(null);
setOperation(null);
setError(null);
setSuccess(null);
try {
const baseUrl = getApiBase();
const params = new URLSearchParams({ query: term, media_type: mediaType });
const response = await authFetch(`${baseUrl}/requests/search?${params.toString()}`);
if (response.status === 401) {
clearToken();
router.push("/login");
return;
}
if (!response.ok) throw new Error(await apiError(response, `Search failed (${response.status}).`));
const payload = await response.json();
const mapped: DiscoveryResult[] = Array.isArray(payload?.results)
? payload.results
.filter((item: Record<string, unknown>) => item.type === mediaType && Number(item.tmdbId) > 0)
.map((item: Record<string, unknown>) => ({
title: String(item?.title || "Untitled"),
year: typeof item?.year === "number" ? item.year : null,
type: mediaType,
tmdbId: Number(item.tmdbId),
requestId: typeof item?.requestId === "number" ? item.requestId : null,
statusLabel: typeof item?.statusLabel === "string" ? item.statusLabel : null,
overview: typeof item?.overview === "string" ? item.overview : null,
posterPath: typeof item.posterPath === "string" ? item.posterPath : null,
backdropPath: typeof item.backdropPath === "string" ? item.backdropPath : null,
}))
: [];
setResults(mapped);
window.setTimeout(() => resultsSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }), 80);
} catch (caught) {
setResults([]);
setError(caught instanceof Error ? caught.message : "Search is unavailable right now.");
} finally {
setSearching(false);
}
};
const selectResult = async (item: DiscoveryResult) => {
setSelected(item);
setOptions(null);
setAcceptOriginalLanguage(null);
setSelectedSeasons([]);
setOperation(null);
setError(null);
setSuccess(null);
if (item.requestId) {
return;
}
setLoadingOptions(true);
try {
const baseUrl = getApiBase();
const params = new URLSearchParams({ media_type: item.type, tmdb_id: String(item.tmdbId) });
const response = await authFetch(`${baseUrl}/requests/request-options?${params.toString()}`);
if (response.status === 401) {
clearToken();
router.push("/login");
return;
}
if (!response.ok)
throw new Error(await apiError(response, `Could not load request options (${response.status}).`));
const payload = (await response.json()) as RequestOptions;
const refreshedSelection: DiscoveryResult = {
...item,
title: payload.media.title || item.title,
year: payload.media.year ?? item.year,
overview: payload.media.overview || item.overview,
posterPath: payload.media.posterPath || item.posterPath,
backdropPath: payload.media.backdropPath || item.backdropPath,
requestId: payload.media.existingRequestId || item.requestId,
statusLabel: payload.media.existingRequestId ? "Already requested" : item.statusLabel,
};
setSelected(refreshedSelection);
if (payload.media.existingRequestId) {
setResults((current) =>
current.map((result) =>
result.tmdbId === item.tmdbId && result.type === item.type ? refreshedSelection : result,
),
);
return;
}
setOptions(payload);
setSelectedSeasons(payload.media.seasons.map((season) => season.seasonNumber));
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Could not load request options.");
} finally {
setLoadingOptions(false);
}
};
const pollOperation = async (operationId: string) => {
try {
const response = await authFetch(`${getApiBase()}/operations/${operationId}`);
if (response.ok) setOperation((await response.json()) as OperationProgress);
} catch {
// The request response remains authoritative if a progress poll is interrupted.
}
};
const submitRequest = async () => {
if (!selected || !options || submitting) return;
if (options.media.originalLanguage && acceptOriginalLanguage === null) {
setError("Choose an audio language option before requesting.");
return;
}
if (selected.type === "tv" && selectedSeasons.length === 0) {
setError("Select at least one season.");
return;
}
setProgressOpen(true);
setSubmitting(true);
setError(null);
setSuccess(null);
const operationId = globalThis.crypto?.randomUUID?.() ?? `request-${Date.now()}`;
setOperation({ status: "running", events: [] });
const interval = window.setInterval(() => void pollOperation(operationId), 500);
try {
const response = await authFetch(`${getApiBase()}/requests/create`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Magent-Operation-ID": operationId,
"X-Magent-Operation-Label": `Requesting ${selected.title}`,
},
body: JSON.stringify({
mediaType: selected.type,
tmdbId: selected.tmdbId,
acceptOriginalLanguage: acceptOriginalLanguage === true,
seasons: selected.type === "tv" ? selectedSeasons : undefined,
}),
});
await pollOperation(operationId);
if (response.status === 401) {
clearToken();
router.push("/login");
return;
}
if (!response.ok) throw new Error(await apiError(response, `Request failed (${response.status}).`));
const payload = await response.json();
const requestId = typeof payload?.requestId === "number" ? payload.requestId : null;
setSelected((current) =>
current ? { ...current, requestId, statusLabel: payload?.statusLabel ?? current.statusLabel } : current,
);
setResults((current) =>
current.map((item) =>
item.tmdbId === selected.tmdbId && item.type === selected.type
? { ...item, requestId, statusLabel: payload?.statusLabel ?? item.statusLabel }
: item,
),
);
setSuccess("Your request has been received.");
} catch (caught) {
setError(caught instanceof Error ? caught.message : "The request could not be submitted.");
} finally {
window.clearInterval(interval);
await pollOperation(operationId);
setSubmitting(false);
}
};
const setEverySeason = (checked: boolean) => {
setSelectedSeasons(checked && options ? options.media.seasons.map((season) => season.seasonNumber) : []);
};
const selectedPoster = artworkUrl(selected?.posterPath, "w185");
const currentFlowStep = success ? 5 : selected ? 4 : searchAttempted ? 3 : mediaType ? 2 : 1;
return (
<main className="card request-portal-page">
<dialog
ref={progressDialog}
className="create-request-dialog"
aria-labelledby="create-progress-title"
onCancel={() => setProgressOpen(false)}
onClose={() => setProgressOpen(false)}
>
<div className="create-progress-header">
<span>Request progress</span>
<button
type="button"
className="ghost-button"
onClick={() => setProgressOpen(false)}
aria-label="Close request progress"
>
Close
</button>
</div>
<div className="create-progress-body" aria-live="polite" aria-atomic="true">
{submitting && <span className="create-progress-spinner" aria-hidden="true" />}
<h2 id="create-progress-title">
{submitting ? "Sending your request" : success ? "Request received" : "Your request needs attention"}
</h2>
<p className="create-progress-title">{selected?.title}</p>
<p>
{submitting
? operation?.events.some((event) => event.service === "Sonarr" || event.service === "Radarr")
? "Setting up your title for collection. Please wait."
: "Checking your selection and sending it to the request service. Please wait."
: success
? "Your request is now in the pipeline. Follow it to see approval, download progress and when it is ready to watch."
: error || "We could not confirm the result. Check My requests before trying again."}
</p>
{submitting && (
<div className="create-progress-track" role="progressbar" aria-label="Submitting request">
<span />
</div>
)}
{success && (
<div className="create-progress-stage">
<span>Current stage</span>
<strong>{selected?.statusLabel || "Request received"}</strong>
</div>
)}
</div>
<div className="create-progress-actions">
{!submitting && (
<button
type="button"
className="create-progress-follow"
onClick={() => router.push(selected?.requestId ? `/requests/${selected.requestId}` : "/")}
>
{success ? "Follow your request" : "Check My requests"} <span aria-hidden="true">&rarr;</span>
</button>
)}
{!submitting && (
<button type="button" className="ghost-button" onClick={() => setProgressOpen(false)}>
{success ? "Back to browsing" : "Back to request"}
</button>
)}
{submitting && (
<small>You can close this window. Submission will continue while you stay on this page.</small>
)}
</div>
</dialog>
<PageHeading title="New request" description="Find a movie or TV show and choose what you want to watch." />
<ol className="request-master-stepper" aria-label="New request progress">
{["Type", "Search", "Select", "Config", "Submit"].map((label, index) => {
const step = index + 1;
return (
<li
key={label}
className={step === currentFlowStep ? "is-active" : step < currentFlowStep ? "is-complete" : ""}
>
<span>{step < currentFlowStep ? "✓" : step}</span>
<strong>{label}</strong>
</li>
);
})}
</ol>
{error && <div className="error-banner request-flow-alert">{error}</div>}
{success && <div className="status-banner request-flow-alert">{success}</div>}
{!selected && (
<section className="request-flow-stage is-current">
<div className="request-flow-heading">
<span className="request-flow-number">01</span>
<div>
<span>Start here</span>
<h2>What are you looking for?</h2>
</div>
</div>
<div className="request-type-grid">
{mediaChoices.map((choice) => (
<button
key={choice.type}
type="button"
className={`request-type-card ${mediaType === choice.type ? "is-selected" : ""}`}
onClick={() => resetAfterType(choice.type)}
aria-pressed={mediaType === choice.type}
>
<span className="request-type-card-body">
<span className="request-service-icon">
<img src={choice.icon} alt={`${choice.collector} logo`} />
</span>
<span className="request-type-card-copy">
<span>{choice.eyebrow}</span>
<strong>{choice.title}</strong>
<span className="request-type-description">{choice.description}</span>
<b>{mediaType === choice.type ? "Selected" : `Choose ${choice.title.toLowerCase()}`}</b>
</span>
</span>
</button>
))}
</div>
</section>
)}
{mediaType && !selected && (
<section ref={searchSectionRef} className="request-flow-stage is-current">
<div className="request-flow-heading">
<span className="request-flow-number">02</span>
<div>
<span>{mediaType === "tv" ? "TV show selected" : "Movie selected"}</span>
<h2>Search for the title</h2>
</div>
</div>
<form className="request-flow-search" onSubmit={runSearch}>
<label htmlFor="request-title-search">Title</label>
<div>
<input
id="request-title-search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={mediaType === "tv" ? "Search TV shows" : "Search movies"}
autoComplete="off"
/>
<button type="submit" disabled={searching}>
{searching ? "Searching…" : "Search Seerr"}
</button>
</div>
</form>
</section>
)}
{mediaType && !selected && searchAttempted && !searching && (
<section ref={resultsSectionRef} className="request-flow-stage is-current">
<div className="request-flow-heading">
<span className="request-flow-number">03</span>
<div>
<span>Search results</span>
<h2>{results.length ? "Select the right title" : "No matches found"}</h2>
</div>
</div>
{results.length === 0 ? (
<div className="request-flow-empty">
<strong>Nothing matched {query.trim()}.</strong>
<p>Check the spelling or try a shorter title.</p>
</div>
) : (
<div className="request-result-grid">
{results.map((item) => {
const poster = artworkUrl(item.posterPath);
return (
<button
key={`${item.type}:${item.tmdbId}`}
type="button"
className="request-result-card"
onClick={() => void selectResult(item)}
>
<span className="request-result-poster">
{poster ? <img src={poster} alt="" loading="lazy" /> : <i>No artwork</i>}
</span>
<span className="request-result-copy">
<small>
{item.type === "tv" ? "TV show" : "Movie"}
{item.year ? ` · ${item.year}` : ""}
</small>
<strong>{item.title}</strong>
<p>{item.overview || "Select this title to view the available request options."}</p>
<b>{item.requestId ? item.statusLabel || "Already requested" : "Select title"}</b>
</span>
</button>
);
})}
</div>
)}
</section>
)}
{selected && (
<section
ref={configureSectionRef}
tabIndex={-1}
aria-labelledby="request-configure-title"
className="request-flow-stage is-current request-configure-stage"
>
<div className="request-flow-heading">
<span className="request-flow-number">04</span>
<div>
<span>Final step</span>
<h2 id="request-configure-title">
{selected.requestId
? "This title is already in the pipeline"
: selected.type === "tv"
? "Choose seasons and request"
: "Review and request"}
</h2>
</div>
</div>
<button type="button" className="ghost-button" onClick={changeTitle} disabled={loadingOptions || submitting}>
Change title
</button>
<div className="request-selection-summary">
<span className="request-selection-poster">
{selectedPoster ? <img src={selectedPoster} alt="" /> : <i>No artwork</i>}
</span>
<div>
<small>
{selected.type === "tv" ? "TV show" : "Movie"}
{selected.year ? ` · ${selected.year}` : ""}
</small>
<h3>{selected.title}</h3>
<p>{selected.overview || "Ready to configure."}</p>
</div>
</div>
{selected.requestId ? (
<div className="request-existing-state">
<div>
<span>Current status</span>
<strong>{selected.statusLabel || "Already requested"}</strong>
<p>Request #{selected.requestId} is already being tracked by Magent.</p>
</div>
<button type="button" onClick={() => router.push(`/requests/${selected.requestId}`)}>
Open request
</button>
</div>
) : loadingOptions ? (
<div className="request-flow-empty">
<strong>Checking Seerr and {selected.type === "tv" ? "Sonarr" : "Radarr"}</strong>
<p>Preparing your request options.</p>
</div>
) : options ? (
<div className="request-options-layout">
{selected.type === "tv" && (
<fieldset className="request-season-picker">
<legend>Which seasons?</legend>
<div className="request-season-actions">
<button type="button" onClick={() => setEverySeason(true)}>
Select all
</button>
<button type="button" onClick={() => setEverySeason(false)}>
Clear
</button>
</div>
<div className="request-season-grid">
{options.media.seasons.map((season) => (
<label
key={season.seasonNumber}
className={selectedSeasons.includes(season.seasonNumber) ? "is-selected" : ""}
>
<input
type="checkbox"
checked={selectedSeasons.includes(season.seasonNumber)}
onChange={(event) =>
setSelectedSeasons((current) =>
event.target.checked
? [...current, season.seasonNumber].sort((a, b) => a - b)
: current.filter((value) => value !== season.seasonNumber),
)
}
/>
<span>
<strong>{season.name}</strong>
<small>
{season.episodeCount} episode{season.episodeCount === 1 ? "" : "s"}
</small>
</span>
</label>
))}
</div>
</fieldset>
)}
{options.media.originalLanguage && (
<div className="request-language-notice">
<h3>Choose your audio language</h3>
<p>
This titles original language is{" "}
<strong>
{new Intl.DisplayNames(["en"], { type: "language" }).of(options.media.originalLanguage.code) ||
options.media.originalLanguage.code}
</strong>
. An English audio track may not be available. Title metadata does not confirm the audio or
subtitles in a download.
</p>
<label>
<input
type="radio"
name="request-audio"
checked={acceptOriginalLanguage === true}
onChange={() => setAcceptOriginalLanguage(true)}
disabled={submitting}
/>
<span>
Original{" "}
{new Intl.DisplayNames(["en"], { type: "language" }).of(options.media.originalLanguage.code)}{" "}
audio Im happy to watch in the original language.
</span>
</label>
<label>
<input
type="radio"
name="request-audio"
checked={acceptOriginalLanguage === false}
onChange={() => setAcceptOriginalLanguage(false)}
disabled={submitting}
/>
<span>Keep standard audio requirements. This title may remain waiting for an English release.</span>
</label>
<small>
{acceptOriginalLanguage
? selected.type === "movie"
? "Search for original-language audio using the same quality requirements."
: "Continue with your selected seasons and the configured TV quality requirements."
: "Choose an option to continue. An English-only profile may leave this title waiting for a suitable release."}
</small>
</div>
)}
<div className="request-submit-bar">
<div>
<span>Delivery route</span>
<strong>Seerr {options.destination.collector} Grizzlyflix</strong>
<small>Your request uses the default quality set by your administrator.</small>
</div>
<button
type="button"
onClick={() => void submitRequest()}
disabled={
submitting ||
(Boolean(options.media.originalLanguage) && acceptOriginalLanguage === null) ||
(selected.type === "tv" && selectedSeasons.length === 0)
}
>
{submitting ? "Sending request…" : `Request ${selected.type === "tv" ? "show" : "movie"}`}
</button>
</div>
</div>
) : null}
{operation && (
<button type="button" className="ghost-button" onClick={() => setProgressOpen(true)}>
View request progress
</button>
)}
{success && selected.requestId && (
<div className="request-complete-actions">
<button type="button" onClick={() => router.push(`/requests/${selected.requestId}`)}>
Follow your request
</button>
<button type="button" className="ghost-button" onClick={() => resetAfterType(selected.type)}>
Request something else
</button>
</div>
)}
</section>
)}
</main>
);
}