"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(null); const resultsSectionRef = useRef(null); const configureSectionRef = useRef(null); const [mediaType, setMediaType] = useState(null); const [query, setQuery] = useState(""); const [searching, setSearching] = useState(false); const [searchAttempted, setSearchAttempted] = useState(false); const [results, setResults] = useState([]); const [selected, setSelected] = useState(null); const [options, setOptions] = useState(null); const [loadingOptions, setLoadingOptions] = useState(false); const [selectedSeasons, setSelectedSeasons] = useState([]); const [acceptOriginalLanguage, setAcceptOriginalLanguage] = useState(null); const [submitting, setSubmitting] = useState(false); const [progressOpen, setProgressOpen] = useState(false); const progressDialog = useRef(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(null); const [error, setError] = useState(null); const [success, setSuccess] = useState(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) => item.type === mediaType && Number(item.tmdbId) > 0) .map((item: Record) => ({ 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 (
setProgressOpen(false)} onClose={() => setProgressOpen(false)} >
Request progress
{submitting &&
{!submitting && ( )} {!submitting && ( )} {submitting && ( You can close this window. Submission will continue while you stay on this page. )}
    {["Type", "Search", "Select", "Config", "Submit"].map((label, index) => { const step = index + 1; return (
  1. {step < currentFlowStep ? "✓" : step} {label}
  2. ); })}
{error &&
{error}
} {success &&
{success}
} {!selected && (
01
Start here

What are you looking for?

{mediaChoices.map((choice) => ( ))}
)} {mediaType && !selected && (
02
{mediaType === "tv" ? "TV show selected" : "Movie selected"}

Search for the title

setQuery(event.target.value)} placeholder={mediaType === "tv" ? "Search TV shows" : "Search movies"} autoComplete="off" />
)} {mediaType && !selected && searchAttempted && !searching && (
03
Search results

{results.length ? "Select the right title" : "No matches found"}

{results.length === 0 ? (
Nothing matched “{query.trim()}”.

Check the spelling or try a shorter title.

) : (
{results.map((item) => { const poster = artworkUrl(item.posterPath); return ( ); })}
)}
)} {selected && (
04
Final step

{selected.requestId ? "This title is already in the pipeline" : selected.type === "tv" ? "Choose seasons and request" : "Review and request"}

{selectedPoster ? : No artwork}
{selected.type === "tv" ? "TV show" : "Movie"} {selected.year ? ` · ${selected.year}` : ""}

{selected.title}

{selected.overview || "Ready to configure."}

{selected.requestId ? (
Current status {selected.statusLabel || "Already requested"}

Request #{selected.requestId} is already being tracked by Magent.

) : loadingOptions ? (
Checking Seerr and {selected.type === "tv" ? "Sonarr" : "Radarr"}…

Preparing your request options.

) : options ? (
{selected.type === "tv" && (
Which seasons?
{options.media.seasons.map((season) => ( ))}
)} {options.media.originalLanguage && (

Choose your audio language

This title’s original language is{" "} {new Intl.DisplayNames(["en"], { type: "language" }).of(options.media.originalLanguage.code) || options.media.originalLanguage.code} . An English audio track may not be available. Title metadata does not confirm the audio or subtitles in a download.

{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."}
)}
Delivery route Seerr → {options.destination.collector} → Grizzlyflix Your request uses the default quality set by your administrator.
) : null} {operation && ( )} {success && selected.requestId && (
)}
)}
); }