main pages
This commit is contained in:
@@ -0,0 +1,391 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user