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]>
274 lines
11 KiB
TypeScript
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 };
|
|
}
|