Files
Magent/frontend/app/requests/[id]/page.tsx
T
Assclaw 3465343a69
Magent CI/CD / verify (push) Successful in 1m52s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 53s
feat: route ready-title issues through guided workflow
2026-09-15 15:46:09 +12:00

1134 lines
49 KiB
TypeScript

'use client'
import PageHeading from '../../ui/PageHeading'
import RequestLanguage from './RequestLanguage'
import { lockBodyScroll } from '../../lib/scrollLock'
import LatestActivity from './LatestActivity'
import Image from 'next/image'
import { useParams, useRouter } from 'next/navigation'
import { useEffect, useMemo, useState } from 'react'
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
import { canAccess } from '../../lib/features'
type TimelineHop = {
service: string
status: string
details?: Record<string, any>
}
type RequestAction = {
id: string
label: string
risk: string
description?: string
requires_confirmation: boolean
}
type PipelineStage = {
id: string
label: string
state: 'complete' | 'active' | 'partial' | 'attention' | 'waiting' | string
stateLabel?: string
searchStatus?: 'searching' | 'queued' | 'idle' | 'unavailable'
summary: string
available?: number
missing?: number
total?: number
seasons?: Array<{ seasonNumber: number; available: number; missing: number; total: number }>
missingEpisodes?: Record<string, number[]>
actionIds?: string[]
visible?: boolean
torrents?: Array<Record<string, any>>
link?: string | null
}
type RepairActivityStep = {
id: string
label: string
state: 'complete' | 'active' | 'attention' | 'waiting' | string
detail: string
}
type RepairActivity = {
visible?: boolean
actionId?: string
state?: 'searching' | 'downloading' | 'importing' | 'indexing' | 'complete' | 'attention' | string
headline?: string
message?: string
service?: string
updatedAt?: string | null
steps?: RepairActivityStep[]
}
type Snapshot = {
request_id: string
title: string
year?: number
request_type: string
state: string
state_reason?: string
timeline: TimelineHop[]
actions: RequestAction[]
artwork?: { poster_url?: string; backdrop_url?: string }
presentation?: {
repairCycle?: string | null
status?: { label?: string; meaning?: string }
download?: {
visible?: boolean
state?: string
summary?: string
torrents?: Array<Record<string, any>>
lastSeenAt?: string | null
}
nextStep?: { title?: string; description?: string; actionIds?: string[] }
pipeline?: PipelineStage[]
repairActivity?: RepairActivity
}
raw?: Record<string, any>
}
type ReleaseOption = {
title?: string
indexer?: string
indexerId?: number
guid?: string
size?: number
seeders?: number
leechers?: number
protocol?: string
publishDate?: string
infoUrl?: string
downloadUrl?: string
magnetUrl?: string
fullSeason?: boolean
seasonNumber?: number
quality?: string
customFormatScore?: number
approved?: boolean
selectionToken?: string
requiresOverride?: boolean
selectable?: boolean
rejections?: string[]
bestPick?: boolean
}
type SnapshotHistory = {
request_id: string
state: string
state_reason?: string
created_at: string
}
type ActionHistory = {
request_id: string
action_id: string
label: string
status: string
message?: string
created_at: string
}
type LiveDownloadProgress = {
repairCycle?: string | null
visible?: boolean
request_id: string
state: string
summary: string
torrents: Array<Record<string, any>>
updated_at: string
}
type OperationEvent = {
id: string
service: string
state: 'active' | 'complete' | 'error' | string
message: string
duration_ms?: number | null
status_code?: number | null
}
type OperationProgress = {
summary?: { title: string; message: string; next: string; action?: string }
id: string
label: string
status: 'running' | 'complete' | 'error' | string
duration_ms?: number | null
events: OperationEvent[]
}
const readApiError = async (response: Response, fallback: string) => {
try {
const contentType = response.headers.get('content-type') ?? ''
if (contentType.includes('application/json')) {
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
} else {
const text = await response.text()
if (text.trim()) return text.trim()
}
} catch (error) {
console.error(error)
}
return fallback
}
const isSnapshotPayload = (value: unknown): value is Snapshot => {
if (!value || typeof value !== 'object') return false
const snapshot = value as Partial<Snapshot>
return (
typeof snapshot.request_id === 'string' &&
typeof snapshot.title === 'string' &&
typeof snapshot.request_type === 'string' &&
typeof snapshot.state === 'string' &&
Array.isArray(snapshot.timeline) &&
Array.isArray(snapshot.actions)
)
}
const formatBytes = (value?: number) => {
if (!value || Number.isNaN(value)) return 'Size unavailable'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let size = value
let index = 0
while (size >= 1024 && index < units.length - 1) {
size /= 1024
index += 1
}
return `${size.toFixed(1)} ${units[index]}`
}
const torrentProgress = (torrent: Record<string, any>) => {
const progress = Number(torrent.progress)
if (!Number.isNaN(progress) && progress >= 0 && progress <= 1) return Math.round(progress * 1000) / 10
const supplied = Number(torrent.progressPercent)
if (!Number.isNaN(supplied) && supplied >= 0 && supplied <= 100) return Math.round(supplied * 10) / 10
return null
}
const formatProgress = (progress: number) => `${progress.toFixed(1).replace(/\.0$/, '')}% complete`
const mergeLiveDownload = (current: Snapshot, live: LiveDownloadProgress): Snapshot => {
if (String(current.request_id) !== String(live.request_id)) return current
if ((current.presentation?.repairCycle ?? null) !== (live.repairCycle ?? null)) return current
if (['COMPLETED', 'AVAILABLE'].includes(current.state)) return current
const downloadStage = current.presentation?.pipeline?.find((stage) => stage.id === 'download')
if (downloadStage?.state === 'complete' && downloadStage.visible === false) return current
const visible = live.visible ?? live.state !== 'not_started'
const stageState = live.state === 'completed'
? 'complete'
: ['missing', 'error'].includes(live.state)
? 'attention'
: live.state === 'not_started' ? 'waiting' : 'active'
const presentation = current.presentation ?? {}
const pipeline = (presentation.pipeline ?? fallbackPipeline(current)).map((stage) =>
stage.id === 'download'
? { ...stage, state: stageState, stateLabel: undefined, summary: live.summary, visible, torrents: live.torrents }
: stage
)
return {
...current,
presentation: {
...presentation,
download: {
...(presentation.download ?? {}),
visible,
state: live.state,
summary: live.summary,
torrents: live.torrents,
lastSeenAt: live.updated_at,
},
pipeline,
},
}
}
const fallbackStatusLabel = (state: string) => {
const labels: Record<string, string> = {
REQUESTED: 'Waiting for approval',
APPROVED: 'Approved — preparing collection',
NEEDS_ADD: 'Approved, but not yet in the library queue',
ADDED_TO_ARR: 'Added to library queue',
SEARCHING: 'Searching for a matching release',
GRABBED: 'Download queued',
DOWNLOADING: 'Download in progress',
IMPORTING: 'Preparing the collected media',
COMPLETED: 'Available to watch',
AVAILABLE: 'Available to watch',
FAILED: 'This request needs attention',
UNKNOWN: 'Checking request status',
}
return labels[state] ?? 'Checking request status'
}
const fallbackPipeline = (snapshot: Snapshot): PipelineStage[] => {
const approved = snapshot.state !== 'REQUESTED'
const complete = ['COMPLETED', 'AVAILABLE'].includes(snapshot.state)
const indexing = snapshot.state === 'IMPORTING'
return [
{ id: 'requested', label: 'Requested', state: 'complete', summary: 'Request received' },
{
id: 'approved',
label: 'Approved',
state: approved ? 'complete' : 'active',
summary: approved ? 'Approved for collection' : 'Waiting for approval',
},
{
id: 'library',
label: 'Library collection',
state: complete ? 'complete' : approved ? 'active' : 'waiting',
summary: complete ? 'Collection complete' : 'Waiting for collector information',
},
{ id: 'search', label: 'Release search', state: 'waiting', summary: 'Search state unavailable' },
{ id: 'download', label: 'Download', state: 'waiting', summary: 'No download attempt yet' },
{
id: 'available',
label: complete ? 'Available to watch' : indexing ? 'Adding to Grizzlyflix' : 'Media server',
state: complete ? 'complete' : indexing ? 'active' : 'waiting',
stateLabel: complete ? 'Ready' : indexing ? 'Indexing' : 'Waiting',
summary: complete
? 'This title is ready to watch in Grizzlyflix.'
: indexing
? 'The download is complete. Grizzlyflix is indexing this title now.'
: 'This title has not reached Grizzlyflix yet.',
link: snapshot.raw?.jellyfin?.link,
},
]
}
const formatWhen = (value?: string | null) => {
if (!value) return 'Time unavailable'
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return value
return date.toLocaleString()
}
export default function RequestTimelinePage() {
const params = useParams<{ id: string | string[] }>()
const requestId = Array.isArray(params?.id) ? params.id[0] : params?.id
const router = useRouter()
const [snapshot, setSnapshot] = useState<Snapshot | null>(null)
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null)
const [showDetails, setShowDetails] = useState(false)
const [actionMessage, setActionMessage] = useState<string | null>(null)
const [actionError, setActionError] = useState<string | null>(null)
const [busyAction, setBusyAction] = useState<string | null>(null)
const [releaseOptions, setReleaseOptions] = useState<ReleaseOption[]>([])
const [releasePickerOpen, setReleasePickerOpen] = useState(false)
const [canIgnoreProfileLimits, setCanIgnoreProfileLimits] = useState(false)
const [ignoreProfileLimits, setIgnoreProfileLimits] = useState(false)
const [nextSearchOffset, setNextSearchOffset] = useState<number | null>(null)
const [releaseCollector, setReleaseCollector] = useState<string | null>(null)
const [releaseSearchMessage, setReleaseSearchMessage] = useState<string | null>(null)
const [historySnapshots, setHistorySnapshots] = useState<SnapshotHistory[]>([])
const [historyActions, setHistoryActions] = useState<ActionHistory[]>([])
const [operationProgress, setOperationProgress] = useState<OperationProgress | null>(null)
const [isAdmin, setIsAdmin] = useState(false)
const [canReportIssues, setCanReportIssues] = useState(false)
const awaitingMediaIndex = Boolean(
snapshot?.presentation?.pipeline?.some(
(stage) => stage.id === 'available' && stage.state === 'active'
)
)
const repairIsActive = Boolean(
snapshot?.presentation?.repairActivity?.visible &&
!['complete', 'attention'].includes(snapshot.presentation.repairActivity.state ?? '')
)
const searchIsActive = Boolean(
snapshot?.presentation?.pipeline?.some(
(stage) => stage.id === 'library' && ['searching', 'queued'].includes(stage.searchStatus ?? '')
)
)
const closeReleasePicker = () => {
if (busyAction?.startsWith('grab:')) return
setReleasePickerOpen(false)
setReleaseOptions([])
setReleaseSearchMessage(null)
}
useEffect(() => {
if (!releasePickerOpen) return
const unlock = lockBodyScroll()
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') closeReleasePicker()
}
window.addEventListener('keydown', handleKeyDown)
return () => {
unlock()
window.removeEventListener('keydown', handleKeyDown)
}
}, [releasePickerOpen, busyAction])
useEffect(() => {
if (!requestId) return
const load = async () => {
setLoading(true)
setLoadError(null)
try {
if (!getToken()) {
router.push('/login')
return
}
const baseUrl = getApiBase()
const [meResponse, snapshotResponse] = await Promise.all([
authFetch(`${baseUrl}/auth/me`),
authFetch(`${baseUrl}/requests/${requestId}/snapshot`),
])
if ([meResponse, snapshotResponse].some((response) => response.status === 401)) {
clearToken()
router.push('/login')
return
}
if (!meResponse.ok) {
throw new Error('Unable to verify your request access.')
}
const me = await meResponse.json()
const viewerIsAdmin = me?.role === 'admin'
setIsAdmin(viewerIsAdmin)
setCanReportIssues(canAccess(me, 'issues'))
if (!snapshotResponse.ok) {
throw new Error(await readApiError(snapshotResponse, 'Unable to load this request.'))
}
const snapshotData = await snapshotResponse.json()
if (!isSnapshotPayload(snapshotData)) throw new Error('Unable to load this request.')
setSnapshot(snapshotData)
if (viewerIsAdmin) {
const [historyResponse, actionsResponse] = await Promise.all([
authFetch(`${baseUrl}/requests/${requestId}/history?limit=10`),
authFetch(`${baseUrl}/requests/${requestId}/actions?limit=10`),
])
if (historyResponse.ok) {
const historyData = await historyResponse.json()
if (Array.isArray(historyData.snapshots)) setHistorySnapshots(historyData.snapshots)
}
if (actionsResponse.ok) {
const actionsData = await actionsResponse.json()
if (Array.isArray(actionsData.actions)) setHistoryActions(actionsData.actions)
}
} else {
setHistorySnapshots([])
setHistoryActions([])
}
} catch (error) {
console.error(error)
setLoadError(error instanceof Error ? error.message : 'Unable to load this request.')
} finally {
setLoading(false)
}
}
void load()
}, [requestId, router])
useEffect(() => {
if (!getToken() || !requestId) return
let stopped = false
let refreshing = false
const refresh = async () => {
if (document.visibilityState === 'hidden' || refreshing) return
refreshing = true
try {
const response = await authFetch(`${getApiBase()}/requests/${requestId}/snapshot`)
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
if (!response.ok) return
const payload = await response.json()
if (!stopped && isSnapshotPayload(payload)) setSnapshot(payload)
} catch (error) {
if (!stopped) console.error(error)
} finally {
refreshing = false
}
}
const timer = window.setInterval(
() => void refresh(),
awaitingMediaIndex || repairIsActive || searchIsActive ? 5_000 : 15_000,
)
return () => {
stopped = true
window.clearInterval(timer)
}
}, [awaitingMediaIndex, repairIsActive, searchIsActive, requestId, router])
const liveDownloadKey = useMemo(() => {
const downloadStage = snapshot?.presentation?.pipeline?.find((stage) => stage.id === 'download')
if (!downloadStage || downloadStage.state === 'complete') return ''
return 'discover-and-track'
}, [snapshot])
useEffect(() => {
if (!getToken() || !requestId || !liveDownloadKey) return
let stopped = false
let timer: number | undefined
let controller: AbortController | null = null
const schedule = () => {
if (!stopped) timer = window.setTimeout(() => void refresh(), 5_000)
}
const refresh = async () => {
if (document.visibilityState === 'hidden') {
schedule()
return
}
controller = new AbortController()
try {
const response = await authFetch(`${getApiBase()}/requests/${requestId}/download-progress`, {
signal: controller.signal,
cache: 'no-store',
})
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
if (response.ok) {
const payload = await response.json() as LiveDownloadProgress
if (!stopped && Array.isArray(payload.torrents)) {
setSnapshot((current) => current ? mergeLiveDownload(current, payload) : current)
}
}
} catch (error) {
if (!stopped && !(error instanceof DOMException && error.name === 'AbortError')) console.error(error)
} finally {
controller = null
schedule()
}
}
timer = window.setTimeout(() => void refresh(), 750)
return () => {
stopped = true
if (timer !== undefined) window.clearTimeout(timer)
controller?.abort()
}
}, [liveDownloadKey, requestId, router])
const actionsById = useMemo(
() => new Map((snapshot?.actions ?? []).map((action) => [action.id, action])),
[snapshot?.actions]
)
if (loading) {
return (
<main className="card request-detail-page">
<div className="loading-center" role="status" aria-live="polite">
<div className="spinner" aria-hidden="true" />
<div className="loading-text">Building a clear request update</div>
</div>
</main>
)
}
if (loadError || !snapshot) {
return (
<main className="card request-detail-page">
<PageHeading title="We could not load this request" description={loadError ?? 'The request API did not return a valid status.'} />
<div className="request-error-actions">
<button type="button" onClick={() => window.location.reload()}>Retry</button>
<button type="button" className="ghost-button" onClick={() => router.push('/')}>Back to requests</button>
</div>
</main>
)
}
const presentation = snapshot.presentation ?? {}
const pipeline = presentation.pipeline?.length ? presentation.pipeline : fallbackPipeline(snapshot)
const availableStage = pipeline.find((stage) => stage.id === 'available')
const mediaServerLink = availableStage?.state === 'complete' && availableStage.link
? availableStage.link
: null
const requestComplete = ['COMPLETED', 'AVAILABLE'].includes(snapshot.state) || availableStage?.state === 'complete'
const issueReportParams = new URLSearchParams({
reportRequest: snapshot.request_id,
title: snapshot.title,
type: snapshot.request_type === 'tv' ? 'tv' : 'movie',
})
if (snapshot.year) issueReportParams.set('year', String(snapshot.year))
const issueReportLink = `/portal/issues?${issueReportParams.toString()}`
const statusLabel = presentation.status?.label ?? fallbackStatusLabel(snapshot.state)
const statusMeaning = presentation.status?.meaning ?? snapshot.state_reason ?? 'Magent is checking this request.'
const download = presentation.download
const repairActivity = presentation.repairActivity
const downloadVisible = Boolean(download?.visible)
const nextStep = presentation.nextStep ?? {
title: snapshot.actions[0]?.label ?? 'No action needed right now',
description: snapshot.actions.length ? 'Choose an option below to continue.' : 'Magent will keep checking automatically.',
actionIds: snapshot.actions.slice(0, 2).map((action) => action.id),
}
const recommendedActions = (nextStep.actionIds ?? [])
.map((actionId) => actionsById.get(actionId))
.filter((action): action is RequestAction => Boolean(action))
const posterUrl = snapshot.artwork?.poster_url
const resolvedPoster = posterUrl?.startsWith('http') ? posterUrl : posterUrl ? `${getApiBase()}${posterUrl}` : null
const backdropUrl = snapshot.artwork?.backdrop_url?.replace('https://image.tmdb.org/t/p/w780/', 'https://image.tmdb.org/t/p/w1280/')
const resolvedBackdrop = backdropUrl?.startsWith('http') ? backdropUrl : backdropUrl ? `${getApiBase()}${backdropUrl}` : null
const trackedPost = async (label: string, url: string, init: RequestInit = {}) => {
const operationId = typeof crypto?.randomUUID === 'function'
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`
const headers = new Headers(init.headers ?? {})
headers.set('X-Magent-Operation-ID', operationId)
headers.set('X-Magent-Operation-Label', label)
setOperationProgress({
id: operationId,
label,
status: 'running',
duration_ms: null,
events: [
{
id: 'sending',
service: 'Magent',
state: 'active',
message: 'Sending the action to Magent…',
},
],
})
let stopped = false
const refreshProgress = async () => {
try {
const progressResponse = await authFetch(`${getApiBase()}/operations/${operationId}`, {
cache: 'no-store',
})
if (!stopped && progressResponse.ok) {
const progress = await progressResponse.json()
if (Array.isArray(progress?.events)) {
setOperationProgress(progress)
return progress as OperationProgress
}
}
} catch (error) {
if (!stopped) console.error(error)
}
}
const request = authFetch(url, { ...init, method: 'POST', headers })
const timer = window.setInterval(() => void refreshProgress(), 650)
try {
const response = await request
await refreshProgress()
let result: any = null
try { result = await response.clone().json() } catch { /* Non-JSON error is handled below. */ }
const needsAttention = !response.ok || result?.status === 'attention' || result?.outcome === 'attention'
const finalState = needsAttention ? 'error' : result?.status === 'searching' ? 'searching' : 'complete'
const interactiveSearch = /\/actions\/search(?:\?|$)/.test(url)
const items = Array.isArray(result?.releases) ? result.releases : []
const available = items.some((item: ReleaseOption) => item.selectable && !item.requiresOverride)
const outside = items.some((item: ReleaseOption) => item.selectable && item.requiresOverride)
const canChoose = available || (outside && result?.canIgnoreProfileLimits === true)
const summary = interactiveSearch
? !response.ok
? { title: 'Search could not finish', message: 'We could not complete the search.', next: 'Try again shortly. If it keeps happening, contact an admin.' }
: canChoose
? { title: available ? 'Downloads found' : 'Other versions are available', message: available ? 'Choose the version you want to download.' : 'These versions are outside your usual download settings.', next: available ? 'Your download starts after you choose a version.' : 'You can review them and confirm a download outside your profile.', action: 'Choose a version' }
: { title: items.length ? 'No suitable downloads' : 'Nothing available yet', message: items.length ? 'The versions found cannot be downloaded with your current settings.' : 'No downloads were found in this search.', next: result?.nextOffset != null ? 'You can check the next group of missing episodes.' : 'You can try again later.', action: result?.nextOffset != null ? 'View search results' : undefined }
: response.ok && result?.status === 'pending'
? { title: 'Waiting for download confirmation', message: 'The search was sent. The download queue may still be updating.', next: 'Close this window and recheck the request shortly. You do not need to start another search yet.' }
: response.ok && result?.status === 'downloading'
? { title: 'Download queued', message: 'The download service has confirmed a download for this title.', next: 'Close this window to follow its progress.' }
: response.ok && /\/actions\/grab$/.test(url)
? { title: 'Waiting to start', message: 'Your download has been sent.', next: 'Close this box to follow its progress. It may take a moment to start.' }
: undefined
setOperationProgress((current) => current?.id === operationId ? {
...current, status: finalState, summary,
events: [...current.events.map(event => event.state === 'active' ? { ...event, state: 'complete' as const } : event), { id: 'result', service: 'Magent', state: needsAttention ? 'error' : 'complete',
message: result?.message || (typeof result?.detail === 'string' ? result.detail : response.ok ? 'Action completed. The pipeline will update as the media services report progress.' : 'The action failed. Recheck the request before trying again.') }],
} : current)
return response
} catch (error) {
setOperationProgress((current) => current?.id === operationId ? {
...current, status: 'error', events: [...current.events, { id: 'connection-error', service: 'Magent', state: 'error',
message: 'The connection was interrupted. Recheck the request before trying the action again—it may already have started.' }],
} : current)
throw error
} finally {
stopped = true
window.clearInterval(timer)
}
}
const recheckRequest = async () => {
setBusyAction('recheck_pipeline')
setActionError(null)
setActionMessage(null)
setReleaseOptions([])
setReleasePickerOpen(false)
setReleaseSearchMessage(null)
try {
const response = await trackedPost(
'Recheck request status',
`${getApiBase()}/requests/${snapshot.request_id}/actions/recheck`,
)
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
if (!response.ok) {
throw new Error(await readApiError(response, 'The request could not be rechecked.'))
}
const data = await response.json()
if (!isSnapshotPayload(data?.snapshot)) {
throw new Error('The request was checked, but Magent did not return a valid pipeline.')
}
setSnapshot(data.snapshot)
setActionMessage(data?.message ?? 'Request status rebuilt from live service data.')
} catch (error) {
console.error(error)
setActionError(error instanceof Error ? error.message : 'The request could not be rechecked.')
} finally {
setBusyAction(null)
}
}
const runAction = async (action: RequestAction, searchOffset = 0) => {
if (action.requires_confirmation && !window.confirm(`Run “${action.label}”?`)) return
const actionPaths: Record<string, string> = {
search_releases: 'actions/search',
search_auto: 'actions/search_auto',
resume_torrent: 'actions/qbit/resume',
readd_to_arr: 'actions/readd',
}
const path = actionPaths[action.id]
if (!path) {
setActionError('This action is not connected yet.')
return
}
if (action.id === 'search_releases') {
setReleaseOptions([])
setIgnoreProfileLimits(false)
setNextSearchOffset(null)
setReleaseCollector(snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr')
setReleaseSearchMessage(null)
setReleasePickerOpen(false)
}
setBusyAction(action.id)
setActionError(null)
setActionMessage(null)
try {
const response = await trackedPost(
action.label,
`${getApiBase()}/requests/${snapshot.request_id}/${path}${action.id === 'search_releases' ? `?offset=${searchOffset}` : ''}`
)
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
if (!response.ok) throw new Error(await readApiError(response, `${action.label} could not be completed.`))
const data = await response.json()
if (action.id === 'search_releases') {
const releases = Array.isArray(data.releases) ? data.releases : []
setReleaseOptions(releases)
setCanIgnoreProfileLimits(data.canIgnoreProfileLimits === true)
setNextSearchOffset(typeof data.nextOffset === 'number' ? data.nextOffset : null)
setReleasePickerOpen(true)
setReleaseCollector(data?.collector ?? (snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr'))
setReleaseSearchMessage(
data?.message ??
(
releases.length
? `Found ${releases.length} approved release${releases.length === 1 ? '' : 's'}.`
: 'No releases currently meet the assigned quality profile.'
)
)
setActionMessage(null)
} else {
setActionMessage(data?.message ?? `${action.label} was started successfully.`)
}
} catch (error) {
console.error(error)
setActionError(error instanceof Error ? error.message : `${action.label} could not be completed.`)
} finally {
setBusyAction(null)
}
}
const downloadRelease = async (release: ReleaseOption) => {
if (!release.guid || !release.indexerId) {
setActionError('This release is missing the details needed to start it.')
return
}
if (release.requiresOverride && (!canIgnoreProfileLimits || !ignoreProfileLimits)) return
if (release.requiresOverride && !window.confirm(`Download this release outside the assigned profile?\n\n${release.title}\n${(release.rejections || []).join('\n')}\n\nThe assigned profile will stay unchanged.`)) return
const collector = snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr'
setBusyAction(`grab:${release.guid}`)
setActionError(null)
try {
const response = await trackedPost(
`Send release through ${collector}`,
`${getApiBase()}/requests/${snapshot.request_id}/actions/grab`, {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...release, ignoreProfileLimits: release.requiresOverride === true && ignoreProfileLimits }),
})
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
if (!response.ok) throw new Error(await readApiError(response, 'The selected release could not be started.'))
const data = await response.json()
setActionMessage(data?.message ?? 'The selected release was queued for download.')
setReleaseOptions([])
setReleasePickerOpen(false)
setReleaseSearchMessage(null)
} catch (error) {
console.error(error)
setActionError(error instanceof Error ? error.message : 'The selected release could not be started.')
} finally {
setBusyAction(null)
}
}
return (
<main className={`card request-detail-page request-cinematic ${resolvedBackdrop ? 'has-backdrop' : ''}`}>
<div className="request-cinematic-art" aria-hidden="true">
{resolvedBackdrop && <Image src={resolvedBackdrop} alt="" fill sizes="100vw" priority unoptimized onError={(event) => { event.currentTarget.style.opacity = '0' }} />}
</div>
<div className="request-cinematic-title">
<PageHeading
title={snapshot.title}
eyebrow={`Request #${snapshot.request_id}`}
description={[snapshot.request_type === 'tv' ? 'TV show' : 'Movie', snapshot.year].filter(Boolean).join(' · ')}
leading={resolvedPoster && <Image className="request-poster" src={resolvedPoster} alt={`${snapshot.title} poster`} width={60} height={90} sizes="60px" unoptimized />}
/>
</div>
<RequestLanguage requestId={snapshot.request_id} disabled={Boolean(busyAction)} onApply={async (code) => {
setBusyAction('language')
try {
const response = await trackedPost('Use original audio and search', `${getApiBase()}/requests/${snapshot.request_id}/actions/language`, {
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ acceptOriginalLanguage: true, languageCode: code }),
})
if (!response.ok) throw new Error(await readApiError(response, 'The audio choice could not be saved.'))
} finally { setBusyAction(null) }
}} />
<section className="request-overview" aria-labelledby="request-status-heading">
<div className="request-overview-block request-overview-status">
<span className="request-overview-label" id="request-status-heading">Status</span>
<strong>{statusLabel}</strong>
</div>
<div className="request-overview-block">
<span className="request-overview-label">What this means</span>
<p>{statusMeaning}</p>
</div>
{downloadVisible && (
<div className="request-overview-block">
<span className="request-overview-label">Current download state</span>
<strong>{download?.summary ?? 'A download attempt has been observed.'}</strong>
{download?.lastSeenAt && !download?.torrents?.length && <small>Last observed {formatWhen(download.lastSeenAt)}</small>}
</div>
)}
{operationProgress && <LatestActivity operation={operationProgress} besideDownload={downloadVisible} onDismiss={() => setOperationProgress(null)} />}
<div className={`request-overview-block request-next-step ${requestComplete ? 'is-ready' : ''}`}>
{requestComplete ? (
<div className="request-ready-actions">
<section>
<span className="request-overview-label">Ready to watch</span>
<strong>Watch this now!</strong>
<p>Open {snapshot.title} directly in Grizzlyflix.</p>
{mediaServerLink ? <a className="request-watch-button" href={mediaServerLink} target="_blank" rel="noreferrer">Watch on Grizzlyflix <span aria-hidden="true">&rarr;</span></a> : <span className="request-ready-unavailable">The Grizzlyflix watch link is not configured.</span>}
</section>
<section>
<span className="request-overview-label">Need help?</span>
<strong>Is there a problem with this?</strong>
<p>Let us know what is wrong and we&apos;ll attach the title and request details automatically.</p>
{canReportIssues ? <a className="request-problem-button" href={issueReportLink}>Start issue report <span aria-hidden="true">&rarr;</span></a> : <span className="request-ready-unavailable">Issue reporting is not enabled for your account.</span>}
</section>
</div>
) : <>
<div className="request-next-step-main">
<div className="request-next-step-copy">
<span className="request-overview-label">Next step</span>
<strong>{nextStep.title}</strong>
<p>{nextStep.description}</p>
</div>
</div>
<div className="request-action-row">
{recommendedActions.map((action) => (
<button key={action.id} type="button" disabled={Boolean(busyAction)} onClick={() => void runAction(action)}>
{busyAction === action.id ? 'Working…' : action.label}
</button>
))}
<button type="button" className="request-recheck-button" disabled={Boolean(busyAction)} onClick={() => void recheckRequest()} title="Recheck Seerr, the library collector, qBittorrent, and the media server">{busyAction === 'recheck_pipeline' ? 'Rechecking…' : 'Recheck request'}</button>
</div>
</>}
</div>
{(actionMessage || actionError) && (
<div className={`request-action-feedback ${actionError ? 'is-error' : 'is-success'}`} role="status">
{actionError ?? actionMessage}
</div>
)}
</section>
{repairActivity?.visible && (
<section className={`request-repair-activity is-${repairActivity.state ?? 'searching'}`} aria-live="polite">
<div className="request-repair-heading">
<div>
<span className="section-kicker">Repair activity</span>
<h2>{repairActivity.headline ?? 'Repair in progress'}</h2>
<p>{repairActivity.message ?? 'Magent is checking the repair with the connected services.'}</p>
</div>
<div className="request-repair-meta">
<span className={`request-operation-status is-${repairActivity.state === 'attention' ? 'error' : repairActivity.state === 'complete' ? 'complete' : 'running'}`}>
{repairActivity.state === 'complete' ? 'Complete' : repairActivity.state === 'attention' ? 'Attention' : 'Live'}
</span>
{repairActivity.updatedAt && <small>Updated {formatWhen(repairActivity.updatedAt)}</small>}
</div>
</div>
<div className="request-repair-steps">
{(repairActivity.steps ?? []).map((step) => (
<div className={`request-repair-step is-${step.state}`} key={step.id}>
<i aria-hidden="true" />
<div>
<strong>{step.label}</strong>
<span>{step.detail}</span>
</div>
</div>
))}
</div>
</section>
)}
{!requestComplete && <section className="request-journey" aria-labelledby="request-journey-heading">
<div className="request-journey-heading">
<div>
<span className="section-kicker">Live collection path</span>
<h2 id="request-journey-heading">Where your request is now</h2>
</div>
<span className="request-live-indicator"><i />Live status</span>
</div>
<div className="request-stage-grid">
{pipeline.map((stage, index) => {
const stageActions = (stage.actionIds ?? [])
.map((actionId) => actionsById.get(actionId))
.filter((action): action is RequestAction => Boolean(action))
const content = (
<>
<div className="request-stage-topline">
<span className="request-stage-number">{String(index + 1).padStart(2, '0')}</span>
<span className={`request-stage-state state-${stage.state}`}>{stage.stateLabel ?? stage.state}</span>
</div>
<h3>{stage.label}</h3>
<p aria-live={stage.id === 'library' ? 'polite' : undefined} aria-atomic={stage.id === 'library' ? true : undefined}>{stage.summary}</p>
{stage.id === 'library' && Boolean(stage.total) && (
<div className="request-availability-meter">
<div className="request-meter-copy"><span>{stage.available ?? 0} collected</span><span>{stage.missing ?? 0} missing</span></div>
<div
className="request-meter-track"
role="progressbar"
aria-label="Collection progress"
aria-valuemin={0}
aria-valuemax={stage.total ?? 0}
aria-valuenow={stage.available ?? 0}
>
<span style={{ width: `${Math.round(((stage.available ?? 0) / Math.max(stage.total ?? 1, 1)) * 100)}%` }} />
</div>
{stage.seasons?.map((season) => (
<div className="request-season-row" key={season.seasonNumber}><span>Season {season.seasonNumber}</span><span>{season.available} collected · {season.missing} missing</span></div>
))}
</div>
)}
{stage.id === 'library' && stage.missingEpisodes && Object.keys(stage.missingEpisodes).length > 0 && (
<div className="request-missing-list">
{Object.entries(stage.missingEpisodes).map(([season, episodes]) => (
<div key={season}><span>Missing from season {season}</span><strong>{episodes.map((episode) => `E${episode}`).join(', ')}</strong></div>
))}
</div>
)}
{stage.id === 'download' && stage.visible && stage.torrents?.map((torrent) => {
const progress = torrentProgress(torrent)
return (
<div className="request-torrent" key={torrent.hash ?? torrent.name}>
{torrent.episodeLabel && <small>{torrent.episodeLabel}</small>}
<div><strong>{torrent.name ?? 'Download'}</strong><span>{progress === null ? 'Progress unavailable' : formatProgress(progress)}</span></div>
{progress !== null && (
<div
className="request-meter-track is-live-download"
role="progressbar"
aria-label={`${torrent.name ?? 'Download'} progress`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={progress}
>
<span style={{ width: `${progress}%` }} />
</div>
)}
</div>
)
})}
{stageActions.length > 0 && (
<div className="request-stage-actions">
{stageActions.map((action) => (
<button key={action.id} type="button" disabled={Boolean(busyAction)} onClick={() => void runAction(action)}>{busyAction === action.id ? 'Working…' : action.label}</button>
))}
</div>
)}
</>
)
return stage.id === 'available' && stage.link ? (
<a className={`request-stage stage-${stage.state} is-link`} href={stage.link} target="_blank" rel="noreferrer" key={stage.id}>{content}<span className="request-stage-link">Open on media server </span></a>
) : (
<article className={`request-stage stage-${stage.state}`} key={stage.id}>{content}</article>
)
})}
</div>
</section>}
{releasePickerOpen && (
<div className="request-release-modal-layer">
<button
type="button"
className="request-release-modal-backdrop"
aria-label="Close available releases"
onClick={closeReleasePicker}
/>
<section
className="request-release-modal"
role="dialog"
aria-modal="true"
aria-labelledby="release-picker-title"
>
<header className="request-release-modal-header">
<div>
<span className="section-kicker">Results from {releaseCollector ?? (snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr')}</span>
<h2 id="release-picker-title">Choose an available download</h2>
<p>Review available releases and any rejection reasons. The assigned profile stays unchanged.</p>
</div>
<button type="button" className="ghost-button" onClick={closeReleasePicker} disabled={busyAction?.startsWith('grab:')}>Close</button>
</header>
<div className="request-release-modal-body">
{busyAction === 'search_releases' && (
<div className="request-release-searching" role="status">
<i aria-hidden="true" />
<div>
<strong>Checking available releases</strong>
<span>{releaseCollector ?? 'The collector'} is applying its quality profile and ranking the results.</span>
</div>
</div>
)}
{busyAction !== 'search_releases' && releaseSearchMessage && (
<div className="request-release-profile-note">
<strong>Search results</strong>
<span>{releaseSearchMessage}</span>
</div>
)}
{busyAction !== 'search_releases' && actionError && (
<div className="request-release-empty is-error" role="alert">
<strong>The release search could not be completed</strong>
<span>{actionError}</span>
</div>
)}
{busyAction !== 'search_releases' && !actionError && releaseOptions.length === 0 && (
<div className="request-release-empty">
<strong>No suitable downloads are available right now</strong>
<span>No releases were returned for this search. Check the indexers or try again later.</span>
</div>
)}
{canIgnoreProfileLimits && <label className="request-release-profile-toggle">
<span><strong>Ignore profile limits</strong><small>Allow releases outside this title&apos;s profile. You&apos;ll confirm each download.</small></span>
<input type="checkbox" checked={ignoreProfileLimits} disabled={Boolean(busyAction)} onChange={event => setIgnoreProfileLimits(event.target.checked)} />
</label>}
{nextSearchOffset !== null && <button type="button" className="ghost-button" disabled={Boolean(busyAction)} onClick={() => void runAction({ id: 'search_releases', label: 'Search next missing episodes', risk: 'low', requires_confirmation: false }, nextSearchOffset)}>Search next missing episodes</button>}
{releaseOptions.length > 0 && (
<div className="request-release-list">
{releaseOptions.map((release) => {
const isBestPick = release.bestPick === true
return (
<article className={`request-release ${isBestPick ? 'is-best-pick' : ''}`} key={`${release.indexerId ?? ''}:${release.guid ?? release.title}`}>
<div className="request-release-copy">
<div className="request-release-badges">
{isBestPick && <span className="request-release-best-badge">Best pick</span>}
{release.requiresOverride && <span>Outside profile</span>}
{release.selectable === false && <span>Unavailable for selection</span>}
{release.quality && <span>{release.quality}</span>}
{release.fullSeason && <span>Season {release.seasonNumber ?? ''} pack</span>}
</div>
<strong>{release.title ?? 'Unknown release'}</strong>
<span>
{release.indexer ?? 'Unknown indexer'} · {release.seeders ?? 0} seeders · {formatBytes(release.size)}
{typeof release.customFormatScore === 'number' ? ` · Score ${release.customFormatScore}` : ''}
</span>
{!!release.rejections?.length && <small className="release-rejection-reasons">{release.rejections.join('; ')}</small>}
{isBestPick && <small>This is the highest-ranked release approved by {releaseCollector ?? 'the collector'}.</small>}
</div>
<button
type="button"
disabled={Boolean(busyAction) || !release.selectionToken || release.selectable === false || (release.requiresOverride && (!canIgnoreProfileLimits || !ignoreProfileLimits))}
onClick={() => void downloadRelease(release)}
>
{busyAction === `grab:${release.guid}`
? 'Sending…'
: release.requiresOverride
? 'Download outside profile'
: isBestPick
? 'Download best pick'
: 'Download this release'}
</button>
</article>
)
})}
</div>
)}
</div>
</section>
</div>
)}
{isAdmin && <section className="request-advanced">
<button type="button" className="request-advanced-toggle" aria-expanded={showDetails} onClick={() => setShowDetails((current) => !current)}>
<span><strong>Advanced details</strong><small>Service diagnostics, status history and recorded actions</small></span>
<span>{showDetails ? 'Hide' : 'Show'}</span>
</button>
{showDetails && (
<div className="request-advanced-content">
<div className="request-diagnostics-grid">
{snapshot.timeline.map((hop, index) => (
<article className="request-diagnostic" key={`${hop.service}-${index}`}>
<div><strong>{hop.service}</strong><span>{hop.status}</span></div>
{hop.details && <pre>{JSON.stringify(hop.details, null, 2)}</pre>}
</article>
))}
</div>
<div className="history-grid">
<div className="summary-card">
<h3>Status changes</h3>
<ul>
{historySnapshots.length === 0 ? <li>No distinct status changes recorded yet.</li> : historySnapshots.map((entry) => (
<li key={`${entry.created_at}-${entry.state}`}><span>{fallbackStatusLabel(entry.state)}</span><small>{entry.state_reason ?? 'No additional detail.'} · {formatWhen(entry.created_at)}</small></li>
))}
</ul>
</div>
<div className="summary-card">
<h3>Recorded actions</h3>
<ul>
{historyActions.length === 0 ? <li>No actions have been run for this request.</li> : historyActions.map((entry) => (
<li key={`${entry.created_at}-${entry.action_id}`}><span>{entry.label}</span><small>{entry.message ?? entry.status} · {formatWhen(entry.created_at)}</small></li>
))}
</ul>
</div>
</div>
</div>
)}
</section>}
</main>
)
}