'use client' import PageHeading from '../ui/PageHeading' 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 }> 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 } const formatDuration = (milliseconds?: number | null) => { if (milliseconds == null) return null if (milliseconds < 1000) return `${Math.round(milliseconds)} ms` return `${(milliseconds / 1000).toFixed(1)} s` } 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 [submitting, setSubmitting] = useState(false) const [operation, setOperation] = useState(null) const [error, setError] = useState(null) const [success, setSuccess] = useState(null) useEffect(() => { if (!getToken()) router.push('/login') }, [router]) const selectedTitleId = selected?.tmdbId useEffect(() => { if (selectedTitleId) configureSectionRef.current?.focus() }, [selectedTitleId]) const changeTitle = () => { setSelected(null) setOptions(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) 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) 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: any) => item?.type === mediaType && Number(item?.tmdbId) > 0) .map((item: any) => ({ 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: item?.posterPath ?? null, backdropPath: 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) 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) return if (selected.type === 'tv' && selectedSeasons.length === 0) { setError('Select at least one season.') return } 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, 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(requestId ? `Request #${requestId} has been accepted by Seerr.` : 'Your request has been accepted by Seerr.') } 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 (
    {['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) => ( ))}
)}
Delivery routeSeerr → {options.destination.collector} → GrizzlyflixYour request uses the default quality set by your administrator.
) : null} {operation && (
Remote activity{operation.status === 'running' ? 'Sending your request' : operation.status === 'complete' ? 'Request hand-off complete' : 'Request hand-off needs attention'}
{formatDuration(operation.duration_ms) && {formatDuration(operation.duration_ms)}}
{operation.events.map((event) => (