import chalk from "chalk"; import { define } from "gunshi"; import { openDb } from "@/db"; import * as authorsQ from "@/db/queries/authors"; import type { SyncRunStatus } from "@/interfaces"; import { syncAuthor, type SyncSummary } from "@/services/sync-service"; import { ExitCodes, exitWith, wrap } from "../envelope"; import { enterJsonMode, isJsonMode, printHuman, printJson } from "../output"; const rollupStatus = (runs: SyncSummary[]): SyncRunStatus => { if (runs.some((r) => r.status === "error")) return "error"; if (runs.some((r) => r.status === "partial")) return "partial"; return "ok"; }; export const syncCommand = define({ name: "sync", description: "Fetch the latest works from FC2 for one or all subscribed authors", args: { author: { type: "string", short: "a", description: "Sync a single author" }, all: { type: "boolean", description: "Sync every subscribed author" }, concurrency: { type: "string", short: "c", default: "3", description: "Max parallel requests" }, delay: { type: "string", short: "d", default: "0", description: "Delay between requests (ms)" }, "with-detail": { type: "boolean", description: "Fetch per-work detail pages to backfill publish date / price" }, "with-sukebei": { type: "boolean", description: "(Phase 3) Cross-check sukebei magnets" }, db: { type: "string", description: "SQLite DB path (default: ~/.fc2cma/db.sqlite or $FC2CMA_DB)" }, json: { type: "boolean", description: "Emit machine-readable JSON envelope on stdout" } }, async run(ctx) { const args = ctx.values; enterJsonMode(Boolean(args.json)); if (!args.author && !args.all) { exitWith(ExitCodes.USAGE, "sync: provide --author or --all"); } if (args.author && args.all) { exitWith(ExitCodes.USAGE, "sync: --author and --all are mutually exclusive"); } const concurrency = Math.max(1, Number.parseInt(args.concurrency, 10) || 3); const delayMs = Math.max(0, Number.parseInt(args.delay, 10) || 0); const withDetail = Boolean(args["with-detail"]); const withSukebei = Boolean(args["with-sukebei"]); const db = openDb(args.db); const targets = args.all ? authorsQ.listAuthors(db).map((a) => a.name) : [args.author as string]; if (targets.length === 0) { const payload = { runs: [] as SyncSummary[] }; if (isJsonMode()) printJson(wrap("sync", payload, ["no authors to sync"])); else printHuman(chalk.yellow("No authors to sync.")); db.close(); return; } const runs: SyncSummary[] = []; const warnings: string[] = []; for (const author of targets) { if (!authorsQ.getAuthor(db, author)) { authorsQ.addAuthor(db, author); } try { const summary = await syncAuthor({ author, concurrency, delayMs, withDetail, withSukebei, db }); runs.push(summary); if (!isJsonMode()) { printHuman( `${chalk.cyan(author)} total=${summary.totalWorks} new=${chalk.green(summary.newWorks)} price_changes=${summary.priceChanges} status=${summary.status}` ); if (summary.warnings.length > 0) { const shown = summary.warnings.slice(0, 5); for (const w of shown) { printHuman(chalk.yellow(` warning: ${w}`)); } if (summary.warnings.length > shown.length) { printHuman( chalk.dim(` (+${summary.warnings.length - shown.length} more — see --json)`) ); } } } warnings.push(...summary.warnings.map((w) => `${author}: ${w}`)); } catch (err) { const msg = err instanceof Error ? err.message : String(err); runs.push({ author, runId: -1, totalWorks: 0, newWorks: 0, priceChanges: 0, newMagnets: 0, warnings: [msg], status: "error" }); warnings.push(`${author}: ${msg}`); if (!isJsonMode()) printHuman(chalk.red(`${author}: error — ${msg}`)); } } const payload = { runs }; if (isJsonMode()) printJson(wrap("sync", payload, warnings)); db.close(); const overall = rollupStatus(runs); if (overall === "error") process.exit(ExitCodes.NETWORK); if (overall === "partial") process.exit(ExitCodes.PARTIAL); } });