392 lines
16 KiB
TypeScript
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>
|
|
)
|
|
}
|