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

View File

@@ -2,7 +2,7 @@ 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 type { Show, Episode } from '../types.js' import type { Show, Episode, NyaaItem } from '../types.js'
let currentTask: cron.ScheduledTask | null = null let currentTask: cron.ScheduledTask | null = null
@@ -61,7 +61,7 @@ async function pollShow(show: Show) {
const rssUrl = show.rss_url ?? buildRssUrl(show) const rssUrl = show.rss_url ?? buildRssUrl(show)
console.log(`[scheduler] Polling "${show.name}" via ${rssUrl}`) console.log(`[scheduler] Polling "${show.name}" via ${rssUrl}`)
let items let items: NyaaItem[]
try { try {
items = await fetchRss(rssUrl) items = await fetchRss(rssUrl)
} catch (err) { } catch (err) {
@@ -69,24 +69,34 @@ async function pollShow(show: Show) {
return return
} }
const slug = slugify(show.name) // Filter out batches and already-tracked items, sort oldest→newest
const newItems = items
for (const item of items) { .filter((item) => {
if (isBatchRelease(item.title)) { if (isBatchRelease(item.title)) return false
console.log(`[scheduler] Skipping batch: ${item.title}`)
continue
}
// Check if already tracked
const existing = db const existing = db
.prepare('SELECT id FROM episodes WHERE show_id = ? AND torrent_id = ?') .prepare('SELECT id FROM episodes WHERE show_id = ? AND torrent_id = ?')
.get(show.id, item.torrent_id) as Episode | undefined .get(show.id, item.torrent_id) as Episode | undefined
return !existing
})
.sort((a, b) => episodeNum(parseEpisodeCode(a.title)) - episodeNum(parseEpisodeCode(b.title)))
if (existing) continue 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) {
const episodeCode = parseEpisodeCode(item.title) const episodeCode = parseEpisodeCode(item.title)
// Insert as pending first // Insert as pending (idempotent)
const insertResult = db const insertResult = db
.prepare(` .prepare(`
INSERT OR IGNORE INTO episodes INSERT OR IGNORE INTO episodes
@@ -99,7 +109,7 @@ async function pollShow(show: Show) {
const epId = insertResult.lastInsertRowid const epId = insertResult.lastInsertRowid
// Auto-download // Download the .torrent file
try { try {
console.log(`[scheduler] Downloading torrent for "${item.title}"`) console.log(`[scheduler] Downloading torrent for "${item.title}"`)
await downloadTorrent(item.torrent_url, slug, episodeCode, item.torrent_id) 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 { function buildRssUrl(show: Show): string {
const parts = [show.search_query] const parts = [show.search_query]
if (show.quality) parts.push(show.quality) if (show.quality) parts.push(show.quality)