534 lines
23 KiB
TypeScript
534 lines
23 KiB
TypeScript
'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
|
||
}>
|
||
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
|
||
}
|
||
|
||
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<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(false)
|
||
const [submitting, setSubmitting] = useState(false)
|
||
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])
|
||
|
||
const selectedTitleId = selected?.tmdbId
|
||
useEffect(() => {
|
||
if (selectedTitleId) configureSectionRef.current?.focus()
|
||
}, [selectedTitleId])
|
||
|
||
const changeTitle = () => {
|
||
setSelected(null)
|
||
setOptions(null)
|
||
setAcceptOriginalLanguage(false)
|
||
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(false)
|
||
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(false)
|
||
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)
|
||
setAcceptOriginalLanguage(false)
|
||
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,
|
||
acceptOriginalLanguage,
|
||
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 (
|
||
<main className="card request-portal-page">
|
||
<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>Check the 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="checkbox" checked={acceptOriginalLanguage} onChange={(event) => setAcceptOriginalLanguage(event.target.checked)} disabled={submitting} /><span>I’m happy to watch in the original language.</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.') : 'Leave this unchecked to keep the standard request settings. 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 || (selected.type === 'tv' && selectedSeasons.length === 0)}>
|
||
{submitting ? 'Sending request…' : `Request ${selected.type === 'tv' ? 'show' : 'movie'}`}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{operation && (
|
||
<div className={`request-submit-progress is-${operation.status}`} aria-live="polite">
|
||
<header><div><span>Remote activity</span><strong>{operation.status === 'running' ? 'Sending your request' : operation.status === 'complete' ? 'Request hand-off complete' : 'Request hand-off needs attention'}</strong></div>{formatDuration(operation.duration_ms) && <small>{formatDuration(operation.duration_ms)}</small>}</header>
|
||
<div>
|
||
{operation.events.map((event) => (
|
||
<p key={event.id} className={`is-${event.state}`}><i aria-hidden="true" /><span><strong>{event.service}</strong>{event.message}</span><small>{formatDuration(event.duration_ms)}{event.status_code ? ` · HTTP ${event.status_code}` : ''}</small></p>
|
||
))}
|
||
{operation.events.length === 0 && <p className="is-active"><i aria-hidden="true" /><span><strong>Magent</strong>Preparing the request…</span></p>}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{success && selected.requestId && (
|
||
<div className="request-complete-actions"><button type="button" onClick={() => router.push(`/requests/${selected.requestId}`)}>Track request #{selected.requestId}</button><button type="button" className="ghost-button" onClick={() => resetAfterType(selected.type)}>Request something else</button></div>
|
||
)}
|
||
</section>
|
||
)}
|
||
</main>
|
||
)
|
||
}
|