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: - Entities:
- **Show**: id, display name, search/RSS query, quality filter, fansub group, active flag. - **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: - Behavior:
- Adding a show: - Adding a show:
- Run an immediate search. - Run an immediate search and ingest existing feed items, then auto-download from the oldest episode upward (batch releases excluded).
- Populate existing episodes in DB as `pending` (no download) to let the user backfill by manually checking already downloaded ones. - Pausing a show:
- Removing a show: - Toggle the active flag off — episodes are preserved, polling stops.
- Leave episodes in DB but mark show as inactive (no further polling). - Deleting a show:
- Permanently remove the show and cascade-delete its episodes. `.torrent` files already written to disk are left in place.
- Manual check: - 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 ### 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: - For each active show:
- Query Nyaa using its stored RSS/search parameters.[^4][^3][^6] - Query Nyaa using its stored RSS/search parameters.[^4][^3][^6]
- Determine the “next” episode: - Skip batch torrents via title heuristics (titles containing "Batch",
- Prefer simplest rule: highest episode number not yet marked downloaded. "Complete", "Vol.", or an episode range).
- Guard against batch torrents by using size or title pattern heuristics (e.g. skip titles containing “Batch”). - Process every feed item not already downloaded (oldest episode first),
- If the next episodes torrent is not yet in DB: plus any previously-`failed` episodes — including ones that have scrolled
- Create an Episode record with status `downloaded_auto`. off the current feed, retried from their stored `torrent_url`.
- Download the `.torrent` file (NOT the media itself) into the mapped host directory. - For each candidate:
- Filename suggestion: `<show-slug>-ep<episode>-<torrent-id>.torrent`. - Insert a `pending` Episode record if new.
- Do not attempt to control or integrate directly with a torrent client (scope is “download the .torrent file” only). - 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 ### 4. Web UI
- Views: - Views:
- **Shows list**: - **Shows list**:
- Add show (form: name, search query, quality, group). - Add show via a Nyaa search-first modal; clicking a result pre-fills the form.
- Toggle active/inactive. - Pause/resume polling per show.
- Quick link to show detail. - Quick link to show detail.
- **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. - Table of episodes: episode number/title, Nyaa ID, status, timestamps.
- Controls: - Controls:
- Manually mark individual episodes as downloaded. - Manually mark individual episodes as downloaded, or reset them to pending (with confirmation).
- Bulk “mark previous episodes as downloaded” helper (e.g. “mark up to episode N”). - Bulk “mark up to episode N as downloaded” helper.
- "Poll Now" to trigger an immediate check; Pause/Resume; Delete (with confirmation).
- **Settings**: - **Settings**:
- Poll interval. - Poll interval.
- Default quality / sub group preferences. - Default quality / sub group preferences.
- Torrent download directory (read-only display; actual path comes from environment/volume). - 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: - UX constraints:
- Keep it extremely simple; focus is internal tool. - Keep it extremely simple; focus is internal tool.
- Assume a single user instance behind LAN. - 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` - `created_at`, `updated_at`
- `episodes` - `episodes`
- `id` (PK) - `id` (PK)
- `show_id` (FK → shows.id) - `show_id` (FK → shows.id, `ON DELETE CASCADE`)
- `episode_code` (string, e.g. “S01E03” or “03”) - `episode_code` (string, e.g. “S01E03” or “03”)
- `title` (string) - `title` (string)
- `torrent_id` (string, Nyaa ID) - `torrent_id` (string, Nyaa ID)
- `torrent_url` (string) - `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) - `downloaded_at` (datetime, nullable)
- `created_at`, `updated_at` - `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 - Search Nyaa.si and add shows to a watch list
- Automatic polling for new episodes (configurable interval, default 15 min) - Automatic polling for new episodes (configurable interval, default 15 min)
- Auto-downloads `.torrent` files to a mapped host directory - 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 - Bulk-mark episodes as already downloaded
- Health check that warns when the torrent output directory isn't writable
- Minimal dark-themed web UI - Minimal dark-themed web UI
- SQLite persistence — easy to back up and migrate - SQLite persistence — easy to back up and migrate
- Unraid-friendly Docker container - 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 - **Backend**: Node.js + TypeScript + Express
- **Frontend**: React + Vite - **Frontend**: React + Vite
- **Database**: SQLite (`better-sqlite3`) - **Database**: SQLite via Node's built-in `node:sqlite` (requires Node 22+)
- **Scheduler**: `node-cron` - **Scheduler**: `node-cron`
- **Nyaa integration**: RSS via `fast-xml-parser` - **Nyaa integration**: RSS via `fast-xml-parser`
@@ -31,6 +35,8 @@ Open `http://localhost:8082` in your browser.
## Quick Start (Development) ## Quick Start (Development)
Requires **Node.js 22+** (the app uses the built-in `node:sqlite` module).
```bash ```bash
npm install npm install
npm run dev npm run dev
@@ -39,6 +45,16 @@ npm run dev
- Client dev server: `http://localhost:5173` (proxies `/api` to `:3000`) - Client dev server: `http://localhost:5173` (proxies `/api` to `:3000`)
- API server: `http://localhost: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 ## Environment Variables
| Variable | Default | Description | | 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. - 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. - 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:client": "vite build",
"build:server": "tsc -p tsconfig.server.json", "build:server": "tsc -p tsconfig.server.json",
"start": "node dist/server/index.js", "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": { "dependencies": {
"express": "^4.18.3", "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 ShowsPage from './pages/ShowsPage'
import ShowDetailPage from './pages/ShowDetailPage' import ShowDetailPage from './pages/ShowDetailPage'
import SettingsPage from './pages/SettingsPage' 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' } type Page = { name: 'shows' } | { name: 'show'; id: number } | { name: 'settings' }
export default function App() { export default function App() {
const [page, setPage] = useState<Page>({ name: 'shows' }) 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 ( return (
<div className="layout"> <div className="layout">
@@ -31,6 +38,7 @@ export default function App() {
</aside> </aside>
<main className="main"> <main className="main">
<HealthBanner health={health} />
{page.name === 'shows' && ( {page.name === 'shows' && (
<ShowsPage onSelectShow={(id) => setPage({ name: 'show', id })} /> <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-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 { 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: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 .title { flex: 1; font-size: 13px; }
.search-result-item .meta { color: var(--text-muted); font-size: 11px; white-space: nowrap; } .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 { useEffect, useState } from 'react'
import { api, type Show, type Episode, type HealthStatus } from '../api' import { api, type Show, type Episode } from '../api'
interface Props { interface Props {
showId: number showId: number
@@ -9,32 +9,41 @@ interface Props {
export default function ShowDetailPage({ showId, onBack }: Props) { export default function ShowDetailPage({ showId, onBack }: Props) {
const [show, setShow] = useState<Show | null>(null) const [show, setShow] = useState<Show | null>(null)
const [episodes, setEpisodes] = useState<Episode[]>([]) const [episodes, setEpisodes] = useState<Episode[]>([])
const [health, setHealth] = useState<HealthStatus | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [bulkCode, setBulkCode] = useState('') const [bulkCode, setBulkCode] = useState('')
const [bulking, setBulking] = useState(false) const [bulking, setBulking] = useState(false)
const [polling, setPolling] = useState(false) const [polling, setPolling] = useState(false)
const [error, setError] = useState('')
useEffect(() => { useEffect(() => {
Promise.all([api.shows.get(showId), api.episodes.list(showId), api.health.get()]) Promise.all([api.shows.get(showId), api.episodes.list(showId)])
.then(([s, eps, h]) => { setShow(s); setEpisodes(eps); setHealth(h) }) .then(([s, eps]) => { setShow(s); setEpisodes(eps) })
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to load show'))
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, [showId]) }, [showId])
const handleStatusChange = async (ep: Episode, status: Episode['status']) => { const handleStatusChange = async (ep: Episode, status: Episode['status']) => {
setError('')
try {
const updated = await api.episodes.update(showId, ep.id, status) const updated = await api.episodes.update(showId, ep.id, status)
setEpisodes((prev) => prev.map((e) => (e.id === updated.id ? (updated as Episode) : e))) 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) => { const handleBulkMark = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (!bulkCode.trim()) return if (!bulkCode.trim()) return
setBulking(true) setBulking(true)
setError('')
try { try {
await api.episodes.bulkMark(showId, bulkCode.trim(), 'downloaded_manual') await api.episodes.bulkMark(showId, bulkCode.trim(), 'downloaded_manual')
const eps = await api.episodes.list(showId) const eps = await api.episodes.list(showId)
setEpisodes(eps) setEpisodes(eps)
setBulkCode('') setBulkCode('')
} catch (err) {
setError(err instanceof Error ? err.message : 'Bulk mark failed')
} finally { } finally {
setBulking(false) setBulking(false)
} }
@@ -42,21 +51,44 @@ export default function ShowDetailPage({ showId, onBack }: Props) {
const handleToggleActive = async () => { const handleToggleActive = async () => {
if (!show) return if (!show) return
setError('')
try {
const updated = await api.shows.update(show.id, { is_active: show.is_active ? 0 : 1 }) const updated = await api.shows.update(show.id, { is_active: show.is_active ? 0 : 1 })
setShow(updated) setShow(updated)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update show')
}
} }
const handlePollNow = async () => { const handlePollNow = async () => {
setPolling(true) setPolling(true)
setError('')
try { try {
await api.health.poll() await api.health.poll()
const eps = await api.episodes.list(showId) const eps = await api.episodes.list(showId)
setEpisodes(eps) setEpisodes(eps)
} catch (err) {
setError(err instanceof Error ? err.message : 'Poll failed')
} finally { } finally {
setPolling(false) 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 (loading) return <div className="empty-state"><div className="spinner" /></div>
if (!show) return <div className="empty-state">Show not found.</div> if (!show) return <div className="empty-state">Show not found.</div>
@@ -75,16 +107,12 @@ export default function ShowDetailPage({ showId, onBack }: Props) {
Shows Shows
</a> </a>
{/* Health warning banner */} {error && (
{health && !health.torrent_dir_writable && (
<div className="card" style={{ marginBottom: 16, borderColor: 'var(--red)', background: '#1a0a0a' }}> <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 className="flex items-center justify-between" style={{ gap: 8 }}>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}> <span style={{ color: 'var(--red)', fontSize: 13 }}>{error}</span>
Path: <code>{health.torrent_dir}</code> <button className="ghost" style={{ fontSize: 12 }} onClick={() => setError('')}>Dismiss</button>
</div> </div>
{health.torrent_dir_error && (
<div style={{ fontSize: 11, color: 'var(--red)', marginTop: 4 }}>{health.torrent_dir_error}</div>
)}
</div> </div>
)} )}
@@ -106,6 +134,9 @@ export default function ShowDetailPage({ showId, onBack }: Props) {
<button className="ghost" onClick={handleToggleActive}> <button className="ghost" onClick={handleToggleActive}>
{show.is_active ? 'Pause Polling' : 'Resume Polling'} {show.is_active ? 'Pause Polling' : 'Resume Polling'}
</button> </button>
<button className="danger" onClick={handleDelete}>
Delete
</button>
</div> </div>
</div> </div>
@@ -163,7 +194,14 @@ export default function ShowDetailPage({ showId, onBack }: Props) {
{/* Episodes table */} {/* Episodes table */}
{episodes.length === 0 ? ( {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' }}> <div className="card" style={{ padding: 0, overflow: 'hidden' }}>
<table> <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 ( return (
<button className="ghost" style={{ fontSize: 12 }} onClick={() => handle('pending')}> <button className="ghost" style={{ fontSize: 12 }} onClick={handleReset}>
Reset Reset
</button> </button>
) )
+35 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { api, type Show, type NyaaItem } from '../api' import { api, type Show, type NyaaItem } from '../api'
import { parseRelease } from '../lib/parseRelease'
interface Props { interface Props {
onSelectShow: (id: number) => void onSelectShow: (id: number) => void
@@ -9,6 +10,7 @@ export default function ShowsPage({ onSelectShow }: Props) {
const [shows, setShows] = useState<Show[]>([]) const [shows, setShows] = useState<Show[]>([])
const [showModal, setShowModal] = useState(false) const [showModal, setShowModal] = useState(false)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [notice, setNotice] = useState('')
useEffect(() => { useEffect(() => {
api.shows.list().then(setShows).finally(() => setLoading(false)) api.shows.list().then(setShows).finally(() => setLoading(false))
@@ -23,6 +25,8 @@ export default function ShowsPage({ onSelectShow }: Props) {
const handleShowAdded = (show: Show) => { const handleShowAdded = (show: Show) => {
setShows((prev) => [show, ...prev]) setShows((prev) => [show, ...prev])
setShowModal(false) setShowModal(false)
setNotice(`Added “${show.name}” — fetching existing episodes in the background. Open the show to track progress.`)
setTimeout(() => setNotice(''), 6000)
} }
return ( return (
@@ -32,6 +36,12 @@ export default function ShowsPage({ onSelectShow }: Props) {
<button onClick={() => setShowModal(true)}>+ Add Show</button> <button onClick={() => setShowModal(true)}>+ Add Show</button>
</div> </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 && <div className="empty-state"><div className="spinner" /></div>}
{!loading && shows.length === 0 && ( {!loading && shows.length === 0 && (
@@ -83,6 +93,7 @@ function AddShowModal({ onClose, onAdded }: { onClose: () => void; onAdded: (s:
const [results, setResults] = useState<NyaaItem[]>([]) const [results, setResults] = useState<NyaaItem[]>([])
const [searching, setSearching] = useState(false) const [searching, setSearching] = useState(false)
const [searchError, setSearchError] = useState('') const [searchError, setSearchError] = useState('')
const [selectedId, setSelectedId] = useState('')
const [name, setName] = useState('') const [name, setName] = useState('')
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
@@ -91,6 +102,17 @@ function AddShowModal({ onClose, onAdded }: { onClose: () => void; onAdded: (s:
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState('') 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) => { const handleSearch = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (!searchQ.trim()) return if (!searchQ.trim()) return
@@ -144,14 +166,26 @@ function AddShowModal({ onClose, onAdded }: { onClose: () => void; onAdded: (s:
</form> </form>
{results.length > 0 && ( {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 }}> <div className="search-results" style={{ marginBottom: 16 }}>
{results.slice(0, 20).map((item) => ( {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="title">{item.title}</span>
<span className="meta">{item.size} · {item.seeders}S</span> <span className="meta">{item.size} · {item.seeders}S</span>
</div> </div>
))} ))}
</div> </div>
</>
)} )}
{/* Add form */} {/* Add form */}
+2
View File
@@ -40,6 +40,7 @@ export function runMigrations() {
torrent_url TEXT NOT NULL, torrent_url TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' status TEXT NOT NULL DEFAULT 'pending'
CHECK(status IN ('pending','downloaded_auto','downloaded_manual','failed')), CHECK(status IN ('pending','downloaded_auto','downloaded_manual','failed')),
attempts INTEGER NOT NULL DEFAULT 0,
downloaded_at TEXT, downloaded_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')), created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_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") // 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 download_error TEXT")
runAlterIfMissing("ALTER TABLE episodes ADD COLUMN file_path 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. // 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 // 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) => { app.post('/api/poll', (_req, res) => {
pollAllShows() pollAllShows()
.then(() => res.json({ status: 'ok' })) .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 // Serve static client build in production
@@ -45,6 +54,13 @@ app.get('*', (_req, res) => {
res.sendFile(path.join(clientDist, 'index.html')) 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 // Bootstrap
runMigrations() runMigrations()
startScheduler() startScheduler()
+9 -3
View File
@@ -9,7 +9,11 @@ const router = Router({ mergeParams: true })
// GET /api/shows/:showId/episodes // GET /api/shows/:showId/episodes
router.get('/', (req: Request<EpParams>, res) => { router.get('/', (req: Request<EpParams>, res) => {
const episodes = db 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[] .all(req.params.showId) as unknown as Episode[]
res.json(episodes) 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) 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(` const update = db.prepare(`
UPDATE episodes UPDATE episodes
SET status = ?, downloaded_at = datetime('now'), updated_at = datetime('now') SET status = ?, downloaded_at = ?, updated_at = datetime('now')
WHERE id = ? WHERE id = ?
`) `)
db.exec('BEGIN') db.exec('BEGIN')
try { try {
for (const id of toUpdate) update.run(status, id) for (const id of toUpdate) update.run(status, downloadedAt, id)
db.exec('COMMIT') db.exec('COMMIT')
} catch (err) { } catch (err) {
db.exec('ROLLBACK') 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)) 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) => { router.delete('/:id', (req, res) => {
const show = db.prepare('SELECT * FROM shows WHERE id = ?').get(req.params.id) as unknown as Show | undefined 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' }) 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 }) 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'. * Returns the matched string or 'unknown'.
*/ */
export function parseEpisodeCode(title: string): string { export function parseEpisodeCode(title: string): string {
// Match " - 12 " or " - 12v2" // Match " - 12", " - 12v2", "- 12.mkv", "- 12[1080p]" (lookahead avoids
let m = title.match(/\s-\s(\d{1,4}(?:\.\d)?(?:v\d)?)\s/) // 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] 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)?)\]/) m = title.match(/\[(\d{1,4}(?:\.\d)?(?:v\d)?)\]/)
if (m) return m[1] if (m) return m[1]
// Match S01E12 // Match S01E12
m = title.match(/[Ss]\d{1,2}[Ee](\d{1,4})/) m = title.match(/[Ss]\d{1,2}[Ee](\d{1,4})/)
if (m) return m[1] if (m) return m[1]
// Match EP12 or E12 // Match EP12 or E12 — \b prevents matching inside hex hashes like [1A2E3B4C]
m = title.match(/[Ee][Pp]?(\d{1,4})/) // (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] if (m) return m[1]
return 'unknown' return 'unknown'
} }
+81 -41
View File
@@ -2,10 +2,19 @@ import cron from 'node-cron'
import { db } from '../db/index.js' import { db } from '../db/index.js'
import { fetchRss, parseEpisodeCode, isBatchRelease } from './nyaa.js' import { fetchRss, parseEpisodeCode, isBatchRelease } from './nyaa.js'
import { downloadTorrent, slugify } from './downloader.js' import { downloadTorrent, slugify } from './downloader.js'
import { secondsToCron } from './cron-util.js'
import type { Show, Episode, NyaaItem } from '../types.js' import type { Show, Episode, NyaaItem } from '../types.js'
let currentTask: cron.ScheduledTask | null = null 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() { export function startScheduler() {
const intervalSeconds = getIntervalSeconds() const intervalSeconds = getIntervalSeconds()
scheduleJob(intervalSeconds) scheduleJob(intervalSeconds)
@@ -27,14 +36,6 @@ function getIntervalSeconds(): number {
return Math.max(60, parseInt(row?.value ?? '900', 10)) 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) { function scheduleJob(intervalSeconds: number) {
const cronExpr = secondsToCron(intervalSeconds) const cronExpr = secondsToCron(intervalSeconds)
currentTask = cron.schedule(cronExpr, () => { currentTask = cron.schedule(cronExpr, () => {
@@ -45,6 +46,12 @@ function scheduleJob(intervalSeconds: number) {
} }
export async function pollAllShows() { 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[] 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)...`) 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) console.error(`[scheduler] Error polling show "${show.name}":`, err)
} }
} }
} finally {
isPolling = false
}
} }
async function pollShow(show: Show) { async function pollShow(show: Show) {
@@ -69,31 +79,54 @@ async function pollShow(show: Show) {
return return
} }
// Separate brand-new items from previously-failed/pending ones that need a retry // Candidates present in the current feed: anything not-yet-downloaded.
const retryableEpisodes = db const feedItems = items
.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
.filter((item) => { .filter((item) => {
if (isBatchRelease(item.title)) return false if (isBatchRelease(item.title)) return false
const existing = db const existing = db
.prepare('SELECT id, status FROM episodes WHERE show_id = ? AND torrent_id = ?') .prepare('SELECT status FROM episodes WHERE show_id = ? AND torrent_id = ?')
.get(show.id, item.torrent_id) as { id: number; status: string } | undefined .get(show.id, item.torrent_id) as { status: string } | undefined
// Include if: never seen, or previously failed/pending
return !existing || existing.status === 'pending' || existing.status === 'failed' 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) { const candidates = [...feedItems, ...offFeedFailures].sort(
console.log(`[scheduler] Found ${newItems.length} new/retryable episode(s) for "${show.name}"`) (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) { for (const item of items) {
const episodeCode = parseEpisodeCode(item.title) const episodeCode = parseEpisodeCode(item.title)
// Upsert: insert new as pending, or reset a failed/pending row so we can retry it const existing = db
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(` .prepare(`
INSERT INTO episodes INSERT INTO episodes
(show_id, episode_code, title, torrent_id, torrent_url, status, created_at, updated_at) (show_id, episode_code, title, torrent_id, torrent_url, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 'pending', datetime('now'), datetime('now')) 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) .run(show.id, episodeCode, item.title, item.torrent_id, item.torrent_url)
epId = Number(result.lastInsertRowid)
// 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
// Download the .torrent file // Download the .torrent file
try { try {
@@ -139,7 +178,7 @@ export async function downloadItems(show: Show, items: NyaaItem[]) {
download_error = NULL, download_error = NULL,
updated_at = datetime('now') updated_at = datetime('now')
WHERE id = ? WHERE id = ?
`).run(filePath, ep.id) `).run(filePath, epId)
console.log(`[scheduler] Downloaded: ${item.title}`) console.log(`[scheduler] Downloaded: ${item.title}`)
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err) const msg = err instanceof Error ? err.message : String(err)
@@ -148,9 +187,10 @@ export async function downloadItems(show: Show, items: NyaaItem[]) {
UPDATE episodes UPDATE episodes
SET status = 'failed', SET status = 'failed',
download_error = ?, download_error = ?,
attempts = attempts + 1,
updated_at = datetime('now') updated_at = datetime('now')
WHERE id = ? 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' status: 'pending' | 'downloaded_auto' | 'downloaded_manual' | 'failed'
download_error: string | null download_error: string | null
file_path: string | null file_path: string | null
attempts: number
downloaded_at: string | null downloaded_at: string | null
created_at: string created_at: string
updated_at: string updated_at: string
+2 -1
View File
@@ -12,5 +12,6 @@
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true "noUnusedParameters": true
}, },
"include": ["src/server"] "include": ["src/server"],
"exclude": ["src/server/**/*.test.ts"]
} }