From 14ce08c3c2e63f43501fb548d0bf6b243487b581 Mon Sep 17 00:00:00 2001 From: Jason Stedwell Date: Sun, 28 Jun 2026 21:47:04 -0500 Subject: [PATCH] version 2 build --- AGENTS.md | 57 ++++++---- README.md | 25 ++++- package.json | 3 +- src/client/App.tsx | 10 +- src/client/components/HealthBanner.tsx | 23 ++++ src/client/index.css | 3 + src/client/lib/parseRelease.test.ts | 24 +++++ src/client/lib/parseRelease.ts | 15 +++ src/client/pages/ShowDetailPage.tsx | 80 ++++++++++---- src/client/pages/ShowsPage.tsx | 50 +++++++-- src/server/db/index.ts | 2 + src/server/index.ts | 18 +++- src/server/routes/episodes.ts | 12 ++- src/server/routes/shows.ts | 6 +- src/server/services/cron-util.test.ts | 19 ++++ src/server/services/cron-util.ts | 14 +++ src/server/services/downloader.test.ts | 16 +++ src/server/services/nyaa.test.ts | 41 +++++++ src/server/services/nyaa.ts | 12 ++- src/server/services/scheduler.ts | 144 ++++++++++++++++--------- src/server/types.ts | 1 + tsconfig.server.json | 3 +- 22 files changed, 462 insertions(+), 116 deletions(-) create mode 100644 src/client/components/HealthBanner.tsx create mode 100644 src/client/lib/parseRelease.test.ts create mode 100644 src/client/lib/parseRelease.ts create mode 100644 src/server/services/cron-util.test.ts create mode 100644 src/server/services/cron-util.ts create mode 100644 src/server/services/downloader.test.ts create mode 100644 src/server/services/nyaa.test.ts diff --git a/AGENTS.md b/AGENTS.md index 69bc4ce..3ce34b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,48 +59,59 @@ Target deployment is an Unraid server using a single Docker container with a sim - Entities: - **Show**: id, display name, search/RSS query, quality filter, fansub group, active flag. - - **Episode**: id, show_id, episode_number (string or parsed integer), nyaa_torrent_id, title, status (`pending`, `downloaded_auto`, `downloaded_manual`), torrent_url, created_at, downloaded_at. + - **Episode**: id, show_id, episode_code (string or parsed integer), nyaa_torrent_id, title, status (`pending`, `downloaded_auto`, `downloaded_manual`, `failed`), torrent_url, download_error, file_path, attempts, created_at, downloaded_at. - Behavior: - Adding a show: - - Run an immediate search. - - Populate existing episodes in DB as `pending` (no download) to let the user backfill by manually checking already downloaded ones. - - Removing a show: - - Leave episodes in DB but mark show as inactive (no further polling). + - Run an immediate search and ingest existing feed items, then auto-download from the oldest episode upward (batch releases excluded). + - Pausing a show: + - Toggle the active flag off — episodes are preserved, polling stops. + - Deleting a show: + - Permanently remove the show and cascade-delete its episodes. `.torrent` files already written to disk are left in place. - Manual check: - - User can mark an episode as already downloaded (`downloaded_manual`), no torrent action taken. + - User can mark an episode as already downloaded (`downloaded_manual`), no torrent action taken; or reset a downloaded/failed episode back to `pending`. ### 3. Auto-Download Logic -- Periodic job (e.g. every 5–15 minutes, configurable): +- Periodic job (configurable interval, default 15 min). A re-entrancy guard + prevents overlapping runs (a slow poll or a manual "Poll Now" won't double up): - For each active show: - Query Nyaa using its stored RSS/search parameters.[^4][^3][^6] - - Determine the “next” episode: - - Prefer simplest rule: highest episode number not yet marked downloaded. - - Guard against batch torrents by using size or title pattern heuristics (e.g. skip titles containing “Batch”). - - If the next episode’s torrent is not yet in DB: - - Create an Episode record with status `downloaded_auto`. - - Download the `.torrent` file (NOT the media itself) into the mapped host directory. - - Filename suggestion: `-ep-.torrent`. -- Do not attempt to control or integrate directly with a torrent client (scope is “download the .torrent file” only). + - Skip batch torrents via title heuristics (titles containing "Batch", + "Complete", "Vol.", or an episode range). + - Process every feed item not already downloaded (oldest episode first), + plus any previously-`failed` episodes — including ones that have scrolled + off the current feed, retried from their stored `torrent_url`. + - For each candidate: + - Insert a `pending` Episode record if new. + - Download the `.torrent` file (NOT the media itself) into the mapped + host directory as `-ep-.torrent`. + - On success, set status `downloaded_auto` and record the file path; on + failure, set status `failed`, store the error, and increment `attempts`. + - Stop retrying an episode once it reaches the attempt cap (5) so a + permanently-dead torrent isn't re-attempted forever. +- Do not attempt to control or integrate directly with a torrent client (scope is "download the .torrent file" only). ### 4. Web UI - Views: - **Shows list**: - - Add show (form: name, search query, quality, group). - - Toggle active/inactive. + - Add show via a Nyaa search-first modal; clicking a result pre-fills the form. + - Pause/resume polling per show. - Quick link to show detail. - **Show detail**: + - Stats summary (total / pending / auto / manual / failed) and a failed-downloads panel with error details. - Table of episodes: episode number/title, Nyaa ID, status, timestamps. - Controls: - - Manually mark individual episodes as downloaded. - - Bulk “mark previous episodes as downloaded” helper (e.g. “mark up to episode N”). + - Manually mark individual episodes as downloaded, or reset them to pending (with confirmation). + - Bulk “mark up to episode N as downloaded” helper. + - "Poll Now" to trigger an immediate check; Pause/Resume; Delete (with confirmation). - **Settings**: - Poll interval. - Default quality / sub group preferences. - Torrent download directory (read-only display; actual path comes from environment/volume). + - A health-warning banner appears on every page when the torrent output directory isn't writable. - UX constraints: - Keep it extremely simple; focus is internal tool. - Assume a single user instance behind LAN. @@ -140,14 +151,18 @@ Target deployment is an Unraid server using a single Docker container with a sim - `created_at`, `updated_at` - `episodes` - `id` (PK) - - `show_id` (FK → shows.id) + - `show_id` (FK → shows.id, `ON DELETE CASCADE`) - `episode_code` (string, e.g. “S01E03” or “03”) - `title` (string) - `torrent_id` (string, Nyaa ID) - `torrent_url` (string) - - `status` (enum: `pending`, `downloaded_auto`, `downloaded_manual`) + - `status` (enum: `pending`, `downloaded_auto`, `downloaded_manual`, `failed`) + - `download_error` (string, nullable — last failure message) + - `file_path` (string, nullable — saved `.torrent` path) + - `attempts` (integer, default 0 — failed download count, capped at 5) - `downloaded_at` (datetime, nullable) - `created_at`, `updated_at` + - `UNIQUE(show_id, torrent_id)` *** diff --git a/README.md b/README.md index 610b70b..842b274 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,12 @@ A dockerized torrent crawler and downloader for [Nyaa.si](https://nyaa.si). Trac - Search Nyaa.si and add shows to a watch list - Automatic polling for new episodes (configurable interval, default 15 min) - Auto-downloads `.torrent` files to a mapped host directory -- Track episode status: pending, auto-downloaded, or manually marked +- Backfills existing episodes (oldest first) when a show is added +- Tracks episode status: pending, auto-downloaded, manually marked, or failed +- Automatically retries failed downloads on each poll (up to 5 attempts), including ones that have scrolled off the RSS feed +- Surfaces failed downloads with their error message, plus a "Poll Now" manual trigger - Bulk-mark episodes as already downloaded +- Health check that warns when the torrent output directory isn't writable - Minimal dark-themed web UI - SQLite persistence — easy to back up and migrate - Unraid-friendly Docker container @@ -17,7 +21,7 @@ A dockerized torrent crawler and downloader for [Nyaa.si](https://nyaa.si). Trac - **Backend**: Node.js + TypeScript + Express - **Frontend**: React + Vite -- **Database**: SQLite (`better-sqlite3`) +- **Database**: SQLite via Node's built-in `node:sqlite` (requires Node 22+) - **Scheduler**: `node-cron` - **Nyaa integration**: RSS via `fast-xml-parser` @@ -31,6 +35,8 @@ Open `http://localhost:8082` in your browser. ## Quick Start (Development) +Requires **Node.js 22+** (the app uses the built-in `node:sqlite` module). + ```bash npm install npm run dev @@ -39,6 +45,16 @@ npm run dev - Client dev server: `http://localhost:5173` (proxies `/api` to `:3000`) - API server: `http://localhost:3000` +## Tests + +```bash +npm test +``` + +Unit tests (via Node's built-in test runner) cover the pure logic most prone to +edge cases: episode-code parsing, batch-release detection, the poll-interval → +cron conversion, filename slugification, and release-title parsing. + ## Environment Variables | Variable | Default | Description | @@ -72,4 +88,7 @@ services: - Only `.torrent` files are downloaded — media is handled by your existing torrent client watching the output directory. - Batch releases (titles containing "Batch", "Complete", "Vol.", or episode ranges) are automatically skipped. -- Removing a show marks it inactive (no further polling) but preserves episode history. +- **Pause** stops further polling for a show but keeps its episodes. **Delete** permanently removes the show and its episode history (already-downloaded `.torrent` files on disk are left untouched). +- Failed downloads are retried automatically on each poll, up to 5 attempts; after that they stay `failed` (mark them downloaded manually, or delete and re-add the show). Failures are deduplicated against the live feed and retried even once they've scrolled off it. +- If the torrent output directory isn't writable, a warning banner appears on every page showing the failing path — fix the volume mapping or permissions and the next poll will recover. +- The SQLite schema migrates automatically on startup; existing databases are upgraded in place. diff --git a/package.json b/package.json index 37b90e2..29e8b3a 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "build:client": "vite build", "build:server": "tsc -p tsconfig.server.json", "start": "node dist/server/index.js", - "migrate": "tsx src/server/db/migrate.ts" + "migrate": "tsx src/server/db/migrate.ts", + "test": "node --import tsx --test \"src/**/*.test.ts\"" }, "dependencies": { "express": "^4.18.3", diff --git a/src/client/App.tsx b/src/client/App.tsx index 84c686b..51dc301 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -1,12 +1,19 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import ShowsPage from './pages/ShowsPage' import ShowDetailPage from './pages/ShowDetailPage' import SettingsPage from './pages/SettingsPage' +import HealthBanner from './components/HealthBanner' +import { api, type HealthStatus } from './api' type Page = { name: 'shows' } | { name: 'show'; id: number } | { name: 'settings' } export default function App() { const [page, setPage] = useState({ name: 'shows' }) + const [health, setHealth] = useState(null) + + useEffect(() => { + api.health.get().then(setHealth).catch(() => setHealth(null)) + }, [page]) return (
@@ -31,6 +38,7 @@ export default function App() {
+ {page.name === 'shows' && ( setPage({ name: 'show', id })} /> )} diff --git a/src/client/components/HealthBanner.tsx b/src/client/components/HealthBanner.tsx new file mode 100644 index 0000000..eb0e13f --- /dev/null +++ b/src/client/components/HealthBanner.tsx @@ -0,0 +1,23 @@ +import { type HealthStatus } from '../api' + +// App-wide warning shown whenever the torrent output directory cannot be +// written to. This is rendered on every page because a non-writable directory +// makes *all* auto-downloads fail silently — the user needs to see it no matter +// where they are in the app, not only on a show's detail page. +export default function HealthBanner({ health }: { health: HealthStatus | null }) { + if (!health || health.torrent_dir_writable) return null + + return ( +
+
+ ⚠ Torrent directory is not writable +
+
+ Downloads will fail until this is fixed. Path: {health.torrent_dir} +
+ {health.torrent_dir_error && ( +
{health.torrent_dir_error}
+ )} +
+ ) +} diff --git a/src/client/index.css b/src/client/index.css index 8d1ac2d..5dd3d19 100644 --- a/src/client/index.css +++ b/src/client/index.css @@ -118,6 +118,9 @@ tr:hover td { background: var(--surface2); } .search-results { margin-top: 12px; max-height: 320px; overflow-y: auto; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface2); } .search-result-item { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-bottom: 1px solid var(--border); gap: 8px; } .search-result-item:last-child { border-bottom: none; } +.search-result-item.clickable { cursor: pointer; transition: background 0.1s; } +.search-result-item.clickable:hover { background: var(--surface); } +.search-result-item.selected { background: var(--surface); box-shadow: inset 3px 0 0 var(--accent); } .search-result-item .title { flex: 1; font-size: 13px; } .search-result-item .meta { color: var(--text-muted); font-size: 11px; white-space: nowrap; } diff --git a/src/client/lib/parseRelease.test.ts b/src/client/lib/parseRelease.test.ts new file mode 100644 index 0000000..4f75a1d --- /dev/null +++ b/src/client/lib/parseRelease.test.ts @@ -0,0 +1,24 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { parseRelease } from './parseRelease.js' + +test('parseRelease: extracts name, quality, and sub group', () => { + assert.deepEqual( + parseRelease('[SubsPlease] Jujutsu Kaisen - 12 (1080p) [A1B2C3D4].mkv'), + { name: 'Jujutsu Kaisen', quality: '1080p', subGroup: 'SubsPlease' } + ) +}) + +test('parseRelease: strips versioned episode markers and trailing brackets', () => { + assert.deepEqual( + parseRelease('[Erai-raws] Some Show Name - 07v2 [720p][ABCD].mkv'), + { name: 'Some Show Name', quality: '720p', subGroup: 'Erai-raws' } + ) +}) + +test('parseRelease: tolerates titles with no group or quality', () => { + assert.deepEqual( + parseRelease('Naruto Shippuuden - 500'), + { name: 'Naruto Shippuuden', quality: '', subGroup: '' } + ) +}) diff --git a/src/client/lib/parseRelease.ts b/src/client/lib/parseRelease.ts new file mode 100644 index 0000000..8e0440f --- /dev/null +++ b/src/client/lib/parseRelease.ts @@ -0,0 +1,15 @@ +// Best-effort extraction of a clean show name, quality, and sub group from a +// raw Nyaa release title like "[SubsPlease] Some Show - 12 (1080p) [A1B2].mkv". +export function parseRelease(title: string): { name: string; quality: string; subGroup: string } { + const subGroup = title.match(/^\[([^\]]+)\]/)?.[1] ?? '' + const quality = title.match(/(2160p|1080p|720p|480p)/i)?.[1] ?? '' + const name = title + .replace(/\.(mkv|mp4|avi)$/i, '') + .replace(/[[(][^\])]*[\])]/g, ' ') // strip [..] and (..) groups + .replace(/\s-\s*\d{1,4}(v\d)?(?=\s|$)/gi, ' ') // strip " - 12" episode markers + .replace(/\b(S\d{1,2}E\d{1,3}|EP?\d{1,3})\b/gi, ' ') + .replace(/\s+/g, ' ') + .replace(/[-–]\s*$/, '') + .trim() + return { name, quality, subGroup } +} diff --git a/src/client/pages/ShowDetailPage.tsx b/src/client/pages/ShowDetailPage.tsx index c7ebfd6..8442d9c 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, type HealthStatus } from '../api' +import { api, type Show, type Episode } from '../api' interface Props { showId: number @@ -9,32 +9,41 @@ 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) + const [error, setError] = useState('') useEffect(() => { - Promise.all([api.shows.get(showId), api.episodes.list(showId), api.health.get()]) - .then(([s, eps, h]) => { setShow(s); setEpisodes(eps); setHealth(h) }) + Promise.all([api.shows.get(showId), api.episodes.list(showId)]) + .then(([s, eps]) => { setShow(s); setEpisodes(eps) }) + .catch((err) => setError(err instanceof Error ? err.message : 'Failed to load show')) .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 as Episode) : e))) + setError('') + try { + const updated = await api.episodes.update(showId, ep.id, status) + setEpisodes((prev) => prev.map((e) => (e.id === updated.id ? (updated as Episode) : e))) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update episode') + } } const handleBulkMark = async (e: React.FormEvent) => { e.preventDefault() if (!bulkCode.trim()) return setBulking(true) + setError('') try { await api.episodes.bulkMark(showId, bulkCode.trim(), 'downloaded_manual') const eps = await api.episodes.list(showId) setEpisodes(eps) setBulkCode('') + } catch (err) { + setError(err instanceof Error ? err.message : 'Bulk mark failed') } finally { setBulking(false) } @@ -42,21 +51,44 @@ export default function ShowDetailPage({ showId, onBack }: Props) { const handleToggleActive = async () => { if (!show) return - const updated = await api.shows.update(show.id, { is_active: show.is_active ? 0 : 1 }) - setShow(updated) + setError('') + try { + const updated = await api.shows.update(show.id, { is_active: show.is_active ? 0 : 1 }) + setShow(updated) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update show') + } } const handlePollNow = async () => { setPolling(true) + setError('') try { await api.health.poll() const eps = await api.episodes.list(showId) setEpisodes(eps) + } catch (err) { + setError(err instanceof Error ? err.message : 'Poll failed') } finally { setPolling(false) } } + const handleDelete = async () => { + if (!show) return + if (!window.confirm( + `Delete "${show.name}"? This permanently removes the show and its episode history. ` + + `Already-downloaded .torrent files on disk are kept.` + )) return + setError('') + try { + await api.shows.remove(show.id) + onBack() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete show') + } + } + if (loading) return
if (!show) return
Show not found.
@@ -75,16 +107,12 @@ export default function ShowDetailPage({ showId, onBack }: Props) { ← Shows - {/* Health warning banner */} - {health && !health.torrent_dir_writable && ( + {error && (
-
⚠ Torrent directory is not writable
-
- Path: {health.torrent_dir} +
+ {error} +
- {health.torrent_dir_error && ( -
{health.torrent_dir_error}
- )}
)} @@ -106,6 +134,9 @@ export default function ShowDetailPage({ showId, onBack }: Props) { +
@@ -163,7 +194,14 @@ export default function ShowDetailPage({ showId, onBack }: Props) { {/* Episodes table */} {episodes.length === 0 ? ( -
No episodes found yet. Polling will populate this list.
+
+

No episodes yet.

+

+ {show.is_active + ? 'The first poll runs shortly — or click ↻ Poll Now to fetch immediately.' + : 'This show is paused. Resume polling to start fetching episodes.'} +

+
) : (
@@ -237,8 +275,14 @@ function EpisodeActions({ ) } + const handleReset = () => { + if (window.confirm( + `Reset episode ${ep.episode_code} to pending? It will be re-downloaded on the next poll.` + )) handle('pending') + } + return ( - ) diff --git a/src/client/pages/ShowsPage.tsx b/src/client/pages/ShowsPage.tsx index 95811a6..775f98d 100644 --- a/src/client/pages/ShowsPage.tsx +++ b/src/client/pages/ShowsPage.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react' import { api, type Show, type NyaaItem } from '../api' +import { parseRelease } from '../lib/parseRelease' interface Props { onSelectShow: (id: number) => void @@ -9,6 +10,7 @@ export default function ShowsPage({ onSelectShow }: Props) { const [shows, setShows] = useState([]) const [showModal, setShowModal] = useState(false) const [loading, setLoading] = useState(true) + const [notice, setNotice] = useState('') useEffect(() => { api.shows.list().then(setShows).finally(() => setLoading(false)) @@ -23,6 +25,8 @@ export default function ShowsPage({ onSelectShow }: Props) { const handleShowAdded = (show: Show) => { setShows((prev) => [show, ...prev]) setShowModal(false) + setNotice(`Added “${show.name}” — fetching existing episodes in the background. Open the show to track progress.`) + setTimeout(() => setNotice(''), 6000) } return ( @@ -32,6 +36,12 @@ export default function ShowsPage({ onSelectShow }: Props) { + {notice && ( +
+ {notice} +
+ )} + {loading &&
} {!loading && shows.length === 0 && ( @@ -83,6 +93,7 @@ function AddShowModal({ onClose, onAdded }: { onClose: () => void; onAdded: (s: const [results, setResults] = useState([]) const [searching, setSearching] = useState(false) const [searchError, setSearchError] = useState('') + const [selectedId, setSelectedId] = useState('') const [name, setName] = useState('') const [query, setQuery] = useState('') @@ -91,6 +102,17 @@ function AddShowModal({ onClose, onAdded }: { onClose: () => void; onAdded: (s: const [saving, setSaving] = useState(false) const [error, setError] = useState('') + // Clicking a result is an explicit "use this release" action: pre-fill the + // form so the user never has to retype (and risk typos in) the query. + const handlePick = (item: NyaaItem) => { + const parsed = parseRelease(item.title) + setSelectedId(item.torrent_id) + if (parsed.name) setName(parsed.name) + if (parsed.quality) setQuality(parsed.quality) + if (parsed.subGroup) setSubGroup(parsed.subGroup) + setQuery(searchQ || parsed.name) + } + const handleSearch = async (e: React.FormEvent) => { e.preventDefault() if (!searchQ.trim()) return @@ -144,14 +166,26 @@ function AddShowModal({ onClose, onAdded }: { onClose: () => void; onAdded: (s: {results.length > 0 && ( -
- {results.slice(0, 20).map((item) => ( -
- {item.title} - {item.size} · {item.seeders}S -
- ))} -
+ <> +

+ Click a result to fill in the form below. +

+
+ {results.slice(0, 20).map((item) => ( +
handlePick(item)} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handlePick(item) } }} + > + {item.title} + {item.size} · {item.seeders}S +
+ ))} +
+ )} {/* Add form */} diff --git a/src/server/db/index.ts b/src/server/db/index.ts index 9d76366..e182bd6 100644 --- a/src/server/db/index.ts +++ b/src/server/db/index.ts @@ -40,6 +40,7 @@ export function runMigrations() { torrent_url TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','downloaded_auto','downloaded_manual','failed')), + attempts INTEGER NOT NULL DEFAULT 0, downloaded_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), @@ -60,6 +61,7 @@ export function runMigrations() { // Incremental migrations — safe to re-run (errors are swallowed for "already exists") runAlterIfMissing("ALTER TABLE episodes ADD COLUMN download_error TEXT") runAlterIfMissing("ALTER TABLE episodes ADD COLUMN file_path TEXT") + runAlterIfMissing("ALTER TABLE episodes ADD COLUMN attempts INTEGER NOT NULL DEFAULT 0") // Widen the status CHECK constraint to include 'failed' for existing DBs. // SQLite doesn't support ALTER COLUMN, so we recreate the table if the old diff --git a/src/server/index.ts b/src/server/index.ts index d8489b4..c9f7909 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -35,7 +35,16 @@ app.get('/api/health', (_req, res) => { app.post('/api/poll', (_req, res) => { pollAllShows() .then(() => res.json({ status: 'ok' })) - .catch((err: unknown) => res.status(500).json({ error: String(err) })) + .catch((err: unknown) => { + console.error('[server] Poll failed:', err) + res.status(500).json({ error: 'Poll failed' }) + }) +}) + +// Unmatched /api routes must return JSON, not the SPA's index.html — otherwise +// the client tries to JSON.parse HTML and throws a confusing "Unexpected token <". +app.use('/api', (_req, res) => { + res.status(404).json({ error: 'Not found' }) }) // Serve static client build in production @@ -45,6 +54,13 @@ app.get('*', (_req, res) => { res.sendFile(path.join(clientDist, 'index.html')) }) +// JSON error handler — keeps internal details out of the client response. +app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + console.error('[server] Unhandled error:', err) + if (res.headersSent) return + res.status(500).json({ error: 'Internal server error' }) +}) + // Bootstrap runMigrations() startScheduler() diff --git a/src/server/routes/episodes.ts b/src/server/routes/episodes.ts index aff17e1..68aa661 100644 --- a/src/server/routes/episodes.ts +++ b/src/server/routes/episodes.ts @@ -9,7 +9,11 @@ const router = Router({ mergeParams: true }) // GET /api/shows/:showId/episodes router.get('/', (req: Request, res) => { const episodes = db - .prepare('SELECT * FROM episodes WHERE show_id = ? ORDER BY episode_code ASC, created_at DESC') + .prepare( + // CAST to REAL so codes sort numerically (1,2,…,10,11) rather than + // lexically (1,10,11,2,…). Matches the ordering used by bulk-mark. + 'SELECT * FROM episodes WHERE show_id = ? ORDER BY CAST(episode_code AS REAL) ASC, episode_code ASC, created_at DESC' + ) .all(req.params.showId) as unknown as Episode[] res.json(episodes) }) @@ -57,14 +61,16 @@ router.post('/bulk-mark', (req: Request, res) => { const toUpdate = episodes.slice(0, targetIdx + 1).map((e) => e.id) + // 'pending' clears the timestamp; a "downloaded" status stamps it. + const downloadedAt = status === 'pending' ? null : new Date().toISOString() const update = db.prepare(` UPDATE episodes - SET status = ?, downloaded_at = datetime('now'), updated_at = datetime('now') + SET status = ?, downloaded_at = ?, updated_at = datetime('now') WHERE id = ? `) db.exec('BEGIN') try { - for (const id of toUpdate) update.run(status, id) + for (const id of toUpdate) update.run(status, downloadedAt, id) db.exec('COMMIT') } catch (err) { db.exec('ROLLBACK') diff --git a/src/server/routes/shows.ts b/src/server/routes/shows.ts index bb6f914..8c4a6c8 100644 --- a/src/server/routes/shows.ts +++ b/src/server/routes/shows.ts @@ -76,11 +76,13 @@ router.patch('/:id', (req, res) => { res.json(db.prepare('SELECT * FROM shows WHERE id = ?').get(show.id)) }) -// DELETE /api/shows/:id – marks inactive (does not delete) +// DELETE /api/shows/:id – permanently removes the show and its episode history +// (episodes cascade via the foreign key). Already-downloaded .torrent files on +// disk are left untouched. router.delete('/:id', (req, res) => { const show = db.prepare('SELECT * FROM shows WHERE id = ?').get(req.params.id) as unknown as Show | undefined if (!show) return res.status(404).json({ error: 'Show not found' }) - db.prepare("UPDATE shows SET is_active = 0, updated_at = datetime('now') WHERE id = ?").run(show.id) + db.prepare('DELETE FROM shows WHERE id = ?').run(show.id) res.json({ success: true }) }) diff --git a/src/server/services/cron-util.test.ts b/src/server/services/cron-util.test.ts new file mode 100644 index 0000000..cdb9ba1 --- /dev/null +++ b/src/server/services/cron-util.test.ts @@ -0,0 +1,19 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { secondsToCron } from './cron-util.js' + +test('secondsToCron: sub-minute intervals run every minute', () => { + assert.equal(secondsToCron(30), '* * * * *') + assert.equal(secondsToCron(59), '* * * * *') +}) + +test('secondsToCron: minute intervals', () => { + assert.equal(secondsToCron(60), '*/1 * * * *') + assert.equal(secondsToCron(900), '*/15 * * * *') + assert.equal(secondsToCron(1800), '*/30 * * * *') +}) + +test('secondsToCron: hour intervals', () => { + assert.equal(secondsToCron(3600), '0 */1 * * *') + assert.equal(secondsToCron(21600), '0 */6 * * *') +}) diff --git a/src/server/services/cron-util.ts b/src/server/services/cron-util.ts new file mode 100644 index 0000000..88578be --- /dev/null +++ b/src/server/services/cron-util.ts @@ -0,0 +1,14 @@ +/** + * Convert a polling interval in seconds into a cron expression. + * Pure and side-effect free so it can be unit-tested without touching the DB. + * - < 60s → every minute ("* * * * *") + * - < 60 minutes → every N minutes ("*\/15 * * * *") + * - otherwise → every N hours ("0 *\/6 * * *") + */ +export function secondsToCron(seconds: number): string { + if (seconds < 60) return '* * * * *' + const minutes = Math.round(seconds / 60) + if (minutes < 60) return `*/${minutes} * * * *` + const hours = Math.round(minutes / 60) + return `0 */${hours} * * *` +} diff --git a/src/server/services/downloader.test.ts b/src/server/services/downloader.test.ts new file mode 100644 index 0000000..ced3ac1 --- /dev/null +++ b/src/server/services/downloader.test.ts @@ -0,0 +1,16 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { slugify } from './downloader.js' + +test('slugify: lowercases and hyphenates', () => { + assert.equal(slugify('Jujutsu Kaisen'), 'jujutsu-kaisen') +}) + +test('slugify: collapses punctuation and trims dashes', () => { + assert.equal(slugify('Re:ZERO -Starting Life-'), 're-zero-starting-life') + assert.equal(slugify(' Spy x Family! '), 'spy-x-family') +}) + +test('slugify: handles already-clean names', () => { + assert.equal(slugify('bleach'), 'bleach') +}) diff --git a/src/server/services/nyaa.test.ts b/src/server/services/nyaa.test.ts new file mode 100644 index 0000000..6be717a --- /dev/null +++ b/src/server/services/nyaa.test.ts @@ -0,0 +1,41 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { parseEpisodeCode, isBatchRelease } from './nyaa.js' + +test('parseEpisodeCode: " - NN" dash form', () => { + assert.equal(parseEpisodeCode('[SubsPlease] Show Name - 12 (1080p) [ABCD1234].mkv'), '12') + assert.equal(parseEpisodeCode('[Group] Another Show - 07v2 [720p].mkv'), '07v2') +}) + +test('parseEpisodeCode: dash form at end of title (no trailing space)', () => { + // Regression: the old pattern required a trailing space and missed these. + assert.equal(parseEpisodeCode('Show Name - 12'), '12') + assert.equal(parseEpisodeCode('Show Name - 12.mkv'), '12') + assert.equal(parseEpisodeCode('Show Name - 12[1080p]'), '12') +}) + +test('parseEpisodeCode: bracket and season/episode forms', () => { + assert.equal(parseEpisodeCode('Show Name [05] [1080p]'), '05') + assert.equal(parseEpisodeCode('Show Name S01E08 [1080p]'), '08') + assert.equal(parseEpisodeCode('Show Name EP03'), '03') + assert.equal(parseEpisodeCode('Show Name E11'), '11') +}) + +test('parseEpisodeCode: does NOT match digits inside a hex hash', () => { + // Regression: "E3" inside [1A2E3B4C] used to be parsed as episode "3". + assert.equal(parseEpisodeCode('[Erai-raws] Mystery Show [1080p][1A2E3B4C].mkv'), 'unknown') +}) + +test('parseEpisodeCode: returns "unknown" when nothing matches', () => { + assert.equal(parseEpisodeCode('Some Movie (2024) [1080p] [BDRip]'), 'unknown') +}) + +test('isBatchRelease: flags batches and complete collections', () => { + assert.equal(isBatchRelease('[Group] Show Name (01-12) Batch [1080p]'), true) + assert.equal(isBatchRelease('[Group] Show Name Complete Series'), true) + assert.equal(isBatchRelease('[Group] Show Name Vol.1'), true) +}) + +test('isBatchRelease: leaves single episodes alone', () => { + assert.equal(isBatchRelease('[SubsPlease] Show Name - 12 (1080p) [ABCD1234].mkv'), false) +}) diff --git a/src/server/services/nyaa.ts b/src/server/services/nyaa.ts index fe147a5..e8e81d8 100644 --- a/src/server/services/nyaa.ts +++ b/src/server/services/nyaa.ts @@ -107,17 +107,19 @@ function parseItem(item: Record): NyaaItem { * Returns the matched string or 'unknown'. */ export function parseEpisodeCode(title: string): string { - // Match " - 12 " or " - 12v2" - let m = title.match(/\s-\s(\d{1,4}(?:\.\d)?(?:v\d)?)\s/) + // Match " - 12", " - 12v2", "- 12.mkv", "- 12[1080p]" (lookahead avoids + // requiring a trailing space, which used to miss codes at end-of-title). + let m = title.match(/\s-\s(\d{1,4}(?:\.\d)?(?:v\d)?)(?=\s|$|[[(.])/) if (m) return m[1] - // Match [12] or [12v2] (but skip hash-like 6+ char hex blocks e.g. [CC3FE38D]) + // Match [12] or [12v2] (digits-only brackets, so hash blocks are skipped) m = title.match(/\[(\d{1,4}(?:\.\d)?(?:v\d)?)\]/) if (m) return m[1] // Match S01E12 m = title.match(/[Ss]\d{1,2}[Ee](\d{1,4})/) if (m) return m[1] - // Match EP12 or E12 - m = title.match(/[Ee][Pp]?(\d{1,4})/) + // Match EP12 or E12 — \b prevents matching inside hex hashes like [1A2E3B4C] + // (no word boundary exists between "2" and "E"), while still catching "E12". + m = title.match(/\b(?:EP|E)(\d{1,4}(?:\.\d)?)\b/i) if (m) return m[1] return 'unknown' } diff --git a/src/server/services/scheduler.ts b/src/server/services/scheduler.ts index 002dd32..3e52979 100644 --- a/src/server/services/scheduler.ts +++ b/src/server/services/scheduler.ts @@ -2,10 +2,19 @@ import cron from 'node-cron' import { db } from '../db/index.js' import { fetchRss, parseEpisodeCode, isBatchRelease } from './nyaa.js' import { downloadTorrent, slugify } from './downloader.js' +import { secondsToCron } from './cron-util.js' import type { Show, Episode, NyaaItem } from '../types.js' let currentTask: cron.ScheduledTask | null = null +// Prevents a slow poll (or a manual "Poll Now") from overlapping with the next +// scheduled tick, which could otherwise download the same torrent twice. +let isPolling = false + +// Give up auto-retrying a download after this many failures so a permanently +// dead torrent (e.g. a 404) isn't re-attempted on every single poll forever. +const MAX_ATTEMPTS = 5 + export function startScheduler() { const intervalSeconds = getIntervalSeconds() scheduleJob(intervalSeconds) @@ -27,14 +36,6 @@ function getIntervalSeconds(): number { return Math.max(60, parseInt(row?.value ?? '900', 10)) } -function secondsToCron(seconds: number): string { - if (seconds < 60) return '* * * * *' - const minutes = Math.round(seconds / 60) - if (minutes < 60) return `*/${minutes} * * * *` - const hours = Math.round(minutes / 60) - return `0 */${hours} * * *` -} - function scheduleJob(intervalSeconds: number) { const cronExpr = secondsToCron(intervalSeconds) currentTask = cron.schedule(cronExpr, () => { @@ -45,15 +46,24 @@ function scheduleJob(intervalSeconds: number) { } export async function pollAllShows() { - const shows = db.prepare('SELECT * FROM shows WHERE is_active = 1').all() as unknown as Show[] - console.log(`[scheduler] Polling ${shows.length} active show(s)...`) + if (isPolling) { + console.log('[scheduler] Poll already in progress — skipping this run.') + return + } + isPolling = true + try { + const shows = db.prepare('SELECT * FROM shows WHERE is_active = 1').all() as unknown as Show[] + console.log(`[scheduler] Polling ${shows.length} active show(s)...`) - for (const show of shows) { - try { - await pollShow(show) - } catch (err) { - console.error(`[scheduler] Error polling show "${show.name}":`, err) + for (const show of shows) { + try { + await pollShow(show) + } catch (err) { + console.error(`[scheduler] Error polling show "${show.name}":`, err) + } } + } finally { + isPolling = false } } @@ -69,31 +79,54 @@ async function pollShow(show: Show) { return } - // Separate brand-new items from previously-failed/pending ones that need a retry - const retryableEpisodes = db - .prepare(`SELECT * FROM episodes WHERE show_id = ? AND status IN ('pending', 'failed')`) - .all(show.id) as unknown as Episode[] - - const retryIds = new Set(retryableEpisodes.map((e) => e.torrent_id)) - - const newItems = items + // Candidates present in the current feed: anything not-yet-downloaded. + const feedItems = items .filter((item) => { if (isBatchRelease(item.title)) return false const existing = db - .prepare('SELECT id, status FROM episodes WHERE show_id = ? AND torrent_id = ?') - .get(show.id, item.torrent_id) as { id: number; status: string } | undefined - // Include if: never seen, or previously failed/pending + .prepare('SELECT status FROM episodes WHERE show_id = ? AND torrent_id = ?') + .get(show.id, item.torrent_id) as { status: string } | undefined return !existing || existing.status === 'pending' || existing.status === 'failed' }) - .sort((a, b) => episodeNum(parseEpisodeCode(a.title)) - episodeNum(parseEpisodeCode(b.title))) - if (newItems.length === 0 && retryIds.size === 0) return + // Previously-failed episodes that have scrolled OFF the current RSS feed. + // These would otherwise never be retried; we re-download them from the + // torrent_url we already stored (until they hit MAX_ATTEMPTS). + const feedIds = new Set(feedItems.map((i) => i.torrent_id)) + const offFeedFailures = ( + db + .prepare( + `SELECT episode_code, title, torrent_id, torrent_url + FROM episodes WHERE show_id = ? AND status = 'failed' AND attempts < ?` + ) + .all(show.id, MAX_ATTEMPTS) as Array> + ) + .filter((e) => !feedIds.has(e.torrent_id)) + .map((e): NyaaItem => ({ + torrent_id: e.torrent_id, + title: e.title, + torrent_url: e.torrent_url, + magnet_url: null, + category: '', + size: '', + seeders: 0, + leechers: 0, + downloads: 0, + published: '', + })) - if (newItems.length > 0) { - console.log(`[scheduler] Found ${newItems.length} new/retryable episode(s) for "${show.name}"`) - } + const candidates = [...feedItems, ...offFeedFailures].sort( + (a, b) => episodeNum(parseEpisodeCode(a.title)) - episodeNum(parseEpisodeCode(b.title)) + ) - await downloadItems(show, newItems) + if (candidates.length === 0) return + + console.log( + `[scheduler] ${candidates.length} episode(s) to process for "${show.name}" ` + + `(${offFeedFailures.length} off-feed retr${offFeedFailures.length === 1 ? 'y' : 'ies'})` + ) + + await downloadItems(show, candidates) } /** @@ -106,26 +139,32 @@ export async function downloadItems(show: Show, items: NyaaItem[]) { for (const item of items) { const episodeCode = parseEpisodeCode(item.title) - // Upsert: insert new as pending, or reset a failed/pending row so we can retry it - db - .prepare(` - INSERT INTO episodes - (show_id, episode_code, title, torrent_id, torrent_url, status, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, 'pending', datetime('now'), datetime('now')) - ON CONFLICT(show_id, torrent_id) DO UPDATE - SET status = 'pending', - download_error = NULL, - updated_at = datetime('now') - WHERE status IN ('failed', 'pending') - `) - .run(show.id, episodeCode, item.title, item.torrent_id, item.torrent_url) + const existing = db + .prepare('SELECT id, status, attempts FROM episodes WHERE show_id = ? AND torrent_id = ?') + .get(show.id, item.torrent_id) as { id: number; status: string; attempts: number } | undefined - // Fetch the episode id (whether just inserted or updated) - const ep = db - .prepare(`SELECT id FROM episodes WHERE show_id = ? AND torrent_id = ? AND status = 'pending'`) - .get(show.id, item.torrent_id) as { id: number } | undefined + // Skip anything already handled or exhausted. + if (existing) { + if (existing.status === 'downloaded_auto' || existing.status === 'downloaded_manual') continue + if (existing.status === 'failed' && existing.attempts >= MAX_ATTEMPTS) { + console.warn(`[scheduler] Giving up on "${item.title}" after ${existing.attempts} attempts`) + continue + } + } - if (!ep) continue // already downloaded_auto or downloaded_manual — skip + let epId: number + if (existing) { + epId = existing.id + } else { + const result = db + .prepare(` + INSERT INTO episodes + (show_id, episode_code, title, torrent_id, torrent_url, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 'pending', datetime('now'), datetime('now')) + `) + .run(show.id, episodeCode, item.title, item.torrent_id, item.torrent_url) + epId = Number(result.lastInsertRowid) + } // Download the .torrent file try { @@ -139,7 +178,7 @@ export async function downloadItems(show: Show, items: NyaaItem[]) { download_error = NULL, updated_at = datetime('now') WHERE id = ? - `).run(filePath, ep.id) + `).run(filePath, epId) console.log(`[scheduler] Downloaded: ${item.title}`) } catch (err) { const msg = err instanceof Error ? err.message : String(err) @@ -148,9 +187,10 @@ export async function downloadItems(show: Show, items: NyaaItem[]) { UPDATE episodes SET status = 'failed', download_error = ?, + attempts = attempts + 1, updated_at = datetime('now') WHERE id = ? - `).run(msg, ep.id) + `).run(msg, epId) } } } diff --git a/src/server/types.ts b/src/server/types.ts index ad612a6..a3fc82a 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -20,6 +20,7 @@ export interface Episode { status: 'pending' | 'downloaded_auto' | 'downloaded_manual' | 'failed' download_error: string | null file_path: string | null + attempts: number downloaded_at: string | null created_at: string updated_at: string diff --git a/tsconfig.server.json b/tsconfig.server.json index 497a1d2..1b66019 100644 --- a/tsconfig.server.json +++ b/tsconfig.server.json @@ -12,5 +12,6 @@ "noUnusedLocals": true, "noUnusedParameters": true }, - "include": ["src/server"] + "include": ["src/server"], + "exclude": ["src/server/**/*.test.ts"] }