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.
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
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 };
|
||||
Reference in New Issue
Block a user