import chokidar, { FSWatcher } from "chokidar"; import fs from "fs"; import path from "path"; import { Service, ServiceBroker, ServiceSchema, Context } from "moleculer"; import ApiGateway from "moleculer-web"; import debounce from "lodash/debounce"; /** * ApiService exposes REST endpoints and watches the comics directory for changes. * It uses chokidar to monitor filesystem events and broadcasts them via the Moleculer broker. * @extends Service */ export default class ApiService extends Service { /** * The chokidar file system watcher instance. * @private */ private fileWatcher?: any; /** * Per-path debounced handlers for add/change events, keyed by file path. * @private */ private debouncedHandlers: Map> = new Map(); /** * Creates an instance of ApiService. * @param {ServiceBroker} broker - The Moleculer service broker instance. */ public constructor(broker: ServiceBroker) { super(broker); this.parseServiceSchema({ name: "api", mixins: [ApiGateway], settings: { port: process.env.PORT || 3000, routes: [ { path: "/api", whitelist: ["**"], cors: { origin: "*", methods: ["GET", "OPTIONS", "POST", "PUT", "DELETE"], allowedHeaders: ["*"], exposedHeaders: [], credentials: false, maxAge: 3600, }, use: [], mergeParams: true, authentication: false, authorization: false, autoAliases: true, aliases: {}, callingOptions: {}, bodyParsers: { json: { strict: false, limit: "1MB" }, urlencoded: { extended: true, limit: "1MB" }, }, mappingPolicy: "all", logging: true, }, { path: "/graphql", cors: { origin: "*", methods: ["GET", "OPTIONS", "POST"], allowedHeaders: ["*"], exposedHeaders: [], credentials: false, maxAge: 3600, }, aliases: { "POST /": "graphql.graphql", "GET /": "graphql.graphql", "GET /health": "graphql.checkRemoteSchema", }, mappingPolicy: "restrict", bodyParsers: { json: { strict: false, limit: "1MB" }, }, }, { path: "/userdata", cors: { origin: "*", methods: ["GET", "OPTIONS"], allowedHeaders: ["*"], exposedHeaders: [], credentials: false, maxAge: 3600, }, use: [ApiGateway.serveStatic(path.resolve("./userdata"))], }, { path: "/comics", cors: { origin: "*", methods: ["GET", "OPTIONS"], allowedHeaders: ["*"], exposedHeaders: [], credentials: false, maxAge: 3600, }, use: [ApiGateway.serveStatic(path.resolve("./comics"))], }, { path: "/logs", use: [ApiGateway.serveStatic("logs")], }, ], log4XXResponses: false, logRequestParams: true, logResponseData: true, assets: { folder: "public", options: {} }, }, events: { /** * Listen for watcher disable events */ "IMPORT_WATCHER_DISABLED": { async handler(ctx: Context<{ reason: string; sessionId: string }>) { const { reason, sessionId } = ctx.params; this.logger.info(`[Watcher] Disabled: ${reason} (session: ${sessionId})`); // Broadcast to frontend await this.broker.call("socket.broadcast", { namespace: "/", event: "IMPORT_WATCHER_STATUS", args: [{ enabled: false, reason, sessionId, }], }); }, }, /** * Listen for watcher enable events */ "IMPORT_WATCHER_ENABLED": { async handler(ctx: Context<{ sessionId: string }>) { const { sessionId } = ctx.params; this.logger.info(`[Watcher] Re-enabled after session: ${sessionId}`); // Broadcast to frontend await this.broker.call("socket.broadcast", { namespace: "/", event: "IMPORT_WATCHER_STATUS", args: [{ enabled: true, sessionId, }], }); }, }, }, actions: {}, methods: {}, started: this.startWatcher, stopped: this.stopWatcher, }); } /** * Initializes and starts the chokidar watcher on the COMICS_DIRECTORY. * Debounces rapid events and logs initial scan completion. * @private */ private async startWatcher(): Promise { const rawDir = process.env.COMICS_DIRECTORY; if (!rawDir) { this.logger.error("COMICS_DIRECTORY not set; cannot start watcher"); return; } const watchDir = path.resolve(rawDir); this.logger.info(`Watching comics folder at: ${watchDir}`); if (!fs.existsSync(watchDir)) { this.logger.error(`✖ Comics folder does not exist: ${watchDir}`); return; } this.fileWatcher = chokidar.watch(watchDir, { persistent: true, ignoreInitial: true, followSymlinks: true, depth: 10, usePolling: true, interval: 5000, atomic: true, awaitWriteFinish: { stabilityThreshold: 2000, pollInterval: 100 }, ignored: (p) => p.endsWith(".dctmp") || p.includes("/.git/"), }); /** * Returns a debounced handler for a specific path, creating one if needed. * Debouncing per-path prevents duplicate events for the same file while * ensuring each distinct path is always processed. */ const getDebouncedForPath = (p: string) => { if (!this.debouncedHandlers.has(p)) { const fn = debounce( (event: string, filePath: string, stats?: fs.Stats) => { this.debouncedHandlers.delete(filePath); try { this.handleFileEvent(event, filePath, stats); } catch (err) { this.logger.error(`Error handling file event [${event}] for ${filePath}:`, err); } }, 200, { leading: true, trailing: true } ); this.debouncedHandlers.set(p, fn); } return this.debouncedHandlers.get(p)!; }; this.fileWatcher .on("ready", () => this.logger.info("Initial scan complete.")) .on("error", (err) => this.logger.error("Watcher error:", err)) .on("add", (p, stats) => getDebouncedForPath(p)("add", p, stats)) .on("change", (p, stats) => getDebouncedForPath(p)("change", p, stats)) // unlink/unlinkDir fire once per path — handle immediately, no debounce needed .on("unlink", (p) => this.handleFileEvent("unlink", p)) .on("addDir", (p) => getDebouncedForPath(p)("addDir", p)) .on("unlinkDir", (p) => this.handleFileEvent("unlinkDir", p)); } /** * Stops and closes the chokidar watcher, freeing resources. * @private */ private async stopWatcher(): Promise { if (this.fileWatcher) { this.logger.info("Stopping file watcher..."); await this.fileWatcher.close(); this.fileWatcher = undefined; } } /** * Handles a filesystem event by logging and optionally importing new files. * @param event - The type of chokidar event ('add', 'change', 'unlink', etc.). * @param filePath - The full path of the file or directory that triggered the event. * @param stats - Optional fs.Stats data for 'add' or 'change' events. * @private */ private async handleFileEvent( event: string, filePath: string, stats?: fs.Stats ): Promise { this.logger.info(`File event [${event}]: ${filePath}`); // Handle file/directory removal — mark affected comics as missing and notify frontend if (event === "unlink" || event === "unlinkDir") { try { const result: any = await this.broker.call("library.markFileAsMissing", { filePath }); if (result.marked > 0) { await this.broker.call("socket.broadcast", { namespace: "/", event: "LS_FILES_MISSING", args: [{ missingComics: result.missingComics, triggerPath: filePath, count: result.marked, }], }); this.logger.info(`[Watcher] Marked ${result.marked} comic(s) as missing for path: ${filePath}`); } } catch (err) { this.logger.error(`[Watcher] Failed to mark comics missing for ${filePath}:`, err); } return; } if (event === "add" && stats) { setTimeout(async () => { try { const newStats = await fs.promises.stat(filePath); if (newStats.mtime.getTime() === stats.mtime.getTime()) { this.logger.info(`[Watcher] Stable file detected: ${filePath}`); // Clear missing flag if this file was previously marked absent await this.broker.call("library.clearFileMissingFlag", { filePath }); await this.broker.call("socket.broadcast", { namespace: "/", event: "LS_FILE_DETECTED", args: [{ filePath, fileSize: newStats.size, extension: path.extname(filePath), }], }); } } catch (error) { this.logger.error(`[Watcher] Error handling detected file ${filePath}:`, error); } }, 3000); } } }