Files
jellybloom/src/pages/RequestsPage.tsx
T
2026-03-30 13:40:42 +03:00

392 lines
16 KiB
TypeScript

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<number, RadarrQueueItem>()
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<number, SonarrQueueItem[]>()
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<AggregatedRequest['state'], number> = { 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 (
<div className="px-7 pt-4 pb-12">
<header className="mb-7 flex flex-col md:flex-row md:items-end md:justify-between gap-4">
<div>
<div className="flex items-center gap-2 mb-1.5">
<span className="w-1 h-3.5 rounded-full bg-accent" />
<span className="text-[11px] font-semibold text-text-2 uppercase tracking-[0.14em]">Requests</span>
</div>
<h1 className="text-3xl md:text-4xl font-bold font-display text-text-1 tracking-tight">
Pending downloads
</h1>
<p className="text-[13px] text-text-3 mt-1.5">
Items currently in your *arr stack that haven't finished landing in Jellyfin.
</p>
</div>
{!noInstance && aggregated.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
{counts.processing > 0 && <CountChip tone="blue" label="Downloading" value={counts.processing} />}
{counts.partial > 0 && <CountChip tone="amber" label="Partial" value={counts.partial} />}
{counts.pending > 0 && <CountChip tone="purple" label="Pending" value={counts.pending} />}
{counts.requested > 0 && <CountChip tone="neutral" label="Requested" value={counts.requested} />}
</div>
)}
</header>
{noInstance ? (
<div className="rounded-xl bg-elevated/30 ring-1 ring-border p-10 text-center">
<div className="relative w-14 h-14 mx-auto mb-4">
<div className="absolute inset-0 rounded-full bg-accent/10 blur-xl" />
<div className="relative w-full h-full rounded-full bg-gradient-to-br from-elevated to-surface ring-1 ring-border grid place-items-center">
<Database size={20} className="text-accent" />
</div>
</div>
<p className="text-[15px] text-text-1 font-semibold tracking-tight mb-1.5">No Sonarr or Radarr connected</p>
<p className="text-[12.5px] text-text-3 max-w-md mx-auto leading-relaxed mb-5">
Connect a Sonarr or Radarr instance to track active downloads, monitor pending releases, and manage requests here.
</p>
<button
onClick={() => navigate('/settings#sonarr-radarr')}
className="inline-flex items-center gap-1.5 h-10 px-5 rounded-full bg-accent hover:bg-accent-hover text-void text-[12.5px] font-semibold tracking-tight transition shadow-[0_8px_24px_-8px_rgba(245,182,66,0.5)] focus-ring"
>
<Settings size={12} stroke={2} />
Open Settings
</button>
</div>
) : aggregated.length === 0 ? (
<div className="rounded-xl bg-elevated/30 ring-1 ring-border p-10 text-center">
<div className="relative w-14 h-14 mx-auto mb-4">
<div className="absolute inset-0 rounded-full bg-success/10 blur-xl" />
<div className="relative w-full h-full rounded-full bg-gradient-to-br from-elevated to-surface ring-1 ring-success/30 grid place-items-center">
<Check size={18} className="text-success" stroke={2.5} />
</div>
</div>
<p className="text-[15px] text-text-1 font-semibold tracking-tight mb-1">All caught up</p>
<p className="text-[12.5px] text-text-3 max-w-sm mx-auto leading-relaxed">
Every monitored item in your *arr stack is fully downloaded.
</p>
</div>
) : (
<ul className="space-y-2">
{aggregated.map((req, i) => (
<RequestRow key={req.key} req={req} index={i} />
))}
</ul>
)}
</div>
)
}
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 (
<motion.li
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: Math.min(index * 0.02, 0.3) }}
className="flex items-center gap-3 p-3 rounded-lg bg-elevated/40 ring-1 ring-border hover:ring-border-strong transition"
>
<div className="shrink-0 w-12 aspect-[2/3] rounded bg-black overflow-hidden ring-1 ring-border">
{req.poster && (
<img src={req.poster} alt="" className="w-full h-full object-cover" loading="lazy" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap mb-1">
<p className="text-[13px] font-medium text-text-1 truncate tracking-tight">{req.title}</p>
{req.year && <span className="text-[11px] text-text-4 tabular-nums">{req.year}</span>}
<StateChip state={req.state} />
{req.tier === '4k' && (
<span className="inline-flex items-center h-[16px] px-1.5 rounded text-[9px] font-bold uppercase tracking-[0.06em] bg-cool/20 text-cool ring-1 ring-cool/30">
4K
</span>
)}
</div>
{req.detail && (
<p className="text-[11.5px] text-text-3">{req.detail}</p>
)}
{req.progress != null && req.progress >= 0 && req.progress <= 100 && (
<div className="mt-1.5 h-1 rounded-full bg-elevated overflow-hidden max-w-md">
<div
className="h-full bg-accent transition-[width] duration-300"
style={{ width: `${req.progress}%` }}
/>
</div>
)}
</div>
<div className="shrink-0 flex items-center gap-1">
<button
onClick={searchNow}
title="Search now"
aria-label="Search now"
className="w-9 h-9 grid place-items-center rounded-full text-text-3 hover:text-accent hover:bg-elevated transition focus-ring"
>
<RefreshCw size={13} stroke={2} />
</button>
<button
onClick={cancel}
title="Remove from *arr"
aria-label="Remove"
className="w-9 h-9 grid place-items-center rounded-full text-text-3 hover:text-red-300 hover:bg-elevated transition focus-ring"
>
<Trash2 size={13} stroke={2} />
</button>
</div>
</motion.li>
)
}
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 (
<span className={`inline-flex items-center gap-1.5 h-7 px-2.5 rounded-full ring-1 text-[11.5px] tracking-tight ${palette}`}>
<span className="font-semibold tabular-nums">{value}</span>
<span className="opacity-80">{label}</span>
</span>
)
}
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 (
<span className={`inline-flex items-center gap-1 h-[16px] px-1.5 rounded text-[9.5px] font-bold uppercase tracking-[0.06em] ring-1 ${tone}`}>
<Icon size={9} stroke={2.5} />
{label}
</span>
)
}