741 lines
29 KiB
TypeScript
741 lines
29 KiB
TypeScript
"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">→</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 title’s 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 — I’m 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>
|
||
);
|
||
}
|