This commit is contained in:
@@ -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: `<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)`
|
||||
|
||||
***
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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 })} />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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: '' }
|
||||
)
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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']) => {
|
||||
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 <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>
|
||||
)
|
||||
|
||||
@@ -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 && (
|
||||
<div className="search-results" style={{ marginBottom: 16 }}>
|
||||
{results.slice(0, 20).map((item) => (
|
||||
<div key={item.torrent_id} className="search-result-item">
|
||||
<span className="title">{item.title}</span>
|
||||
<span className="meta">{item.size} · {item.seeders}S</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
<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 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 */}
|
||||
|
||||
@@ -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
@@ -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,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')
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
|
||||
@@ -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 * * *')
|
||||
})
|
||||
@@ -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} * * *`
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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<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
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,5 +12,6 @@
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
},
|
||||
"include": ["src/server"]
|
||||
"include": ["src/server"],
|
||||
"exclude": ["src/server/**/*.test.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user