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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
const slug = slugify(show.name)
|
||||||
|
|
||||||
for (const item of items) {
|
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)
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user