- 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.
173 lines
5.2 KiB
JavaScript
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 };
|