529 lines
22 KiB
TypeScript
529 lines
22 KiB
TypeScript
'use client'
|
|
|
|
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<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 [profileId, setProfileId] = useState<number | null>(null)
|
|
const [selectedSeasons, setSelectedSeasons] = useState<number[]>([])
|
|
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 resetAfterType = (nextType: MediaType) => {
|
|
setMediaType(nextType)
|
|
setQuery('')
|
|
setResults([])
|
|
setSearchAttempted(false)
|
|
setSelected(null)
|
|
setOptions(null)
|
|
setProfileId(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)
|
|
setProfileId(null)
|
|
setSelectedSeasons([])
|
|
setOperation(null)
|
|
setError(null)
|
|
setSuccess(null)
|
|
if (item.requestId) {
|
|
window.setTimeout(() => configureSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 80)
|
|
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)
|
|
setProfileId(payload.destination.defaultProfileId)
|
|
setSelectedSeasons(payload.media.seasons.map((season) => season.seasonNumber))
|
|
} catch (caught) {
|
|
setError(caught instanceof Error ? caught.message : 'Could not load request options.')
|
|
} finally {
|
|
setLoadingOptions(false)
|
|
window.setTimeout(() => configureSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 80)
|
|
}
|
|
}
|
|
|
|
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 || !profileId) 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,
|
|
profileId,
|
|
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">
|
|
<header className="request-portal-hero">
|
|
<div>
|
|
<span className="section-kicker">New requests</span>
|
|
<h1>Find something worth watching.</h1>
|
|
<p>Choose what you want, find the right title, then tailor the request before it goes to Seerr.</p>
|
|
</div>
|
|
<div className="request-portal-route">
|
|
<span>Seerr</span><i aria-hidden="true" />
|
|
<span>{mediaType === 'tv' ? 'Sonarr' : mediaType === 'movie' ? 'Radarr' : 'Collector'}</span><i aria-hidden="true" />
|
|
<span>Grizzlyflix</span>
|
|
</div>
|
|
</header>
|
|
|
|
<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>}
|
|
|
|
<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 && (
|
|
<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 && 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)
|
|
const isSelected = selected?.tmdbId === item.tmdbId && selected.type === item.type
|
|
return (
|
|
<button
|
|
key={`${item.type}:${item.tmdbId}`}
|
|
type="button"
|
|
className={`request-result-card ${isSelected ? 'is-selected' : ''}`}
|
|
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' : isSelected ? 'Selected' : 'Select title'}</b>
|
|
</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</section>
|
|
)}
|
|
|
|
{selected && (
|
|
<section ref={configureSectionRef} 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>{selected.requestId ? 'This title is already in the pipeline' : 'Configure your request'}</h2></div>
|
|
</div>
|
|
|
|
<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>Loading valid profiles and request choices.</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>
|
|
)}
|
|
|
|
<label className="request-profile-field">
|
|
<span>Quality profile</span>
|
|
<select value={profileId ?? ''} onChange={(event) => setProfileId(Number(event.target.value))}>
|
|
{options.destination.profiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}
|
|
</select>
|
|
<small>Live options from {options.destination.collector}. Seerr will use {options.destination.serverName}.</small>
|
|
</label>
|
|
|
|
<div className="request-submit-bar">
|
|
<div><span>Delivery route</span><strong>Seerr → {options.destination.collector} → Grizzlyflix</strong><small>Only settings currently accepted by {options.destination.collector} are available.</small></div>
|
|
<button type="button" onClick={() => void submitRequest()} disabled={submitting || !profileId || (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>
|
|
)
|
|
}
|