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 = { 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 { const db = d.ctx.db; const { videos, truncated } = await collectVideos(d, body); const localCache = new Map(); const remoteCache = new Map(); const entityKinds = new Map(); const localFor = async (p: ParsedRelease): Promise => { const key = `${queryTitles(p).join("|")}|${body.hints?.tmdbId ?? ""}`; const hit = localCache.get(key); if (hit) return hit; const ids = new Set(); 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 }; }