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:
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 };
|
||||
}
|
||||
Reference in new issue
Block a user