feat(file-manager): v2 backend gaps — TMDB candidate entities, min size, on-existing, naming source (W9-H)

- G1 createEntityFromTmdbCandidate (sources-identity service): entity + accepted
  manual external identity + season/episode units, idempotent per TMDB id;
  POST /files/entity-from-candidate and organize items with `external`
- G2 minSizeBytes for organize (excluded below_min_size) and identify
  (skipped + excludedBySize)
- G3 onExisting passed to planImport; dryRun marks each file
  (same/skip/replace/conflict); replace = explicit grant
- G4 naming template|keep; planImport namingMode/namingSource, frozen
  plan.naming {mode, templateSource, templateVersion}; targetSegments
- G5 identify prior-record hints, GET /files/record-sources, organize
  refreshMetadata, POST /files/scrape-batch (dedupe by entity)
- G8 GET /files/tree (parent, subdirs hasChildren, video/registered counts)
- G9 POST /files/suggest-name returns 501 until the naming engine lands

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 15:08:17 +08:00
1 parent 88bbed409c
commit 13132e7ad8
12 files changed
+977 -36

No files matched your search

@@ -0,0 +1,101 @@
import { type TmdbClient, TmdbError } from "@umt/adapters";
import type { MediaKind } from "@umt/contracts";
import type { DB, Kysely } from "@umt/db";
import type { ParsedRelease } from "@umt/domain";
import { type Actor, audit, type Exec } from "./common.ts";
import { createEntityFromTmdb, ensureUnits, entityByExternal, IdentityConflictError } from "./entities.ts";
// W9-H(文件管理整理向导 G1):选中"仅 TMDB 候选"时就地建本地作品 + 外部映射(accepted,证据等级 manual)
// + 季/集单元。幂等:同一 TMDB id 已有 accepted 映射直接复用(跟随合并跳转),并发创建冲突时重试一次。
export class CandidateError extends Error {
constructor(
readonly reason: "not_configured" | "not_found" | "upstream",
message: string,
) {
super(message);
}
}
export interface EntityFromCandidate {
mediaEntityId: string;
created: boolean;
title: string;
year: number | null;
kind: MediaKind;
/** 本次确保存在的季/集单元数 */
contentUnits: number;
}
/** 单季最多建的集单元数(ensureUnits 内部也限 200) */
const MAX_EPISODES_PER_SEASON = 200;
export async function createEntityFromTmdbCandidate(
db: Kysely<DB>,
tmdb: () => Promise<TmdbClient | null>,
ref: { externalId: string; kind: "movie" | "tv" },
actor: Actor,
): Promise<EntityFromCandidate> {
const extRef = { provider: "tmdb", entityType: ref.kind, externalId: ref.externalId };
const existing = await entityByExternal(db, extRef);
if (existing) return describe(db, existing, false, 0);
const client = await tmdb();
if (!client) throw new CandidateError("not_configured", "未配置 TMDB,无法按候选建作品");
let detail: Awaited<ReturnType<TmdbClient["details"]>>;
try {
detail = await client.details(ref.kind, ref.externalId);
} catch (e) {
if (e instanceof TmdbError && e.kind === "notfound")
throw new CandidateError("not_found", `TMDB 无此条目 ${ref.kind}/${ref.externalId}`);
if (e instanceof TmdbError) throw new CandidateError("upstream", `TMDB 查询失败:${e.kind}`);
throw e;
}
for (let attempt = 0; ; attempt++) {
try {
return await db.transaction().execute(async (trx) => {
const r = await createEntityFromTmdb(trx, detail, {
level: "manual",
evidence: { by: `${actor.type}:${actor.id ?? ""}`, via: "files.organize" },
actor,
});
let units = 0;
if (r.created && ref.kind === "tv") {
for (const s of detail.seasons ?? []) {
if (s.seasonNumber < 0) continue;
const eps = Math.min(s.episodeCount, MAX_EPISODES_PER_SEASON);
const parsed = {
season: s.seasonNumber,
...(eps > 0 ? { episodes: { start: 1, end: eps } } : {}),
} as ParsedRelease;
const u = await ensureUnits(trx, r.entityId, "series", parsed);
units += u.unitIds.length + (eps > 0 ? 1 : 0);
}
}
if (r.created)
await audit(
trx,
actor,
"identity.entity_created_from_candidate",
{ type: "media_entity", id: r.entityId },
{ provider: "tmdb", entityType: ref.kind, externalId: ref.externalId, contentUnits: units },
);
return describe(trx, r.entityId, r.created, units);
});
} catch (e) {
// 并发:另一事务刚接受了同一身份 → 本事务回滚,重试时直接复用
if (e instanceof IdentityConflictError && attempt === 0) continue;
throw e;
}
}
}
async function describe(exec: Exec, id: string, created: boolean, units: number): Promise<EntityFromCandidate> {
const e = await exec
.selectFrom("media_entity")
.select(["id", "title", "year", "kind"])
.where("id", "=", id)
.executeTakeFirstOrThrow();
return { mediaEntityId: e.id, created, title: e.title, year: e.year, kind: e.kind, contentUnits: units };
}