version 2 build
Build and Push Docker Image / build (push) Failing after 1s

This commit is contained in:
Jason Stedwell
2026-06-28 21:47:04 -05:00
parent 8498f7abc4
commit 14ce08c3c2
22 changed files with 462 additions and 116 deletions
+36 -21
View File
@@ -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 515 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 episodes 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: `<show-slug>-ep<episode>-<torrent-id>.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 `<show-slug>-ep<episode>-<torrent-id>.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)`
***
+22 -3
View File
@@ -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.
+2 -1
View File
@@ -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",
+9 -1
View File
@@ -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<Page>({ name: 'shows' })
const [health, setHealth] = useState<HealthStatus | null>(null)
useEffect(() => {
api.health.get().then(setHealth).catch(() => setHealth(null))
}, [page])
return (
<div className="layout">
@@ -31,6 +38,7 @@ export default function App() {
</aside>
<main className="main">
<HealthBanner health={health} />
{page.name === 'shows' && (
<ShowsPage onSelectShow={(id) => setPage({ name: 'show', id })} />
)}
+23
View File
@@ -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 (
<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)' }}>
Downloads will fail until this is fixed. 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>
)
}
+3
View File
@@ -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; }
+24
View File
@@ -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: '' }
)
})
+15
View File
@@ -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 }
}
+58 -14
View File
@@ -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<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)
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']) => {
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
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 <div className="empty-state"><div className="spinner" /></div>
if (!show) return <div className="empty-state">Show not found.</div>
@@ -75,16 +107,12 @@ export default function ShowDetailPage({ showId, onBack }: Props) {
Shows
</a>
{/* Health warning banner */}
{health && !health.torrent_dir_writable && (
{error && (
<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 className="flex items-center justify-between" style={{ gap: 8 }}>
<span style={{ color: 'var(--red)', fontSize: 13 }}>{error}</span>
<button className="ghost" style={{ fontSize: 12 }} onClick={() => setError('')}>Dismiss</button>
</div>
{health.torrent_dir_error && (
<div style={{ fontSize: 11, color: 'var(--red)', marginTop: 4 }}>{health.torrent_dir_error}</div>
)}
</div>
)}
@@ -106,6 +134,9 @@ export default function ShowDetailPage({ showId, onBack }: Props) {
<button className="ghost" onClick={handleToggleActive}>
{show.is_active ? 'Pause Polling' : 'Resume Polling'}
</button>
<button className="danger" onClick={handleDelete}>
Delete
</button>
</div>
</div>
@@ -163,7 +194,14 @@ export default function ShowDetailPage({ showId, onBack }: Props) {
{/* Episodes table */}
{episodes.length === 0 ? (
<div className="empty-state">No episodes found yet. Polling will populate this list.</div>
<div className="empty-state">
<p>No episodes yet.</p>
<p className="text-sm" style={{ marginTop: 6 }}>
{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.'}
</p>
</div>
) : (
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
<table>
@@ -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 (
<button className="ghost" style={{ fontSize: 12 }} onClick={() => handle('pending')}>
<button className="ghost" style={{ fontSize: 12 }} onClick={handleReset}>
Reset
</button>
)
+35 -1
View File
@@ -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<Show[]>([])
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) {
<button onClick={() => setShowModal(true)}>+ Add Show</button>
</div>
{notice && (
<div className="card" style={{ marginBottom: 16, borderColor: 'var(--green)', background: '#0a1a0f' }}>
<span style={{ color: 'var(--green)', fontSize: 13 }}>{notice}</span>
</div>
)}
{loading && <div className="empty-state"><div className="spinner" /></div>}
{!loading && shows.length === 0 && (
@@ -83,6 +93,7 @@ function AddShowModal({ onClose, onAdded }: { onClose: () => void; onAdded: (s:
const [results, setResults] = useState<NyaaItem[]>([])
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:
</form>
{results.length > 0 && (
<>
<p className="text-muted text-sm" style={{ marginBottom: 6 }}>
Click a result to fill in the form below.
</p>
<div className="search-results" style={{ marginBottom: 16 }}>
{results.slice(0, 20).map((item) => (
<div key={item.torrent_id} className="search-result-item">
<div
key={item.torrent_id}
className={`search-result-item clickable ${selectedId === item.torrent_id ? 'selected' : ''}`}
role="button"
tabIndex={0}
onClick={() => handlePick(item)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handlePick(item) } }}
>
<span className="title">{item.title}</span>
<span className="meta">{item.size} · {item.seeders}S</span>
</div>
))}
</div>
</>
)}
{/* Add form */}
+2
View File
@@ -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
+17 -1
View File
@@ -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()
+9 -3
View File
@@ -9,7 +9,11 @@ const router = Router({ mergeParams: true })
// GET /api/shows/:showId/episodes
router.get('/', (req: Request<EpParams>, 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<EpParams>, 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')
+4 -2
View File
@@ -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 })
})
+19
View File
@@ -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 * * *')
})
+14
View File
@@ -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} * * *`
}
+16
View File
@@ -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')
})
+41
View File
@@ -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)
})
+7 -5
View File
@@ -107,17 +107,19 @@ function parseItem(item: Record<string, unknown>): 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'
}
+81 -41
View File
@@ -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,6 +46,12 @@ function scheduleJob(intervalSeconds: number) {
}
export async function pollAllShows() {
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)...`)
@@ -55,6 +62,9 @@ export async function pollAllShows() {
console.error(`[scheduler] Error polling show "${show.name}":`, err)
}
}
} finally {
isPolling = false
}
}
async function pollShow(show: Show) {
@@ -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<Pick<Episode, 'episode_code' | 'title' | 'torrent_id' | 'torrent_url'>>
)
.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
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
// 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
}
}
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'))
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)
// 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
if (!ep) continue // already downloaded_auto or downloaded_manual — skip
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)
}
}
}
+1
View File
@@ -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
+2 -1
View File
@@ -12,5 +12,6 @@
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["src/server"]
"include": ["src/server"],
"exclude": ["src/server/**/*.test.ts"]
}