feat: auto-download from episode 1 on show add

When a show is added, ingest the Nyaa feed, sort episodes oldest→newest
(ep 1 first), and auto-download them all in order rather than leaving
them as pending. Ongoing polls continue downloading new episodes the
same way.

Extracted a shared downloadItems() helper from the scheduler so both
the initial ingest and the cron poll use identical insert→download→
status-update logic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jason
2026-03-17 14:54:22 -05:00
parent 89fb18e3cc
commit 2872ae8c01
2 changed files with 51 additions and 35 deletions

View File

@@ -1,6 +1,7 @@
import { Router } from 'express'
import { db } from '../db/index.js'
import { searchNyaa, parseEpisodeCode, isBatchRelease, buildRssUrl } from '../services/nyaa.js'
import { downloadItems } from '../services/scheduler.js'
import type { Show } from '../types.js'
const router = Router()
@@ -42,7 +43,7 @@ router.post('/', async (req, res) => {
const showId = result.lastInsertRowid
// Ingest existing Nyaa feed as pending episodes (fire and forget, don't block response)
// Ingest existing feed and auto-download from ep 1 upward (fire and forget)
ingestExistingEpisodes(Number(showId), search_query, quality, sub_group).catch((err: unknown) =>
console.error('[shows] Ingest error:', err)
)
@@ -98,28 +99,27 @@ async function ingestExistingEpisodes(
try {
items = await searchNyaa(query)
} catch (err) {
console.error('[shows] Failed to ingest episodes:', err)
console.error('[shows] Failed to fetch episodes from Nyaa:', err)
return
}
const insert = db.prepare(`
INSERT OR IGNORE INTO episodes
(show_id, episode_code, title, torrent_id, torrent_url, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 'pending', datetime('now'), datetime('now'))
`)
// Filter batches, then sort oldest episode first so downloads begin from ep 1
const filtered = items
.filter((item) => !isBatchRelease(item.title))
.sort((a, b) => {
const na = parseFloat(parseEpisodeCode(a.title))
const nb = parseFloat(parseEpisodeCode(b.title))
return (isNaN(na) ? Infinity : na) - (isNaN(nb) ? Infinity : nb)
})
db.exec('BEGIN')
try {
for (const item of items) {
if (isBatchRelease(item.title)) continue
insert.run(showId, parseEpisodeCode(item.title), item.title, item.torrent_id, item.torrent_url)
}
db.exec('COMMIT')
} catch (err) {
db.exec('ROLLBACK')
throw err
}
console.log(`[shows] Ingested ${items.length} episodes for show ${showId}`)
console.log(`[shows] Ingesting ${filtered.length} episode(s) for show ${showId} (oldest→newest)`)
// Look up the show record needed by downloadItems
const show = db.prepare('SELECT * FROM shows WHERE id = ?').get(showId) as unknown as Show | undefined
if (!show) return
// downloadItems handles insert + download + status update in order
await downloadItems(show, filtered)
}
export default router

View File

@@ -2,7 +2,7 @@ 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 type { Show, Episode } from '../types.js'
import type { Show, Episode, NyaaItem } from '../types.js'
let currentTask: cron.ScheduledTask | null = null
@@ -61,7 +61,7 @@ async function pollShow(show: Show) {
const rssUrl = show.rss_url ?? buildRssUrl(show)
console.log(`[scheduler] Polling "${show.name}" via ${rssUrl}`)
let items
let items: NyaaItem[]
try {
items = await fetchRss(rssUrl)
} catch (err) {
@@ -69,24 +69,34 @@ async function pollShow(show: Show) {
return
}
// Filter out batches and already-tracked items, sort oldest→newest
const newItems = items
.filter((item) => {
if (isBatchRelease(item.title)) return false
const existing = db
.prepare('SELECT id FROM episodes WHERE show_id = ? AND torrent_id = ?')
.get(show.id, item.torrent_id) as Episode | undefined
return !existing
})
.sort((a, b) => episodeNum(parseEpisodeCode(a.title)) - episodeNum(parseEpisodeCode(b.title)))
if (newItems.length === 0) return
console.log(`[scheduler] Found ${newItems.length} new episode(s) for "${show.name}"`)
await downloadItems(show, newItems)
}
/**
* Shared download pipeline used by both the scheduler and the initial ingest.
* Items should already be sorted oldest→newest before calling.
*/
export async function downloadItems(show: Show, items: NyaaItem[]) {
const slug = slugify(show.name)
for (const item of items) {
if (isBatchRelease(item.title)) {
console.log(`[scheduler] Skipping batch: ${item.title}`)
continue
}
// Check if already tracked
const existing = db
.prepare('SELECT id FROM episodes WHERE show_id = ? AND torrent_id = ?')
.get(show.id, item.torrent_id) as Episode | undefined
if (existing) continue
const episodeCode = parseEpisodeCode(item.title)
// Insert as pending first
// Insert as pending (idempotent)
const insertResult = db
.prepare(`
INSERT OR IGNORE INTO episodes
@@ -99,7 +109,7 @@ async function pollShow(show: Show) {
const epId = insertResult.lastInsertRowid
// Auto-download
// Download the .torrent file
try {
console.log(`[scheduler] Downloading torrent for "${item.title}"`)
await downloadTorrent(item.torrent_url, slug, episodeCode, item.torrent_id)
@@ -115,6 +125,12 @@ async function pollShow(show: Show) {
}
}
/** Parse an episode code string to a float for numeric sorting. Unknown → Infinity (goes last). */
function episodeNum(code: string): number {
const n = parseFloat(code)
return isNaN(n) ? Infinity : n
}
function buildRssUrl(show: Show): string {
const parts = [show.search_query]
if (show.quality) parts.push(show.quality)