'use client' import { useEffect, useState } from 'react' import { useRouter } from 'next/navigation' import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth' type TimelineHop = { service: string status: string details?: Record timestamp?: string } type Snapshot = { request_id: string title: string year?: number request_type: string state: string state_reason?: string timeline: TimelineHop[] actions: { id: string; label: string; risk: string; requires_confirmation: boolean }[] artwork?: { poster_url?: string; backdrop_url?: string } raw?: Record } type ReleaseOption = { title?: string indexer?: string indexerId?: number guid?: string size?: number seeders?: number leechers?: number protocol?: string infoUrl?: string downloadUrl?: string } 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 } const percentFromTorrent = (torrent: Record) => { const progress = Number(torrent.progress) if (!Number.isNaN(progress) && progress >= 0 && progress <= 1) { return Math.round(progress * 100) } const size = Number(torrent.size) const left = Number(torrent.amount_left) if (!Number.isNaN(size) && size > 0 && !Number.isNaN(left)) { return Math.round(((size - left) / size) * 100) } return null } const formatBytes = (value?: number) => { if (!value || Number.isNaN(value)) return 'n/a' const units = ['B', 'KB', 'MB', 'GB', 'TB'] let size = value let idx = 0 while (size >= 1024 && idx < units.length - 1) { size /= 1024 idx += 1 } return `${size.toFixed(1)} ${units[idx]}` } type SeasonStat = { seasonNumber: number available: number missing: number } const seasonStatsFromSeries = (series: Record): SeasonStat[] => { const dateValue = series?.previousAiring ?? series?.firstAired const airedAt = dateValue ? new Date(dateValue) : null if (!airedAt || Number.isNaN(airedAt.valueOf()) || airedAt > new Date()) { return [] } const seasons = Array.isArray(series?.seasons) ? series.seasons : [] return seasons .filter((season: Record) => season?.monitored === true) .map((season: Record) => { const stats = season.statistics const available = stats && typeof stats === 'object' ? Number(stats.episodeFileCount) : NaN const aired = stats && typeof stats === 'object' ? Number(stats.episodeCount) : NaN const fallbackTotal = stats && typeof stats === 'object' ? Number(stats.totalEpisodeCount) : NaN const total = !Number.isNaN(aired) && aired > 0 ? aired : fallbackTotal const seasonDateValue = stats?.previousAiring ?? stats?.firstAired ?? null const seasonAiredAt = seasonDateValue ? new Date(seasonDateValue) : null if ( !Number.isNaN(available) && !Number.isNaN(total) && total > 0 && (!seasonAiredAt || Number.isNaN(seasonAiredAt.valueOf()) || seasonAiredAt <= new Date()) ) { return { seasonNumber: season.seasonNumber, available, missing: Math.max(0, total - available), } } return null }) .filter((season): season is SeasonStat => season !== null && season.missing > 0) } const friendlyState = (value: string) => { const map: Record = { REQUESTED: 'Waiting for approval', APPROVED: 'Approved and queued', NEEDS_ADD: 'Needs adding to the library', ADDED_TO_ARR: 'Added to the library queue', SEARCHING: 'Searching for releases', GRABBED: 'Download queued', DOWNLOADING: 'Downloading', IMPORTING: 'Adding to your library', COMPLETED: 'Ready to watch', AVAILABLE: 'Ready to watch', FAILED: 'Needs attention', UNKNOWN: 'Status not available yet', } return map[value] ?? value.replaceAll('_', ' ').toLowerCase() } const friendlyTimelineStatus = (service: string, status: string) => { if (service === 'Jellyseerr') { const map: Record = { Pending: 'Waiting for approval', Approved: 'Approved', Declined: 'Declined', Available: 'Ready to watch', Processing: 'Working on it', 'Partially Available': 'Partially ready', 'Waiting for approval': 'Waiting for approval', 'Working on it': 'Working on it', 'Partially ready': 'Partially ready', 'Ready to watch': 'Ready to watch', } return map[status] ?? status } if (service === 'Sonarr/Radarr') { const map: Record = { missing: 'Not added yet', added: 'Added to the library queue', searching: 'Searching for releases', available: 'Ready to watch', error: 'Needs attention', unknown: 'Checking…', } return map[status] ?? status } if (service === 'Prowlarr') { const map: Record = { ok: 'Search sources OK', issues: 'Search sources need attention', error: 'Search sources unavailable', } return map[status] ?? status } if (service === 'qBittorrent') { const map: Record = { downloading: 'Downloading', paused: 'Paused', completed: 'Content downloaded', idle: 'No active downloads', error: 'Downloader error', } return map[status] ?? status } if (service === 'Jellyfin') { const map: Record = { available: 'Ready to watch', missing: 'Not in Jellyfin yet', error: 'Jellyfin unavailable', } return map[status] ?? status } return status } export default function RequestTimelinePage({ params }: { params: { id: string } }) { const router = useRouter() const [snapshot, setSnapshot] = useState(null) const [loading, setLoading] = useState(true) const [showDetails, setShowDetails] = useState(false) const [actionMessage, setActionMessage] = useState(null) const [releaseOptions, setReleaseOptions] = useState([]) const [searchRan, setSearchRan] = useState(false) const [modalMessage, setModalMessage] = useState(null) const [historySnapshots, setHistorySnapshots] = useState([]) const [historyActions, setHistoryActions] = useState([]) useEffect(() => { const load = async () => { try { if (!getToken()) { router.push('/login') return } const baseUrl = getApiBase() const [snapshotResponse, historyResponse, actionsResponse] = await Promise.all([ authFetch(`${baseUrl}/requests/${params.id}/snapshot`), authFetch(`${baseUrl}/requests/${params.id}/history?limit=5`), authFetch(`${baseUrl}/requests/${params.id}/actions?limit=5`), ]) if (snapshotResponse.status === 401) { clearToken() router.push('/login') return } const snapshotData = await snapshotResponse.json() setSnapshot(snapshotData) setReleaseOptions([]) setSearchRan(false) setModalMessage(null) 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) } } } catch (error) { console.error(error) } finally { setLoading(false) } } load() }, [params.id]) if (loading) { return (
) } if (!snapshot) { return
Could not load that request.
} const summary = snapshot.state_reason ?? `This request is currently ${snapshot.state.replaceAll('_', ' ').toLowerCase()}.` const downloadHop = snapshot.timeline.find((hop) => hop.service === 'qBittorrent') const downloadState = downloadHop?.details?.summary ?? downloadHop?.status ?? 'Unknown' const jellyfinAvailable = Boolean(snapshot.raw?.jellyfin?.available) const pipelineSteps = [ { key: 'Jellyseerr', label: 'Jellyseerr' }, { key: 'Sonarr/Radarr', label: 'Library queue' }, { key: 'Prowlarr', label: 'Search' }, { key: 'qBittorrent', label: 'Download' }, { key: 'Jellyfin', label: 'Jellyfin' }, ] const stageFromState = (state: string) => { if (jellyfinAvailable || state === 'COMPLETED' || state === 'AVAILABLE') return 4 if (state === 'DOWNLOADING' || state === 'IMPORTING') return 3 if (state === 'GRABBED') return 2 if (state === 'SEARCHING' || state === 'ADDED_TO_ARR' || state === 'NEEDS_ADD') return 1 if (state === 'APPROVED' || state === 'REQUESTED') return 0 return 1 } const activeStage = stageFromState(snapshot.state) const extendedTimeline: TimelineHop[] = [ ...snapshot.timeline, { service: 'Jellyfin', status: jellyfinAvailable ? 'available' : 'missing', details: snapshot.raw?.jellyfin ?? {}, }, ] const jellyfinLink = snapshot.raw?.jellyfin?.link const posterUrl = snapshot.artwork?.poster_url const resolvedPoster = posterUrl && posterUrl.startsWith('http') ? posterUrl : posterUrl ? `${getApiBase()}${posterUrl}` : null return (
{resolvedPoster && ( {`${snapshot.title} )}

{snapshot.title}

{snapshot.request_type.toUpperCase()} {snapshot.year ?? ''}
{jellyfinAvailable && jellyfinLink && ( Open in Jellyfin )}

Status

{friendlyState(snapshot.state)}

What this means

{summary}

{(actionMessage || (searchRan && releaseOptions.length === 0)) && (

Last action

{actionMessage &&

{actionMessage}

} {searchRan && releaseOptions.length === 0 && (

Nothing to grab yet. We did not find a match on your torrent providers.

)}
)}

Current download state

{downloadState}

Next step

{snapshot.actions.length === 0 ? 'Nothing to do right now.' : snapshot.actions[0].label}

Use the buttons below if you want to run a safe retry or a fix.

Pipeline location

{pipelineSteps.map((step, index) => (
{step.label}
))}

The glowing light shows where your request is right now.

{extendedTimeline.map((hop, index) => (
{hop.service} {friendlyTimelineStatus(hop.service, hop.status)}
{hop.service === 'Sonarr/Radarr' && hop.details?.series && (() => { const seasons = seasonStatsFromSeries(hop.details.series) if (seasons.length === 0) { return
Up to date
} return (
Seasons available vs missing
    {seasons.map((season) => (
  • Season {season.seasonNumber} {season.available} available / {season.missing} missing
  • ))}
) })()} {hop.service === 'Sonarr/Radarr' && hop.details?.missingEpisodes && (
Missing episodes
    {Object.entries(hop.details.missingEpisodes as Record).map( ([seasonNumber, episodes]) => (
  • Season {seasonNumber} {episodes.length ? episodes.map((ep) => `E${ep}`).join(', ') : 'Episode numbers unavailable'}
  • ) )}
)} {hop.service === 'Sonarr/Radarr' && hop.details?.note && (
{hop.details.note}
)} {hop.service === 'qBittorrent' && Array.isArray(hop.details?.torrents) && hop.details.torrents.length > 0 && (
Downloads in qBittorrent
    {hop.details.torrents.map((torrent: Record) => { const percent = percentFromTorrent(torrent) return (
  • {torrent.name ?? 'Unknown item'} {percent === null ? 'n/a' : `${percent}%`}
  • ) })}
)} {showDetails && hop.details && (
{JSON.stringify(hop.details, null, 2)}
)}
))}

Try a safe fix

{actionMessage &&
{actionMessage}
}
{snapshot.actions.map((action) => ( ))}
{releaseOptions.length > 0 && (
Download options found
    {releaseOptions.map((release) => (
  • {release.title ?? 'Unknown option'}{' '} {release.indexer ? `(${release.indexer})` : ''} {release.seeders ?? 0} seeders · {formatBytes(release.size)}
  • ))}
)}

History

Recent status changes

    {historySnapshots.length === 0 ? (
  • No history recorded yet.
  • ) : ( historySnapshots.map((entry) => (
  • {entry.state.replaceAll('_', ' ')} {entry.state_reason ?? 'No reason provided.'}
  • )) )}

Recent actions

    {historyActions.length === 0 ? (
  • No actions recorded yet.
  • ) : ( historyActions.map((entry) => (
  • {entry.label} {entry.message ?? entry.status}
  • )) )}
{modalMessage && (

Update

{modalMessage}

)}
) }