Files
cxyz/src/server/watcher.js
T
nsrb 3799ad59f8 Add initial project structure with Docker support and chapter downloading functionality
- Created .dockerignore and .gitignore files to exclude unnecessary files from Docker builds.
- Added Dockerfile for multi-stage build setup, including dependencies and production environment configuration.
- Introduced docker-compose.yml for service orchestration, including FlareSolverr and the main application.
- Implemented core functionality for downloading manga chapters, including utilities for handling file paths and chapter metadata.
- Added ESLint configuration for code quality and consistency.
- Included JSDoc configuration for documentation generation.
- Established initial package.json and package-lock.json with necessary dependencies for the project.
2026-08-03 07:35:37 +07:00

173 lines
5.2 KiB
JavaScript

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 };