Files
UHD_Media_Tool/apps/server/src/modules/file-manager/index.ts
T
rootandClaude Opus 5.5 ceb66f8c77 feat: v0.3.8–v0.3.14 工作区汇总(依赖升级、整理来源、手动入库、原地入库等)
生产 v0.3.8~v0.3.13 均由 8013196 加工作区修改构建部署,此前未提交;本提交补齐源码,
并含未部署的 v0.3.14 原地入库。中间各版本源码状态未保存,无法逐版拆分,详见
docs/plan/17-full-upgrade.md 与 docs/plan/18-organize-release.md。

- v0.3.8 全面升级:Deno workspace 依赖、Router 8 / Vite 8、camofox 1.18.0 / Node 24、
  PostgreSQL 18.6、备份客户端 18.6、演示 qB 5.2.4
- v0.3.9 媒体整理:分类规则、整理来源与目录快照(0059)、文件管理整理入口
- v0.3.10 手动下载与入库:独立手动下载、详情页资源下载、TMDB / Local 元数据兜底
  (metadata.resolve_organize)、正常多版本 / 一次性洗版(0060)
- v0.3.11 搜索详情与桌面黑边修复
- v0.3.12 列表分页、搜索历史、首页概况
- v0.3.13 统一整理弹窗与队列
- v0.3.14 原地入库(0061,未部署)

同批审计修复:
- 刮削入队:确认原记录存在后才删重放产生的新记录;写回 run 关联失败时保持 queued,
  由处理器补关联(对齐 MP 先登记、只在入队失败时记失败)
- 识别升级链只在结果更确定时采用,不再用 no_match 顶替原有歧义候选
- bluray_dir 资产以原盘目录本身为刮削根(MP 同做法)
- metadata.resolve_organize 登记到入库排障 Skill 与能力地图
- db/src/types.ts 按迁移重新生成;contracts 整理动作枚举顺序与数据库对齐
- 测试:原地入库用例改为直接查记录表;domain 测试模板补 version
- 前端:通知铃铛弹层与用户菜单统一样式;站点卡片共用卡头、底栏贴底、分页间距

依赖升级证据只提交摘要文件,registry-snapshot.json 移至 .local/(SHA-256 记于文档)。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-02 15:47:17 +08:00

132 lines
5.4 KiB
TypeScript

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<FmOptions>;
/** 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();