Sync from /srv/compose/unified-media-manager

This commit is contained in:
Christopher Mayor
2026-04-24 10:45:19 -07:00
commit 7dbd00e537
132 changed files with 25394 additions and 0 deletions

76
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,76 @@
import { Routes, Route, NavLink } from 'react-router-dom'
import { lazy, Suspense } from 'react'
import { QueryProvider } from './api/queryClient'
import { ToastProvider } from './components/Toast'
import Loading from './components/Loading'
const Dashboard = lazy(() => import('./pages/Dashboard'))
const Library = lazy(() => import('./pages/Library'))
const Discover = lazy(() => import('./pages/Discover'))
const Calendar = lazy(() => import('./pages/Calendar'))
const MediaDetail = lazy(() => import('./pages/MediaDetail'))
const Queue = lazy(() => import('./pages/Queue'))
const Requests = lazy(() => import('./pages/Requests'))
const Activity = lazy(() => import('./pages/Activity'))
const Blocklist = lazy(() => import('./pages/Blocklist'))
const Settings = lazy(() => import('./pages/Settings'))
const Search = lazy(() => import('./pages/Search'))
const navItems = [
{ to: '/', label: 'Dashboard' },
{ to: '/library', label: 'Library' },
{ to: '/discover', label: 'Discover' },
{ to: '/calendar', label: 'Calendar' },
{ to: '/queue', label: 'Queue' },
{ to: '/search', label: 'Search' },
{ to: '/activity', label: 'Activity' },
{ to: '/requests', label: 'Requests' },
{ to: '/blocklist', label: 'Blocklist' },
{ to: '/settings', label: 'Settings' },
]
export default function App() {
return (
<QueryProvider>
<ToastProvider>
<div className="min-h-screen bg-gray-950">
<a href="#main-content" className="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:bg-indigo-600 focus:text-white focus:px-4 focus:py-2 focus:rounded focus:outline-none focus:ring-2 focus:ring-indigo-400">
Skip to content
</a>
<nav className="bg-gray-900 border-b border-gray-800 px-6 py-3 flex items-center gap-6">
<h1 className="text-xl font-bold text-indigo-400">UMM</h1>
{navItems.map(item => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) =>
`text-sm font-medium ${isActive ? 'text-indigo-400' : 'text-gray-400 hover:text-gray-200'}`
}
>
{item.label}
</NavLink>
))}
</nav>
<main id="main-content" className="p-6">
<Suspense fallback={<Loading />}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/library" element={<Library />} />
<Route path="/discover" element={<Discover />} />
<Route path="/calendar" element={<Calendar />} />
<Route path="/library/:type/:id" element={<MediaDetail />} />
<Route path="/queue" element={<Queue />} />
<Route path="/search" element={<Search />} />
<Route path="/activity" element={<Activity />} />
<Route path="/requests" element={<Requests />} />
<Route path="/blocklist" element={<Blocklist />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
</main>
</div>
</ToastProvider>
</QueryProvider>
)
}

View File

@@ -0,0 +1,64 @@
const API = import.meta.env.VITE_API_URL || ''
function getAPIKey(): string | null {
return localStorage.getItem('umm_api_key')
}
function authHeaders(): Record<string, string> {
const key = getAPIKey()
if (key) return { 'X-API-Key': key }
return {}
}
function jsonHeaders(): Record<string, string> {
return { 'Content-Type': 'application/json', ...authHeaders() }
}
export function setAPIKey(key: string) {
localStorage.setItem('umm_api_key', key)
}
export function clearAPIKey() {
localStorage.removeItem('umm_api_key')
}
export function hasAPIKey(): boolean {
return !!getAPIKey()
}
export async function fetchAPI<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API}${path}`, { ...init, headers: { ...authHeaders(), ...(init?.headers || {}) } })
if (res.status === 401 && path.startsWith('/api/requests')) {
throw new Error('Authentication required. Please set your API key in Settings.')
}
if (!res.ok) throw new Error(`API error: ${res.status}`)
return res.json()
}
export async function postAPI<T>(path: string, body: unknown, init?: RequestInit): Promise<T> {
const res = await fetch(`${API}${path}`, {
method: 'POST',
headers: jsonHeaders(),
body: JSON.stringify(body),
...init,
})
if (!res.ok) throw new Error(`API error: ${res.status}`)
return res.json()
}
export async function putAPI<T>(path: string, body: unknown, init?: RequestInit): Promise<T> {
const res = await fetch(`${API}${path}`, {
method: 'PUT',
headers: jsonHeaders(),
body: JSON.stringify(body),
...init,
})
if (!res.ok) throw new Error(`API error: ${res.status}`)
return res.json()
}
export async function deleteAPI<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API}${path}`, { method: 'DELETE', headers: authHeaders(), ...init })
if (!res.ok) throw new Error(`API error: ${res.status}`)
return res.json()
}

View File

@@ -0,0 +1,22 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000,
gcTime: 5 * 60 * 1000,
retry: 1,
refetchOnWindowFocus: false,
},
},
})
export function QueryProvider({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
export { queryClient }

View File

@@ -0,0 +1,85 @@
import { useEffect, useRef, type ReactNode } from 'react'
interface ConfirmModalProps {
open: boolean
title: string
message: string
onConfirm: () => void
onCancel: () => void
destructive?: boolean
confirmLabel?: string
children?: ReactNode
}
const FOCUSABLE = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
export default function ConfirmModal({ open, title, message, onConfirm, onCancel, destructive, confirmLabel, children }: ConfirmModalProps) {
const dialogRef = useRef<HTMLDivElement>(null)
const cancelRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
if (!open) return
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onCancel()
return
}
if (e.key === 'Tab' && dialogRef.current) {
const focusable = Array.from(dialogRef.current.querySelectorAll<HTMLElement>(FOCUSABLE))
if (!focusable.length) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault()
last.focus()
}
} else {
if (document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
}
}
document.addEventListener('keydown', handleKey)
cancelRef.current?.focus()
return () => document.removeEventListener('keydown', handleKey)
}, [open, onCancel])
if (!open) return null
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onCancel} role="presentation">
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="confirm-modal-title"
aria-describedby="confirm-modal-desc"
className="bg-gray-900 border border-gray-800 rounded-lg p-6 max-w-md w-full mx-4"
onClick={e => e.stopPropagation()}
>
<h3 id="confirm-modal-title" className="text-xl font-semibold mb-2 text-gray-100">{title}</h3>
<p id="confirm-modal-desc" className="text-gray-400 text-sm mb-4">{message}</p>
{children}
<div className="flex gap-3 justify-end">
<button ref={cancelRef} onClick={onCancel} className="px-4 py-2 text-sm bg-gray-700 hover:bg-gray-600 rounded text-gray-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400">
Cancel
</button>
<button
onClick={onConfirm}
className={`px-4 py-2 text-sm rounded font-semibold min-h-[36px] ${
destructive ? 'bg-red-600 hover:bg-red-500 text-white' : 'bg-indigo-500 hover:bg-indigo-400 text-white'
} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400`}
>
{confirmLabel ?? 'Confirm'}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,12 @@
export default function ErrorBanner({ error, onRetry }: { error: string; onRetry?: () => void }) {
return (
<div className="bg-red-900/30 border border-red-800 rounded-lg p-4 mb-4">
<p className="text-red-400 text-sm">{error}</p>
{onRetry && (
<button onClick={onRetry} className="text-red-300 text-xs underline mt-1">
Retry
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,7 @@
export default function Loading() {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin h-6 w-6 border-2 border-indigo-400 border-t-transparent rounded-full" />
</div>
)
}

View File

@@ -0,0 +1,52 @@
export default function Pagination({ total, page, pageSize, onPageChange }: {
total: number
page: number
pageSize: number
onPageChange: (p: number) => void
}) {
const totalPages = Math.ceil(total / pageSize)
if (totalPages <= 1) return null
const pages: number[] = []
const maxVisible = 7
let start = Math.max(1, page - Math.floor(maxVisible / 2))
const end = Math.min(totalPages, start + maxVisible - 1)
start = Math.max(1, end - maxVisible + 1)
for (let i = start; i <= end; i++) {
pages.push(i)
}
return (
<div className="flex items-center justify-between py-4">
<span className="text-sm text-gray-500">{total} items</span>
<div className="flex gap-1">
<button
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
aria-label="Previous page"
className="px-3 py-1 text-sm rounded bg-gray-800 text-gray-400 hover:bg-gray-700 disabled:opacity-40"
>
&lt;
</button>
{pages.map(p => (
<button
key={p}
onClick={() => onPageChange(p)}
className={`px-3 py-1 text-sm rounded ${p === page ? 'bg-indigo-400 text-gray-950' : 'bg-gray-800 text-gray-400 hover:bg-gray-700'}`}
>
{p}
</button>
))}
<button
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
aria-label="Next page"
className="px-3 py-1 text-sm rounded bg-gray-800 text-gray-400 hover:bg-gray-700 disabled:opacity-40"
>
&gt;
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,218 @@
import { useState } from 'react'
import { postAPI } from '../api/client'
import { useToast } from './Toast'
interface SearchResult {
title: string
guid: string
size: number
pub_date: string
indexer_name: string
indexer_priority: number
quality: {
title: string
resolution: string
source: string
video_codec: string
release_group: string
parse_warning: boolean
}
quality_tier: {
name: string
rank: number
resolution: string
} | null
seeders: number
peers: number
category: string
download_url: string
source_indexers: string[]
}
interface GrabPayload {
download_url: string
title: string
media_type: string
quality: SearchResult['quality']
indexer_name: string
media_id: number
}
interface ReleaseSearchResultsProps {
results: SearchResult[]
mediaId: number
mediaType: string
loading?: boolean
}
type SortCol = 'quality' | 'size' | 'seeders' | 'age'
type SortDir = 'asc' | 'desc'
function formatFileSize(bytes: number): string {
if (bytes >= 1024 * 1024 * 1024) {
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
}
if (bytes >= 1024 * 1024) {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
return `${(bytes / 1024).toFixed(0)} KB`
}
function formatAge(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime()
const minutes = Math.floor(diff / 60000)
if (minutes < 1) return 'now'
if (minutes < 60) return `${minutes}m`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h`
const days = Math.floor(hours / 24)
if (days < 30) return `${days}d`
const months = Math.floor(days / 30)
return `${months}mo`
}
export function ReleaseSearchResults({ results, mediaId, mediaType, loading }: ReleaseSearchResultsProps) {
const [sortCol, setSortCol] = useState<SortCol>('quality')
const [sortDir, setSortDir] = useState<SortDir>('asc')
const [grabbing, setGrabbing] = useState<Set<string>>(new Set())
const { showToast } = useToast()
function handleSort(col: SortCol) {
if (sortCol === col) {
setSortDir(prev => (prev === 'asc' ? 'desc' : 'asc'))
} else {
setSortCol(col)
setSortDir('asc')
}
}
const sorted = [...results].sort((a, b) => {
let cmp = 0
switch (sortCol) {
case 'quality':
cmp = (a.quality_tier?.rank ?? 999) - (b.quality_tier?.rank ?? 999)
break
case 'size':
cmp = a.size - b.size
break
case 'seeders':
cmp = a.seeders - b.seeders
break
case 'age':
cmp = new Date(a.pub_date).getTime() - new Date(b.pub_date).getTime()
break
}
return sortDir === 'asc' ? cmp : -cmp
})
async function handleGrab(result: SearchResult) {
setGrabbing(prev => new Set(prev).add(result.guid))
try {
await postAPI<{ queue_id: number }>('/api/releases/grab', {
download_url: result.download_url,
title: result.title,
media_type: mediaType,
quality: result.quality,
indexer_name: result.indexer_name,
media_id: mediaId,
} satisfies GrabPayload)
showToast(`✓ Grabbed "${result.title}"`)
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
showToast(`✗ Failed to grab: ${message}`)
} finally {
setGrabbing(prev => {
const next = new Set(prev)
next.delete(result.guid)
return next
})
}
}
function SortHeader({ col, label }: { col: SortCol; label: string }) {
const active = sortCol === col
return (
<button
onClick={() => handleSort(col)}
className={`text-left text-xs font-semibold uppercase tracking-wide transition-colors ${
active ? 'text-indigo-400 hover:text-indigo-300' : 'text-gray-400 hover:text-gray-200'
}`}
>
{label} {active && (sortDir === 'asc' ? '▲' : '▼')}
</button>
)
}
if (loading) {
return (
<div className="animate-pulse space-y-3">
<div className="h-10 bg-gray-800 rounded" />
<div className="h-10 bg-gray-800 rounded" />
<div className="h-10 bg-gray-800 rounded" />
</div>
)
}
if (results.length === 0) {
return <div className="text-gray-500 text-sm text-center py-12">No releases found</div>
}
return (
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-gray-800">
<th className="px-4 py-3"><SortHeader col="quality" label="Quality" /></th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wide">Title</th>
<th className="px-4 py-3"><SortHeader col="size" label="Size" /></th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wide">Indexer</th>
<th className="px-4 py-3"><SortHeader col="seeders" label="Seeders" /></th>
<th className="px-4 py-3"><SortHeader col="age" label="Age" /></th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wide w-20">Action</th>
</tr>
</thead>
<tbody>
{sorted.map(result => {
const isGrabbing = grabbing.has(result.guid)
const noUrl = !result.download_url
return (
<tr key={result.guid} className="border-b border-gray-800/50 hover:bg-gray-800/30">
<td className="px-4 py-3 text-sm">
{result.quality_tier ? (
<span className="text-gray-200">{result.quality_tier.name}</span>
) : result.quality.resolution || result.quality.source ? (
<span className="text-gray-200">{result.quality.resolution} {result.quality.source}</span>
) : (
<span className="text-gray-500">Unknown</span>
)}
</td>
<td className="px-4 py-3 text-sm text-gray-200 max-w-lg truncate" title={result.title}>
{result.title}
</td>
<td className="px-4 py-3 text-sm text-gray-400">{formatFileSize(result.size)}</td>
<td className="px-4 py-3 text-sm text-gray-400">{result.indexer_name}</td>
<td className="px-4 py-3 text-sm text-gray-400">
<span className={result.seeders > 0 ? 'text-green-400' : ''}>{result.seeders}</span>
{' / '}
<span>{result.peers}</span>
</td>
<td className="px-4 py-3 text-sm text-gray-400">{formatAge(result.pub_date)}</td>
<td className="px-4 py-3">
<button
disabled={isGrabbing || noUrl}
onClick={() => handleGrab(result)}
className="px-3 py-1 text-xs font-semibold rounded bg-indigo-600 hover:bg-indigo-500 text-white disabled:opacity-50 disabled:cursor-not-allowed"
>
{isGrabbing ? 'Grabbing...' : noUrl ? 'N/A' : 'Grab'}
</button>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}
export default ReleaseSearchResults

View File

@@ -0,0 +1,23 @@
const statusColors: Record<string, string> = {
pending: 'bg-yellow-900/30 text-yellow-400',
approved: 'bg-blue-900/30 text-blue-400',
searching: 'bg-cyan-900/30 text-cyan-400',
downloading: 'bg-orange-900/30 text-orange-400',
fulfilled: 'bg-green-900/30 text-green-400',
failed: 'bg-red-900/30 text-red-400',
available: 'bg-green-900/30 text-green-400',
unavailable: 'bg-red-900/30 text-red-400',
imported: 'bg-green-900/30 text-green-400',
completed: 'bg-green-900/30 text-green-400',
rejected: 'bg-red-900/30 text-red-400',
}
export default function StatusBadge({ status }: { status: string }) {
const colorClass = statusColors[status] ?? 'bg-gray-800 text-gray-400'
return (
<span className={`px-2 py-0.5 rounded text-xs font-medium ${colorClass}`} aria-label={status}>
{status}
</span>
)
}

View File

@@ -0,0 +1,32 @@
import { createContext, useContext, useState, useCallback } from 'react'
import type { ReactNode } from 'react'
interface ToastContextValue {
showToast: (message: string) => void
}
const ToastContext = createContext<ToastContextValue>({ showToast: () => {} })
export function useToast(): ToastContextValue {
return useContext(ToastContext)
}
export function ToastProvider({ children }: { children: ReactNode }) {
const [toast, setToast] = useState<string | null>(null)
const showToast = useCallback((message: string) => {
setToast(message)
setTimeout(() => setToast(null), 3000)
}, [])
return (
<ToastContext.Provider value={{ showToast }}>
{children}
{toast && (
<div role="status" aria-live="polite" className="fixed bottom-4 right-4 bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 shadow-lg z-50">
<p className="text-sm text-gray-200">{toast}</p>
</div>
)}
</ToastContext.Provider>
)
}

22
frontend/src/index.css Normal file
View File

@@ -0,0 +1,22 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
:focus-visible {
outline: 2px solid #818cf8;
outline-offset: 2px;
}
:focus:not(:focus-visible) {
outline: none;
}
@media (max-width: 768px) {
input, select, textarea {
font-size: 16px !important;
}
}

15
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,15 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<Routes>
<Route path="/*" element={<App />} />
</Routes>
</BrowserRouter>
</React.StrictMode>,
)

View File

@@ -0,0 +1,160 @@
import { useEffect, useState, useCallback } from 'react'
import { fetchAPI } from '../api/client'
import Pagination from '../components/Pagination'
import ErrorBanner from '../components/ErrorBanner'
import Loading from '../components/Loading'
interface ActivityEvent {
id: number
event_type: string
media_id: number | null
media_type: string | null
title: string
description: string | null
data: Record<string, unknown>
created_at: string
}
interface PaginatedResponse {
data: ActivityEvent[]
total: number
page: number
page_size: number
total_pages: number
}
const PAGE_SIZE = 50
function eventTypeBadge(type: string): { label: string; className: string } {
switch (type) {
case 'grab':
return { label: 'Grab', className: 'bg-blue-600' }
case 'import':
return { label: 'Import', className: 'bg-green-600' }
case 'download_complete':
return { label: 'Download', className: 'bg-green-600' }
case 'download_failed':
return { label: 'Failed', className: 'bg-red-600' }
case 'quality_upgrade':
return { label: 'Upgrade', className: 'bg-purple-600' }
case 'safety_block':
return { label: 'Blocked', className: 'bg-orange-600' }
case 'error':
return { label: 'Error', className: 'bg-red-600' }
default:
return { label: 'Info', className: 'bg-gray-600' }
}
}
function formatTimeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime()
const minutes = Math.floor(diff / 60000)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
return `${days}d ago`
}
export default function Activity() {
const [events, setEvents] = useState<ActivityEvent[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [typeFilter, setTypeFilter] = useState('')
const fetchActivity = useCallback(() => {
setLoading(true)
setError(null)
let url = `/api/activity?page=${page}&page_size=${PAGE_SIZE}`
if (typeFilter) url += `&event_type=${typeFilter}`
fetchAPI<PaginatedResponse>(url)
.then(res => {
setEvents(res.data ?? [])
setTotal(res.total)
})
.catch(err => setError(err.message || 'Something went wrong. Please try again.'))
.finally(() => setLoading(false))
}, [page, typeFilter])
useEffect(() => {
fetchActivity()
}, [fetchActivity])
return (
<div>
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-semibold text-gray-100">Activity</h2>
<select
value={typeFilter}
onChange={e => { setTypeFilter(e.target.value); setPage(1) }}
className="bg-gray-800 border border-gray-700 text-gray-200 rounded px-3 py-2 text-sm"
>
<option value="">All Events</option>
<option value="grab">Grabs</option>
<option value="import">Imports</option>
<option value="download_complete">Downloads</option>
<option value="download_failed">Failures</option>
<option value="quality_upgrade">Upgrades</option>
<option value="safety_block">Safety Blocks</option>
<option value="error">Errors</option>
</select>
</div>
{error && <ErrorBanner error={error} onRetry={fetchActivity} />}
{loading ? (
<Loading />
) : events.length === 0 ? (
<div className="text-gray-500 text-center py-20 text-sm">No activity events</div>
) : (
<>
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-gray-800">
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-28">Type</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase">Title</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-24">Media</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-20">Time</th>
</tr>
</thead>
<tbody>
{events.map(event => {
const badge = eventTypeBadge(event.event_type)
return (
<tr key={event.id} className="border-b border-gray-800/50 hover:bg-gray-800/30">
<td className="px-4 py-3">
<span className={`inline-block px-2 py-0.5 rounded text-xs font-semibold text-white ${badge.className}`}>
{badge.label}
</span>
</td>
<td className="px-4 py-3">
<p className="text-sm text-gray-100 truncate max-w-md">{event.title}</p>
{event.description && (
<p className="text-xs text-gray-500 mt-0.5">{event.description}</p>
)}
</td>
<td className="px-4 py-3 text-sm text-gray-400">
{event.media_id != null ? `${event.media_type} #${event.media_id}` : '—'}
</td>
<td className="px-4 py-3 text-xs text-gray-500">{formatTimeAgo(event.created_at)}</td>
</tr>
)
})}
</tbody>
</table>
</div>
<Pagination
total={total}
page={page}
pageSize={PAGE_SIZE}
onPageChange={setPage}
/>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,243 @@
import { useEffect, useState, useCallback } from 'react'
import { fetchAPI, deleteAPI } from '../api/client'
import { useToast } from '../components/Toast'
import Pagination from '../components/Pagination'
import ErrorBanner from '../components/ErrorBanner'
import Loading from '../components/Loading'
import ConfirmModal from '../components/ConfirmModal'
interface BlocklistItem {
id: number
release_title: string
source_title: string | null
quality: { resolution?: string } | null
indexer: string | null
protocol: string
size: number | null
message: string | null
block_reason: string
created_at: string
}
interface PaginatedResponse {
data: BlocklistItem[]
total: number
page: number
page_size: number
total_pages: number
}
const PAGE_SIZE = 50
export default function Blocklist() {
const { showToast } = useToast()
const [items, setItems] = useState<BlocklistItem[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [selected, setSelected] = useState<Set<number>>(new Set())
const [clearAllOpen, setClearAllOpen] = useState(false)
const [deleting, setDeleting] = useState(false)
const fetchBlocklist = useCallback(() => {
setLoading(true)
setError(null)
fetchAPI<PaginatedResponse>(`/api/blocklist?page=${page}&page_size=${PAGE_SIZE}`)
.then(res => {
setItems(res.data ?? [])
setTotal(res.total)
setSelected(new Set())
})
.catch(err => setError(err.message || 'Something went wrong. Please try again.'))
.finally(() => setLoading(false))
}, [page])
useEffect(() => {
fetchBlocklist()
}, [fetchBlocklist])
const toggleSelect = (id: number) => {
setSelected(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const toggleSelectAll = () => {
if (selected.size === items.length) {
setSelected(new Set())
} else {
setSelected(new Set(items.map(i => i.id)))
}
}
const deleteSelected = async () => {
setDeleting(true)
let failed = 0
for (const id of selected) {
try {
await deleteAPI(`/api/blocklist/${id}`)
} catch {
failed++
}
}
showToast(failed ? `Deleted ${selected.size - failed} items, ${failed} failed` : `Deleted ${selected.size} items`)
setDeleting(false)
fetchBlocklist()
}
const clearExpired = async () => {
try {
await deleteAPI('/api/blocklist/expired')
showToast('Expired entries cleared')
fetchBlocklist()
} catch {
showToast('Failed to clear expired entries')
}
}
const clearAll = async () => {
try {
await deleteAPI('/api/blocklist')
showToast('All entries cleared')
fetchBlocklist()
} catch {
showToast('Failed to clear blocklist')
}
setClearAllOpen(false)
}
const deleteOne = async (id: number) => {
try {
await deleteAPI(`/api/blocklist/${id}`)
setItems(items.filter(i => i.id !== id))
setTotal(t => t - 1)
showToast('Entry removed')
} catch {
showToast('Failed to remove entry')
}
}
const formatTimeAgo = (dateStr: string): string => {
const diff = Date.now() - new Date(dateStr).getTime()
const minutes = Math.floor(diff / 60000)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
return `${days}d ago`
}
return (
<div>
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-semibold text-gray-100">Blocklist</h2>
<div className="flex gap-2">
<button onClick={clearExpired} className="bg-orange-600 hover:bg-orange-500 px-4 py-2 rounded text-sm font-semibold min-h-[36px]">
Clear Expired
</button>
<button onClick={() => setClearAllOpen(true)} className="bg-red-600 hover:bg-red-500 px-4 py-2 rounded text-sm font-semibold min-h-[36px]">
Clear All
</button>
</div>
</div>
{error && <ErrorBanner error={error} onRetry={fetchBlocklist} />}
{loading ? (
<Loading />
) : items.length === 0 ? (
<div className="text-gray-500 text-center py-20 text-sm">No blocked releases</div>
) : (
<>
{selected.size > 0 && (
<div className="mb-4 flex items-center gap-4">
<span className="text-sm text-gray-400">{selected.size} selected</span>
<button
onClick={deleteSelected}
disabled={deleting}
className="bg-red-600 hover:bg-red-500 px-4 py-2 rounded text-sm font-semibold min-h-[36px] disabled:opacity-50"
>
Delete Selected
</button>
</div>
)}
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full min-w-[640px]">
<thead>
<tr className="border-b border-gray-800">
<th className="px-4 py-3 w-10">
<span className="sr-only">
<input
type="checkbox"
checked={selected.size === items.length && items.length > 0}
onChange={toggleSelectAll}
className="rounded bg-gray-800 border-gray-600"
aria-label="Select all"
/>
</span>
</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase">Release</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-24">Indexer</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-20">Quality</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-20">Reason</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-20">Date</th>
<th className="w-10 px-4 py-3" />
</tr>
</thead>
<tbody>
{items.map(item => (
<tr key={item.id} className={`border-b border-gray-800/50 hover:bg-gray-800/30 ${selected.has(item.id) ? 'bg-gray-800/20' : ''}`}>
<td className="px-4 py-3">
<input
type="checkbox"
checked={selected.has(item.id)}
onChange={() => toggleSelect(item.id)}
className="rounded bg-gray-800 border-gray-600"
aria-label={`Select ${item.release_title}`}
/>
</td>
<td className="px-4 py-3">
<p className="text-sm text-gray-100 truncate max-w-md">{item.release_title}</p>
</td>
<td className="px-4 py-3 text-sm text-gray-400">{item.indexer ?? '—'}</td>
<td className="px-4 py-3 text-sm text-gray-400">{item.quality?.resolution ?? '—'}</td>
<td className="px-4 py-3 text-sm text-gray-400 capitalize">{item.block_reason}</td>
<td className="px-4 py-3 text-xs text-gray-500">{formatTimeAgo(item.created_at)}</td>
<td className="px-4 py-3">
<button onClick={() => deleteOne(item.id)} aria-label="Remove from blocklist" className="text-red-400 hover:text-red-300 text-xs min-h-[36px]">
×
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<Pagination
total={total}
page={page}
pageSize={PAGE_SIZE}
onPageChange={setPage}
/>
</>
)}
<ConfirmModal
open={clearAllOpen}
title="Clear Blocklist"
message="Remove all blocked releases? This cannot be undone."
onConfirm={clearAll}
onCancel={() => setClearAllOpen(false)}
destructive
/>
</div>
)
}

View File

@@ -0,0 +1,233 @@
import { useEffect, useState, useCallback, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { fetchAPI } from '../api/client'
import ErrorBanner from '../components/ErrorBanner'
interface CalendarEvent {
id: number
media_type: string
title: string
date: string
year: number | null
status: string
poster_url: string
}
interface CalendarResponse {
data: CalendarEvent[]
month: string
}
const typeColors: Record<string, { bg: string; border: string; text: string }> = {
movie: { bg: 'bg-indigo-600/20', border: 'border-l-indigo-500', text: 'text-indigo-300' },
series: { bg: 'bg-emerald-600/20', border: 'border-l-emerald-500', text: 'text-emerald-300' },
music: { bg: 'bg-amber-600/20', border: 'border-l-amber-500', text: 'text-amber-300' },
audiobook: { bg: 'bg-rose-600/20', border: 'border-l-rose-500', text: 'text-rose-300' },
podcast: { bg: 'bg-violet-600/20', border: 'border-l-violet-500', text: 'text-violet-300' },
}
const dayLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const monthNames = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
]
export default function Calendar() {
const navigate = useNavigate()
const now = useMemo(() => new Date(), [])
const [year, setYear] = useState(now.getFullYear())
const [month, setMonth] = useState(now.getMonth())
const [events, setEvents] = useState<CalendarEvent[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const monthParam = `${year}-${String(month + 1).padStart(2, '0')}`
const fetchData = useCallback(() => {
setLoading(true)
setError(null)
fetchAPI<CalendarResponse>(`/api/calendar?month=${monthParam}`)
.then(res => {
setEvents(res.data ?? [])
})
.catch(err => setError(err.message || 'Failed to load calendar events'))
.finally(() => setLoading(false))
}, [monthParam])
useEffect(() => {
fetchData()
}, [fetchData])
const goToPrevMonth = () => {
if (month === 0) {
setMonth(11)
setYear(y => y - 1)
} else {
setMonth(m => m - 1)
}
}
const goToNextMonth = () => {
if (month === 11) {
setMonth(0)
setYear(y => y + 1)
} else {
setMonth(m => m + 1)
}
}
const goToToday = () => {
setYear(now.getFullYear())
setMonth(now.getMonth())
}
const handleEventClick = (event: CalendarEvent) => {
navigate(`/library/${event.media_type}/${event.id}`)
}
// Calendar grid calculations
const firstDay = new Date(year, month, 1).getDay()
const daysInMonth = new Date(year, month + 1, 0).getDate()
const today = new Date()
const isCurrentMonth = today.getFullYear() === year && today.getMonth() === month
const todayDate = today.getDate()
// Group events by day
const eventsByDay = useMemo(() => {
const map = new Map<number, CalendarEvent[]>()
for (const event of events) {
const day = parseInt(event.date.split('-')[2] ?? '0', 10)
if (day > 0) {
const existing = map.get(day) ?? []
existing.push(event)
map.set(day, existing)
}
}
return map
}, [events])
// Build the 42-cell grid (6 rows × 7 cols)
const cells: (number | null)[] = []
for (let i = 0; i < firstDay; i++) {
cells.push(null)
}
for (let d = 1; d <= daysInMonth; d++) {
cells.push(d)
}
while (cells.length < 42) {
cells.push(null)
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-semibold text-gray-100">Calendar</h2>
<p className="text-gray-500 text-sm">Upcoming release dates for monitored media</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={goToToday}
className="px-3 py-1.5 text-sm font-medium bg-gray-800 border border-gray-700 rounded-lg text-gray-300 hover:bg-gray-700 transition-colors"
>
Today
</button>
<div className="flex items-center gap-2">
<button
onClick={goToPrevMonth}
aria-label="Previous month"
className="p-1.5 rounded-lg bg-gray-800 border border-gray-700 text-gray-400 hover:text-gray-200 hover:bg-gray-700 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<span className="text-gray-100 font-medium min-w-[140px] text-center">
{monthNames[month]} {year}
</span>
<button
onClick={goToNextMonth}
aria-label="Next month"
className="p-1.5 rounded-lg bg-gray-800 border border-gray-700 text-gray-400 hover:text-gray-200 hover:bg-gray-700 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
</div>
{error && <ErrorBanner error={error} onRetry={fetchData} />}
{/* Day-of-week headers */}
<div className="grid grid-cols-7 gap-1 mb-1">
{dayLabels.map(d => (
<div key={d} className="text-center text-xs font-medium text-gray-500 py-2">
{d}
</div>
))}
</div>
{loading ? (
<div className="grid grid-cols-7 gap-1">
{Array.from({ length: 42 }).map((_, i) => (
<div key={i} className="bg-gray-900 border border-gray-800 rounded-lg min-h-[100px] p-2 animate-pulse">
<div className="h-3 w-6 bg-gray-800 rounded" />
</div>
))}
</div>
) : (
<div className="grid grid-cols-7 gap-1">
{cells.map((day, idx) => {
if (day === null) {
return <div key={`empty-${idx}`} className="min-h-[100px]" />
}
const dayEvents = eventsByDay.get(day) ?? []
const isToday = isCurrentMonth && day === todayDate
const maxVisible = 3
return (
<div
key={`day-${day}`}
className="bg-gray-900 border border-gray-800 rounded-lg min-h-[100px] p-2"
>
<span
className={`text-xs font-medium ${
isToday
? 'text-indigo-400 ring-1 ring-indigo-500 rounded-full w-5 h-5 flex items-center justify-center'
: 'text-gray-500'
}`}
>
{day}
</span>
<div className="mt-1 space-y-0.5">
{dayEvents.slice(0, maxVisible).map(event => {
const colors = typeColors[event.media_type] ?? { bg: 'bg-gray-600/20', border: 'border-l-gray-500', text: 'text-gray-300' }
return (
<button
key={event.id}
onClick={() => handleEventClick(event)}
className={`w-full text-left text-xs px-1.5 py-0.5 rounded border-l-2 ${colors.bg} ${colors.border} ${colors.text} hover:brightness-125 transition-all line-clamp-1 cursor-pointer`}
title={event.title}
>
{event.title}
</button>
)
})}
{dayEvents.length > maxVisible && (
<span className="text-xs text-gray-500 pl-1">
+{dayEvents.length - maxVisible} more
</span>
)}
</div>
</div>
)
})}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,90 @@
import { useEffect, useState, useCallback } from 'react'
import { fetchAPI } from '../api/client'
import ErrorBanner from '../components/ErrorBanner'
import Loading from '../components/Loading'
interface DashboardStats {
total_media: number
monitored: number
unavailable: number
available: number
quality_upgrades: number
queue_pending: number
queue_downloading: number
queue_failed: number
blocklist_count: number
blocklist_expired: number
indexers_enabled: number
recent_downloads: number
media_by_type: Record<string, number>
storage_by_type: Record<string, number>
}
export default function Dashboard() {
const [data, setData] = useState<DashboardStats | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const fetchDashboard = useCallback(() => {
setLoading(true)
setError(null)
fetchAPI<DashboardStats>('/api/dashboard')
.then(setData)
.catch(err => setError(err.message || 'Something went wrong. Please try again.'))
.finally(() => setLoading(false))
}, [])
useEffect(() => {
fetchDashboard()
}, [fetchDashboard])
if (error) {
return (
<div>
<h2 className="text-2xl font-semibold mb-6 text-gray-100">Dashboard</h2>
<ErrorBanner error={error} onRetry={fetchDashboard} />
</div>
)
}
const cards = [
{ label: 'Movies', value: data?.media_by_type?.movie ?? 0, color: 'bg-blue-600' },
{ label: 'TV Series', value: data?.media_by_type?.series ?? 0, color: 'bg-green-600' },
{ label: 'Music', value: data?.media_by_type?.music ?? 0, color: 'bg-purple-600' },
{ label: 'Books', value: data?.media_by_type?.book ?? 0, color: 'bg-teal-600' },
{ label: 'Active Downloads', value: data?.queue_downloading ?? 0, color: 'bg-orange-600' },
{ label: 'Missing', value: data?.unavailable ?? 0, color: 'bg-red-600' },
{ label: 'Upgrades Available', value: data?.quality_upgrades ?? 0, color: 'bg-cyan-600' },
{ label: 'Total Storage', value: data ? formatStorage(data.storage_by_type) : '—', color: 'bg-gray-600' },
]
return (
<div>
<h2 className="text-2xl font-semibold mb-6 text-gray-100">Dashboard</h2>
{loading && !data ? (
<Loading />
) : (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{cards.map(card => (
<div key={card.label} className={`${card.color} rounded-lg p-4`}>
<p className="text-sm text-gray-200 font-normal">{card.label}</p>
{loading ? (
<div className="mt-2 h-8 w-16 bg-white/10 animate-pulse rounded" />
) : (
<p className="text-2xl font-semibold mt-1">{card.value}</p>
)}
</div>
))}
</div>
)}
</div>
)
}
function formatStorage(storageByType: Record<string, number>): string {
const total = Object.values(storageByType).reduce((sum, v) => sum + v, 0)
const tb = total / 1e12
if (tb >= 1) return `${tb.toFixed(1)} TB`
const gb = total / 1e9
return `${gb.toFixed(1)} GB`
}

View File

@@ -0,0 +1,207 @@
import { useEffect, useState, useCallback } from 'react'
import { fetchAPI, postAPI } from '../api/client'
import { useToast } from '../components/Toast'
import ErrorBanner from '../components/ErrorBanner'
interface DiscoverItem {
tmdb_id: number
title: string
year: number | null
media_type: string
overview: string
poster_url: string
backdrop_url: string
vote_average: number
in_library: boolean
}
interface DiscoverResponse {
data: DiscoverItem[]
page: number
}
export default function Discover() {
const { showToast } = useToast()
const [items, setItems] = useState<DiscoverItem[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [mediaType, setMediaType] = useState<string>('movie')
const [tab, setTab] = useState<string>('trending')
const [page, setPage] = useState(1)
const [adding, setAdding] = useState<Set<number>>(new Set())
const fetchData = useCallback(() => {
setLoading(true)
setError(null)
fetchAPI<DiscoverResponse>(`/api/discover/${tab}?type=${mediaType}&page=${page}`)
.then(res => {
setItems(res.data ?? [])
})
.catch(err => setError(err.message || 'Failed to load discover content'))
.finally(() => setLoading(false))
}, [tab, mediaType, page])
useEffect(() => {
fetchData()
}, [fetchData])
const handleMediaTypeChange = (value: string) => {
setMediaType(value)
setPage(1)
}
const handleTabChange = (value: string) => {
setTab(value)
setPage(1)
}
const handleAddToLibrary = async (item: DiscoverItem) => {
setAdding(prev => new Set(prev).add(item.tmdb_id))
try {
const result = await postAPI<{ id: number; existing?: boolean }>(
'/api/discover/add',
{ tmdb_id: item.tmdb_id, media_type: item.media_type }
)
setItems(prev =>
prev.map(i => i.tmdb_id === item.tmdb_id ? { ...i, in_library: true } : i)
)
if (result.existing) {
showToast(`"${item.title}" is already in your library`)
} else {
showToast(`Added "${item.title}" to library`)
}
} catch {
showToast(`Failed to add "${item.title}" to library`)
} finally {
setAdding(prev => {
const next = new Set(prev)
next.delete(item.tmdb_id)
return next
})
}
}
return (
<div>
<h2 className="text-2xl font-semibold mb-2 text-gray-100">Discover</h2>
<p className="text-gray-500 text-sm mb-6">Browse trending and popular content from TMDB</p>
<div className="flex gap-3 mb-6">
<div className="flex rounded-lg overflow-hidden border border-gray-700">
<button
onClick={() => handleTabChange('trending')}
className={`px-4 py-2 text-sm font-medium transition-colors ${
tab === 'trending' ? 'bg-indigo-600 text-white' : 'bg-gray-800 text-gray-400 hover:text-gray-200'
}`}
>
Trending
</button>
<button
onClick={() => handleTabChange('popular')}
className={`px-4 py-2 text-sm font-medium transition-colors ${
tab === 'popular' ? 'bg-indigo-600 text-white' : 'bg-gray-800 text-gray-400 hover:text-gray-200'
}`}
>
Popular
</button>
</div>
<select
value={mediaType}
onChange={e => handleMediaTypeChange(e.target.value)}
className="bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-gray-100 text-sm"
>
<option value="movie">Movies</option>
<option value="series">Series</option>
</select>
</div>
{error && <ErrorBanner error={error} onRetry={fetchData} />}
{loading ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden animate-pulse">
<div className="aspect-[2/3] bg-gray-800" />
</div>
))}
</div>
) : items.length === 0 ? (
<div className="text-gray-500 text-center py-20 text-sm">No results found</div>
) : (
<>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{items.map(item => (
<div
key={item.tmdb_id}
className="group relative bg-gray-900 border border-gray-800 rounded-lg overflow-hidden hover:border-gray-700 transition-colors"
>
{item.poster_url ? (
<img
src={item.poster_url}
alt={item.title}
className="w-full aspect-[2/3] object-cover"
loading="lazy"
/>
) : (
<div className="w-full aspect-[2/3] bg-gray-800 flex items-center justify-center">
<span className="text-3xl text-gray-400 font-bold">
{item.title.charAt(0).toUpperCase()}
</span>
</div>
)}
{item.in_library && (
<div className="absolute top-2 right-2 bg-green-600 rounded-full w-6 h-6 flex items-center justify-center text-white text-xs font-bold z-10">
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex flex-col justify-end p-3">
<p className="text-white text-sm font-medium leading-tight mb-1 line-clamp-2">{item.title}</p>
<div className="flex items-center gap-2 text-xs text-gray-300 mb-2">
{item.year && <span>{item.year}</span>}
<span> {item.vote_average.toFixed(1)}</span>
</div>
{item.in_library ? (
<button
disabled
className="bg-green-800 text-green-300 text-xs px-3 py-1.5 rounded font-medium cursor-not-allowed"
>
In Library
</button>
) : (
<button
onClick={() => handleAddToLibrary(item)}
disabled={adding.has(item.tmdb_id)}
className="bg-indigo-600 hover:bg-indigo-500 disabled:bg-indigo-800 disabled:cursor-wait text-white text-xs px-3 py-1.5 rounded font-medium transition-colors"
>
{adding.has(item.tmdb_id) ? 'Adding...' : 'Add to Library'}
</button>
)}
</div>
</div>
))}
</div>
<div className="flex items-center justify-center gap-4 mt-8">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-300 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-700 transition-colors"
>
Previous
</button>
<span className="text-sm text-gray-400">Page {page}</span>
<button
onClick={() => setPage(p => p + 1)}
disabled={page >= 10}
className="px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-300 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-700 transition-colors"
>
Next
</button>
</div>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,225 @@
import { useEffect, useState, useCallback, useRef } from 'react'
import { useSearchParams, useNavigate } from 'react-router-dom'
import { fetchAPI, putAPI } from '../api/client'
import { useToast } from '../components/Toast'
import StatusBadge from '../components/StatusBadge'
import Pagination from '../components/Pagination'
import ErrorBanner from '../components/ErrorBanner'
import Loading from '../components/Loading'
interface MediaItem {
id: number
media_type: string
title: string
year: number | null
status: string
monitored: boolean
current_quality: { resolution?: string } | null
}
interface PaginatedResponse {
data: MediaItem[]
total: number
page: number
page_size: number
total_pages: number
}
const MEDIA_TYPES = ['all', 'movie', 'series', 'music', 'book', 'audiobook', 'podcast', 'photo', 'other']
const STATUS_FILTERS = ['all', 'available', 'unavailable', 'downloading', 'failed']
const PAGE_SIZE = 50
export default function Library() {
const [searchParams, setSearchParams] = useSearchParams()
const navigate = useNavigate()
const { showToast } = useToast()
const [items, setItems] = useState<MediaItem[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const q = searchParams.get('q') ?? ''
const typeFilter = searchParams.get('type') ?? 'all'
const statusFilter = searchParams.get('status') ?? 'all'
const page = Number(searchParams.get('page') ?? '1')
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const fetchMedia = useCallback(() => {
setLoading(true)
setError(null)
const params = new URLSearchParams()
if (q) params.set('q', q)
if (typeFilter !== 'all') params.set('type', typeFilter)
if (statusFilter !== 'all') params.set('status', statusFilter)
params.set('page', String(page))
params.set('page_size', String(PAGE_SIZE))
fetchAPI<PaginatedResponse>(`/api/media?${params}`)
.then(res => {
setItems(res.data ?? [])
setTotal(res.total)
})
.catch(err => setError(err.message || 'Something went wrong. Please try again.'))
.finally(() => setLoading(false))
}, [q, typeFilter, statusFilter, page])
useEffect(() => {
fetchMedia()
}, [fetchMedia])
const updateSearch = (key: string, value: string) => {
setSearchParams(prev => {
const next = new URLSearchParams(prev)
if (value && value !== 'all') {
next.set(key, value)
} else {
next.delete(key)
}
if (key !== 'page') next.set('page', '1')
return next
})
}
const handleSearchInput = (value: string) => {
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => updateSearch('q', value), 300)
}
const toggleMonitored = async (item: MediaItem) => {
const prev = item.monitored
setItems(items.map(i => i.id === item.id ? { ...i, monitored: !prev } : i))
try {
await putAPI(`/api/media/${item.media_type}/${item.id}`, { monitored: !prev })
} catch {
setItems(items.map(i => i.id === item.id ? { ...i, monitored: prev } : i))
showToast('Failed to update monitoring status')
}
}
const totalPages = Math.ceil(total / PAGE_SIZE)
return (
<div>
<h2 className="text-2xl font-semibold mb-6 text-gray-100">Library</h2>
<div className="flex gap-4 mb-6">
<label htmlFor="lib-search" className="sr-only">Search media</label>
<input
id="lib-search"
type="text"
placeholder="Search across all media..."
defaultValue={q}
onChange={e => handleSearchInput(e.target.value)}
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-gray-100 placeholder-gray-500 text-sm"
/>
<select
value={typeFilter}
onChange={e => updateSearch('type', e.target.value)}
className="bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-gray-100 text-sm"
>
{MEDIA_TYPES.map(t => (
<option key={t} value={t}>{t === 'all' ? 'All Types' : t.charAt(0).toUpperCase() + t.slice(1)}</option>
))}
</select>
<select
value={statusFilter}
onChange={e => updateSearch('status', e.target.value)}
className="bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-gray-100 text-sm"
>
{STATUS_FILTERS.map(s => (
<option key={s} value={s}>{s === 'all' ? 'All Statuses' : s.charAt(0).toUpperCase() + s.slice(1)}</option>
))}
</select>
</div>
{error && <ErrorBanner error={error} onRetry={fetchMedia} />}
{loading && !items.length ? (
<div className="space-y-2">
{[1, 2, 3].map(i => (
<div key={i} className="bg-gray-900 border border-gray-800 rounded-lg p-4 animate-pulse">
<div className="h-4 bg-gray-800 rounded w-1/3 mb-2" />
<div className="h-3 bg-gray-800 rounded w-1/5" />
</div>
))}
</div>
) : items.length === 0 ? (
<div className="text-gray-500 text-center py-20 text-sm">
{q || typeFilter !== 'all' || statusFilter !== 'all'
? 'No media found matching your search'
: 'Your library is empty. Add media or request new content.'}
</div>
) : (
<>
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full min-w-[640px]">
<thead>
<tr className="border-b border-gray-800">
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase">Title</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-20">Type</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-24">Status</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-24">Quality</th>
<th className="text-center px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-16">Monitor</th>
</tr>
</thead>
<tbody>
{items.map(item => (
<tr
key={item.id}
tabIndex={0}
role="link"
aria-label={`View ${item.title}`}
className="border-b border-gray-800/50 hover:bg-gray-800/30 cursor-pointer"
onClick={() => navigate(`/library/${item.media_type}/${item.id}`)}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); navigate(`/library/${item.media_type}/${item.id}`) } }}
>
<td className="px-4 py-3">
<span className="text-sm text-gray-100 font-normal hover:text-indigo-400">{item.title}</span>
{item.year && <span className="text-xs text-gray-500 ml-2">{item.year}</span>}
</td>
<td className="px-4 py-3">
<span className="text-xs text-gray-400 capitalize">{item.media_type}</span>
</td>
<td className="px-4 py-3">
<StatusBadge status={item.status} />
</td>
<td className="px-4 py-3">
<span className="text-sm text-gray-400">
{item.current_quality?.resolution ?? '—'}
</span>
</td>
<td className="px-4 py-3 text-center">
<button
onClick={e => { e.stopPropagation(); toggleMonitored(item) }}
role="switch"
aria-checked={item.monitored}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-900 ${
item.monitored ? 'bg-indigo-500' : 'bg-gray-700'
}`}
>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white transition-transform ${
item.monitored ? 'translate-x-4' : 'translate-x-1'
}`} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<Pagination
total={total}
page={page}
pageSize={PAGE_SIZE}
onPageChange={p => setSearchParams(prev => { const n = new URLSearchParams(prev); n.set('page', String(p)); return n })}
/>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,757 @@
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { fetchAPI, postAPI, deleteAPI } from '../api/client'
import { useToast } from '../components/Toast'
import ConfirmModal from '../components/ConfirmModal'
import StatusBadge from '../components/StatusBadge'
import ErrorBanner from '../components/ErrorBanner'
import ReleaseSearchResults from '../components/ReleaseSearchResults'
interface Media {
id: number
media_type: string
title: string
sort_title: string
original_title: string | null
overview: string | null
year: number | null
status: string
monitored: boolean
external_ids: Record<string, string>
metadata: Record<string, unknown>
images: Array<{ url: string; type: string; width: number; height: number }>
quality_profile_id: number | null
current_quality: { resolution?: string; source?: string } | null
desired_quality: { resolution?: string; source?: string } | null
quality_upgrade_needed: boolean
added_at: string
updated_at: string
}
interface MediaFile {
id: number
media_id: number
path: string
original_path: string | null
file_name: string
file_size: number
quality: { resolution?: string; source?: string; codec?: string } | null
codec: string | null
resolution: string | null
source: string | null
transcode_status: string
created_at: string
}
interface MediaRelation {
id: number
parent_id: number
child_id: number
relation: string
position: number | null
season: number | null
}
interface MediaDetail {
media: Media
files: MediaFile[]
relations: MediaRelation[]
}
interface QualityProfileInfo {
id: number
name: string
cutoff_quality: Record<string, unknown>
allowed_qualities: Record<string, unknown>[]
}
interface SubtitleInfo {
file_name: string
language: string
language_code: string
hi: boolean
forced: boolean
source: string
}
interface FileWithSubtitles extends MediaFile {
subtitles: SubtitleInfo[]
}
interface EpisodeInfo {
media_id: number
title: string
season: number
episode: number
status: string
monitored: boolean
air_date: string | null
has_file: boolean
quality: { resolution?: string } | null
}
interface MediaHistoryItem {
id: number
event_type: string
title: string
description: string | null
data: Record<string, unknown>
created_at: string
}
interface FullMediaDetail {
media: MediaDetail
quality_profile: QualityProfileInfo | null
files_with_subtitles: FileWithSubtitles[]
episodes: EpisodeInfo[]
history: MediaHistoryItem[]
}
interface ReleaseSearchResult {
title: string
guid: string
size: number
pub_date: string
indexer_name: string
indexer_priority: number
quality: {
title: string
resolution: string
source: string
video_codec: string
release_group: string
parse_warning: boolean
}
quality_tier: { name: string; rank: number; resolution: string } | null
seeders: number
peers: number
category: string
download_url: string
source_indexers: string[]
}
interface SubtitleSearchResult {
id: string
file_name: string
language: string
language_code: string
hi: boolean
forced: boolean
download_count: number
release_name: string
provider: string
}
function eventTypeBadge(type: string): { label: string; className: string } {
switch (type) {
case 'grab':
return { label: 'Grab', className: 'bg-blue-600' }
case 'import':
return { label: 'Import', className: 'bg-green-600' }
case 'download_complete':
return { label: 'Download', className: 'bg-green-600' }
case 'download_failed':
return { label: 'Failed', className: 'bg-red-600' }
case 'quality_upgrade':
return { label: 'Upgrade', className: 'bg-purple-600' }
case 'safety_block':
return { label: 'Blocked', className: 'bg-orange-600' }
case 'error':
return { label: 'Error', className: 'bg-red-600' }
default:
return { label: 'Info', className: 'bg-gray-600' }
}
}
function formatTimeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime()
const minutes = Math.floor(diff / 60000)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
return `${days}d ago`
}
function formatFileSize(bytes: number): string {
if (bytes >= 1024 * 1024 * 1024) {
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
}
if (bytes >= 1024 * 1024) {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
return `${(bytes / 1024).toFixed(0)} KB`
}
type TabKey = 'overview' | 'search' | 'files' | 'episodes' | 'history'
export default function MediaDetail() {
const { type, id } = useParams<{ type: string; id: string }>()
const navigate = useNavigate()
const [detail, setDetail] = useState<FullMediaDetail | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<TabKey>('overview')
const [searchResults, setSearchResults] = useState<ReleaseSearchResult[]>([])
const [searchLoading, setSearchLoading] = useState(false)
const [searchError, setSearchError] = useState<string | null>(null)
const { showToast } = useToast()
const [subtitleSearchFile, setSubtitleSearchFile] = useState<FileWithSubtitles | null>(null)
const [subtitleResults, setSubtitleResults] = useState<SubtitleSearchResult[]>([])
const [subtitleSearchLoading, setSubtitleSearchLoading] = useState(false)
const [subtitleDownloading, setSubtitleDownloading] = useState<Set<string>>(new Set())
const [extracting, setExtracting] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const [showDeleteModal, setShowDeleteModal] = useState(false)
useEffect(() => {
if (!type || !id) return
setLoading(true)
setError(null)
fetchAPI<FullMediaDetail>(`/api/media/${type}/${id}/detail`)
.then(res => setDetail(res))
.catch(err => setError(err.message || 'Failed to load media detail'))
.finally(() => setLoading(false))
}, [type, id])
useEffect(() => {
if (!type || !id || activeTab !== 'search') return
const media = detail?.media.media
if (!media) return
setSearchLoading(true)
setSearchError(null)
const query = encodeURIComponent(media.title)
fetchAPI<{ data: ReleaseSearchResult[]; total: number }>(`/api/releases/search?query=${query}&media_type=${media.media_type}`)
.then(res => setSearchResults(res.data ?? []))
.catch(err => setSearchError(err.message || 'Search failed'))
.finally(() => setSearchLoading(false))
}, [activeTab, type, id, detail?.media.media])
const searchSubtitles = async (file: FileWithSubtitles) => {
if (!type || !id) return
setSubtitleSearchFile(file)
setSubtitleSearchLoading(true)
setSubtitleResults([])
try {
const res = await fetchAPI<{ data: SubtitleSearchResult[] }>(
`/api/media/${type}/${id}/subtitles/search?languages=en`
)
setSubtitleResults(res.data ?? [])
} catch {
showToast('Failed to search subtitles')
} finally {
setSubtitleSearchLoading(false)
}
}
const downloadSub = async (result: SubtitleSearchResult) => {
if (!type || !id) return
setSubtitleDownloading(prev => new Set(prev).add(result.id))
try {
await postAPI(`/api/media/${type}/${id}/subtitles/download`, {
subtitle_id: result.id,
language_code: result.language_code,
hi: result.hi,
forced: result.forced,
})
showToast(`✓ Downloaded ${result.language} subtitle`)
// Refresh media detail to show new subtitle
const refreshed = await fetchAPI<FullMediaDetail>(`/api/media/${type}/${id}/detail`)
setDetail(refreshed)
setSubtitleResults([])
setSubtitleSearchFile(null)
} catch {
showToast('Failed to download subtitle')
} finally {
setSubtitleDownloading(prev => {
const next = new Set(prev)
next.delete(result.id)
return next
})
}
}
const extractSubs = async () => {
if (!type || !id) return
setExtracting(true)
try {
await postAPI(`/api/media/${type}/${id}/subtitles/extract`, {})
showToast('✓ Subtitles extracted')
const refreshed = await fetchAPI<FullMediaDetail>(`/api/media/${type}/${id}/detail`)
setDetail(refreshed)
} catch {
showToast('Failed to extract subtitles')
} finally {
setExtracting(false)
}
}
const handleRefreshMetadata = async () => {
if (!type || !id) return
setRefreshing(true)
try {
await postAPI(`/api/media/${type}/${id}/refresh-metadata`, {})
showToast('✓ Metadata refreshed')
const refreshed = await fetchAPI<FullMediaDetail>(`/api/media/${type}/${id}/detail`)
setDetail(refreshed)
} catch {
showToast('Failed to refresh metadata')
} finally {
setRefreshing(false)
}
}
const handleDeleteMedia = async () => {
if (!type || !id) return
setShowDeleteModal(false)
try {
await deleteAPI(`/api/media/${type}/${id}`)
showToast('✓ Media deleted')
navigate('/library')
} catch {
showToast('Failed to delete media')
}
}
if (loading) {
return (
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-800 rounded w-1/3" />
<div className="h-64 bg-gray-800 rounded" />
</div>
)
}
if (error) {
return <ErrorBanner error={error} onRetry={() => window.location.reload()} />
}
if (!detail) return null
const media = detail.media.media
const posterImage = detail.media.media.images?.find(i => i.type === 'poster')
const isSeries = media.media_type === 'series'
const metadata = media.metadata as Record<string, unknown> | null
const tmdbRating = (metadata?.tmdb_rating as number) ?? null
const genres = (metadata?.genres as string[]) ?? []
const totalSize = detail.files_with_subtitles.reduce((sum, f) => sum + f.file_size, 0)
const tabs: { key: TabKey; label: string }[] = [
{ key: 'overview', label: 'Overview' },
{ key: 'search', label: 'Search' },
{ key: 'files', label: 'Files' },
...(isSeries ? [{ key: 'episodes' as TabKey, label: 'Episodes' }] : []),
{ key: 'history', label: 'History' },
]
// Group episodes by season
const seasons: Record<number, EpisodeInfo[]> = {}
if (isSeries && detail.episodes) {
for (const ep of detail.episodes) {
if (!seasons[ep.season]) seasons[ep.season] = []
seasons[ep.season].push(ep)
}
}
return (
<div>
{/* Header */}
<div className="flex items-center gap-4 mb-6">
<button
onClick={() => navigate('/library')}
className="text-sm text-indigo-400 hover:text-indigo-300 transition-colors"
>
Back to Library
</button>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-semibold text-gray-100">{media.title}</h2>
<span className="px-2 py-0.5 rounded text-xs font-medium bg-gray-800 text-gray-400 capitalize">
{media.media_type}
</span>
</div>
<div className="ml-auto flex items-center gap-3">
<button
onClick={handleRefreshMetadata}
disabled={refreshing}
className="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 px-4 py-2 rounded text-sm font-semibold min-h-[36px]"
>
{refreshing ? 'Refreshing...' : '↻ Refresh Metadata'}
</button>
<button
onClick={() => setShowDeleteModal(true)}
className="text-sm text-red-400 hover:text-red-300 px-2 min-h-[36px]"
>
Delete
</button>
<span className="text-xs text-gray-500">
{detail.files_with_subtitles.length} file{detail.files_with_subtitles.length !== 1 ? 's' : ''} · {formatFileSize(totalSize)}
</span>
</div>
</div>
{/* Tab navigation */}
<div className="flex gap-6 border-b border-gray-800 mb-6">
{tabs.map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`pb-3 text-sm font-medium transition-colors ${
activeTab === tab.key
? 'border-b-2 border-indigo-400 text-indigo-400'
: 'text-gray-400 hover:text-gray-200'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Overview Tab */}
{activeTab === 'overview' && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Poster */}
<div>
{posterImage ? (
<img
src={posterImage.url}
alt={media.title}
className="w-full max-w-xs rounded-lg shadow-lg"
/>
) : (
<div className="w-full max-w-xs aspect-[2/3] bg-gray-800 rounded-lg flex items-center justify-center">
<span className="text-4xl text-gray-500 font-bold">
{media.title.charAt(0).toUpperCase()}
</span>
</div>
)}
</div>
{/* Info */}
<div className="lg:col-span-2 space-y-4">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold text-white">{media.title}</h1>
{media.year && (
<span className="px-2 py-0.5 rounded text-sm bg-gray-800 text-gray-300">{media.year}</span>
)}
</div>
{media.original_title && media.original_title !== media.title && (
<p className="text-sm text-gray-500">{media.original_title}</p>
)}
<div className="flex items-center gap-3">
<StatusBadge status={media.status} />
<span className={`text-xs px-2 py-0.5 rounded ${media.monitored ? 'bg-indigo-900/30 text-indigo-400' : 'bg-gray-800 text-gray-500'}`}>
{media.monitored ? 'Monitored' : 'Not Monitored'}
</span>
</div>
{tmdbRating != null && (
<p className="text-sm text-gray-300">
{tmdbRating.toFixed(1)} / 10
</p>
)}
{genres.length > 0 && (
<div className="flex flex-wrap gap-2">
{genres.map(g => (
<span key={g} className="px-2 py-0.5 rounded text-xs bg-gray-800 text-gray-400">{g}</span>
))}
</div>
)}
<div className="text-sm text-gray-400">
<span className="font-medium text-gray-300">Quality Profile:</span>{' '}
{detail.quality_profile?.name ?? <span className="text-gray-500">None configured</span>}
</div>
{(media.current_quality || media.desired_quality) && (
<div className="flex gap-4 text-sm">
{media.current_quality && (
<span className="text-gray-400">
Current: <span className="text-gray-200">{media.current_quality.resolution ?? 'Unknown'}</span>
</span>
)}
{media.desired_quality && (
<span className="text-gray-400">
Desired: <span className="text-gray-200">{media.desired_quality.resolution ?? 'Unknown'}</span>
</span>
)}
</div>
)}
{media.overview && (
<div className="mt-4">
<p className="text-gray-300 leading-relaxed">{media.overview}</p>
</div>
)}
</div>
</div>
)}
{/* Search Tab */}
{activeTab === 'search' && (
<div>
<div className="flex items-center justify-between mb-4">
<p className="text-sm text-gray-400">
Searching indexers for "<span className="text-gray-200">{media.title}</span>"
</p>
{searchResults.length > 0 && (
<p className="text-xs text-gray-500">{searchResults.length} result{searchResults.length !== 1 ? 's' : ''}</p>
)}
</div>
{searchError ? (
<ErrorBanner error={searchError} onRetry={() => setActiveTab('search')} />
) : (
<ReleaseSearchResults
results={searchResults}
mediaId={Number(id)}
mediaType={media.media_type}
loading={searchLoading}
/>
)}
</div>
)}
{/* Files Tab */}
{activeTab === 'files' && (
detail.files_with_subtitles.length === 0 ? (
<div className="text-gray-500 text-center py-20 text-sm">No imported files yet</div>
) : (
<div>
<div className="flex justify-end mb-3">
<button
onClick={extractSubs}
disabled={extracting}
className="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 px-4 py-2 rounded text-sm font-semibold min-h-[36px]"
>
{extracting ? 'Extracting...' : 'Extract All Subtitles'}
</button>
</div>
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-gray-800">
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase">File Name</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-28">Quality</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-24">Size</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-24">Source</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-24">Codec</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase">Subtitles</th>
</tr>
</thead>
<tbody>
{detail.files_with_subtitles.map(file => (
<tr key={file.id} className="border-b border-gray-800/50 hover:bg-gray-800/30">
<td className="px-4 py-3 text-sm text-gray-200 max-w-md truncate">{file.file_name}</td>
<td className="px-4 py-3 text-sm text-gray-400">
{file.quality?.resolution ?? file.resolution ?? 'Unknown'}
</td>
<td className="px-4 py-3 text-sm text-gray-400">{formatFileSize(file.file_size)}</td>
<td className="px-4 py-3 text-sm text-gray-400 capitalize">{file.source ?? '—'}</td>
<td className="px-4 py-3 text-sm text-gray-400">{file.codec ?? '—'}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
{file.subtitles && file.subtitles.length > 0 ? (
<div className="flex flex-wrap gap-1">
{file.subtitles.map((sub, idx) => (
<span key={idx} className="bg-gray-700 rounded px-2 py-0.5 text-xs text-gray-300 flex items-center gap-1">
{sub.language_code.toUpperCase()}
{sub.hi && <span className="text-yellow-400">SDH</span>}
{sub.forced && <span className="text-blue-400">F</span>}
<span className="text-gray-500 text-[10px]">{sub.source === 'extracted' ? 'EX' : 'DL'}</span>
</span>
))}
</div>
) : (
<span className="text-gray-600 text-xs">None</span>
)}
<button
onClick={() => searchSubtitles(file)}
className="text-xs text-indigo-400 hover:text-indigo-300 ml-1 whitespace-nowrap"
>
Search
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{subtitleSearchFile && (
<div className="mt-4 bg-gray-900 border border-gray-800 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-semibold text-gray-200">
Subtitles for &ldquo;{subtitleSearchFile.file_name}&rdquo;
</h4>
<button
onClick={() => { setSubtitleSearchFile(null); setSubtitleResults([]) }}
className="text-gray-500 hover:text-gray-300 text-sm"
>
Close
</button>
</div>
{subtitleSearchLoading ? (
<div className="animate-pulse space-y-2">
<div className="h-8 bg-gray-800 rounded" />
<div className="h-8 bg-gray-800 rounded" />
</div>
) : subtitleResults.length === 0 ? (
<div className="text-gray-500 text-sm text-center py-8">No subtitles found</div>
) : (
<table className="w-full">
<thead>
<tr className="border-b border-gray-800">
<th className="text-left px-3 py-2 text-xs font-semibold text-gray-400 uppercase">Language</th>
<th className="text-left px-3 py-2 text-xs font-semibold text-gray-400 uppercase">Release</th>
<th className="text-left px-3 py-2 text-xs font-semibold text-gray-400 uppercase w-20">Downloads</th>
<th className="text-left px-3 py-2 text-xs font-semibold text-gray-400 uppercase w-24">Source</th>
<th className="text-left px-3 py-2 text-xs font-semibold text-gray-400 uppercase w-20">Action</th>
</tr>
</thead>
<tbody>
{subtitleResults.map(result => {
const isDownloading = subtitleDownloading.has(result.id)
return (
<tr key={result.id} className="border-b border-gray-800/50 hover:bg-gray-800/30">
<td className="px-3 py-2 text-sm text-gray-200">
{result.language}
{result.hi && <span className="text-yellow-400 ml-1 text-xs">SDH</span>}
{result.forced && <span className="text-blue-400 ml-1 text-xs">Forced</span>}
</td>
<td className="px-3 py-2 text-sm text-gray-400 max-w-xs truncate" title={result.release_name}>
{result.release_name || '—'}
</td>
<td className="px-3 py-2 text-sm text-gray-400">{result.download_count}</td>
<td className="px-3 py-2 text-sm text-gray-400">{result.provider}</td>
<td className="px-3 py-2">
<button
onClick={() => downloadSub(result)}
disabled={isDownloading}
className="px-3 py-1 text-xs font-semibold rounded bg-indigo-600 hover:bg-indigo-500 text-white disabled:opacity-50 disabled:cursor-not-allowed"
>
{isDownloading ? '...' : 'Download'}
</button>
</td>
</tr>
)
})}
</tbody>
</table>
)}
</div>
)}
</div>
)
)}
{/* Episodes Tab */}
{activeTab === 'episodes' && isSeries && (
Object.keys(seasons).length === 0 ? (
<div className="text-gray-500 text-center py-20 text-sm">No episode information available</div>
) : (
<div className="space-y-6">
{Object.keys(seasons)
.map(Number)
.sort((a, b) => a - b)
.map(seasonNum => (
<div key={seasonNum}>
<h3 className="text-lg font-semibold text-gray-200 mb-3">Season {seasonNum}</h3>
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-gray-800">
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-16">#</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase">Title</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-28">Status</th>
<th className="text-center px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-16">Monitor</th>
<th className="text-center px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-16">File</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-28">Quality</th>
</tr>
</thead>
<tbody>
{seasons[seasonNum].map(ep => (
<tr key={ep.media_id} className="border-b border-gray-800/50 hover:bg-gray-800/30">
<td className="px-4 py-3 text-sm text-gray-400">{ep.episode}</td>
<td className="px-4 py-3 text-sm text-gray-200">{ep.title}</td>
<td className="px-4 py-3">
<StatusBadge status={ep.status} />
</td>
<td className="px-4 py-3 text-center">
{ep.monitored ? (
<span className="text-green-400"></span>
) : (
<span className="text-gray-600"></span>
)}
</td>
<td className="px-4 py-3 text-center">
{ep.has_file ? (
<span className="text-green-400"></span>
) : (
<span className="text-gray-600"></span>
)}
</td>
<td className="px-4 py-3 text-sm text-gray-400">
{ep.quality?.resolution ?? '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
))}
</div>
)
)}
{/* History Tab */}
{activeTab === 'history' && (
detail.history.length === 0 ? (
<div className="text-gray-500 text-center py-20 text-sm">No history recorded for this item</div>
) : (
<div className="space-y-0">
{detail.history.map(event => {
const badge = eventTypeBadge(event.event_type)
return (
<div
key={event.id}
className="flex gap-4 py-3 border-l-2 border-gray-800 pl-4 ml-2 hover:border-gray-600 transition-colors"
>
<div className="flex-shrink-0">
<span className={`inline-block px-2 py-0.5 rounded text-xs font-semibold text-white ${badge.className}`}>
{badge.label}
</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-200 truncate">{event.title}</p>
{event.description && (
<p className="text-xs text-gray-500 mt-0.5">{event.description}</p>
)}
</div>
<div className="flex-shrink-0 text-xs text-gray-500">
{formatTimeAgo(event.created_at)}
</div>
</div>
)
})}
</div>
)
)}
<ConfirmModal
open={showDeleteModal}
title="Delete Media"
message={`Are you sure you want to delete "${media.title}"? This will remove the media item and its associated files from the library. Files on disk will not be deleted.`}
confirmLabel="Delete"
destructive
onConfirm={handleDeleteMedia}
onCancel={() => setShowDeleteModal(false)}
/>
</div>
)
}

View File

@@ -0,0 +1,333 @@
import { useEffect, useState, useCallback, useRef } from 'react'
import { fetchAPI, deleteAPI, postAPI } from '../api/client'
import { useToast } from '../components/Toast'
import StatusBadge from '../components/StatusBadge'
import ErrorBanner from '../components/ErrorBanner'
import Loading from '../components/Loading'
import ConfirmModal from '../components/ConfirmModal'
import Pagination from '../components/Pagination'
interface QueueItem {
id: number
media_id: number
release_title: string
indexer: string
download_client: string
quality: { resolution?: string } | null
size: number | null
status: string
progress: number
error_message: string | null
protocol: string
created_at: string
updated_at: string
}
interface ImportReport {
imported: number
skipped: number
errors: number
results: ImportResult[]
}
interface ImportResult {
media_id: number
media_type: string
source_path: string
dest_path: string
file_size: number
quality: string
status: string
}
interface ImportHistoryItem {
id: number
media_id: number
media_type: string
action: string
release_title: string
quality: string
created_at: string
}
const TABS = ['all', 'downloading', 'pending', 'failed', 'imported', 'history'] as const
export default function Queue() {
const { showToast } = useToast()
const [items, setItems] = useState<QueueItem[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<string>('all')
const [cancelTarget, setCancelTarget] = useState<QueueItem | null>(null)
const [clearAllConfirm, setClearAllConfirm] = useState(false)
const [importing, setImporting] = useState(false)
const [importHistory, setImportHistory] = useState<ImportHistoryItem[]>([])
const [historyPage, setHistoryPage] = useState(1)
const [historyTotal, setHistoryTotal] = useState(0)
const historyPageSize = 50
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const fetchQueue = useCallback(() => {
setError(null)
const params = new URLSearchParams()
if (activeTab !== 'all') params.set('status', activeTab)
fetchAPI<{ data: QueueItem[] }>(`/api/queue?${params}`)
.then(res => setItems(res.data ?? []))
.catch(err => {
setError(err.message || 'Something went wrong. Please try again.')
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
})
.finally(() => setLoading(false))
}, [activeTab])
useEffect(() => {
setLoading(true)
fetchQueue()
intervalRef.current = setInterval(fetchQueue, 5000)
return () => {
if (intervalRef.current) clearInterval(intervalRef.current)
}
}, [fetchQueue])
const fetchImportHistory = useCallback(() => {
fetchAPI<{ data: ImportHistoryItem[]; total: number; page: number; page_size: number; total_pages: number }>(`/api/imports/history?page=${historyPage}&page_size=${historyPageSize}`)
.then(res => {
setImportHistory(res.data ?? [])
setHistoryTotal(res.total)
})
.catch(() => {
setImportHistory([])
setHistoryTotal(0)
})
}, [historyPage])
useEffect(() => {
if (activeTab === 'history') {
fetchImportHistory()
}
}, [activeTab, fetchImportHistory])
const cancelItem = async (item: QueueItem) => {
try {
await deleteAPI(`/api/queue/${item.id}`)
setItems(items.filter(i => i.id !== item.id))
showToast('Download cancelled')
} catch {
showToast('Failed to cancel download')
}
setCancelTarget(null)
}
const retryItem = async (item: QueueItem) => {
try {
await postAPI(`/api/queue/${item.id}/retry`, {})
showToast('Retrying download')
fetchQueue()
} catch {
showToast('Failed to retry download')
}
}
const retryAllFailed = async () => {
try {
await postAPI('/api/queue/retry-failed', {})
showToast('Retrying all failed downloads')
fetchQueue()
} catch {
showToast('Failed to retry downloads')
}
}
const clearCompleted = async () => {
try {
await postAPI('/api/queue/clear', {})
showToast('Completed downloads cleared')
fetchQueue()
} catch {
showToast('Failed to clear downloads')
}
}
const triggerImport = async () => {
setImporting(true)
try {
const report = await postAPI<ImportReport>('/api/imports/trigger', {})
showToast(`Import complete: ${report.imported} imported, ${report.skipped} skipped, ${report.errors} errors`)
fetchQueue()
if (activeTab === 'history') fetchImportHistory()
} catch {
showToast('Failed to trigger import')
} finally {
setImporting(false)
}
}
const formatSize = (bytes: number | null): string => {
if (!bytes) return '—'
const gb = bytes / 1e9
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(bytes / 1e6).toFixed(1)} MB`
}
const failedCount = items.filter(i => i.status === 'failed').length
const completedCount = items.filter(i => i.status === 'imported').length
return (
<div>
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-semibold text-gray-100">Download Queue</h2>
<div className="flex gap-2">
<button
onClick={triggerImport}
disabled={importing}
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 px-4 py-2 rounded text-sm font-semibold min-h-[36px]"
>
{importing ? 'Importing...' : 'Check for Completed Downloads'}
</button>
{completedCount > 0 && (
<button onClick={clearCompleted} className="bg-orange-600 hover:bg-orange-500 px-4 py-2 rounded text-sm font-semibold min-h-[36px]">
Clear Completed
</button>
)}
{failedCount > 0 && (
<button onClick={retryAllFailed} className="bg-gray-700 hover:bg-gray-600 px-4 py-2 rounded text-sm font-semibold min-h-[36px]">
Retry All Failed
</button>
)}
</div>
</div>
<div className="flex gap-1 mb-4">
{TABS.map(tab => (
<button
key={tab}
onClick={() => { setActiveTab(tab); setLoading(true) }}
className={`px-4 py-2 text-sm rounded capitalize ${activeTab === tab ? 'bg-indigo-400 text-gray-950' : 'bg-gray-800 text-gray-400 hover:bg-gray-700'}`}
>
{tab}
</button>
))}
</div>
{error && <ErrorBanner error={error} onRetry={fetchQueue} />}
{loading ? (
<Loading />
) : items.length === 0 ? (
<div className="text-gray-500 text-center py-20 text-sm">No active downloads</div>
) : (
<div className="space-y-2">
{items.map(item => (
<div key={item.id} className="bg-gray-900 border border-gray-800 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex-1 min-w-0 mr-4">
<p className="text-sm text-gray-100 truncate">{item.release_title}</p>
<div className="flex items-center gap-3 mt-1">
<StatusBadge status={item.status} />
<span className="text-xs text-gray-500">{item.download_client}</span>
{item.quality?.resolution && (
<span className="text-xs text-gray-400">{item.quality.resolution}</span>
)}
<span className="text-xs text-gray-500">{formatSize(item.size)}</span>
</div>
</div>
<div className="flex items-center gap-2">
{item.status === 'failed' && (
<button onClick={() => retryItem(item)} className="text-xs text-indigo-400 hover:text-indigo-300 min-h-[36px] px-2">
Retry
</button>
)}
{(item.status === 'downloading' || item.status === 'pending' || item.status === 'failed') && (
<button
onClick={() => {
if (item.status === 'downloading') {
setCancelTarget(item)
} else {
cancelItem(item)
}
}}
className="text-xs text-red-400 hover:text-red-300 min-h-[36px] px-2"
>
Cancel
</button>
)}
</div>
</div>
{(item.status === 'downloading' || item.status === 'pending') && (
<div className="w-full bg-gray-800 rounded-full h-1.5">
<div
className="bg-indigo-400 h-1.5 rounded-full transition-all duration-500"
style={{ width: `${Math.min(100, item.progress * 100)}%` }}
/>
</div>
)}
{item.status === 'downloading' && (
<p className="text-xs text-gray-500 mt-1">{(item.progress * 100).toFixed(0)}%</p>
)}
{item.error_message && (
<p className="text-xs text-red-400 mt-1">{item.error_message}</p>
)}
</div>
))}
</div>
)}
{activeTab === 'history' && (
<div>
{importHistory.length === 0 ? (
<div className="text-gray-500 text-center py-20 text-sm">No import history yet</div>
) : (
<>
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-gray-800">
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase">Release Title</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-28">Media Type</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-24">Quality</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-40">Imported At</th>
</tr>
</thead>
<tbody>
{importHistory.map(item => (
<tr key={item.id} className="border-b border-gray-800/50 hover:bg-gray-800/30">
<td className="px-4 py-3 text-sm text-gray-200 max-w-md truncate">{item.release_title || '—'}</td>
<td className="px-4 py-3 text-sm text-gray-400 capitalize">{item.media_type}</td>
<td className="px-4 py-3 text-sm text-gray-400">{item.quality || '—'}</td>
<td className="px-4 py-3 text-sm text-gray-400">{new Date(item.created_at).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination total={historyTotal} page={historyPage} pageSize={historyPageSize} onPageChange={setHistoryPage} />
</>
)}
</div>
)}
<ConfirmModal
open={!!cancelTarget}
title="Cancel Download"
message={`Cancel "${cancelTarget?.release_title ?? ''}"? This will remove it from the queue.`}
onConfirm={() => cancelTarget && cancelItem(cancelTarget)}
onCancel={() => setCancelTarget(null)}
destructive
confirmLabel="Cancel Download"
/>
<ConfirmModal
open={clearAllConfirm}
title="Clear All Completed"
message="Remove all completed downloads from the queue?"
onConfirm={() => { clearCompleted(); setClearAllConfirm(false) }}
onCancel={() => setClearAllConfirm(false)}
destructive
/>
</div>
)
}

View File

@@ -0,0 +1,454 @@
import { useEffect, useState, useCallback } from 'react'
import { fetchAPI, postAPI, putAPI, deleteAPI } from '../api/client'
import { useToast } from '../components/Toast'
import Pagination from '../components/Pagination'
import ErrorBanner from '../components/ErrorBanner'
import Loading from '../components/Loading'
import ConfirmModal from '../components/ConfirmModal'
import StatusBadge from '../components/StatusBadge'
interface RequestItem {
id: number
media_id: number
title: string
media_type: string
year: number | null
quality_profile_id: number | null
quality_profile_name: string | null
root_folder_id: number | null
root_folder_path: string | null
status: string
requested_by: string
notes: string | null
created_at: string
updated_at: string
}
interface PaginatedResponse {
data: RequestItem[]
total: number
page: number
page_size: number
total_pages: number
}
interface QualityProfile {
id: number
name: string
}
interface RootFolder {
id: number
path: string
media_type: string
}
const PAGE_SIZE = 20
const FILTER_TABS = [
{ value: '', label: 'All' },
{ value: 'pending', label: 'Pending' },
{ value: 'approved', label: 'Approved' },
{ value: 'fulfilled', label: 'Fulfilled' },
{ value: 'rejected', label: 'Rejected' },
]
const MEDIA_TYPES = ['movie', 'series', 'music', 'book', 'audiobook', 'podcast', 'other']
function formatTimeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime()
const minutes = Math.floor(diff / 60000)
if (minutes < 1) return 'just now'
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
if (days < 30) return `${days}d ago`
const months = Math.floor(days / 30)
return `${months}mo ago`
}
export default function Requests() {
const { showToast } = useToast()
const [requests, setRequests] = useState<RequestItem[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [statusFilter, setStatusFilter] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// New request modal
const [showNewModal, setShowNewModal] = useState(false)
const [newTitle, setNewTitle] = useState('')
const [newType, setNewType] = useState('movie')
const [newYear, setNewYear] = useState('')
const [newQualityId, setNewQualityId] = useState<number | ''>('')
const [newRootFolderId, setNewRootFolderId] = useState<number | ''>('')
const [submitting, setSubmitting] = useState(false)
const [formError, setFormError] = useState<string | null>(null)
// Reject confirmation
const [rejectTarget, setRejectTarget] = useState<RequestItem | null>(null)
// Dropdown data
const [qualityProfiles, setQualityProfiles] = useState<QualityProfile[]>([])
const [rootFolders, setRootFolders] = useState<RootFolder[]>([])
const fetchRequests = useCallback(() => {
setLoading(true)
setError(null)
const params = new URLSearchParams({ page: String(page), page_size: String(PAGE_SIZE) })
if (statusFilter) params.set('status', statusFilter)
fetchAPI<PaginatedResponse>(`/api/requests?${params}`)
.then(res => {
setRequests(res.data ?? [])
setTotal(res.total)
})
.catch(err => setError(err.message || 'Something went wrong. Please try again.'))
.finally(() => setLoading(false))
}, [page, statusFilter])
useEffect(() => {
fetchRequests()
}, [fetchRequests])
// Fetch dropdown data on mount
useEffect(() => {
fetchAPI<{ data: QualityProfile[] }>('/api/quality-profiles?page=1&page_size=100')
.then(res => setQualityProfiles(res.data ?? []))
.catch(() => {})
fetchAPI<{ data: RootFolder[] }>('/api/root-folders?page=1&page_size=100')
.then(res => setRootFolders(res.data ?? []))
.catch(() => {})
}, [])
const handleFilterChange = (value: string) => {
setStatusFilter(value)
setPage(1)
}
const openNewModal = () => {
setNewTitle('')
setNewType('movie')
setNewYear('')
setNewQualityId('')
setNewRootFolderId('')
setFormError(null)
setShowNewModal(true)
}
const submitNewRequest = async () => {
if (!newTitle.trim()) {
setFormError('Title is required')
return
}
setSubmitting(true)
setFormError(null)
try {
const body: Record<string, unknown> = {
title: newTitle.trim(),
media_type: newType,
}
if (newYear) body.year = parseInt(newYear, 10)
if (newQualityId !== '') body.quality_profile_id = newQualityId
if (newRootFolderId !== '') body.root_folder_id = newRootFolderId
await postAPI('/api/requests', body)
showToast('Request submitted')
setShowNewModal(false)
fetchRequests()
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Failed to submit request'
setFormError(msg)
} finally {
setSubmitting(false)
}
}
const approveRequest = async (req: RequestItem) => {
try {
await putAPI(`/api/requests/${req.id}/approve`, {})
showToast('Request approved — searching for release')
fetchRequests()
} catch {
showToast('Failed to approve request')
}
}
const rejectRequest = async () => {
if (!rejectTarget) return
try {
await putAPI(`/api/requests/${rejectTarget.id}/reject`, {})
showToast('Request rejected')
fetchRequests()
} catch {
showToast('Failed to reject request')
}
setRejectTarget(null)
}
const withdrawRequest = async (req: RequestItem) => {
try {
await deleteAPI(`/api/requests/${req.id}`)
showToast('Request withdrawn')
fetchRequests()
} catch {
showToast('Failed to withdraw request')
}
}
// Filter root folders by selected media type
const filteredFolders = rootFolders.filter(f => f.media_type === newType)
return (
<div>
{/* Header */}
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-semibold text-gray-100">Requests</h2>
<button
onClick={openNewModal}
className="bg-indigo-400 hover:bg-indigo-500 px-4 py-2 rounded text-sm font-semibold text-gray-950 min-h-[36px]"
>
+ New Request
</button>
</div>
{/* Filter tabs */}
<div className="flex gap-2 mb-6">
{FILTER_TABS.map(tab => (
<button
key={tab.value}
onClick={() => handleFilterChange(tab.value)}
className={`px-4 py-2 rounded text-sm font-medium min-h-[36px] ${
statusFilter === tab.value
? 'bg-indigo-400 text-gray-950'
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Error */}
{error && <ErrorBanner error={error} onRetry={fetchRequests} />}
{/* Loading */}
{loading ? (
<Loading />
) : requests.length === 0 ? (
/* Empty */
<div className="text-gray-500 text-center py-20 text-sm">
{statusFilter
? `No ${statusFilter} requests`
: 'No requests yet. Click + New Request to get started.'}
</div>
) : (
<>
{/* Request cards */}
<div className="space-y-3">
{requests.map(req => (
<div
key={req.id}
className="bg-gray-900 border border-gray-800 rounded-lg p-4"
>
{/* Header row */}
<div className="flex items-start justify-between mb-2">
<h3 className="text-xl font-semibold text-gray-100">
{req.title}
{req.year ? (
<span className="text-gray-400 font-normal"> ({req.year})</span>
) : null}
</h3>
<StatusBadge status={req.status} />
</div>
{/* Meta row */}
<div className="text-sm text-gray-400 mb-3">
<span>Requested by: {req.requested_by}</span>
<span className="mx-2">&bull;</span>
<span>{formatTimeAgo(req.created_at)}</span>
{req.quality_profile_name && (
<>
<span className="mx-2">&bull;</span>
<span>Quality: {req.quality_profile_name}</span>
</>
)}
{req.root_folder_path && (
<>
<span className="mx-2">&bull;</span>
<span>Root: {req.root_folder_path}</span>
</>
)}
</div>
{/* Actions row */}
{req.status === 'pending' && (
<div className="flex items-center gap-3">
<button
onClick={() => approveRequest(req)}
className="bg-green-600 hover:bg-green-700 text-sm font-medium px-4 py-2 rounded min-h-[36px]"
aria-label="Approve request"
>
Approve
</button>
<button
onClick={() => setRejectTarget(req)}
className="bg-red-600 hover:bg-red-700 text-sm font-medium px-4 py-2 rounded min-h-[36px]"
aria-label="Reject request"
>
Reject
</button>
</div>
)}
{/* Withdraw link for own pending/approved requests */}
{(req.status === 'pending' || req.status === 'approved') && (
<button
onClick={() => withdrawRequest(req)}
className="text-xs text-gray-500 hover:text-gray-300 mt-2"
>
Withdraw
</button>
)}
</div>
))}
</div>
{/* Pagination */}
<Pagination
total={total}
page={page}
pageSize={PAGE_SIZE}
onPageChange={setPage}
/>
</>
)}
{/* New Request Modal */}
{showNewModal && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={() => setShowNewModal(false)}>
<div className="bg-gray-900 border border-gray-800 rounded-lg p-6 max-w-md w-full mx-4" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-xl font-semibold text-gray-100">New Request</h3>
<button onClick={() => setShowNewModal(false)} className="text-gray-500 hover:text-gray-300 text-lg">
&times;
</button>
</div>
{formError && (
<div className="bg-red-900/30 border border-red-800 rounded-lg p-3 mb-4">
<p className="text-red-400 text-sm">{formError}</p>
</div>
)}
<div className="space-y-4">
{/* Title */}
<div>
<label className="block text-sm text-gray-400 mb-1">Title</label>
<input
type="text"
value={newTitle}
onChange={e => setNewTitle(e.target.value)}
placeholder="Movie or show title"
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-100 focus:border-indigo-500 focus:outline-none"
autoFocus
/>
</div>
{/* Type */}
<div>
<label className="block text-sm text-gray-400 mb-1">Type</label>
<select
value={newType}
onChange={e => {
setNewType(e.target.value)
setNewRootFolderId('')
}}
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-100 focus:border-indigo-500 focus:outline-none"
>
{MEDIA_TYPES.map(t => (
<option key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>
))}
</select>
</div>
{/* Year */}
<div>
<label className="block text-sm text-gray-400 mb-1">Year</label>
<input
type="number"
value={newYear}
onChange={e => setNewYear(e.target.value)}
placeholder="2024"
min="1900"
max="2099"
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-100 focus:border-indigo-500 focus:outline-none"
/>
</div>
{/* Quality Profile */}
<div>
<label className="block text-sm text-gray-400 mb-1">Quality Profile</label>
<select
value={newQualityId}
onChange={e => setNewQualityId(e.target.value ? parseInt(e.target.value, 10) : '')}
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-100 focus:border-indigo-500 focus:outline-none"
>
<option value="">Any</option>
{qualityProfiles.map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
{/* Root Folder */}
<div>
<label className="block text-sm text-gray-400 mb-1">Root Folder</label>
<select
value={newRootFolderId}
onChange={e => setNewRootFolderId(e.target.value ? parseInt(e.target.value, 10) : '')}
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-100 focus:border-indigo-500 focus:outline-none"
>
<option value="">Default</option>
{filteredFolders.map(f => (
<option key={f.id} value={f.id}>{f.path}</option>
))}
</select>
</div>
</div>
{/* Modal actions */}
<div className="flex gap-3 justify-end mt-6">
<button
onClick={() => setShowNewModal(false)}
className="px-4 py-2 text-sm bg-gray-700 hover:bg-gray-600 rounded text-gray-200 min-h-[36px]"
>
Cancel
</button>
<button
onClick={submitNewRequest}
disabled={submitting}
className="px-4 py-2 text-sm bg-indigo-500 hover:bg-indigo-400 rounded font-semibold text-white min-h-[36px] disabled:opacity-50"
>
{submitting ? 'Submitting...' : 'Submit'}
</button>
</div>
</div>
</div>
)}
{/* Reject Confirmation Modal */}
<ConfirmModal
open={!!rejectTarget}
title="Reject Request"
message={`Reject "${rejectTarget?.title}"? The requester will be notified.`}
onConfirm={rejectRequest}
onCancel={() => setRejectTarget(null)}
destructive
confirmLabel="Reject"
/>
</div>
)
}

View File

@@ -0,0 +1,427 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { fetchAPI, postAPI } from '../api/client'
import { useToast } from '../components/Toast'
import ErrorBanner from '../components/ErrorBanner'
interface MediaItem {
id: number
media_type: string
title: string
year: number | null
status: string
monitored: boolean
}
interface SearchResult {
title: string
guid: string
size: number
pub_date: string
indexer_name: string
indexer_priority: number
quality: {
title: string
resolution: string
source: string
video_codec: string
release_group: string
parse_warning: boolean
}
quality_tier: {
name: string
rank: number
resolution: string
} | null
seeders: number
peers: number
category: string
download_url: string
source_indexers: string[]
}
type SortCol = 'quality' | 'size' | 'seeders' | 'age'
type SortDir = 'asc' | 'desc'
function formatFileSize(bytes: number): string {
if (bytes >= 1024 * 1024 * 1024) {
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
}
if (bytes >= 1024 * 1024) {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
return `${(bytes / 1024).toFixed(0)} KB`
}
function formatAge(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime()
const minutes = Math.floor(diff / 60000)
if (minutes < 1) return 'now'
if (minutes < 60) return `${minutes}m`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h`
const days = Math.floor(hours / 24)
if (days < 30) return `${days}d`
const months = Math.floor(days / 30)
return `${months}mo`
}
function extractSearchHint(title: string): string {
// Take the first few meaningful words from a release title as a media search hint
const cleaned = title.replace(/[.\-]+/g, ' ').trim()
const words = cleaned.split(/\s+/)
// Take up to 3 words, but stop at common quality/year markers
const stopWords = new Set(['720p', '1080p', '2160p', '4k', 'bluray', 'webrip', 'webdl', 'hdtv', 'dvdrip', 'x264', 'x265', 'hevc', 'aac', 'dts'])
const hint: string[] = []
for (const word of words.slice(0, 6)) {
if (stopWords.has(word.toLowerCase())) break
if (/^\d{4}$/.test(word)) break
hint.push(word)
if (hint.length >= 3) break
}
return hint.join(' ') || title.slice(0, 30)
}
export default function Search() {
// Search state
const [query, setQuery] = useState('')
const [results, setResults] = useState<SearchResult[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [hasSearched, setHasSearched] = useState(false)
// Sorting
const [sortCol, setSortCol] = useState<SortCol>('quality')
const [sortDir, setSortDir] = useState<SortDir>('asc')
// Media selector modal state
const [modalOpen, setModalOpen] = useState(false)
const [pendingResult, setPendingResult] = useState<SearchResult | null>(null)
const [mediaQuery, setMediaQuery] = useState('')
const [mediaItems, setMediaItems] = useState<MediaItem[]>([])
const [mediaLoading, setMediaLoading] = useState(false)
// Grab tracking
const [grabbing, setGrabbing] = useState<Set<string>>(new Set())
const { showToast } = useToast()
const mediaSearchRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const modalInputRef = useRef<HTMLInputElement>(null)
function handleSort(col: SortCol) {
if (sortCol === col) {
setSortDir(prev => (prev === 'asc' ? 'desc' : 'asc'))
} else {
setSortCol(col)
setSortDir('asc')
}
}
const sorted = [...results].sort((a, b) => {
let cmp = 0
switch (sortCol) {
case 'quality':
cmp = (a.quality_tier?.rank ?? 999) - (b.quality_tier?.rank ?? 999)
break
case 'size':
cmp = a.size - b.size
break
case 'seeders':
cmp = a.seeders - b.seeders
break
case 'age':
cmp = new Date(a.pub_date).getTime() - new Date(b.pub_date).getTime()
break
}
return sortDir === 'asc' ? cmp : -cmp
})
async function doSearch() {
const trimmed = query.trim()
if (!trimmed) return
setLoading(true)
setHasSearched(true)
setError(null)
try {
const res = await fetchAPI<{ data: SearchResult[]; total: number }>(
'/api/releases/search?query=' + encodeURIComponent(trimmed)
)
setResults(res.data ?? [])
} catch (err) {
const message = err instanceof Error ? err.message : 'Search failed'
setError(message)
} finally {
setLoading(false)
}
}
function handleSearchSubmit(e: React.FormEvent) {
e.preventDefault()
doSearch()
}
// Media selector: open modal
function openMediaSelector(result: SearchResult) {
setPendingResult(result)
const hint = extractSearchHint(result.title)
setMediaQuery(hint)
setMediaItems([])
setModalOpen(true)
}
// Fetch media items when modal query changes (debounced)
const fetchMedia = useCallback(async (q: string) => {
if (!q.trim()) {
setMediaItems([])
return
}
setMediaLoading(true)
try {
const res = await fetchAPI<{ data: MediaItem[]; total: number }>(
'/api/search?q=' + encodeURIComponent(q) + '&page_size=20'
)
setMediaItems(res.data ?? [])
} catch {
setMediaItems([])
} finally {
setMediaLoading(false)
}
}, [])
// Debounce media search input
useEffect(() => {
if (!modalOpen) return
if (mediaSearchRef.current) clearTimeout(mediaSearchRef.current)
mediaSearchRef.current = setTimeout(() => {
fetchMedia(mediaQuery)
}, 300)
return () => {
if (mediaSearchRef.current) clearTimeout(mediaSearchRef.current)
}
}, [mediaQuery, modalOpen, fetchMedia])
// Focus modal input when modal opens
useEffect(() => {
if (modalOpen) {
// Small delay to let modal render
setTimeout(() => modalInputRef.current?.focus(), 50)
}
}, [modalOpen])
async function handleGrabWithMedia(item: MediaItem) {
if (!pendingResult) return
setGrabbing(prev => new Set(prev).add(pendingResult.guid))
try {
await postAPI<{ queue_id: number }>('/api/releases/grab', {
download_url: pendingResult.download_url,
title: pendingResult.title,
media_type: item.media_type,
quality: pendingResult.quality,
indexer_name: pendingResult.indexer_name,
media_id: item.id,
})
showToast(`✓ Grabbed "${pendingResult.title}"`)
setModalOpen(false)
setPendingResult(null)
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
showToast(`✗ Failed to grab: ${message}`)
} finally {
if (pendingResult) {
setGrabbing(prev => {
const next = new Set(prev)
next.delete(pendingResult.guid)
return next
})
}
}
}
function closeModal() {
setModalOpen(false)
setPendingResult(null)
setMediaItems([])
setMediaQuery('')
}
function SortHeader({ col, label }: { col: SortCol; label: string }) {
const active = sortCol === col
return (
<button
onClick={() => handleSort(col)}
className={`text-left text-xs font-semibold uppercase tracking-wide transition-colors ${
active ? 'text-indigo-400 hover:text-indigo-300' : 'text-gray-400 hover:text-gray-200'
}`}
>
{label} {active && (sortDir === 'asc' ? '▲' : '▼')}
</button>
)
}
return (
<div>
<h2 className="text-2xl font-semibold text-gray-100 mb-6">Search Indexers</h2>
{/* Search input */}
<form onSubmit={handleSearchSubmit} className="mb-6">
<div className="flex gap-3 max-w-2xl">
<input
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search all indexers..."
className="bg-gray-800 border border-gray-700 focus:border-indigo-500 text-white rounded-lg px-4 py-3 w-full outline-none transition-colors"
/>
<button
type="submit"
disabled={loading || !query.trim()}
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-white px-6 py-3 rounded-lg font-semibold transition-colors"
>
{loading ? 'Searching...' : 'Search'}
</button>
</div>
<p className="text-gray-500 text-xs mt-2">Search across all enabled indexers for any release</p>
</form>
{/* Results area */}
{!hasSearched && (
<div className="text-center py-20 text-gray-500">
<p className="text-lg">Enter a search query to find releases</p>
</div>
)}
{hasSearched && loading && (
<div className="animate-pulse space-y-3">
<div className="h-10 bg-gray-800 rounded" />
<div className="h-10 bg-gray-800 rounded" />
<div className="h-10 bg-gray-800 rounded" />
</div>
)}
{hasSearched && error && !loading && (
<ErrorBanner error={error} onRetry={doSearch} />
)}
{hasSearched && !loading && !error && results.length === 0 && (
<div className="text-gray-500 text-center py-12">No releases found</div>
)}
{hasSearched && !loading && !error && results.length > 0 && (
<div>
<p className="text-gray-400 text-sm mb-3">{results.length} release{results.length !== 1 ? 's' : ''} found</p>
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-gray-800">
<th className="px-4 py-3"><SortHeader col="quality" label="Quality" /></th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wide">Title</th>
<th className="px-4 py-3"><SortHeader col="size" label="Size" /></th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wide">Indexer</th>
<th className="px-4 py-3"><SortHeader col="seeders" label="Seeders" /></th>
<th className="px-4 py-3"><SortHeader col="age" label="Age" /></th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wide w-28">Action</th>
</tr>
</thead>
<tbody>
{sorted.map(result => {
const isGrabbing = grabbing.has(result.guid)
const noUrl = !result.download_url
return (
<tr key={result.guid} className="border-b border-gray-800/50 hover:bg-gray-800/30">
<td className="px-4 py-3 text-sm">
{result.quality_tier ? (
<span className="text-gray-200">{result.quality_tier.name}</span>
) : result.quality.resolution || result.quality.source ? (
<span className="text-gray-200">{result.quality.resolution} {result.quality.source}</span>
) : (
<span className="text-gray-500">Unknown</span>
)}
</td>
<td className="px-4 py-3 text-sm text-gray-200 max-w-lg truncate" title={result.title}>
{result.title}
</td>
<td className="px-4 py-3 text-sm text-gray-400">{formatFileSize(result.size)}</td>
<td className="px-4 py-3 text-sm text-gray-400">{result.indexer_name}</td>
<td className="px-4 py-3 text-sm text-gray-400">
<span className={result.seeders > 0 ? 'text-green-400' : ''}>{result.seeders}</span>
{' / '}
<span>{result.peers}</span>
</td>
<td className="px-4 py-3 text-sm text-gray-400">{formatAge(result.pub_date)}</td>
<td className="px-4 py-3">
<button
disabled={isGrabbing || noUrl}
onClick={() => openMediaSelector(result)}
className="px-3 py-1 text-xs font-semibold rounded bg-indigo-600 hover:bg-indigo-500 text-white disabled:opacity-50 disabled:cursor-not-allowed"
>
{isGrabbing ? 'Grabbing...' : noUrl ? 'N/A' : 'Select & Grab'}
</button>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)}
{/* Media selector modal */}
{modalOpen && pendingResult && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={closeModal}>
<div className="bg-gray-900 border border-gray-800 rounded-lg p-6 max-w-lg w-full mx-4" onClick={e => e.stopPropagation()}>
<h3 className="text-lg font-semibold text-gray-100 mb-1">Select Media Item</h3>
<p className="text-sm text-gray-400 mb-4">
Associate "<span className="text-gray-200">{pendingResult.title}</span>" with a media item in your library
</p>
{/* Media search input */}
<input
ref={modalInputRef}
type="text"
value={mediaQuery}
onChange={e => setMediaQuery(e.target.value)}
placeholder="Search your library..."
className="bg-gray-800 border border-gray-700 focus:border-indigo-500 text-white rounded-lg px-4 py-2.5 w-full mb-3 text-sm outline-none transition-colors"
/>
{/* Media items list */}
<div className="max-h-64 overflow-y-auto space-y-1">
{mediaLoading && mediaItems.length === 0 && (
<div className="text-gray-500 text-sm text-center py-6">Searching library...</div>
)}
{!mediaLoading && mediaItems.length === 0 && mediaQuery.trim() && (
<div className="text-gray-500 text-sm text-center py-6">No matching media items found</div>
)}
{mediaItems.map(item => {
const isGrabbingItem = grabbing.has(pendingResult.guid)
return (
<button
key={item.id}
disabled={isGrabbingItem}
onClick={() => handleGrabWithMedia(item)}
className="w-full text-left px-3 py-2.5 rounded hover:bg-gray-800 transition-colors disabled:opacity-50"
>
<span className="text-sm text-gray-200">{item.title}</span>
<span className="text-xs text-gray-500 ml-2">
{item.media_type}{item.year ? ` · ${item.year}` : ''}
</span>
</button>
)
})}
</div>
{/* Modal actions */}
<div className="flex justify-end gap-3 mt-4 pt-3 border-t border-gray-800">
<button
onClick={closeModal}
className="px-4 py-2 text-sm bg-gray-700 hover:bg-gray-600 rounded text-gray-200 transition-colors"
>
Cancel
</button>
</div>
</div>
</div>
)}
</div>
)
}

File diff suppressed because it is too large Load Diff