const cron = require("node-cron"); const dbApi = require("./db.js"); const { fetchSeriesChapters, resolveCbzPath, cbzExists, } = require("./sources.js"); /** * Sync a title's chapter list from the source site and enqueue missing downloads. * @param {import("better-sqlite3").Database} db - Database handle. * @param {import("./queue.js").DownloadQueue} queue - Download queue. * @param {number} titleId - Title to sync. * @param {string} dataDir - Absolute data root for CBZ paths. * @param {{ enqueue?: boolean }} [opts] - Options; `enqueue` defaults to true. * @returns {Promise<{ added: number, enqueued: number, total: number }>} Sync summary. */ async function syncTitle(db, queue, titleId, dataDir, opts = {}) { const title = dbApi.getTitle(db, titleId); if (!title) { throw new Error(`Title ${String(titleId)} not found.`); } const syncJobId = dbApi.createJob(db, { type: "watch_sync", title_id: titleId, message: `Syncing ${title.title}`, }); dbApi.updateJob(db, syncJobId, { status: "running" }); try { const series = await fetchSeriesChapters(title.url, title.source); let added = 0; for (const ch of series.chapters) { const cbzPath = resolveCbzPath( title.source, series.comicTitle || title.title, ch.url, dataDir, ); const exists = cbzExists(cbzPath); const { inserted } = dbApi.upsertChapter(db, { title_id: titleId, chapter_key: ch.chapter_key, chapter_number: ch.chapter_number, label: ch.title, url: ch.url, status: exists ? "downloaded" : "pending", cbz_path: cbzPath, }); if (inserted) { added += 1; } // Heal statuses without interrupting an in-flight download const row = db .prepare( "SELECT id, status, cbz_path FROM chapters WHERE title_id = ? AND chapter_key = ?", ) .get(titleId, ch.chapter_key); if (row && row.status === "downloading") { continue; } if (row && row.status === "downloaded" && !cbzExists(row.cbz_path)) { dbApi.updateChapter(db, row.id, { status: "pending", error: "CBZ missing on disk", }); } if (row && exists && row.status !== "downloaded") { dbApi.updateChapter(db, row.id, { status: "downloaded", cbz_path: cbzPath, downloaded_at: new Date().toISOString(), error: null, }); } } // Refresh comic title if site provides a better one if (series.comicTitle && series.comicTitle !== title.title) { dbApi.updateTitle(db, titleId, { title: series.comicTitle }); } dbApi.touchTitleChecked(db, titleId); let enqueued = 0; if (opts.enqueue !== false) { enqueued = queue.enqueueMissingForTitle(titleId, { alsoMissingFiles: true }); } dbApi.updateJob(db, syncJobId, { status: "done", message: `Sync complete: +${String(added)} chapter(s), ${String(enqueued)} queued`, finished_at: new Date().toISOString(), }); return { added, enqueued, total: series.chapters.length, }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); dbApi.updateJob(db, syncJobId, { status: "failed", message: msg, finished_at: new Date().toISOString(), }); throw err; } } /** * Watches titles on a schedule. Each tick syncs at most one due title * (oldest `last_checked_at` first), so titles are staggered instead of * all hitting the source sites at once. Default cadence is hourly wake + * 24h per-title cooldown → each title roughly once per day, unlimited count. * @param {import("better-sqlite3").Database} db - Database handle. * @param {import("./queue.js").DownloadQueue} queue - Download queue. * @param {string} dataDir - Absolute data root. * @param {string} cronExpr - node-cron expression. * @param {{ titleIntervalHours?: number }} [opts] - Per-title cooldown in hours. * @returns {{ stop: () => void }} Handle to stop the cron task. */ function startWatcher(db, queue, dataDir, cronExpr, opts = {}) { if (!cron.validate(cronExpr)) { throw new Error(`Invalid WATCH_CRON expression: ${cronExpr}`); } const titleIntervalHours = Number(opts.titleIntervalHours) > 0 ? Number(opts.titleIntervalHours) : 24; let busy = false; const task = cron.schedule(cronExpr, () => { if (busy) { console.warn("[watcher] previous sync still running; skipping this tick"); return; } busy = true; void (async () => { try { const title = dbApi.pickNextTitleDue(db, titleIntervalHours); if (!title) { console.info("[watcher] no titles due this tick"); return; } console.info( `[watcher] syncing 1 title: ${title.title} (${title.source})`, ); await syncTitle(db, queue, title.id, dataDir, { enqueue: true }); } catch (err) { console.error( "[watcher] sync failed:", err instanceof Error ? err.message : err, ); } finally { busy = false; } })(); }); return { stop() { task.stop(); }, }; } module.exports = { syncTitle, startWatcher };