feat(release): publish minimal self-contained Magent source

This commit is contained in:
Magent release tooling
2026-09-19 16:58:12 +12:00
commit 5fa5d45535
272 changed files with 79305 additions and 0 deletions
@@ -0,0 +1,740 @@
"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} Jellyfin</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>
);
}