fix: torrent downloads — User-Agent, failure tracking, retry logic

Problems fixed:
- No User-Agent header: Nyaa returns HTML/redirect for bot requests;
  add browser-like UA + Accept + Referer + redirect:follow
- Silent failures: downloads failed with no visible feedback; add
  'failed' episode status + download_error column to store the error
- No retry: failed/pending episodes were skipped forever on subsequent
  polls; scheduler now retries them via ON CONFLICT upsert
- Content validation: check response starts with 'd' (bencoded dict)
  to catch HTML error pages masquerading as torrents

Also adds:
- /api/health write-test for torrent dir (shows ⚠ banner if not writable)
- /api/poll endpoint for manual immediate poll trigger
- '↻ Poll Now' button in ShowDetailPage
- Failed episode badge with hover tooltip showing the error message
- Migration to widen status CHECK constraint to include 'failed'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jason
2026-03-17 15:16:25 -05:00
parent 96e7c6e8e5
commit 3f2362a4a4
9 changed files with 272 additions and 40 deletions
+14 -1
View File
@@ -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<Omit<Settings, 'torrent_output_dir'>>) =>
request<Settings>('/api/settings', { method: 'PATCH', body: JSON.stringify(data) }),
},
health: {
get: () => request<HealthStatus>('/api/health'),
poll: () => request<{ status: string }>('/api/poll', { method: 'POST' }),
},
}
+12
View File
@@ -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; }
+76 -16
View File
@@ -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<Show | null>(null)
const [episodes, setEpisodes] = useState<Episode[]>([])
const [health, setHealth] = useState<HealthStatus | null>(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 <div className="empty-state"><div className="spinner" /></div>
if (!show) return <div className="empty-state">Show not found.</div>
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
</a>
{/* Health warning banner */}
{health && !health.torrent_dir_writable && (
<div className="card" style={{ marginBottom: 16, borderColor: 'var(--red)', background: '#1a0a0a' }}>
<div style={{ color: 'var(--red)', fontWeight: 600, marginBottom: 4 }}> Torrent directory is not writable</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
Path: <code>{health.torrent_dir}</code>
</div>
{health.torrent_dir_error && (
<div style={{ fontSize: 11, color: 'var(--red)', marginTop: 4 }}>{health.torrent_dir_error}</div>
)}
</div>
)}
<div className="page-header">
<div>
<h1>{show.name}</h1>
@@ -71,28 +99,51 @@ export default function ShowDetailPage({ showId, onBack }: Props) {
</span>
</div>
</div>
<button className="ghost" onClick={handleToggleActive}>
{show.is_active ? 'Pause Polling' : 'Resume Polling'}
</button>
<div className="flex" style={{ gap: 8 }}>
<button className="ghost" onClick={handlePollNow} disabled={polling}>
{polling ? <span className="spinner" /> : '↻ Poll Now'}
</button>
<button className="ghost" onClick={handleToggleActive}>
{show.is_active ? 'Pause Polling' : 'Resume Polling'}
</button>
</div>
</div>
{/* Stats */}
<div className="flex" style={{ gap: 12, marginBottom: 20, flexWrap: 'wrap' }}>
{[
{ 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 }) => (
<div key={label} className="card" style={{ minWidth: 100, textAlign: 'center' }}>
<div style={{ fontSize: 22, fontWeight: 700 }}>{value}</div>
<div style={{ fontSize: 22, fontWeight: 700, color }}>{value}</div>
<div className="text-muted text-sm">{label}</div>
</div>
))}
</div>
{/* Failed downloads summary */}
{failed.length > 0 && (
<div className="card" style={{ marginBottom: 16, borderColor: 'var(--red)' }}>
<div style={{ fontWeight: 600, marginBottom: 8, color: 'var(--red)' }}>
{failed.length} failed download{failed.length > 1 ? 's' : ''} will retry on next poll (or click Poll Now)
</div>
{failed.slice(0, 3).map((ep) => (
<div key={ep.id} style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 4 }}>
<strong>Ep {ep.episode_code}</strong>: {ep.download_error ?? 'Unknown error'}
</div>
))}
{failed.length > 3 && (
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>and {failed.length - 3} more</div>
)}
</div>
)}
{/* Bulk mark */}
<form onSubmit={handleBulkMark} className="card mb-4" style={{ marginBottom: 16 }}>
<form onSubmit={handleBulkMark} className="card" style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 8, fontWeight: 600 }}>Bulk Mark as Downloaded</div>
<div className="flex" style={{ gap: 8 }}>
<input
@@ -136,7 +187,16 @@ export default function ShowDetailPage({ showId, onBack }: Props) {
</a>
</td>
<td className="text-muted">{ep.torrent_id}</td>
<td><span className={`badge ${ep.status}`}>{ep.status.replace('_', ' ')}</span></td>
<td>
{ep.status === 'failed' && ep.download_error ? (
<span className="error-tooltip">
<span className="badge failed">failed</span>
<span className="tooltip-text">{ep.download_error}</span>
</span>
) : (
<span className={`badge ${ep.status}`}>{ep.status.replace(/_/g, ' ')}</span>
)}
</td>
<td className="text-muted text-sm">
{ep.downloaded_at ? new Date(ep.downloaded_at).toLocaleDateString() : '—'}
</td>
@@ -169,7 +229,7 @@ function EpisodeActions({
if (busy) return <span className="spinner" />
if (ep.status === 'pending') {
if (ep.status === 'pending' || ep.status === 'failed') {
return (
<button style={{ fontSize: 12 }} onClick={() => handle('downloaded_manual')}>
Mark Downloaded