From 6e7f1ced358538359ea9c92b8c56cf01e5cee4f9 Mon Sep 17 00:00:00 2001 From: Nasyarobby Putra Date: Sat, 22 Aug 2026 21:39:59 +0700 Subject: [PATCH] Implement chapter transfer functionality and enhance routing - Added a new API endpoint for transferring selected chapters to a specified directory, including error handling and feedback for users. - Introduced a new TitlePage component to display detailed information about a title and its chapters, including transfer options. - Updated routing logic to support navigation to individual title pages, improving user experience. - Refactored existing components to streamline state management and enhance modularity. - Enhanced UI elements for better interaction, including checkboxes for selecting chapters and improved error/success messaging. --- docker-compose.yml | 2 + package.json | 4 +- src/server/index.js | 13 +- src/server/routes.js | 92 +++++++- src/server/sources.js | 42 ++++ web/src/App.svelte | 213 +++++++++++++----- web/src/app.css | 291 +++++++++++++++++++++++-- web/src/components/AddTitleForm.svelte | 4 +- web/src/components/AppHeader.svelte | 13 +- web/src/components/ChapterList.svelte | 71 +++++- web/src/components/ErrorBanner.svelte | 9 +- web/src/components/JobsStrip.svelte | 6 +- web/src/components/TitleCard.svelte | 93 ++++---- web/src/components/TitleList.svelte | 34 +-- web/src/components/TitlePage.svelte | 208 ++++++++++++++++++ web/src/lib/route.js | 34 +++ 16 files changed, 954 insertions(+), 175 deletions(-) create mode 100644 web/src/components/TitlePage.svelte create mode 100644 web/src/lib/route.js diff --git a/docker-compose.yml b/docker-compose.yml index aaa74c3..aad08ec 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,8 @@ services: environment: PORT: "3000" DATA_DIR: /app/data + # Softlink destination for Transfer (default: $DATA_DIR/transfers). + # TRANSFER_DIR: /app/data/transfers # Wake hourly; sync at most one due title per wake. WATCH_CRON: "0 * * * *" # Each title is eligible again after this many hours (daily). diff --git a/package.json b/package.json index e76e51a..7753833 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "", "main": "src/index.js", "scripts": { - "start": "node src/index.js", + "start": "node src/server/index.js", "web": "node src/server/index.js", "dev:web": "vite --config web/vite.config.mjs", "build:web": "vite build --config web/vite.config.mjs", @@ -43,4 +43,4 @@ "svelte": "^5.56.8", "vite": "^8.2.0" } -} +} \ No newline at end of file diff --git a/src/server/index.js b/src/server/index.js index 77ee970..ab337a8 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -13,6 +13,10 @@ const routesPlugin = require("./routes.js"); const PORT = Number(process.env.PORT) || 3000; const DATA_DIR = path.resolve(process.env.DATA_DIR || path.join(process.cwd(), "data")); +/** Softlink destination for Transfer (default: data/transfers). */ +const TRANSFER_DIR = path.resolve( + process.env.TRANSFER_DIR || path.join(DATA_DIR, "transfers"), +); /** How often the watcher wakes (default: every hour). */ const WATCH_CRON = process.env.WATCH_CRON || "0 * * * *"; /** Only sync a title if it has not been checked for this many hours (default: daily). */ @@ -24,6 +28,7 @@ const PUBLIC_DIR = path.join(process.cwd(), "public"); */ async function main() { fs.mkdirSync(DATA_DIR, { recursive: true }); + fs.mkdirSync(TRANSFER_DIR, { recursive: true }); fs.mkdirSync(PUBLIC_DIR, { recursive: true }); const db = openDatabase(DATA_DIR); @@ -55,7 +60,12 @@ async function main() { await app.register(fastifyCookie); registerAuth(app, authConfig); - await app.register(routesPlugin, { db, queue, dataDir: DATA_DIR }); + await app.register(routesPlugin, { + db, + queue, + dataDir: DATA_DIR, + transferDir: TRANSFER_DIR, + }); await app.register(fastifyStatic, { root: PUBLIC_DIR, @@ -93,6 +103,7 @@ async function main() { await app.listen({ port: PORT, host: "0.0.0.0" }); app.log.info(`Data dir: ${DATA_DIR}`); + app.log.info(`Transfer dir: ${TRANSFER_DIR}`); app.log.info(`Watch cron: ${WATCH_CRON}`); app.log.info(`Watch title interval: ${String(WATCH_TITLE_INTERVAL_HOURS)}h (one title per tick)`); app.log.info(`Trust proxy: ${trustProxy}`); diff --git a/src/server/routes.js b/src/server/routes.js index 399c278..e7c8d54 100644 --- a/src/server/routes.js +++ b/src/server/routes.js @@ -1,3 +1,5 @@ +const fs = require("fs"); +const path = require("path"); const dbApi = require("./db.js"); const { detectSource, @@ -5,16 +7,18 @@ const { fetchSeriesChapters, resolveCbzPath, cbzExists, + sanitizeComicDirNameForSource, + transferLinkBasename, } = require("./sources.js"); const { syncTitle } = require("./watcher.js"); const { formatUpstreamError } = require("../flaresolverr.js"); /** * @param {import("fastify").FastifyInstance} fastify - Fastify app. - * @param {{ db: import("better-sqlite3").Database, queue: import("./queue.js").DownloadQueue, dataDir: string }} opts - Shared deps. + * @param {{ db: import("better-sqlite3").Database, queue: import("./queue.js").DownloadQueue, dataDir: string, transferDir: string }} opts - Shared deps. */ async function routesPlugin(fastify, opts) { - const { db, queue, dataDir } = opts; + const { db, queue, dataDir, transferDir } = opts; fastify.get("/api/titles", async () => { return { titles: dbApi.listTitlesWithCounts(db) }; @@ -185,6 +189,90 @@ async function routesPlugin(fastify, opts) { } }); + fastify.post("/api/titles/:id/transfer", async (request, reply) => { + const id = Number(request.params.id); + const title = dbApi.getTitle(db, id); + if (!title) { + return reply.code(404).send({ error: "Title not found" }); + } + + const body = request.body || {}; + const rawIds = Array.isArray(body.chapterIds) ? body.chapterIds : []; + const chapterIds = [ + ...new Set( + rawIds + .map((n) => Number(n)) + .filter((n) => Number.isInteger(n) && n > 0), + ), + ]; + if (chapterIds.length === 0) { + return reply.code(400).send({ error: "chapterIds is required" }); + } + + const comicDir = path.join( + transferDir, + sanitizeComicDirNameForSource(title.source, title.title), + ); + fs.mkdirSync(comicDir, { recursive: true }); + + /** @type {Array<{ id: number, link: string }>} */ + const transferred = []; + /** @type {Array<{ id: number, reason: string }>} */ + const skipped = []; + /** @type {Set} */ + const usedNames = new Set(); + + for (const chapterId of chapterIds) { + const chapter = dbApi.getChapter(db, chapterId); + if (!chapter || chapter.title_id !== id) { + skipped.push({ id: chapterId, reason: "Chapter not found for this title" }); + continue; + } + if (chapter.status !== "downloaded") { + skipped.push({ id: chapterId, reason: "Chapter is not downloaded" }); + continue; + } + if (!cbzExists(chapter.cbz_path)) { + skipped.push({ id: chapterId, reason: "CBZ file missing" }); + continue; + } + + let basename = transferLinkBasename( + chapter.chapter_number, + chapter.label, + chapter.cbz_path, + ); + if (usedNames.has(basename)) { + const stem = basename.replace(/\.cbz$/iu, ""); + basename = `${stem}-${String(chapterId)}.cbz`; + } + usedNames.add(basename); + + const dest = path.join(comicDir, basename); + const target = path.resolve(chapter.cbz_path); + + try { + try { + fs.lstatSync(dest); + fs.unlinkSync(dest); + } catch (err) { + if (err && err.code !== "ENOENT") { + throw err; + } + } + fs.symlinkSync(target, dest); + transferred.push({ id: chapterId, link: dest }); + } catch (err) { + skipped.push({ + id: chapterId, + reason: err instanceof Error ? err.message : String(err), + }); + } + } + + return { ok: true, transferred, skipped }; + }); + fastify.post("/api/chapters/:id/redownload", async (request, reply) => { const id = Number(request.params.id); const chapter = dbApi.getChapter(db, id); diff --git a/src/server/sources.js b/src/server/sources.js index dd1b352..423c621 100644 --- a/src/server/sources.js +++ b/src/server/sources.js @@ -236,6 +236,46 @@ function cbzExists(cbzPath) { } } +/** + * Sanitize comic folder name for a source (matches on-disk comic dirs). + * @param {SourceId} source + * @param {string} comicTitle + * @returns {string} + */ +function sanitizeComicDirNameForSource(source, comicTitle) { + return source === "manhwasusu" + ? manhwa.sanitizeComicDirName(comicTitle) + : mangareadSanitizeDir(comicTitle); +} + +/** + * Pad leading chapter integer to 3 digits; keep decimal tail and letter suffix. + * @param {string|null|undefined} chapterNumber + * @param {string|null|undefined} [label] + * @param {string|null|undefined} [cbzPath] - Fallback basename if parse fails. + * @returns {string} e.g. "chapter 001.cbz" + */ +function transferLinkBasename(chapterNumber, label, cbzPath) { + const candidates = [chapterNumber, label].filter( + (s) => typeof s === "string" && s.trim(), + ); + for (const raw of candidates) { + const text = raw.trim(); + const m = /(\d+)((?:\.\d+)*)([a-zA-Z]*)/u.exec(text); + if (m) { + const padded = m[1].padStart(3, "0"); + return `chapter ${padded}${m[2] || ""}${m[3] || ""}.cbz`; + } + } + if (cbzPath) { + const base = path.basename(cbzPath); + if (base) { + return base; + } + } + return "chapter unknown.cbz"; +} + /** * @param {string|null|undefined} cbzPath */ @@ -250,6 +290,8 @@ module.exports = { normalizeListingUrl, chapterKeyFromUrl, resolveCbzPath, + sanitizeComicDirNameForSource, + transferLinkBasename, fetchSeriesChapters, downloadChapter, cbzExists, diff --git a/web/src/App.svelte b/web/src/App.svelte index 5328ccf..068570d 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -1,19 +1,24 @@
-

Watch & Download

+

+ { + event.preventDefault(); + navigate("/"); + }}>Watch & Download +

{#if showLogout}
{#if username} diff --git a/web/src/components/ChapterList.svelte b/web/src/components/ChapterList.svelte index a4940eb..1dd40e9 100644 --- a/web/src/components/ChapterList.svelte +++ b/web/src/components/ChapterList.svelte @@ -11,19 +11,62 @@ * @typedef {object} Props * @property {Chapter[]} chapters * @property {number} titleId + * @property {number[]} selectedIds + * @property {(chapterId: number, checked: boolean) => void} onToggle + * @property {(checked: boolean) => void} onToggleAll * @property {(chapterId: number, titleId: number) => void | Promise} onCancel * @property {(chapterId: number, titleId: number) => void | Promise} onRedownload */ /** @type {Props} */ - let { chapters = [], titleId, onCancel, onRedownload } = $props(); + let { + chapters = [], + titleId, + selectedIds = [], + onToggle, + onToggleAll, + onCancel, + onRedownload, + } = $props(); + + /** @type {HTMLInputElement | undefined} */ + let selectAllEl = $state(); + + const downloadedIds = $derived( + chapters.filter((ch) => ch.status === "downloaded").map((ch) => ch.id), + ); + const selectedSet = $derived(new Set(selectedIds)); + const allDownloadedSelected = $derived( + downloadedIds.length > 0 && downloadedIds.every((id) => selectedSet.has(id)), + ); + const someDownloadedSelected = $derived( + downloadedIds.some((id) => selectedSet.has(id)) && !allDownloadedSelected, + ); + + $effect(() => { + if (selectAllEl) { + selectAllEl.indeterminate = someDownloadedSelected; + } + });
    {#each chapters as ch (ch.id)} + {@const selectable = ch.status === "downloaded"}
  • -
    {ch.label}
    - {ch.status} +
    + + {ch.status} +
    {#if ch.error}
    {ch.error}
    {/if} @@ -49,6 +92,17 @@ + @@ -56,7 +110,18 @@ {#each chapters as ch (ch.id)} + {@const selectable = ch.status === "downloaded"} +
    + + onToggleAll(/** @type {HTMLInputElement} */ (event.currentTarget).checked)} + /> + Chapter Status
    + + onToggle(ch.id, /** @type {HTMLInputElement} */ (event.currentTarget).checked)} + /> + {ch.label} {ch.status} diff --git a/web/src/components/ErrorBanner.svelte b/web/src/components/ErrorBanner.svelte index 146624e..ade5486 100644 --- a/web/src/components/ErrorBanner.svelte +++ b/web/src/components/ErrorBanner.svelte @@ -1,13 +1,16 @@ {#if message} - +

    + {message} +

    {/if} diff --git a/web/src/components/JobsStrip.svelte b/web/src/components/JobsStrip.svelte index 33a5be4..c026f52 100644 --- a/web/src/components/JobsStrip.svelte +++ b/web/src/components/JobsStrip.svelte @@ -21,6 +21,7 @@ let { jobs = [], onClear, onCancel } = $props(); +{#if jobs.length > 0}

    Activity

    @@ -33,9 +34,6 @@ Clear queue
    - {#if jobs.length === 0} -

    No active jobs.

    - {:else}
      {#each jobs as job (job.id)}
    • @@ -58,5 +56,5 @@
    • {/each}
    - {/if}
    +{/if} diff --git a/web/src/components/TitleCard.svelte b/web/src/components/TitleCard.svelte index aff46da..a6d6bf9 100644 --- a/web/src/components/TitleCard.svelte +++ b/web/src/components/TitleCard.svelte @@ -1,5 +1,5 @@ -
    -
    -
    - {title.title} - {title.source} +
    + +
    +
    + {title.title} + {title.source} +
    +
    + - {title.downloaded} / {title.total} downloaded + {title.downloaded}/{title.total} {#if title.downloading > 0} - · {title.downloading} downloading + · {title.downloading} dl {/if} {#if title.failed > 0} · {title.failed} failed {/if}
    -

    - {title.url} +

    + {title.enabled ? "Watching" : "Paused"} {#if title.last_checked_at} - · last checked {title.last_checked_at} + · {title.last_checked_at} {/if} - · watch {title.enabled ? "on" : "off"}

    - - + - +
    -
    - - {#if expanded} - {#if loading && chapters.length === 0} -

    Loading chapters…

    - {:else} - - {/if} - {/if} +
    diff --git a/web/src/components/TitleList.svelte b/web/src/components/TitleList.svelte index 6641ffc..78e47ed 100644 --- a/web/src/components/TitleList.svelte +++ b/web/src/components/TitleList.svelte @@ -4,53 +4,31 @@ /** * @typedef {object} Props * @property {any[]} titles - * @property {Record} expanded - * @property {Record} loadingChapters - * @property {Record} chaptersByTitle * @property {boolean} busy - * @property {(titleId: number) => void | Promise} onToggleExpand * @property {(titleId: number) => void | Promise} onSync * @property {(title: any) => void | Promise} onToggleEnabled * @property {(titleId: number) => void | Promise} onRemove - * @property {(chapterId: number, titleId: number) => void | Promise} onCancelChapter - * @property {(chapterId: number, titleId: number) => void | Promise} onRedownload */ /** @type {Props} */ - let { - titles = [], - expanded = {}, - loadingChapters = {}, - chaptersByTitle = {}, - busy = false, - onToggleExpand, - onSync, - onToggleEnabled, - onRemove, - onCancelChapter, - onRedownload, - } = $props(); + let { titles = [], busy = false, onSync, onToggleEnabled, onRemove } = $props(); -
    -

    Titles

    +
    +

    Comics

    {#if titles.length === 0} -

    No titles yet. Add a series URL above.

    +

    No comics yet. Add a series URL above.

    {:else} +
    {#each titles as title (title.id)} onToggleExpand(title.id)} onSync={() => onSync(title.id)} onToggleEnabled={() => onToggleEnabled(title)} onRemove={() => onRemove(title.id)} - {onCancelChapter} - {onRedownload} /> {/each} +
    {/if}
    diff --git a/web/src/components/TitlePage.svelte b/web/src/components/TitlePage.svelte new file mode 100644 index 0000000..78b7ddb --- /dev/null +++ b/web/src/components/TitlePage.svelte @@ -0,0 +1,208 @@ + + +
    +

    + { + event.preventDefault(); + onBack(); + }}>← Comics +

    + + {#if !title} + {#if loading} +

    Loading comic…

    + {:else} +

    Comic not found.

    + {/if} + {:else} +
    +
    +

    {title.title}

    + {title.source} +
    +
    + + + {title.downloaded}/{title.total} downloaded + {#if title.downloading > 0} + · {title.downloading} downloading + {/if} + {#if title.failed > 0} + · {title.failed} failed + {/if} + +
    +

    + {title.url} + {#if title.last_checked_at} + · last checked {title.last_checked_at} + {/if} + · {title.enabled ? "Watching" : "Paused"} +

    +
    + + + +
    +
    + +
    +

    Chapters

    +
    + + {#if selectedIds.length > 0} + {selectedIds.length} selected + {/if} + +
    +
    + {#if loading && chapters.length === 0} +

    Loading chapters…

    + {:else if chapters.length === 0} +

    No chapters yet. Sync to fetch the listing.

    + {:else} + + {/if} + {/if} +
    diff --git a/web/src/lib/route.js b/web/src/lib/route.js new file mode 100644 index 0000000..c2d4aa9 --- /dev/null +++ b/web/src/lib/route.js @@ -0,0 +1,34 @@ +/** + * @typedef {{ name: "home" } | { name: "title", id: number }} AppRoute + */ + +/** + * @param {string} [pathname] + * @returns {AppRoute} + */ +export function parseRoute(pathname = window.location.pathname) { + const match = pathname.match(/^\/titles\/(\d+)\/?$/); + if (match) { + return { name: "title", id: Number(match[1]) }; + } + return { name: "home" }; +} + +/** + * @param {number} id + * @returns {string} + */ +export function titlePath(id) { + return `/titles/${id}`; +} + +/** + * @param {string} href + */ +export function navigate(href) { + if (window.location.pathname === href) { + return; + } + history.pushState({}, "", href); + window.dispatchEvent(new PopStateEvent("popstate")); +}