'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' type TimelineHop = { service: string status: string details?: Record } 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 actionIds?: string[] visible?: boolean torrents?: Array> 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> lastSeenAt?: string | null } nextStep?: { title?: string; description?: string; actionIds?: string[] } pipeline?: PipelineStage[] repairActivity?: RepairActivity } raw?: Record } 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> 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 = { 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 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) => { 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 = { 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(null) const [loading, setLoading] = useState(true) const [loadError, setLoadError] = useState(null) const [showDetails, setShowDetails] = useState(false) const [actionMessage, setActionMessage] = useState(null) const [actionError, setActionError] = useState(null) const [busyAction, setBusyAction] = useState(null) const [releaseOptions, setReleaseOptions] = useState([]) const [releasePickerOpen, setReleasePickerOpen] = useState(false) const [canIgnoreProfileLimits, setCanIgnoreProfileLimits] = useState(false) const [ignoreProfileLimits, setIgnoreProfileLimits] = useState(false) const [nextSearchOffset, setNextSearchOffset] = useState(null) const [releaseCollector, setReleaseCollector] = useState(null) const [releaseSearchMessage, setReleaseSearchMessage] = useState(null) const [historySnapshots, setHistorySnapshots] = useState([]) const [historyActions, setHistoryActions] = useState([]) const [operationProgress, setOperationProgress] = useState(null) const [isAdmin, setIsAdmin] = 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) 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 (
) } if (loadError || !snapshot) { return (
) } 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 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 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' setOperationProgress((current) => current?.id === operationId ? { ...current, status: finalState, 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 = { 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 (
} /> { 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) } }} />
Status {statusLabel}
What this means

{statusMeaning}

{downloadVisible && (
Current download state {download?.summary ?? 'A download attempt has been observed.'} {download?.lastSeenAt && !download?.torrents?.length && Last observed {formatWhen(download.lastSeenAt)}}
)} {operationProgress && setOperationProgress(null)} />}
Next step {nextStep.title}

{nextStep.description}

{mediaServerLink && ( Watch on Grizzlyflix )}
{recommendedActions.map((action) => ( ))}
{(actionMessage || actionError) && (
{actionError ?? actionMessage}
)}
{repairActivity?.visible && (
Repair activity

{repairActivity.headline ?? 'Repair in progress'}

{repairActivity.message ?? 'Magent is checking the repair with the connected services.'}

{repairActivity.state === 'complete' ? 'Complete' : repairActivity.state === 'attention' ? 'Attention' : 'Live'} {repairActivity.updatedAt && Updated {formatWhen(repairActivity.updatedAt)}}
{(repairActivity.steps ?? []).map((step) => (