import { randomUUID } from "node:crypto"; import type { TmdbClient } from "@umt/adapters"; import { FilesIdentifyRequest } from "@umt/contracts"; import { defineTask } from "@umt/tasks"; import { z } from "zod"; import type { AoOverrides } from "../acquisition-organize/index.ts"; import { createPorts } from "../acquisition-organize/ports.ts"; import { registerScheduledJob } from "../schedules/registry.ts"; import { SourcesRuntime } from "../sources-identity/runtime.ts"; import type { ServerContext, ServerModule } from "../types.ts"; import { defaultOptions, type FmDeps, type FmOptions } from "./common.ts"; import { IDENTIFY_REMOTE_TASK, runIdentifyRemote } from "./identify.ts"; import { registerOrganizeSourceRoutes } from "./routes/organize-sources.ts"; import { registerFileRoutes } from "./routes.ts"; import { queueDueSourceScans, scanOrganizeSource } from "./source-monitor.ts"; import { purgeTrash } from "./trash.ts"; /** * W5-A 文件管理(/files,docs/plan/06-file-manager-spec.md):只浏览已登记根(库根 + 下载器路径映射), * 行内业务状态、改名同步资产与路径绑定修订、移动/复制两段式 planId、整理走 planImport、回收站 + 引用检查、识别/探测/刮削。 * 复用 acquisition-organize 的服务导出(planImport / 路径守卫 / Move 授权 / H&R),不改其文件。 * 持久任务 purgeTrash:每日清理过期回收站项(FILES_TRASH_RETENTION_DAYS,默认 7 天);周期登记为定时任务 trash.purge。 */ export interface FmOverrides { ao?: AoOverrides; options?: Partial; /** W9-H:TMDB 客户端工厂(测试注入);缺省按设置 metadata.tmdb* 取(与 sources-identity 同一实现) */ tmdb?: (() => TmdbClient | null) | undefined; } export function createFileManagerModule(overrides: FmOverrides = {}): ServerModule & { deps(): FmDeps } { let deps: FmDeps | null = null; let sources: SourcesRuntime | null = null; const lazy = (ctx: ServerContext): FmDeps => { if (!deps) { const { options: aoOptions, ...aoRest } = overrides.ao ?? {}; deps = { ctx, ao: { ctx, ports: createPorts(ctx, { ...aoRest, ...(aoOptions ? { options: aoOptions } : {}) }) }, options: { ...defaultOptions(ctx), ...overrides.options }, tmdb: () => { sources ??= new SourcesRuntime(ctx, overrides.tmdb ? { tmdb: overrides.tmdb } : {}); return sources.tmdb(); }, }; } return deps; }; return { name: "file-manager", deps() { if (!deps) throw new Error("file-manager 尚未装配"); return deps; }, registerRoutes(app, ctx) { registerFileRoutes(app, lazy(ctx)); registerOrganizeSourceRoutes(app, lazy(ctx)); }, registerTasks(registry, ctx) { registry.register( defineTask({ name: "scanOrganizeSource", version: 1, lane: "maintenance", inputSchema: z.object({ sourceId: z.string().uuid() }), retryPolicy: { maximumAttempts: 3, initialInterval: "5s" }, handler: async (t) => t.step.run("scan", () => scanOrganizeSource(lazy(ctx), t.input)), }), defineTask({ name: "purgeTrash", version: 1, lane: "maintenance", inputSchema: z.object({}).passthrough(), retryPolicy: { maximumAttempts: 3, initialInterval: "5s", backoffCoefficient: 2, maximumInterval: "60s" }, handler: async (t) => t.step.run("purge", async () => { const r = await purgeTrash(lazy(ctx), undefined, {}); return { purged: r.purged.length, failed: r.failed.length }; }), }), // S-7:/files/identify 的远端(TMDB)查询;只读,失败发 status=failed 的 identify.completed,不重试 defineTask({ name: IDENTIFY_REMOTE_TASK, version: 1, lane: "network", inputSchema: z.object({ body: FilesIdentifyRequest, actorId: z.string() }), retryPolicy: { maximumAttempts: 1 }, handler: async (t) => t.step.run("identify", async () => { const r = await runIdentifyRemote(lazy(ctx), t.runId, t.input, t.abortSignal); return { status: r.status, items: r.items.length }; }), }), ); }, start(ctx) { const d = lazy(ctx); if (!d.options.backgroundLoops) return; registerScheduledJob(ctx, { key: "organize.sources", defaults: { name: "整理来源扫描", description: "检查来源目录的新文件", group: "maintain", schedule: { type: "interval", everyMs: 60_000 }, }, run: async (c) => queueDueSourceScans(d, c.slot), }); if (d.options.purgeIntervalMs <= 0) return; registerScheduledJob(ctx, { key: "trash.purge", defaults: { name: "回收站清理", description: "清除过期的回收站项", group: "maintain", schedule: { type: "interval", everyMs: d.options.purgeIntervalMs }, }, run: async (c) => { const r = await d.ctx.engine.enqueue( "purgeTrash", {}, { idempotencyKey: `purgeTrash:${c.slot}`, traceId: randomUUID() }, ); return { summary: r.deduplicated ? "本周期已入队" : "已入队清理" }; }, }); }, }; } export const fileManagerModule = createFileManagerModule();