import { useMemo } from 'react' import { motion } from 'framer-motion' import { useNavigate } from 'react-router-dom' import { useQueryClient } from '@tanstack/react-query' import { Database, RefreshCw, Trash2, Clock, Check, AlertCircle, Settings } from '../lib/icons' import { useArrInstances } from '../stores/arr-instances-store' import { useRadarrLibrary, useSonarrLibrary, useRadarrQueue, useSonarrQueue } from '../hooks/use-arr' import { radarrClient, type RadarrMovie, type RadarrQueueItem } from '../api/radarr' import { sonarrClient, type SonarrSeries, type SonarrQueueItem } from '../api/sonarr' /** * Aggregated requests view: every movie + show currently sitting in * Sonarr/Radarr but not fully available yet, with per-item actions. * * Data sources: * - Radarr/Sonarr libraries (filter to monitored + missing/partial) * - Their queues (in-flight downloads with progress) * * Hides itself with a setup prompt when no *arr instance is configured. */ type Tier = 'default' | '4k' interface AggregatedRequest { key: string kind: 'movie' | 'tv' tier: Tier arrId: number title: string year?: number | null poster?: string | null /** Render label like "Downloading 64%" or "Pending release". */ state: 'processing' | 'pending' | 'partial' | 'requested' detail?: string /** Optional progress 0..100 when processing. */ progress?: number } export default function RequestsPage() { const navigate = useNavigate() const radarr = useArrInstances(s => s.pick('radarr', 'default')) const radarr4k = useArrInstances(s => s.pick('radarr', '4k')) const sonarr = useArrInstances(s => s.pick('sonarr', 'default')) const sonarr4k = useArrInstances(s => s.pick('sonarr', '4k')) const radarrA = useRadarrLibrary('default') const radarrB = useRadarrLibrary('4k') const sonarrA = useSonarrLibrary('default') const sonarrB = useSonarrLibrary('4k') const radarrQA = useRadarrQueue('default') const radarrQB = useRadarrQueue('4k') const sonarrQA = useSonarrQueue('default') const sonarrQB = useSonarrQueue('4k') const aggregated: AggregatedRequest[] = useMemo(() => { const out: AggregatedRequest[] = [] function pushMovies(list: RadarrMovie[] | null | undefined, queue: { records?: RadarrQueueItem[] } | null | undefined, tier: Tier) { if (!list) return const queueByMovieId = new Map() for (const q of queue?.records || []) if (q.movieId != null) queueByMovieId.set(q.movieId, q) for (const m of list) { if (m.hasFile) continue if (!m.id) continue const q = queueByMovieId.get(m.id) const poster = m.images?.find(x => x.coverType === 'poster')?.remoteUrl || null if (q) { const total = (q.size ?? 0) const left = (q.sizeleft ?? 0) const progress = total > 0 ? Math.round(((total - left) / total) * 100) : undefined out.push({ key: `radarr-${tier}-${m.id}`, kind: 'movie', tier, arrId: m.id, title: m.title, year: m.year, poster, state: 'processing', detail: q.timeleft ? `${progress ?? 0}% ยท ${q.timeleft} left` : `${progress ?? 0}%`, progress, }) } else if (m.monitored) { out.push({ key: `radarr-${tier}-${m.id}`, kind: 'movie', tier, arrId: m.id, title: m.title, year: m.year, poster, state: 'pending', detail: m.status === 'announced' ? 'Announced' : m.status === 'inCinemas' ? 'In cinemas' : 'Searching for release', }) } else { out.push({ key: `radarr-${tier}-${m.id}`, kind: 'movie', tier, arrId: m.id, title: m.title, year: m.year, poster, state: 'requested', detail: 'Not monitored', }) } } } function pushSeries(list: SonarrSeries[] | null | undefined, queue: { records?: SonarrQueueItem[] } | null | undefined, tier: Tier) { if (!list) return const queueBySeriesId = new Map() for (const q of queue?.records || []) { if (q.seriesId == null) continue const existing = queueBySeriesId.get(q.seriesId) || [] queueBySeriesId.set(q.seriesId, [...existing, q]) } for (const s of list) { if (!s.id) continue const seasons = s.seasons || [] const totalEps = seasons.reduce((acc, x) => acc + (x.statistics?.episodeCount || 0), 0) const haveEps = seasons.reduce((acc, x) => acc + (x.statistics?.episodeFileCount || 0), 0) if (totalEps > 0 && haveEps >= totalEps) continue const poster = s.images?.find(x => x.coverType === 'poster')?.remoteUrl || null const q = queueBySeriesId.get(s.id) const monitoredSeasons = seasons.filter(x => x.monitored).length if (q && q.length > 0) { out.push({ key: `sonarr-${tier}-${s.id}`, kind: 'tv', tier, arrId: s.id, title: s.title, year: s.year, poster, state: 'processing', detail: `${q.length} episode${q.length === 1 ? '' : 's'} downloading`, }) } else if (haveEps > 0 && haveEps < totalEps) { out.push({ key: `sonarr-${tier}-${s.id}`, kind: 'tv', tier, arrId: s.id, title: s.title, year: s.year, poster, state: 'partial', detail: `${haveEps} of ${totalEps} episodes`, progress: totalEps > 0 ? Math.round((haveEps / totalEps) * 100) : undefined, }) } else if (monitoredSeasons > 0) { out.push({ key: `sonarr-${tier}-${s.id}`, kind: 'tv', tier, arrId: s.id, title: s.title, year: s.year, poster, state: 'pending', detail: `${monitoredSeasons} season${monitoredSeasons === 1 ? '' : 's'} monitored`, }) } else { out.push({ key: `sonarr-${tier}-${s.id}`, kind: 'tv', tier, arrId: s.id, title: s.title, year: s.year, poster, state: 'requested', detail: 'No seasons monitored', }) } } } pushMovies(radarrA.data, radarrQA.data, 'default') pushMovies(radarrB.data, radarrQB.data, '4k') pushSeries(sonarrA.data, sonarrQA.data, 'default') pushSeries(sonarrB.data, sonarrQB.data, '4k') // Sort: processing first, then partial, pending, requested. Within // each bucket, alphabetical. const order: Record = { processing: 0, partial: 1, pending: 2, requested: 3 } return out.sort((a, b) => { const oa = order[a.state] const ob = order[b.state] if (oa !== ob) return oa - ob return a.title.localeCompare(b.title) }) }, [radarrA.data, radarrB.data, sonarrA.data, sonarrB.data, radarrQA.data, radarrQB.data, sonarrQA.data, sonarrQB.data]) const noInstance = !radarr && !radarr4k && !sonarr && !sonarr4k const counts = useMemo(() => { const out = { processing: 0, partial: 0, pending: 0, requested: 0 } for (const r of aggregated) out[r.state]++ return out }, [aggregated]) return (
Requests

Pending downloads

Items currently in your *arr stack that haven't finished landing in Jellyfin.

{!noInstance && aggregated.length > 0 && (
{counts.processing > 0 && } {counts.partial > 0 && } {counts.pending > 0 && } {counts.requested > 0 && }
)}
{noInstance ? (

No Sonarr or Radarr connected

Connect a Sonarr or Radarr instance to track active downloads, monitor pending releases, and manage requests here.

) : aggregated.length === 0 ? (

All caught up

Every monitored item in your *arr stack is fully downloaded.

) : (
    {aggregated.map((req, i) => ( ))}
)}
) } function RequestRow({ req, index }: { req: AggregatedRequest; index: number }) { const qc = useQueryClient() const radarrInstance = useArrInstances(s => s.pick('radarr', req.tier)) const sonarrInstance = useArrInstances(s => s.pick('sonarr', req.tier)) async function searchNow() { if (req.kind === 'movie' && radarrInstance) { await radarrClient(radarrInstance).searchMovie(req.arrId) qc.invalidateQueries({ queryKey: ['radarr', 'queue'] }) } else if (req.kind === 'tv' && sonarrInstance) { await sonarrClient(sonarrInstance).searchSeries(req.arrId) qc.invalidateQueries({ queryKey: ['sonarr', 'queue'] }) } } async function cancel() { const ok = confirm(`Remove "${req.title}" from ${req.kind === 'movie' ? 'Radarr' : 'Sonarr'}? Files on disk will be left in place.`) if (!ok) return if (req.kind === 'movie' && radarrInstance) { await radarrClient(radarrInstance).removeMovie(req.arrId, false) qc.invalidateQueries({ queryKey: ['radarr', 'library'] }) } else if (req.kind === 'tv' && sonarrInstance) { await sonarrClient(sonarrInstance).removeSeries(req.arrId, false) qc.invalidateQueries({ queryKey: ['sonarr', 'library'] }) } } return (
{req.poster && ( )}

{req.title}

{req.year && {req.year}} {req.tier === '4k' && ( 4K )}
{req.detail && (

{req.detail}

)} {req.progress != null && req.progress >= 0 && req.progress <= 100 && (
)}
) } function CountChip({ tone, label, value }: { tone: 'blue' | 'amber' | 'purple' | 'neutral'; label: string; value: number }) { const palette = tone === 'blue' ? 'bg-blue-500/12 text-blue-200 ring-blue-400/30' : tone === 'amber' ? 'bg-amber-500/12 text-amber-200 ring-amber-400/30' : tone === 'purple' ? 'bg-purple-500/12 text-purple-200 ring-purple-400/30' : 'bg-elevated/60 text-text-2 ring-border' return ( {value} {label} ) } function StateChip({ state }: { state: AggregatedRequest['state'] }) { const Icon = state === 'processing' ? RefreshCw : state === 'partial' ? Check : state === 'pending' ? Clock : AlertCircle const tone = state === 'processing' ? 'bg-blue-500/20 text-blue-200 ring-blue-400/30' : state === 'partial' ? 'bg-amber-500/20 text-amber-200 ring-amber-400/30' : state === 'pending' ? 'bg-purple-500/20 text-purple-200 ring-purple-400/30' : 'bg-elevated/80 text-text-2 ring-border' const label = state === 'processing' ? 'Downloading' : state === 'partial' ? 'Partial' : state === 'pending' ? 'Pending' : 'Requested' return ( {label} ) }