diff --git a/src/client/api.ts b/src/client/api.ts index 5e86834..c169168 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -17,12 +17,21 @@ export interface Episode { title: string torrent_id: string torrent_url: string - status: 'pending' | 'downloaded_auto' | 'downloaded_manual' + status: 'pending' | 'downloaded_auto' | 'downloaded_manual' | 'failed' + download_error: string | null + file_path: string | null downloaded_at: string | null created_at: string updated_at: string } +export interface HealthStatus { + status: 'ok' | 'degraded' + torrent_dir: string + torrent_dir_writable: boolean + torrent_dir_error: string | null +} + export interface NyaaItem { torrent_id: string title: string @@ -87,4 +96,8 @@ export const api = { update: (data: Partial>) => request('/api/settings', { method: 'PATCH', body: JSON.stringify(data) }), }, + health: { + get: () => request('/api/health'), + poll: () => request<{ status: string }>('/api/poll', { method: 'POST' }), + }, } diff --git a/src/client/index.css b/src/client/index.css index bb939bd..8d1ac2d 100644 --- a/src/client/index.css +++ b/src/client/index.css @@ -73,6 +73,18 @@ tr:hover td { background: var(--surface2); } .badge.pending { background: #2a2a1a; color: var(--yellow); } .badge.downloaded_auto { background: #1a2a1a; color: var(--green); } .badge.downloaded_manual { background: #1a2533; color: #4da6ff; } +.badge.failed { background: #2a1a1a; color: var(--red); } + +.error-tooltip { position: relative; display: inline-flex; align-items: center; gap: 4px; cursor: help; } +.error-tooltip .tooltip-text { + visibility: hidden; opacity: 0; transition: opacity 0.15s; + position: absolute; z-index: 10; bottom: 125%; left: 0; + background: #1a0a0a; color: var(--red); border: 1px solid var(--red); + border-radius: var(--radius); padding: 6px 10px; font-size: 11px; + white-space: pre-wrap; max-width: 360px; min-width: 180px; word-break: break-word; + pointer-events: none; +} +.error-tooltip:hover .tooltip-text { visibility: visible; opacity: 1; } .layout { display: flex; height: 100vh; } .sidebar { width: 200px; flex-shrink: 0; background: var(--surface); border-right: 1px solid var(--border); display: flex; flex-direction: column; padding: 16px 0; } diff --git a/src/client/pages/ShowDetailPage.tsx b/src/client/pages/ShowDetailPage.tsx index 3d8fa9c..c7ebfd6 100644 --- a/src/client/pages/ShowDetailPage.tsx +++ b/src/client/pages/ShowDetailPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { api, type Show, type Episode } from '../api' +import { api, type Show, type Episode, type HealthStatus } from '../api' interface Props { showId: number @@ -9,19 +9,21 @@ interface Props { export default function ShowDetailPage({ showId, onBack }: Props) { const [show, setShow] = useState(null) const [episodes, setEpisodes] = useState([]) + const [health, setHealth] = useState(null) const [loading, setLoading] = useState(true) const [bulkCode, setBulkCode] = useState('') const [bulking, setBulking] = useState(false) + const [polling, setPolling] = useState(false) useEffect(() => { - Promise.all([api.shows.get(showId), api.episodes.list(showId)]) - .then(([s, eps]) => { setShow(s); setEpisodes(eps) }) + Promise.all([api.shows.get(showId), api.episodes.list(showId), api.health.get()]) + .then(([s, eps, h]) => { setShow(s); setEpisodes(eps); setHealth(h) }) .finally(() => setLoading(false)) }, [showId]) const handleStatusChange = async (ep: Episode, status: Episode['status']) => { const updated = await api.episodes.update(showId, ep.id, status) - setEpisodes((prev) => prev.map((e) => (e.id === updated.id ? updated : e))) + setEpisodes((prev) => prev.map((e) => (e.id === updated.id ? (updated as Episode) : e))) } const handleBulkMark = async (e: React.FormEvent) => { @@ -44,14 +46,27 @@ export default function ShowDetailPage({ showId, onBack }: Props) { setShow(updated) } + const handlePollNow = async () => { + setPolling(true) + try { + await api.health.poll() + const eps = await api.episodes.list(showId) + setEpisodes(eps) + } finally { + setPolling(false) + } + } + if (loading) return
if (!show) return
Show not found.
+ const failed = episodes.filter((e) => e.status === 'failed') const stats = { total: episodes.length, pending: episodes.filter((e) => e.status === 'pending').length, auto: episodes.filter((e) => e.status === 'downloaded_auto').length, manual: episodes.filter((e) => e.status === 'downloaded_manual').length, + failed: failed.length, } return ( @@ -60,6 +75,19 @@ export default function ShowDetailPage({ showId, onBack }: Props) { ← Shows + {/* Health warning banner */} + {health && !health.torrent_dir_writable && ( +
+
⚠ Torrent directory is not writable
+
+ Path: {health.torrent_dir} +
+ {health.torrent_dir_error && ( +
{health.torrent_dir_error}
+ )} +
+ )} +

{show.name}

@@ -71,28 +99,51 @@ export default function ShowDetailPage({ showId, onBack }: Props) {
- +
+ + +
{/* Stats */}
{[ - { label: 'Total', value: stats.total }, - { label: 'Pending', value: stats.pending }, - { label: 'Auto DL', value: stats.auto }, - { label: 'Manual', value: stats.manual }, - ].map(({ label, value }) => ( + { label: 'Total', value: stats.total, color: undefined }, + { label: 'Pending', value: stats.pending, color: undefined }, + { label: 'Auto DL', value: stats.auto, color: undefined }, + { label: 'Manual', value: stats.manual, color: undefined }, + { label: 'Failed', value: stats.failed, color: stats.failed > 0 ? 'var(--red)' : undefined }, + ].map(({ label, value, color }) => (
-
{value}
+
{value}
{label}
))}
+ {/* Failed downloads summary */} + {failed.length > 0 && ( +
+
+ {failed.length} failed download{failed.length > 1 ? 's' : ''} — will retry on next poll (or click ↻ Poll Now) +
+ {failed.slice(0, 3).map((ep) => ( +
+ Ep {ep.episode_code}: {ep.download_error ?? 'Unknown error'} +
+ ))} + {failed.length > 3 && ( +
…and {failed.length - 3} more
+ )} +
+ )} + {/* Bulk mark */} -
+
Bulk Mark as Downloaded
{ep.torrent_id} - {ep.status.replace('_', ' ')} + + {ep.status === 'failed' && ep.download_error ? ( + + failed + {ep.download_error} + + ) : ( + {ep.status.replace(/_/g, ' ')} + )} + {ep.downloaded_at ? new Date(ep.downloaded_at).toLocaleDateString() : '—'} @@ -169,7 +229,7 @@ function EpisodeActions({ if (busy) return - if (ep.status === 'pending') { + if (ep.status === 'pending' || ep.status === 'failed') { return (