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
+9 -1
View File
@@ -1,12 +1,19 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import ShowsPage from './pages/ShowsPage'
import ShowDetailPage from './pages/ShowDetailPage'
import SettingsPage from './pages/SettingsPage'
import HealthBanner from './components/HealthBanner'
import { api, type HealthStatus } from './api'
type Page = { name: 'shows' } | { name: 'show'; id: number } | { name: 'settings' }
export default function App() {
const [page, setPage] = useState<Page>({ name: 'shows' })
const [health, setHealth] = useState<HealthStatus | null>(null)
useEffect(() => {
api.health.get().then(setHealth).catch(() => setHealth(null))
}, [page])
return (
<div className="layout">
@@ -31,6 +38,7 @@ export default function App() {
</aside>
<main className="main">
<HealthBanner health={health} />
{page.name === 'shows' && (
<ShowsPage onSelectShow={(id) => setPage({ name: 'show', id })} />
)}
+23
View File
@@ -0,0 +1,23 @@
import { type HealthStatus } from '../api'
// App-wide warning shown whenever the torrent output directory cannot be
// written to. This is rendered on every page because a non-writable directory
// makes *all* auto-downloads fail silently — the user needs to see it no matter
// where they are in the app, not only on a show's detail page.
export default function HealthBanner({ health }: { health: HealthStatus | null }) {
if (!health || health.torrent_dir_writable) return null
return (
<div className="card" style={{ marginBottom: 16, borderColor: 'var(--red)', background: '#1a0a0a' }}>
<div style={{ color: 'var(--red)', fontWeight: 600, marginBottom: 4 }}>
Torrent directory is not writable
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
Downloads will fail until this is fixed. Path: <code>{health.torrent_dir}</code>
</div>
{health.torrent_dir_error && (
<div style={{ fontSize: 11, color: 'var(--red)', marginTop: 4 }}>{health.torrent_dir_error}</div>
)}
</div>
)
}
+3
View File
@@ -118,6 +118,9 @@ tr:hover td { background: var(--surface2); }
.search-results { margin-top: 12px; max-height: 320px; overflow-y: auto; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface2); }
.search-result-item { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-bottom: 1px solid var(--border); gap: 8px; }
.search-result-item:last-child { border-bottom: none; }
.search-result-item.clickable { cursor: pointer; transition: background 0.1s; }
.search-result-item.clickable:hover { background: var(--surface); }
.search-result-item.selected { background: var(--surface); box-shadow: inset 3px 0 0 var(--accent); }
.search-result-item .title { flex: 1; font-size: 13px; }
.search-result-item .meta { color: var(--text-muted); font-size: 11px; white-space: nowrap; }
+24
View File
@@ -0,0 +1,24 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { parseRelease } from './parseRelease.js'
test('parseRelease: extracts name, quality, and sub group', () => {
assert.deepEqual(
parseRelease('[SubsPlease] Jujutsu Kaisen - 12 (1080p) [A1B2C3D4].mkv'),
{ name: 'Jujutsu Kaisen', quality: '1080p', subGroup: 'SubsPlease' }
)
})
test('parseRelease: strips versioned episode markers and trailing brackets', () => {
assert.deepEqual(
parseRelease('[Erai-raws] Some Show Name - 07v2 [720p][ABCD].mkv'),
{ name: 'Some Show Name', quality: '720p', subGroup: 'Erai-raws' }
)
})
test('parseRelease: tolerates titles with no group or quality', () => {
assert.deepEqual(
parseRelease('Naruto Shippuuden - 500'),
{ name: 'Naruto Shippuuden', quality: '', subGroup: '' }
)
})
+15
View File
@@ -0,0 +1,15 @@
// Best-effort extraction of a clean show name, quality, and sub group from a
// raw Nyaa release title like "[SubsPlease] Some Show - 12 (1080p) [A1B2].mkv".
export function parseRelease(title: string): { name: string; quality: string; subGroup: string } {
const subGroup = title.match(/^\[([^\]]+)\]/)?.[1] ?? ''
const quality = title.match(/(2160p|1080p|720p|480p)/i)?.[1] ?? ''
const name = title
.replace(/\.(mkv|mp4|avi)$/i, '')
.replace(/[[(][^\])]*[\])]/g, ' ') // strip [..] and (..) groups
.replace(/\s-\s*\d{1,4}(v\d)?(?=\s|$)/gi, ' ') // strip " - 12" episode markers
.replace(/\b(S\d{1,2}E\d{1,3}|EP?\d{1,3})\b/gi, ' ')
.replace(/\s+/g, ' ')
.replace(/[-]\s*$/, '')
.trim()
return { name, quality, subGroup }
}
+62 -18
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { api, type Show, type Episode, type HealthStatus } from '../api'
import { api, type Show, type Episode } from '../api'
interface Props {
showId: number
@@ -9,32 +9,41 @@ interface Props {
export default function ShowDetailPage({ showId, onBack }: Props) {
const [show, setShow] = useState<Show | null>(null)
const [episodes, setEpisodes] = useState<Episode[]>([])
const [health, setHealth] = useState<HealthStatus | null>(null)
const [loading, setLoading] = useState(true)
const [bulkCode, setBulkCode] = useState('')
const [bulking, setBulking] = useState(false)
const [polling, setPolling] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
Promise.all([api.shows.get(showId), api.episodes.list(showId), api.health.get()])
.then(([s, eps, h]) => { setShow(s); setEpisodes(eps); setHealth(h) })
Promise.all([api.shows.get(showId), api.episodes.list(showId)])
.then(([s, eps]) => { setShow(s); setEpisodes(eps) })
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to load show'))
.finally(() => setLoading(false))
}, [showId])
const handleStatusChange = async (ep: Episode, status: Episode['status']) => {
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>
)
+42 -8
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { api, type Show, type NyaaItem } from '../api'
import { parseRelease } from '../lib/parseRelease'
interface Props {
onSelectShow: (id: number) => void
@@ -9,6 +10,7 @@ export default function ShowsPage({ onSelectShow }: Props) {
const [shows, setShows] = useState<Show[]>([])
const [showModal, setShowModal] = useState(false)
const [loading, setLoading] = useState(true)
const [notice, setNotice] = useState('')
useEffect(() => {
api.shows.list().then(setShows).finally(() => setLoading(false))
@@ -23,6 +25,8 @@ export default function ShowsPage({ onSelectShow }: Props) {
const handleShowAdded = (show: Show) => {
setShows((prev) => [show, ...prev])
setShowModal(false)
setNotice(`Added “${show.name}” — fetching existing episodes in the background. Open the show to track progress.`)
setTimeout(() => setNotice(''), 6000)
}
return (
@@ -32,6 +36,12 @@ export default function ShowsPage({ onSelectShow }: Props) {
<button onClick={() => setShowModal(true)}>+ Add Show</button>
</div>
{notice && (
<div className="card" style={{ marginBottom: 16, borderColor: 'var(--green)', background: '#0a1a0f' }}>
<span style={{ color: 'var(--green)', fontSize: 13 }}>{notice}</span>
</div>
)}
{loading && <div className="empty-state"><div className="spinner" /></div>}
{!loading && shows.length === 0 && (
@@ -83,6 +93,7 @@ function AddShowModal({ onClose, onAdded }: { onClose: () => void; onAdded: (s:
const [results, setResults] = useState<NyaaItem[]>([])
const [searching, setSearching] = useState(false)
const [searchError, setSearchError] = useState('')
const [selectedId, setSelectedId] = useState('')
const [name, setName] = useState('')
const [query, setQuery] = useState('')
@@ -91,6 +102,17 @@ function AddShowModal({ onClose, onAdded }: { onClose: () => void; onAdded: (s:
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
// Clicking a result is an explicit "use this release" action: pre-fill the
// form so the user never has to retype (and risk typos in) the query.
const handlePick = (item: NyaaItem) => {
const parsed = parseRelease(item.title)
setSelectedId(item.torrent_id)
if (parsed.name) setName(parsed.name)
if (parsed.quality) setQuality(parsed.quality)
if (parsed.subGroup) setSubGroup(parsed.subGroup)
setQuery(searchQ || parsed.name)
}
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault()
if (!searchQ.trim()) return
@@ -144,14 +166,26 @@ function AddShowModal({ onClose, onAdded }: { onClose: () => void; onAdded: (s:
</form>
{results.length > 0 && (
<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 */}
+2
View File
@@ -40,6 +40,7 @@ export function runMigrations() {
torrent_url TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending'
CHECK(status IN ('pending','downloaded_auto','downloaded_manual','failed')),
attempts INTEGER NOT NULL DEFAULT 0,
downloaded_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
@@ -60,6 +61,7 @@ export function runMigrations() {
// Incremental migrations — safe to re-run (errors are swallowed for "already exists")
runAlterIfMissing("ALTER TABLE episodes ADD COLUMN download_error TEXT")
runAlterIfMissing("ALTER TABLE episodes ADD COLUMN file_path TEXT")
runAlterIfMissing("ALTER TABLE episodes ADD COLUMN attempts INTEGER NOT NULL DEFAULT 0")
// Widen the status CHECK constraint to include 'failed' for existing DBs.
// SQLite doesn't support ALTER COLUMN, so we recreate the table if the old
+17 -1
View File
@@ -35,7 +35,16 @@ app.get('/api/health', (_req, res) => {
app.post('/api/poll', (_req, res) => {
pollAllShows()
.then(() => res.json({ status: 'ok' }))
.catch((err: unknown) => res.status(500).json({ error: String(err) }))
.catch((err: unknown) => {
console.error('[server] Poll failed:', err)
res.status(500).json({ error: 'Poll failed' })
})
})
// Unmatched /api routes must return JSON, not the SPA's index.html — otherwise
// the client tries to JSON.parse HTML and throws a confusing "Unexpected token <".
app.use('/api', (_req, res) => {
res.status(404).json({ error: 'Not found' })
})
// Serve static client build in production
@@ -45,6 +54,13 @@ app.get('*', (_req, res) => {
res.sendFile(path.join(clientDist, 'index.html'))
})
// JSON error handler — keeps internal details out of the client response.
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error('[server] Unhandled error:', err)
if (res.headersSent) return
res.status(500).json({ error: 'Internal server error' })
})
// Bootstrap
runMigrations()
startScheduler()
+9 -3
View File
@@ -9,7 +9,11 @@ const router = Router({ mergeParams: true })
// GET /api/shows/:showId/episodes
router.get('/', (req: Request<EpParams>, res) => {
const episodes = db
.prepare('SELECT * FROM episodes WHERE show_id = ? ORDER BY episode_code ASC, created_at DESC')
.prepare(
// CAST to REAL so codes sort numerically (1,2,…,10,11) rather than
// lexically (1,10,11,2,…). Matches the ordering used by bulk-mark.
'SELECT * FROM episodes WHERE show_id = ? ORDER BY CAST(episode_code AS REAL) ASC, episode_code ASC, created_at DESC'
)
.all(req.params.showId) as unknown as Episode[]
res.json(episodes)
})
@@ -57,14 +61,16 @@ router.post('/bulk-mark', (req: Request<EpParams>, res) => {
const toUpdate = episodes.slice(0, targetIdx + 1).map((e) => e.id)
// 'pending' clears the timestamp; a "downloaded" status stamps it.
const downloadedAt = status === 'pending' ? null : new Date().toISOString()
const update = db.prepare(`
UPDATE episodes
SET status = ?, downloaded_at = datetime('now'), updated_at = datetime('now')
SET status = ?, downloaded_at = ?, updated_at = datetime('now')
WHERE id = ?
`)
db.exec('BEGIN')
try {
for (const id of toUpdate) update.run(status, id)
for (const id of toUpdate) update.run(status, downloadedAt, id)
db.exec('COMMIT')
} catch (err) {
db.exec('ROLLBACK')
+4 -2
View File
@@ -76,11 +76,13 @@ router.patch('/:id', (req, res) => {
res.json(db.prepare('SELECT * FROM shows WHERE id = ?').get(show.id))
})
// DELETE /api/shows/:id marks inactive (does not delete)
// DELETE /api/shows/:id permanently removes the show and its episode history
// (episodes cascade via the foreign key). Already-downloaded .torrent files on
// disk are left untouched.
router.delete('/:id', (req, res) => {
const show = db.prepare('SELECT * FROM shows WHERE id = ?').get(req.params.id) as unknown as Show | undefined
if (!show) return res.status(404).json({ error: 'Show not found' })
db.prepare("UPDATE shows SET is_active = 0, updated_at = datetime('now') WHERE id = ?").run(show.id)
db.prepare('DELETE FROM shows WHERE id = ?').run(show.id)
res.json({ success: true })
})
+19
View File
@@ -0,0 +1,19 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { secondsToCron } from './cron-util.js'
test('secondsToCron: sub-minute intervals run every minute', () => {
assert.equal(secondsToCron(30), '* * * * *')
assert.equal(secondsToCron(59), '* * * * *')
})
test('secondsToCron: minute intervals', () => {
assert.equal(secondsToCron(60), '*/1 * * * *')
assert.equal(secondsToCron(900), '*/15 * * * *')
assert.equal(secondsToCron(1800), '*/30 * * * *')
})
test('secondsToCron: hour intervals', () => {
assert.equal(secondsToCron(3600), '0 */1 * * *')
assert.equal(secondsToCron(21600), '0 */6 * * *')
})
+14
View File
@@ -0,0 +1,14 @@
/**
* Convert a polling interval in seconds into a cron expression.
* Pure and side-effect free so it can be unit-tested without touching the DB.
* - < 60s → every minute ("* * * * *")
* - < 60 minutes → every N minutes ("*\/15 * * * *")
* - otherwise → every N hours ("0 *\/6 * * *")
*/
export function secondsToCron(seconds: number): string {
if (seconds < 60) return '* * * * *'
const minutes = Math.round(seconds / 60)
if (minutes < 60) return `*/${minutes} * * * *`
const hours = Math.round(minutes / 60)
return `0 */${hours} * * *`
}
+16
View File
@@ -0,0 +1,16 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { slugify } from './downloader.js'
test('slugify: lowercases and hyphenates', () => {
assert.equal(slugify('Jujutsu Kaisen'), 'jujutsu-kaisen')
})
test('slugify: collapses punctuation and trims dashes', () => {
assert.equal(slugify('Re:ZERO -Starting Life-'), 're-zero-starting-life')
assert.equal(slugify(' Spy x Family! '), 'spy-x-family')
})
test('slugify: handles already-clean names', () => {
assert.equal(slugify('bleach'), 'bleach')
})
+41
View File
@@ -0,0 +1,41 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { parseEpisodeCode, isBatchRelease } from './nyaa.js'
test('parseEpisodeCode: " - NN" dash form', () => {
assert.equal(parseEpisodeCode('[SubsPlease] Show Name - 12 (1080p) [ABCD1234].mkv'), '12')
assert.equal(parseEpisodeCode('[Group] Another Show - 07v2 [720p].mkv'), '07v2')
})
test('parseEpisodeCode: dash form at end of title (no trailing space)', () => {
// Regression: the old pattern required a trailing space and missed these.
assert.equal(parseEpisodeCode('Show Name - 12'), '12')
assert.equal(parseEpisodeCode('Show Name - 12.mkv'), '12')
assert.equal(parseEpisodeCode('Show Name - 12[1080p]'), '12')
})
test('parseEpisodeCode: bracket and season/episode forms', () => {
assert.equal(parseEpisodeCode('Show Name [05] [1080p]'), '05')
assert.equal(parseEpisodeCode('Show Name S01E08 [1080p]'), '08')
assert.equal(parseEpisodeCode('Show Name EP03'), '03')
assert.equal(parseEpisodeCode('Show Name E11'), '11')
})
test('parseEpisodeCode: does NOT match digits inside a hex hash', () => {
// Regression: "E3" inside [1A2E3B4C] used to be parsed as episode "3".
assert.equal(parseEpisodeCode('[Erai-raws] Mystery Show [1080p][1A2E3B4C].mkv'), 'unknown')
})
test('parseEpisodeCode: returns "unknown" when nothing matches', () => {
assert.equal(parseEpisodeCode('Some Movie (2024) [1080p] [BDRip]'), 'unknown')
})
test('isBatchRelease: flags batches and complete collections', () => {
assert.equal(isBatchRelease('[Group] Show Name (01-12) Batch [1080p]'), true)
assert.equal(isBatchRelease('[Group] Show Name Complete Series'), true)
assert.equal(isBatchRelease('[Group] Show Name Vol.1'), true)
})
test('isBatchRelease: leaves single episodes alone', () => {
assert.equal(isBatchRelease('[SubsPlease] Show Name - 12 (1080p) [ABCD1234].mkv'), false)
})
+7 -5
View File
@@ -107,17 +107,19 @@ function parseItem(item: Record<string, unknown>): NyaaItem {
* Returns the matched string or 'unknown'.
*/
export function parseEpisodeCode(title: string): string {
// Match " - 12 " or " - 12v2"
let m = title.match(/\s-\s(\d{1,4}(?:\.\d)?(?:v\d)?)\s/)
// Match " - 12", " - 12v2", "- 12.mkv", "- 12[1080p]" (lookahead avoids
// requiring a trailing space, which used to miss codes at end-of-title).
let m = title.match(/\s-\s(\d{1,4}(?:\.\d)?(?:v\d)?)(?=\s|$|[[(.])/)
if (m) return m[1]
// Match [12] or [12v2] (but skip hash-like 6+ char hex blocks e.g. [CC3FE38D])
// Match [12] or [12v2] (digits-only brackets, so hash blocks are skipped)
m = title.match(/\[(\d{1,4}(?:\.\d)?(?:v\d)?)\]/)
if (m) return m[1]
// Match S01E12
m = title.match(/[Ss]\d{1,2}[Ee](\d{1,4})/)
if (m) return m[1]
// Match EP12 or E12
m = title.match(/[Ee][Pp]?(\d{1,4})/)
// Match EP12 or E12 — \b prevents matching inside hex hashes like [1A2E3B4C]
// (no word boundary exists between "2" and "E"), while still catching "E12".
m = title.match(/\b(?:EP|E)(\d{1,4}(?:\.\d)?)\b/i)
if (m) return m[1]
return 'unknown'
}
+92 -52
View File
@@ -2,10 +2,19 @@ import cron from 'node-cron'
import { db } from '../db/index.js'
import { fetchRss, parseEpisodeCode, isBatchRelease } from './nyaa.js'
import { downloadTorrent, slugify } from './downloader.js'
import { secondsToCron } from './cron-util.js'
import type { Show, Episode, NyaaItem } from '../types.js'
let currentTask: cron.ScheduledTask | null = null
// Prevents a slow poll (or a manual "Poll Now") from overlapping with the next
// scheduled tick, which could otherwise download the same torrent twice.
let isPolling = false
// Give up auto-retrying a download after this many failures so a permanently
// dead torrent (e.g. a 404) isn't re-attempted on every single poll forever.
const MAX_ATTEMPTS = 5
export function startScheduler() {
const intervalSeconds = getIntervalSeconds()
scheduleJob(intervalSeconds)
@@ -27,14 +36,6 @@ function getIntervalSeconds(): number {
return Math.max(60, parseInt(row?.value ?? '900', 10))
}
function secondsToCron(seconds: number): string {
if (seconds < 60) return '* * * * *'
const minutes = Math.round(seconds / 60)
if (minutes < 60) return `*/${minutes} * * * *`
const hours = Math.round(minutes / 60)
return `0 */${hours} * * *`
}
function scheduleJob(intervalSeconds: number) {
const cronExpr = secondsToCron(intervalSeconds)
currentTask = cron.schedule(cronExpr, () => {
@@ -45,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)
}
}
}
+1
View File
@@ -20,6 +20,7 @@ export interface Episode {
status: 'pending' | 'downloaded_auto' | 'downloaded_manual' | 'failed'
download_error: string | null
file_path: string | null
attempts: number
downloaded_at: string | null
created_at: string
updated_at: string