Files
UHD_Media_Tool/apps/server/src/modules/file-manager/identify.ts
T
rootandClaude Opus 5.5 29cd10b27e feat(server): W5-A file manager module (/files)
Roots/list/stat with inline business status, path guard (realpath within
registered roots, symlink escape, offline root 503), mkdir/rename syncing
asset_location and path_binding revisions, two-phase move/copy/organize
(planId + source facts), trash with reference checks and purgeTrash task,
identify/probe/scrape, audit + idempotency keys. Runbook docs/runbook/files.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 23:02:20 +08:00

274 lines
11 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { readdir } from "node:fs/promises";
import { join } from "node:path";
import type {
FileIdentifyCandidate,
FileIdentifyItem,
FilesIdentifyRequest,
FilesIdentifyResult,
} from "@umt/contracts";
import { sql } from "@umt/db";
import { type IdentityCandidate, type ParsedRelease, parseReleasePath, queryTitles, rankCandidates } from "@umt/domain";
import { localIdentityCandidate } from "@umt/mapping";
import type { Principal } from "../../http/auth.ts";
import { actorOfP, errMessage, FilesError, type FmDeps, resolveIn } from "./common.ts";
import { kindOfName } from "./list.ts";
// 识别(只读,不落库):parseReleasePath(文件 + 父/祖父目录上下文)→ 本地召回(别名 pg_trgm + 外部身份)
// → 远端候选(catalog.lookup 能力,走 TMDB 缓存;失败只降级为提示)→ rankCandidates,每项前 5。
const TRGM = 0.45;
const LEVEL: Record<string, FileIdentifyCandidate["level"]> = {
rule_confirmed: "high",
multi_evidence: "medium",
needs_confirm: "low",
};
interface Video {
root: string;
rel: string;
}
async function collectVideos(d: FmDeps, body: FilesIdentifyRequest): Promise<{ videos: Video[]; truncated: boolean }> {
const max = d.options.identifyMaxVideos;
const videos: Video[] = [];
let truncated = false;
for (const it of body.items) {
const r = await resolveIn(d, it.root, it.path);
if (!r.lst?.isDirectory() || r.isSymlink) {
if (videos.length >= max) truncated = true;
else videos.push({ root: it.root, rel: r.rel });
continue;
}
const stack = [r.rel];
while (stack.length > 0) {
const rel = stack.shift()!;
const names = (await readdir(join(r.rootReal, rel), { withFileTypes: true }).catch(() => [])).sort((a, b) =>
a.name.localeCompare(b.name),
);
for (const n of names) {
if (n.name.startsWith(".")) continue;
const child = rel ? `${rel}/${n.name}` : n.name;
if (n.isDirectory()) stack.push(child);
else if (n.isFile() && kindOfName(n.name) === "video") {
if (videos.length >= max) {
truncated = true;
break;
}
videos.push({ root: it.root, rel: child });
}
}
if (truncated) break;
}
}
return { videos, truncated };
}
function segments(rel: string) {
const parts = rel.split("/");
const file = parts.pop()!;
const parent = parts.pop();
const grandParent = parts.pop();
return { file, ...(parent ? { parent } : {}), ...(grandParent ? { grandParent } : {}) };
}
export async function identify(
d: FmDeps,
principal: Principal | undefined,
body: FilesIdentifyRequest,
): Promise<FilesIdentifyResult> {
const db = d.ctx.db;
const { videos, truncated } = await collectVideos(d, body);
const localCache = new Map<string, IdentityCandidate[]>();
const remoteCache = new Map<string, { list: IdentityCandidate[]; warning: string | null }>();
const entityKinds = new Map<string, string>();
const localFor = async (p: ParsedRelease): Promise<IdentityCandidate[]> => {
const key = `${queryTitles(p).join("|")}|${body.hints?.tmdbId ?? ""}`;
const hit = localCache.get(key);
if (hit) return hit;
const ids = new Set<string>();
for (const q of queryTitles(p)) {
const rows = await sql<{ media_entity_id: string }>`
select distinct a.media_entity_id from title_alias a join media_entity e on e.id = a.media_entity_id
where e.status not in ('merged', 'archived')
and (a.normalized = ${q} or (length(${q}) >= 3 and similarity(a.normalized, ${q}) >= ${TRGM}))
limit 20`.execute(db);
for (const r of rows.rows) ids.add(r.media_entity_id);
}
const explicit = [
...p.explicitIds.map((x) => ({ provider: x.provider as string, id: x.id })),
...(body.hints?.tmdbId ? [{ provider: "tmdb", id: String(body.hints.tmdbId) }] : []),
];
for (const x of explicit) {
const rows = await db
.selectFrom("external_identity")
.select(["media_entity_id"])
.where("provider", "=", x.provider)
.where("external_id", "=", x.id)
.where("status", "=", "accepted")
.execute();
for (const r of rows) if (r.media_entity_id) ids.add(r.media_entity_id);
}
const out: IdentityCandidate[] = [];
if (ids.size > 0) {
const list = [...ids];
const ents = await db.selectFrom("media_entity").selectAll().where("id", "in", list).execute();
const aliases = await db
.selectFrom("title_alias")
.select(["media_entity_id", "alias"])
.where("media_entity_id", "in", list)
.execute();
const idents = await db
.selectFrom("external_identity")
.select(["media_entity_id", "provider", "external_id", "status"])
.where("media_entity_id", "in", list)
.execute();
for (const e of ents) {
entityKinds.set(e.id, e.kind);
const c = localIdentityCandidate(
{ id: e.id, kind: e.kind, title: e.title, originalTitle: e.original_title, year: e.year },
aliases.filter((a) => a.media_entity_id === e.id).map((a) => a.alias),
idents
.filter((i) => i.media_entity_id === e.id)
.map((i) => ({ provider: i.provider, externalId: i.external_id, status: i.status })),
);
if (c) out.push(c);
}
}
localCache.set(key, out);
return out;
};
const remoteFor = async (p: ParsedRelease) => {
const q = p.titleCn ?? p.title ?? p.titleEn;
if (!q) return { list: [], warning: null };
const key = `${q}|${body.hints?.year ?? p.year ?? ""}`;
const hit = remoteCache.get(key);
if (hit) return hit;
let res: { list: IdentityCandidate[]; warning: string | null };
try {
const out = (await d.ctx.capabilities.invoke(
"catalog.lookup",
{ query: q, includeRemote: true, limit: 10, ...(body.hints?.year ? { year: body.hints.year } : {}) },
{
actorId: actorOfP(principal),
taskId: `files.identify:${randomUUID()}`,
scopeIds: [],
signal: AbortSignal.timeout(15_000),
source: "web",
},
)) as {
remote?: { provider: string; entityType: string; externalId: string; title: string; year: number | null }[];
};
const list: IdentityCandidate[] = (out.remote ?? [])
.filter((m) => m.entityType === "movie" || m.entityType === "tv")
.map((m) => ({
provider: m.provider,
entityType: m.entityType as "movie" | "tv",
externalId: m.externalId,
titles: [m.title],
...(m.year !== null ? { year: m.year } : {}),
aliases: [],
}));
res = { list, warning: null };
} catch (e) {
res = { list: [], warning: `远端候选不可用(仅本地召回):${errMessage(e).slice(0, 200)}` };
}
remoteCache.set(key, res);
return res;
};
const items: FileIdentifyItem[] = [];
for (const v of videos) {
const parsed0 = parseReleasePath(segments(v.rel));
const parsed: ParsedRelease = {
...parsed0,
...(parsed0.year === undefined && body.hints?.year ? { year: body.hints.year } : {}),
};
const warnings: string[] = [];
const local = parsed.title || parsed.explicitIds.length || body.hints?.tmdbId ? await localFor(parsed) : [];
const remote = parsed.title ? await remoteFor(parsed) : { list: [], warning: null };
if (remote.warning) warnings.push(remote.warning);
if (!parsed.title) warnings.push("文件名无法解析出标题");
// 已关联到本地作品的远端候选不重复出现
const linked = new Set(local.flatMap((c) => (c.linkedExternalIds ?? []).map((l) => `${l.provider}:${l.id}`)));
const all = [...local, ...remote.list.filter((c) => !linked.has(`${c.provider}:${c.externalId}`))];
const kindHint = body.hints?.mediaKind;
const ranked = rankCandidates(parsed, all)
.filter((r) => r.level !== "rejected")
.filter((r) => !kindHint || (kindHint === "movie") === (r.candidate.entityType === "movie"))
.slice(0, 5);
const candidates: FileIdentifyCandidate[] = ranked.map((r, i) => {
const c = r.candidate;
const localKind = c.localEntityId ? entityKinds.get(c.localEntityId) : undefined;
return {
mediaEntityId: c.localEntityId ?? null,
provider: c.provider,
externalId: c.localEntityId ? null : c.externalId,
title: c.titles[0] ?? c.externalId,
year: c.year ?? null,
kind: (localKind ?? (c.entityType === "movie" ? "movie" : "series")) as FileIdentifyCandidate["kind"],
level: LEVEL[r.level] ?? "low",
score: 5 - i,
reasons: r.evidence,
};
});
const best = candidates[0];
let contentUnitId: string | null = null;
let contentUnit: string | null = null;
const season = parsed.season ?? (parsed.episodes ? 1 : undefined);
if (season !== undefined) {
const pad = (n: number) => String(n).padStart(2, "0");
contentUnit = parsed.episodes ? `S${pad(season)}E${pad(parsed.episodes.start)}` : `S${pad(season)}`;
if (best?.mediaEntityId) {
let q = db
.selectFrom("content_unit")
.select(["id"])
.where("media_entity_id", "=", best.mediaEntityId)
.where("season_number", "=", season)
.where("status", "<>", "retired");
q = parsed.episodes ? q.where("episode_number", "=", parsed.episodes.start) : q.where("kind", "=", "season");
contentUnitId = (await q.executeTakeFirst())?.id ?? null;
}
}
const def = v.root.startsWith("lib:") ? v.root.slice(4) : null;
const existing = def
? await db
.selectFrom("asset_location")
.select(["media_asset_id"])
.where("library_root_id", "=", def)
.where("relative_path", "=", v.rel)
.where("status", "<>", "removed")
.executeTakeFirst()
: undefined;
items.push({
root: v.root as FileIdentifyItem["root"],
path: v.rel,
parsed: {
title: parsed.title ?? null,
year: parsed.year ?? null,
season: season ?? null,
episodes: parsed.episodes
? Array.from(
{ length: Math.min(200, parsed.episodes.end - parsed.episodes.start + 1) },
(_, i) => parsed.episodes!.start + i,
)
: [],
resolution: parsed.resolution ?? null,
source: parsed.source ?? null,
releaseGroup: parsed.releaseGroup?.name ?? null,
mediaKind:
parsed.episodes || parsed.season !== undefined ? "series" : parsed.mediaTypeHint === "tv" ? "series" : null,
},
candidates,
suggestion: { mediaEntityId: best?.mediaEntityId ?? null, contentUnitId, contentUnit },
existing: { assetId: existing?.media_asset_id ?? null },
warnings,
});
}
if (videos.length === 0 && body.items.length > 0 && !truncated)
throw new FilesError("bad_request", "所选项中没有视频文件");
return { items, truncated };
}