diff --git a/apps/server/src/modules/acquisition-organize/services/organize-exec.ts b/apps/server/src/modules/acquisition-organize/services/organize-exec.ts index 38cc6677..044e5db6 100644 --- a/apps/server/src/modules/acquisition-organize/services/organize-exec.ts +++ b/apps/server/src/modules/acquisition-organize/services/organize-exec.ts @@ -21,7 +21,12 @@ import { type AoDeps, upsertDecision } from "./common.ts"; import { finalizeRecord, removeMovedDownload } from "./organize-finalize.ts"; import { emitRecord } from "./organize-plan.ts"; import type { FrozenPlan } from "./organize-types.ts"; -import { cleanupReplacement, settleSamePathReplacement, stagedReplacementBackup } from "./replacement.ts"; +import { + cleanupReplacement, + quarantineNotVideo, + settleSamePathReplacement, + stagedReplacementBackup, +} from "./replacement.ts"; import { scrapeAfterImport } from "./scrape.ts"; // ── 执行 ── @@ -81,11 +86,10 @@ export async function executeRecord( if (rec.status !== "running" && rec.status !== "queued") { await settleSamePathReplacement(d, rec.plan as unknown as FrozenPlan, recordId); await cleanupReplacement(d, rec.plan as unknown as FrozenPlan, recordId); + await quarantineNotVideo(d, rec.plan as unknown as FrozenPlan, recordId); const frozen = rec.plan as unknown as FrozenPlan; - if ( - (frozen.metadataMode === "local" || frozen.scrapeAfterImport !== undefined) && - ["succeeded", "partial"].includes(rec.status) - ) { + // 与 finalizeRecord 同一口径(按设置 / 本次选项);排队按整理记录幂等,重放不会重复刮 + if (["succeeded", "partial"].includes(rec.status)) { await scrapeAfterImport(d, frozen.entityId, recordId, { force: frozen.metadataMode === "local", enabled: frozen.scrapeAfterImport, @@ -767,14 +771,8 @@ export async function transferFile( }, }; } - const r = await px.crossDeviceMove( - src, - dst, - { - hash: d.ports.options.verifyHash, - }, - call, - ); + // 跨卷移动删源前一律全量 sha256 校验(抽样校验不够当删源依据;只影响跨卷 Move,复制 / 同卷仍按 verifyHash) + const r = await px.crossDeviceMove(src, dst, { hash: "sha256" }, call); const failedStep = r.steps.find((st) => st.status === "failed"); return { status: r.status === "completed" ? "succeeded" : r.status, diff --git a/apps/server/src/modules/acquisition-organize/services/organize-finalize.ts b/apps/server/src/modules/acquisition-organize/services/organize-finalize.ts index ebf781cf..fae3c190 100644 --- a/apps/server/src/modules/acquisition-organize/services/organize-finalize.ts +++ b/apps/server/src/modules/acquisition-organize/services/organize-finalize.ts @@ -15,11 +15,19 @@ import type { ExecuteResult } from "./organize-exec.ts"; import { emitRecord } from "./organize-plan.ts"; import { finalizeTransfer } from "./organize-transfer.ts"; import type { FrozenPlan } from "./organize-types.ts"; -import { cleanupReplacement, settleSamePathReplacement } from "./replacement.ts"; +import { cleanupReplacement, quarantineNotVideo, settleSamePathReplacement } from "./replacement.ts"; import { scrapeAfterImport } from "./scrape.ts"; import { slotComplete as sharedSlotComplete } from "./subscriptions.ts"; import { downloadTasksUnder } from "./takeover.ts"; +/** 被排除待确认的视频原因(待处理摘要里显示) */ +const EXCLUDED_TEXT: Record = { + episode_unresolved: "集数待确认", + no_target_dir: "没有季目录", + naming_failed: "命名失败", + title_mismatch: "标题不是本作品", +}; + /** 登记资产/位置/绑定 + Slot/计划/下载任务 + 记录终态,同一事务;记录行锁作 fencing */ export async function finalizeRecord(d: AoDeps, recordId: string): Promise { const db = d.ctx.db; @@ -82,6 +90,7 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise(); for (const f of files) { const p = probes.get(f.id) as { status?: string; video?: unknown[] } | undefined; if (p?.status === "ok" && Array.isArray(p.video) && p.video.length === 0) { @@ -97,6 +106,21 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise [pf.src, pf.mainSrc])); + for (const f of files) { + if (f.status !== "succeeded" || f.role === "main" || !notVideo.has(mainOf.get(f.source_path) ?? "")) continue; + await trx + .updateTable("organization_file_result") + .set({ status: "skipped", skip_reason: "main_failed" }) + .where("id", "=", f.id) + .execute(); + f.status = "skipped"; + f.skip_reason = "main_failed"; } } @@ -145,16 +169,22 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise a.files.some((old) => old.locationId === hit!.id)) + ((pf?.action === "replace" && + (!frozen.replacement || + frozen.replacement.assets.some((a) => a.files.some((old) => old.locationId === hit!.id)))) || + hit.media_entity_id !== frozen.entityId) ) { await trx.updateTable("asset_location").set({ status: "removed" }).where("id", "=", hit.id).execute(); await sql`update media_asset set status='retired' where id=${hit.media_asset_id} and not exists(select 1 from asset_location where media_asset_id=${hit.media_asset_id} and status <> 'removed')`.execute( @@ -166,46 +196,80 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise { + const code = f.skip_reason?.replace(/^excluded:/, "") ?? ""; + return `${f.source_path.split("/").pop()}(${EXCLUDED_TEXT[code] ?? code})`; + }); await upsertDecision(d, trx, { kind: "other", title: `有 ${unresolved.length} 个视频没有整理`, - summary: `季集、季目录或命名没能确定,文件留在原处${ + summary: `季集、季目录、命名没能确定,或文件标题不是本作品,文件留在原处${ rec.action === "move" ? ",下载任务保留" : "" }:${shown.join("、")}${unresolved.length > shown.length ? " 等" : ""}`, subjectType: "organization_record", @@ -463,6 +523,7 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise = {}; const unresolvedEpisodes: NonNullable = {}; + // 文件级标题粗核:混合包 / 合集里标题明显不是本作品的视频不按本作品命名入库(待确认 title_mismatch); + // 用户逐文件指定过的不核 + const mismatched = await fileTitleMismatches( + d, + entity, + mediaType, + files.filter((f) => !overrideFor(req.fileOverrides, f.path) && fileKindOf(basename(f.path)) === "video"), + ); for (const f of files) { let parsed = parseReleasePath(segmentsWithin(f.path, sourceRoot)); if (parsed.fileKind !== "video") continue; + const wrongTitle = mismatched.get(f.path); + if (wrongTitle !== undefined) { + mappedOverrides[f.path] = { exclude: { reason: "title_mismatch", detail: wrongTitle } }; + continue; + } const ov = overrideFor(req.fileOverrides, f.path); let resolution: EpisodeResolution | undefined; if (mediaType === "tv" && (ov?.season !== undefined || ov?.episodes)) { @@ -841,9 +856,12 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise x.path === f.src); - if (src) src.sha256 = (await sha256Full(src.path)).digest; + if (src) { + const ss = await statSafe(src.path); + src.sha256 = ss.kind === "ok" ? await cachedSha256(src.path, ss.facts) : (await sha256Full(src.path)).digest; + } } existing.push(e); } @@ -1221,3 +1239,58 @@ export async function emitRecord(d: AoDeps, exec: Exec, recordId: string): Promi mediaEntityId: r.media_entity_id, }); } + +/** + * 计划期全量 sha256 按 (路径, 大小, 修改时间, inode) 缓存:向导预览 / 复核 / 执行各算一遍同一对大文件时只算一次。 + * 文件被改动(大小 / 修改时间 / inode 变)就重算;只留最近的 SHA_CACHE_MAX 条。 + */ +const SHA_CACHE_MAX = 2000; +const shaCache = new Map(); +export async function cachedSha256( + path: string, + facts: { size: number; mtimeMs: number; ino?: number | bigint | null }, +): Promise { + const key = `${path}\u0000${facts.size}\u0000${facts.mtimeMs}\u0000${facts.ino ?? ""}`; + const hit = shaCache.get(key); + if (hit) { + shaCache.delete(key); + shaCache.set(key, hit); + return hit; + } + const digest = (await sha256Full(path)).digest; + shaCache.set(key, digest); + if (shaCache.size > SHA_CACHE_MAX) shaCache.delete(shaCache.keys().next().value!); + return digest; +} + +/** 整理时补全元数据(metadata.resolve_organize:模型 + 网页搜索)的超时 */ +export const RESOLVE_ORGANIZE_TIMEOUT_MS = 3 * 60_000; + +/** 作品标题 / 原名 / 别名 → 这批视频里标题明显不是本作品的(路径 → 文件标题) */ +async function fileTitleMismatches( + d: AoDeps, + entity: Pick, + mediaType: "movie" | "tv", + videos: readonly { path: string }[], +): Promise> { + if (videos.length < 2) return new Map(); + const aliases = await d.ctx.db + .selectFrom("title_alias") + .select("alias") + .where("media_entity_id", "=", entity.id) + .where("content_unit_id", "is", null) + .execute(); + const meta = (entity.metadata ?? {}) as Record; + const names = [ + entity.title, + entity.original_title, + meta.title?.value, + meta.originalTitle?.value, + ...aliases.map((a) => a.alias), + ].filter((n): n is string => typeof n === "string" && n.trim().length > 0); + return titleMismatches( + videos.map((v) => ({ path: v.path, name: basename(v.path) })), + names, + mediaType, + ); +} diff --git a/apps/server/src/modules/acquisition-organize/services/replacement.ts b/apps/server/src/modules/acquisition-organize/services/replacement.ts index f4b37f7e..ffcfb5c8 100644 --- a/apps/server/src/modules/acquisition-organize/services/replacement.ts +++ b/apps/server/src/modules/acquisition-organize/services/replacement.ts @@ -500,3 +500,107 @@ export async function stagedReplacementBackup(d: AoDeps, file: ReplacementFile): } return null; } + +/** + * 收尾判定「不是视频」(ffprobe 没有视频流)的文件:已拷 / 搬进库的新文件和它的附属文件移入回收站(Move 时源已删, + * 回收站里是唯一一份),并开待处理。原地入库不动用户原有的文件;洗版替换由 settleSamePathReplacement 回滚。 + * 可重入:目标已不在、或已登记成资产、或大小对不上(不是本次写入的)就不动。 + */ +export async function quarantineNotVideo(d: AoDeps, frozen: FrozenPlan, recordId: string): Promise { + const db = d.ctx.db; + const rec = await db + .selectFrom("organization_record") + .select(["action", "download_task_id"]) + .where("id", "=", recordId) + .executeTakeFirst(); + if (!rec || rec.action === "in_place" || frozen.transfer) return; + const results = await db + .selectFrom("organization_file_result") + .select(["id", "source_path", "target_path", "status", "skip_reason", "error", "source_size_bytes", "role"]) + .where("organization_record_id", "=", recordId) + .orderBy("ordinal") + .execute(); + const planned = new Map(frozen.organize.files.map((f) => [f.src, f])); + const rejectedMains = new Set( + results + .filter((f) => f.status === "failed" && (f.error as { code?: string } | null)?.code === "NOT_VIDEO") + .map((f) => f.source_path), + ); + if (!rejectedMains.size) return; + const targets = results.filter( + (f) => + f.target_path && + (rejectedMains.has(f.source_path) || + (f.status === "skipped" && + f.skip_reason === "main_failed" && + rejectedMains.has(planned.get(f.source_path)?.mainSrc ?? ""))), + ); + const fsr = fsRuntime(d); + const moved: { path: string; trash: string }[] = []; + const left: { path: string; error: string }[] = []; + for (const f of targets) { + const pf = planned.get(f.source_path); + if (frozen.replacement && pf?.action === "replace") continue; + const rel = f.target_path!; + try { + if ((await fsr.proxy.checkRoot(frozen.rootPath, frozen.sentinel)).kind !== "online") throw new Error("库根离线"); + const rootReal = await realpath(frozen.rootPath); + const abs = join(rootReal, rel); + const st = await fsr.proxy.statSafe(abs, { root: rootReal, sentinel: frozen.sentinel }); + if (st.kind === "missing") continue; + if (st.kind !== "ok" || !st.facts.isFile || st.facts.isSymbolicLink) throw new Error("目标不是普通文件"); + const registered = await db + .selectFrom("asset_location") + .select("id") + .where("library_root_id", "=", frozen.libraryRootId) + .where("relative_path", "=", rel) + .where("status", "<>", "removed") + .executeTakeFirst(); + if (registered) continue; + const size = Number(f.source_size_bytes ?? pf?.size ?? -1); + if (size >= 0 && st.facts.size !== size) throw new Error("目标大小与本次写入的不同,保留待核对"); + const id = randomUUID(); + const now = d.ports.now(); + const trash = join(rootReal, ".umt-trash", now.toISOString().slice(0, 10).replace(/-/g, ""), id, rel); + await mkdir(dirname(trash), { recursive: true }); + const m = await fsr.proxy.moveSafe(abs, trash); + if (m.status !== "moved") throw new Error(`移入回收站失败:${m.status}`); + const days = Number(Deno.env.get("FILES_TRASH_RETENTION_DAYS") ?? 7) || 7; + await db + .insertInto("trash_item") + .values({ + id, + root_id: `lib:${frozen.libraryRootId}`, + library_root_id: frozen.libraryRootId, + root_host_path: rootReal, + original_path: rel, + trash_path: trash, + is_dir: false, + size_bytes: st.facts.size, + actor: "system:organize", + deleted_at: now, + expires_at: new Date(now.getTime() + days * 86_400_000), + asset_snapshot: jsonb({ source: "organize_not_video", recordId, sourcePath: f.source_path }), + }) + .execute(); + moved.push({ path: rel, trash }); + } catch (error) { + left.push({ path: rel, error: error instanceof Error ? error.message : String(error) }); + } + } + if (!moved.length && !left.length) return; + const names = [...rejectedMains].map((p) => basename(p)); + await upsertDecision(d, db, { + kind: "other", + title: `${rejectedMains.size} 个文件不是视频,没有入库`, + summary: `${names.slice(0, 5).join("、")}${names.length > 5 ? " 等" : ""} 没有视频流(伪装或损坏的文件)。${ + moved.length ? "已拷进媒体库的文件移入回收站" : "" + }${left.length ? `${moved.length ? "," : ""}${left.length} 个文件留在媒体库里待核对` : ""}${ + rec.action === "move" ? ";源文件已随移动删除,回收站里是唯一一份" : "" + }`, + subjectType: "organization_record", + subjectId: recordId, + dedupeKey: `organize_not_video:${recordId}`, + evidence: { downloadTaskId: rec.download_task_id, moved, left }, + }); +} diff --git a/apps/server/src/modules/acquisition-organize/services/scrape.ts b/apps/server/src/modules/acquisition-organize/services/scrape.ts index 35335ea7..2f22d634 100644 --- a/apps/server/src/modules/acquisition-organize/services/scrape.ts +++ b/apps/server/src/modules/acquisition-organize/services/scrape.ts @@ -11,10 +11,10 @@ import { type ScrapeRecord, } from "@umt/contracts"; import { - episodeNfo, fileKindOf, inferWorkDir, isSeasonDirName, + multiEpisodeNfo, parseReleasePath, planScrapeTargets, type ScrapeTarget, @@ -81,6 +81,8 @@ export interface ScrapeLocation { rootPath: string; libraryRootId: string | null; path: string; + /** 只刮这些视频(作品重刮:电影目录里属于本作品的视频);缺省 = 目录下全部 */ + videos?: readonly string[] | undefined; } export interface QueueScrapeInput extends ScrapeLocation { @@ -341,6 +343,8 @@ export async function entityScrapeLocations( .where("status", "=", "active") .execute(); const out = new Map(); + /** 电影:每个目录里属于本作品的视频(重刮只刮这些,同目录别的电影不写本作品的 NFO);有原盘目录的不限定 */ + const own = new Map(); for (const l of locs) { const bound = bindings.find( (b) => @@ -358,9 +362,26 @@ export async function entityScrapeLocations( } // 父目录已在列表里的子路径去掉,避免同一处刮两次 const all = [...out.values()]; - return all.filter( + const kept = all.filter( (x) => !all.some((y) => y !== x && y.rootId === x.rootId && !!y.path && x.path.startsWith(`${y.path}/`)), ); + if (kind !== "movie") return kept; + for (const l of locs) { + const loc = kept.find( + (x) => + x.libraryRootId === l.root_id && + (!x.path || l.relative_path === x.path || l.relative_path.startsWith(`${x.path}/`)), + ); + if (!loc) continue; + const key = `${loc.rootId}\u0000${loc.path}`; + if (l.kind !== "video") own.set(key, null); + else if (own.get(key) !== null) own.set(key, [...(own.get(key) ?? []), l.relative_path]); + } + return kept.map((x) => { + const videos = own.get(`${x.rootId}\u0000${x.path}`); + // 选中的就是视频文件本身时不用限定 + return videos?.length && !(videos.length === 1 && videos[0] === x.path) ? { ...x, videos } : x; + }); } /** 对作品的目录刮削(每个目录一条记录);没有已入库文件时返回空 */ @@ -422,7 +443,55 @@ export async function scheduleEntityRescrape(d: QueueDeps, entityId: string): Pr return out.length; } -/** 整理入库成功后(设置打开或本次要求刮削):照 MP 对作品目录刮削,集只刮本次整理的文件。 */ +/** + * 入库后自动刮削没排上:写一条 skipped 刮削记录挂在整理记录上(整理详情「整理后刮削」显示「未刮削(原因)」)。 + * 同一整理记录同一原因只记一次(崩溃重放会再调)。失败只告警。 + */ +async function recordScrapeSkipped( + d: AoDeps, + recordId: string, + reason: string, + at: { root?: { id: string; local_path: string } | undefined; path?: string; match?: ScrapeMatch | null }, +): Promise { + try { + const dup = await d.ctx.db + .selectFrom("scrape_record") + .select("id") + .where("organization_record_id", "=", recordId) + .where("status", "=", "skipped") + .where("reason", "=", reason) + .executeTakeFirst(); + if (dup) return; + const now = d.ports.now(); + await d.ctx.db + .insertInto("scrape_record") + .values({ + root_id: at.root ? `lib:${at.root.id}` : "", + root_path: at.root?.local_path ?? "", + library_root_id: at.root?.id ?? null, + path: at.path ?? "", + media_entity_id: at.match?.mediaEntityId ?? null, + match: at.match ? jsonb(at.match) : null, + status: "skipped", + trigger: "auto", + mode: (await scrapeSettings(d)).scrapeMode, + organization_record_id: recordId, + actor_type: "system", + reason, + requested_at: now, + finished_at: now, + }) + .execute(); + } catch (err) { + d.ctx.logger.warn({ recordId, reason, err: { message: errMessage(err) } }, "记录「未刮削」失败"); + } +} + +/** + * 整理入库成功后(设置打开或本次要求刮削):照 MP 对作品目录刮削,集只刮本次整理的文件。 + * 只对有 TMDB id 或本地元数据(Local)的作品排;没排上(目标不在媒体库、库根只登记、作品没有元数据、排队失败)时 + * 在整理记录上记「未刮削(原因)」;设置关闭是预期,不记。 + */ export async function scrapeAfterImport( d: AoDeps, entityId: string | null, @@ -431,23 +500,11 @@ export async function scrapeAfterImport( ): Promise { if (!entityId) return; if (opts.enabled === false) return; + let root: { id: string; local_path: string; management_mode: string } | undefined; + let match: ScrapeMatch | null = null; try { const s = await scrapeSettings(d); if (!s.scrapeAfterImport && !opts.force && opts.enabled !== true) return; - const organization = await d.ctx.db - .selectFrom("organization_record") - .select(["target_library_root_id"]) - .where("id", "=", recordId) - .executeTakeFirst(); - const match = await entityScrapeMatch(d, entityId, "library"); - const root = organization?.target_library_root_id - ? await d.ctx.db - .selectFrom("library_root") - .selectAll() - .where("id", "=", organization.target_library_root_id) - .executeTakeFirst() - : undefined; - if (!match || !root || root.management_mode !== "managed") return; const files = await d.ctx.db .selectFrom("organization_file_result") .select("target_path") @@ -456,6 +513,35 @@ export async function scrapeAfterImport( .where("status", "=", "succeeded") .orderBy("ordinal") .execute(); + // 本次没有新入库的视频(都已在库等):没什么可刮 + if (!files.some((f) => f.target_path)) return; + const organization = await d.ctx.db + .selectFrom("organization_record") + .select(["target_library_root_id"]) + .where("id", "=", recordId) + .executeTakeFirst(); + root = organization?.target_library_root_id + ? await d.ctx.db + .selectFrom("library_root") + .select(["id", "local_path", "management_mode"]) + .where("id", "=", organization.target_library_root_id) + .executeTakeFirst() + : undefined; + match = await entityScrapeMatch(d, entityId, "library"); + if (!root) return await recordScrapeSkipped(d, recordId, "scrape_not_library", { match }); + if (root.management_mode !== "managed") + return await recordScrapeSkipped(d, recordId, "scrape_root_read_only", { root, match }); + if (!match) return await recordScrapeSkipped(d, recordId, "scrape_no_metadata", { root }); + if (!match.externalId) { + // 没有 TMDB id:只有本地元数据(Local 模式补全的字段)时才刮 + const e = await d.ctx.db + .selectFrom("media_entity") + .select("metadata") + .where("id", "=", entityId) + .executeTakeFirst(); + if (!Object.keys((e?.metadata ?? {}) as object).length) + return await recordScrapeSkipped(d, recordId, "scrape_no_metadata", { root, match }); + } // 照 MP 整理后刮削:按作品目录分组,对作品目录刮(作品 / 季的 NFO 与图片照常初始化),集只刮本次整理的文件; // 视频直接在库根(推不出作品目录)时对文件本身刮。 const byDir = new Map(); @@ -464,20 +550,35 @@ export async function scrapeAfterImport( const dir = inferWorkDir(match.kind, f.target_path) ?? f.target_path; byDir.set(dir, [...(byDir.get(dir) ?? []), f.target_path]); } - for (const [dir, videos] of byDir) - await queueScrape(d, { - rootId: `lib:${root.id}`, - rootPath: root.local_path, - libraryRootId: root.id, - path: dir, - match, - trigger: "auto", - organizationRecordId: recordId, - idempotencyKey: `${SCRAPE_TASK}:record:${recordId}:${dir}`, - ...(dir === videos[0] ? {} : { videos }), - }); + for (const [dir, videos] of byDir) { + try { + await queueScrape(d, { + rootId: `lib:${root.id}`, + rootPath: root.local_path, + libraryRootId: root.id, + path: dir, + match, + trigger: "auto", + organizationRecordId: recordId, + idempotencyKey: `${SCRAPE_TASK}:record:${recordId}:${dir}`, + ...(dir === videos[0] ? {} : { videos }), + }); + } catch (err) { + // 新记录入队失败时 queueScrape 已把它标成 task_enqueue_failed(整理详情可见);这里再兜一条,防止没留下记录 + d.ctx.logger.warn({ entityId, recordId, dir, err: { message: errMessage(err) } }, "入库后自动刮削没排上"); + const marked = await d.ctx.db + .selectFrom("scrape_record") + .select("id") + .where("organization_record_id", "=", recordId) + .where("path", "=", dir) + .where("reason", "=", "task_enqueue_failed") + .executeTakeFirst(); + if (!marked) await recordScrapeSkipped(d, recordId, "task_enqueue_failed", { root, path: dir, match }); + } + } } catch (err) { d.ctx.logger.warn({ entityId, recordId, err: { message: errMessage(err) } }, "入库后自动刮削没排上"); + await recordScrapeSkipped(d, recordId, "scrape_internal_error", { root, match }); } } @@ -531,6 +632,8 @@ async function listDir(rootReal: string, rel: string) { interface Layout { videos: string[]; + /** 选中目录里的全部视频(不进子目录);判断电影目录里有没有别的作品 */ + dirVideos: string[]; workDir: string | null; parts: { work: boolean; season: boolean }; /** 季目录刮削时目录名给出的季号 */ @@ -543,18 +646,31 @@ async function layoutOf(rootReal: string, rel: string, isDir: boolean, kind: "mo const all = { work: true, season: true }; if (!isDir) return kind === "movie" - ? { videos: [rel], workDir: parent, parts: all, dirSeason: null } - : { videos: [rel], workDir: null, parts: { work: false, season: false }, dirSeason: null }; + ? { + videos: [rel], + dirVideos: (await listDir(rootReal, parent ?? "")).videos, + workDir: parent, + parts: all, + dirSeason: null, + } + : { videos: [rel], dirVideos: [], workDir: null, parts: { work: false, season: false }, dirSeason: null }; const here = await listDir(rootReal, rel); if (kind === "movie") { // 蓝光原盘目录:写 <目录名>.nfo(借一个同名的虚拟视频路径让布局按目录名命名)+ 目录海报 if (rel && here.dirs.some((n) => /^(BDMV|VIDEO_TS)$/i.test(n))) - return { videos: [joinRel(rel, `${baseOf(rel)}.bluray`)], workDir: rel, parts: all, dirSeason: null }; - return { videos: here.videos, workDir: rel || null, parts: all, dirSeason: null }; + return { + videos: [joinRel(rel, `${baseOf(rel)}.bluray`)], + dirVideos: [], + workDir: rel, + parts: all, + dirSeason: null, + }; + return { videos: here.videos, dirVideos: here.videos, workDir: rel || null, parts: all, dirSeason: null }; } if (rel && isSeasonDirName(baseOf(rel))) return { videos: here.videos, + dirVideos: here.videos, workDir: parent, parts: { work: false, season: true }, dirSeason: seasonOfDirName(baseOf(rel)), @@ -562,18 +678,26 @@ async function layoutOf(rootReal: string, rel: string, isDir: boolean, kind: "mo const videos = [...here.videos]; for (const sub of here.dirs) if (isSeasonDirName(sub)) videos.push(...(await listDir(rootReal, joinRel(rel, sub))).videos); - return { videos, workDir: rel || null, parts: all, dirSeason: null }; + return { videos, dirVideos: here.videos, workDir: rel || null, parts: all, dirSeason: null }; } -/** 已登记视频的季集(覆盖单元里最小的一集,多集文件按首集) */ +/** 已登记视频的季集:覆盖单元里最小的一集为首集,同一季覆盖的其余集一并列出(多集文件每集一个 NFO 块) */ +interface Covered { + season: number; + episode: number; + episodes: number[]; +} +/** 一个文件最多按这么多集写 NFO 块(整季合成一个文件的极端情况不至于写出巨型 NFO) */ +const MAX_EPISODES_PER_FILE = 100; + async function coverageOf( d: AoDeps, libraryRootId: string, entityId: string, videos: readonly string[], units: readonly { id: string; kind: string; season_number: number | null; episode_number: number | null }[], -): Promise> { - const out = new Map(); +): Promise> { + const out = new Map(); if (!videos.length) return out; const rows = await d.ctx.db .selectFrom("asset_location as l") @@ -586,16 +710,103 @@ async function coverageOf( .where("a.media_entity_id", "=", entityId) .execute(); const byId = new Map(units.map((u) => [u.id, u])); + const all = new Map(); for (const r of rows) { const u = byId.get(r.content_unit_id); if (u?.kind !== "episode" || u.season_number === null || u.episode_number === null) continue; - const cur = out.get(r.relative_path); - if (!cur || u.season_number < cur.season || (u.season_number === cur.season && u.episode_number < cur.episode)) - out.set(r.relative_path, { season: u.season_number, episode: u.episode_number }); + all.set(r.relative_path, [ + ...(all.get(r.relative_path) ?? []), + { season: u.season_number, episode: u.episode_number }, + ]); + } + for (const [path, list] of all) { + list.sort((a, b) => a.season - b.season || a.episode - b.episode); + const first = list[0]!; + const episodes = [...new Set(list.filter((x) => x.season === first.season).map((x) => x.episode))].slice( + 0, + MAX_EPISODES_PER_FILE, + ); + out.set(path, { season: first.season, episode: first.episode, episodes }); } return out; } +/** 集区间 → 集号列表(多集文件 E01-E02) */ +const rangeOf = (r: { start: number; end: number }): number[] => + Array.from({ length: Math.max(1, Math.min(r.end - r.start + 1, MAX_EPISODES_PER_FILE)) }, (_, i) => r.start + i); + +/** 播出日期统一成 YYYY-MM-DD(解析结果是 YYYYMMDD;单元列可能是 Date) */ +function isoDay(v: unknown): string | null { + if (v instanceof Date) return Number.isNaN(+v) ? null : v.toISOString().slice(0, 10); + if (typeof v !== "string") return null; + const m = /^(\d{4})-?(\d{2})-?(\d{2})/.exec(v.trim()); + return m ? `${m[1]}-${m[2]}-${m[3]}` : null; +} + +/** 按播出日期在集单元里找集(综艺按日期命名的期数):季已知时只看该季;多于一个候选时不猜 */ +function episodeByAirDate( + units: ReadonlyMap, + day: string, + season: number | null, +): { season: number; episode: number } | null { + const hits: { season: number; episode: number }[] = []; + for (const u of units.values()) { + if (u.season_number === null || u.episode_number === null) continue; + if (season !== null && u.season_number !== season) continue; + const m = (u.metadata ?? {}) as { airDate?: { value?: unknown } }; + if (isoDay(m.airDate?.value) === day || isoDay(u.air_date) === day) + hits.push({ season: u.season_number, episode: u.episode_number }); + } + return hits.length === 1 ? hits[0]! : null; +} + +/** 图片 MIME → 扩展名;认不出返回 null(沿用按 URL 定的扩展名) */ +function imageExtOf(mime: string | null | undefined): string | null { + const m = (mime ?? "").split(";")[0]!.trim().toLowerCase(); + return m === "image/jpeg" || m === "image/jpg" + ? ".jpg" + : m === "image/png" + ? ".png" + : m === "image/webp" + ? ".webp" + : null; +} + +/** 预期内的跳过(不影响记录状态):已存在、内容没变、被人改过 */ +const EXPECTED_SKIPS = new Set(["scrape_exists", "scrape_unchanged", "scrape_user_modified"]); +/** + * 电影目录里有没有别的作品:目录里的视频登记在别的作品下,或(只刮部分视频时)有没登记到本作品、也不在本次范围里的视频。 + * 同一作品的多个版本不算。 + */ +async function movieDirShared( + d: AoDeps, + rec: { library_root_id: string | null; videos: string[] | null }, + lay: Layout, + entityId: string | null, +): Promise { + const others = lay.dirVideos.filter((v) => !lay.videos.includes(v)); + if (!lay.dirVideos.length) return false; + const owners = new Map>(); + if (rec.library_root_id) { + const rows = await d.ctx.db + .selectFrom("asset_location as l") + .innerJoin("media_asset as a", "a.id", "l.media_asset_id") + .select(["l.relative_path", "a.media_entity_id"]) + .where("l.library_root_id", "=", rec.library_root_id) + .where("l.relative_path", "in", lay.dirVideos) + .where("l.status", "<>", "removed") + .where("a.status", "=", "active") + .execute(); + for (const r of rows) + owners.set(r.relative_path, (owners.get(r.relative_path) ?? new Set()).add(r.media_entity_id ?? "")); + } + return lay.dirVideos.some((v) => { + const own = owners.get(v); + if (own && [...own].some((e) => e !== entityId)) return true; + return !own?.size && others.includes(v); + }); +} + const sha256 = (c: Uint8Array | string) => createHash("sha256").update(c).digest("hex"); /** 旧版本写的文件没有 hash:修改时间不晚于那次刮削结束(加这点余量)就当没被改过 */ const LEGACY_MTIME_SLACK_MS = 2_000; @@ -757,7 +968,7 @@ export async function runScrapeRecord( // ── 字段:本地作品(有元数据)优先 ── let fields: Fields | null = null; - let coverage = new Map(); + let coverage = new Map(); let mapEpisodes: EntityEpisodeMapper | null = null; if (match.mediaEntityId) { const e = await db @@ -807,11 +1018,17 @@ export async function runScrapeRecord( } } + // 电影目录里还有别的作品(自定义平铺模板):图片按视频名命名,不写目录共用的 poster / fanart + const sharedDir = kind === "movie" && (await movieDirShared(d, rec, lay, match.mediaEntityId)); + // 季 / 集号:已登记的按覆盖单元;其余按路径解析(季目录名兜底,同 MP 对每集重新 MetaInfoPath) const videos: ScrapeVideo[] = []; - const noEpisode: ScrapeFileResult[] = []; + /** 集数待确认(不写分集 NFO,原因 episode_unresolved) */ + const unresolved = new Set(); + /** 只有播出日期、没有集号的视频(综艺按日期命名的期数):路径 → YYYY-MM-DD,季 / 集元数据到位后按日期对集 */ + const dated = new Map(); // 集数映射(批次 3):未登记的文件按本地剧集作品的季表 + 规则把文件名季集换成规范季集再对集元数据; - // 集数待确认的不写分集 NFO(原因 episode_unresolved) + // 综艺按播出日期对上季表里的集 for (const v of lay.videos) { if (kind === "movie") { videos.push({ path: v }); @@ -819,33 +1036,29 @@ export async function runScrapeRecord( } const cov = coverage.get(v); if (cov) { - videos.push({ path: v, season: cov.season, episode: cov.episode }); + videos.push({ path: v, season: cov.season, episode: cov.episode, episodes: cov.episodes }); continue; } const p = parseReleasePath(v); let season = p.season ?? lay.dirSeason ?? seasonOfDirName(baseOf(dirOf(v))) ?? (p.episodes ? 1 : null); - let episode = p.episodes?.start ?? null; - if (mapEpisodes && p.episodes) { + let range = p.episodes ?? null; + if (mapEpisodes && (p.episodes || p.airDate)) { const m = mapEpisodes(p); if (m.resolution.method === "unresolved") { - noEpisode.push({ - path: v, - kind: "nfo", - role: "episode_nfo", - action: "skipped", - reason: "episode_unresolved", - }); + unresolved.add(v); videos.push({ path: v, season, episode: null }); continue; } if (m.resolution.canonical) { season = m.resolution.canonical.season; - episode = m.resolution.canonical.episodes.start; + range = m.resolution.canonical.episodes; } } - if (episode === null) - noEpisode.push({ path: v, kind: "nfo", role: "episode_nfo", action: "skipped", reason: "scrape_no_episode" }); - videos.push({ path: v, season, episode }); + if (!range && p.airDate) { + const day = isoDay(p.airDate); + if (day) dated.set(v, day); + } + videos.push({ path: v, season, episode: range?.start ?? null, ...(range ? { episodes: rangeOf(range) } : {}) }); } // ── 没有本地字段:按 TMDB 条目直接取 ── @@ -868,6 +1081,21 @@ export async function runScrapeRecord( // ── 季 / 集元数据:TMDB 分支取每个涉及的季;本地分支只在季 / 集单元缺元数据时按 TMDB id 现取补齐 ── const { seasonUnit, episodeUnit } = fields; + const epsOf = (v: ScrapeVideo) => + v.episodes?.length ? v.episodes : typeof v.episode === "number" ? [v.episode] : []; + /** 只有播出日期的视频按日期对集(本地集单元或刚取到的 TMDB 季详情) */ + const resolveDated = () => { + for (const v of videos) { + const day = dated.get(v.path); + if (!day || typeof v.episode === "number") continue; + const hit = episodeByAirDate(episodeUnit, day, v.season ?? null); + if (!hit) continue; + v.season = hit.season; + v.episode = hit.episode; + v.episodes = [hit.episode]; + } + }; + resolveDated(); if (kind === "tv") { const tmdbId = match.externalId ?? fields.ids.tmdb ?? null; const seasons = [...new Set(videos.map((v) => v.season).filter((s): s is number => typeof s === "number"))]; @@ -877,8 +1105,8 @@ export async function runScrapeRecord( videos.some( (v) => v.season === n && - typeof v.episode === "number" && - episodeMetadataMissing(episodeUnit.get(`${n}:${v.episode}`)), + (epsOf(v).some((ep) => episodeMetadataMissing(episodeUnit.get(`${n}:${ep}`))) || + (typeof v.episode !== "number" && dated.has(v.path))), ); if (!tmdbId || (!fromTmdb && !lacking)) continue; const sd = await d.ports.scrapeTmdbSeason(tmdbId, n).catch((err) => { @@ -894,6 +1122,17 @@ export async function runScrapeRecord( if (fromTmdb || episodeMetadataMissing(episodeUnit.get(k))) episodeUnit.set(k, eu); } } + resolveDated(); + for (const v of videos) { + if (typeof v.episode === "number") continue; + files.push({ + path: v.path, + kind: "nfo", + role: "episode_nfo", + action: "skipped", + reason: unresolved.has(v.path) ? "episode_unresolved" : "scrape_no_episode", + }); + } } const work = nfoWork(fields.entity, fields.ids); const seasonPosters = new Map(); @@ -908,15 +1147,15 @@ export async function runScrapeRecord( images: { ...imagePathsOf(fields.entity.metadata), seasonPosters, stills }, options: { images: settings.scrapeImages, episodeThumbs: settings.scrapeEpisodeThumbs }, parts: lay.parts, + sharedDir, }); - files.push(...noEpisode); // 「更新」模式:本工具以前写过的文件(路径 → 写入时的 hash / 时间) const ours = mode === "update" ? await previousWrites(d, rec.root_id, recordId) : new Map(); - const imageCache = new Map>(); + const imageCache = new Map>(); const fetchImage = (p: string) => { let hit = imageCache.get(p); if (!hit) { - hit = d.ports.scrapeImage(p).then((r) => r.content); + hit = d.ports.scrapeImage(p); imageCache.set(p, hit); } return hit; @@ -978,40 +1217,53 @@ export async function runScrapeRecord( } xml = seasonNfo(nfoSeason(t.season!, u)); } else { - const u = episodeUnit.get(`${t.season}:${t.episode}`); - if (episodeMetadataMissing(u) && hasTmdbId) { + // 多集文件每集一块;缺元数据的集不写(全缺才跳过整个 NFO) + const eps = (t.episodes?.length ? t.episodes : [t.episode!]).filter( + (ep) => !(hasTmdbId && episodeMetadataMissing(episodeUnit.get(`${t.season}:${ep}`))), + ); + if (!eps.length) { files.push(missingMeta(t)); continue; } - xml = episodeNfo(nfoEpisode(t.season!, t.episode!, u)); + xml = multiEpisodeNfo(eps.map((ep) => nfoEpisode(t.season!, ep, episodeUnit.get(`${t.season}:${ep}`)))); } files.push(await write(t, dec, xml)); } // 图片:限并发,失败不影响 NFO const imgs = targets.filter((x) => x.kind === "image" && x.imagePath); files.push( - ...(await pool(imgs, IMAGE_CONCURRENCY, async (t): Promise => { + ...(await pool(imgs, IMAGE_CONCURRENCY, async (planned0): Promise => { if (signal?.aborted) throw new Error("aborted"); - const dec = await decide(t); + let t = planned0; + let dec = await decide(t); if (!("abs" in dec)) return dec; - let bytes: Uint8Array; + let got: { content: Uint8Array; mime: string }; try { - bytes = await fetchImage(t.imagePath!); + got = await fetchImage(t.imagePath!); } catch (err) { d.ctx.logger.warn({ recordId, image: t.imagePath, err: { message: errMessage(err) } }, "刮削图片下载失败"); return { path: t.path, kind: t.kind, role: t.role, action: "failed", reason: "scrape_image_failed" }; } - return write(t, dec, bytes); + // 扩展名按实际类型(URL 认不出或与内容不符时,例如 Local 封面链接是 webp、内容是 jpeg) + const ext = imageExtOf(got.mime); + const planned = /\.(jpe?g|png|webp)$/i.exec(t.path)?.[0]?.toLowerCase(); + if (ext && planned && ext !== planned && !(ext === ".jpg" && planned === ".jpeg")) { + t = { ...t, path: t.path.slice(0, -planned.length) + ext }; + dec = await decide(t); + if (!("abs" in dec)) return dec; + } + return write(t, dec, got.content); })), ); const written = files.filter((f) => f.action === "written").length; const failed = files.filter((f) => f.action === "failed").length; - // 缺元数据跳过的季 / 集不算成功(要刷新元数据后再刮) - const lacking = files.some((f) => f.reason === "scrape_metadata_missing"); + // 缺元数据、认不出集号、集数待确认而跳过的不算成功(要刷新元数据 / 确认集数后再刮); + // 已存在、内容没变、被人改过的跳过是预期的 + const incomplete = files.find((f) => f.action === "skipped" && !EXPECTED_SKIPS.has(f.reason ?? "")); const status: ScrapeRecord["status"] = failed === 0 - ? lacking + ? incomplete ? "partial" : "succeeded" : written > 0 || files.some((f) => f.action === "skipped") @@ -1021,13 +1273,17 @@ export async function runScrapeRecord( status, reason: failed ? (files.find((f) => f.action === "failed")?.reason ?? null) - : lacking + : files.some((f) => f.reason === "scrape_metadata_missing") ? "scrape_metadata_missing" - : null, + : (incomplete?.reason ?? null), files, }); } catch (err) { - if (signal?.aborted) throw err; + if (signal?.aborted) { + // 任务被取消(引擎不会再跑这一步):记录收尾为失败,已写的文件结果照留,不停在进行中 + await finish({ status: "failed", reason: "scrape_canceled", files }).catch(() => {}); + throw err; + } d.ctx.logger.warn({ recordId, err: { message: errMessage(err) } }, "刮削失败"); // 不是写文件的错(如查库失败):记内部错误,已写的文件结果照留 const anyWritten = files.some((f) => f.action === "written"); diff --git a/apps/server/src/modules/file-manager/trash.ts b/apps/server/src/modules/file-manager/trash.ts index 2806eb17..e330e0fb 100644 --- a/apps/server/src/modules/file-manager/trash.ts +++ b/apps/server/src/modules/file-manager/trash.ts @@ -584,7 +584,10 @@ export async function restoreTrash( return { restored, failed, recordId }; } -/** 清除:先删行(复核仍存在)再删文件;ids 缺省 = 只清过期项 */ +/** + * 清除:先删文件再删行(删文件失败时行留着,下次再清,不留没有登记的孤儿文件);ids 缺省 = 只清过期项, + * 并顺带清掉各库根 `.umt-trash/replaced-*` 里过期、没有回收站登记的旧文件(替换旧文件登记回收站失败时留下的)。 + */ export async function purgeTrash( d: FmDeps, principal: Principal | undefined, @@ -598,18 +601,51 @@ export async function purgeTrash( for (const row of rows) { try { if (!trashPathSafe(d, row)) throw new FilesError("path_escape", "回收站路径异常,拒绝清除"); - const gone = await d.ctx.db.deleteFrom("trash_item").where("id", "=", row.id).returning("id").executeTakeFirst(); - if (!gone) continue; await rm(row.trash_path, { recursive: true, force: true }); // 顺带清掉空的 / 目录 const uuidDir = row.trash_path.slice(0, row.trash_path.length - row.original_path.length - 1); await rm(uuidDir, { recursive: true, force: true }).catch(() => undefined); - purged.push(row.id); + const gone = await d.ctx.db.deleteFrom("trash_item").where("id", "=", row.id).returning("id").executeTakeFirst(); + if (gone) purged.push(row.id); } catch (e) { failed.push({ id: row.id, code: e instanceof FilesError ? e.reason : "io_error", message: errMessage(e) }); } } - if (purged.length || failed.length) - await audit(d, principal, "trash_purge", { purged, failed, expiredOnly: !body.ids }); + const orphans = body.ids ? [] : await purgeReplacedOrphans(d); + if (purged.length || failed.length || orphans.length) + await audit(d, principal, "trash_purge", { purged, failed, orphans, expiredOnly: !body.ids }); return { purged, failed }; } + +/** 各启用库根 `.umt-trash/replaced-*`:修改时间早于保留期、里面没有任何回收站登记的,整个删掉;返回删掉的目录 */ +async function purgeReplacedOrphans(d: FmDeps): Promise { + const out: string[] = []; + const cutoff = d.ao.ports.now().getTime() - d.options.trashRetentionDays * 86_400_000; + const roots = await d.ctx.db + .selectFrom("library_root") + .select(["local_path"]) + .where("enabled", "=", true) + .where("management_mode", "=", "managed") + .execute(); + for (const r of roots) { + const rootReal = await realpath(r.local_path).catch(() => null); + if (!rootReal) continue; + const bin = join(rootReal, TRASH_DIR); + for (const name of await readdir(bin).catch(() => [] as string[])) { + if (!name.startsWith("replaced-")) continue; + const dir = join(bin, name); + const st = await lstat(dir).catch(() => null); + if (!st?.isDirectory() || st.mtimeMs > cutoff) continue; + const held = await d.ctx.db + .selectFrom("trash_item") + .select("id") + .where("trash_path", "like", likePrefix(dir)) + .executeTakeFirst(); + if (held) continue; + await rm(dir, { recursive: true, force: true }) + .then(() => out.push(dir)) + .catch(() => undefined); + } + } + return out; +} diff --git a/apps/server/test/acquisition-organize/organize-asset-facts.test.ts b/apps/server/test/acquisition-organize/organize-asset-facts.test.ts new file mode 100644 index 00000000..539fa2c2 --- /dev/null +++ b/apps/server/test/acquisition-organize/organize-asset-facts.test.ts @@ -0,0 +1,95 @@ +import { rm } from "node:fs/promises"; +import { createTempTree, type TempTree } from "@umt/testkit"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts"; +import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts"; + +// 整理登记资产时同一路径已有位置: +// - 非洗版「替换」(向导 / 待处理选替换 = replace_with_verify)退役旧位置并新建资产,size / hash / 版本信息是新文件的; +// - 复用之前判为缺失的位置(同名文件重新入库)时,位置恢复可用,资产事实换成这次的文件。 + +const hasPg = await pgAvailable(); +const OLD = "Film.2020.1080p.BluRay.x264-AAA.mkv"; +const NEW = "Film.2020.1080p.WEB-DL.x264-BBB.mkv"; + +describe.skipIf(!hasPg)("整理:替换与缺失位置复用时资产事实跟着新文件", () => { + let h: Harness; + let lib: TempTree; + let src: TempTree; + let entity = ""; + + beforeAll(async () => { + lib = await createTempTree({}, { sentinel: true, prefix: "umt-aflib-" }); + src = await createTempTree({ [OLD]: 200_000, [NEW]: 300_000 }, { prefix: "umt-afsrc-" }); + h = await createHarness("asset_facts"); + await seedBase(h.db, { libraryPath: lib.root, cachePath: src.root, qbUrl: "http://127.0.0.1:9" }); + await h.setSettings("organize", { scrapeAfterImport: false }); + entity = await seedEntity(h.db, { kind: "movie", title: "替换片", year: 2020 }); + }, 60_000); + afterAll(async () => { + await h?.close(); + for (const t of [lib, src]) await t?.cleanup(); + }); + + async function organize(file: string, extra: Record = {}) { + const plan = await planImport(h.d, { + sourcePaths: [src.path(file)], + mediaEntityId: entity, + action: "copy", + trigger: "manual", + actorType: "user", + ...extra, + }); + expect(plan.status).toBe("running"); + expect((await executeRecord(h.d, plan.recordId!)).status).toBe("succeeded"); + return h.db + .selectFrom("organization_file_result") + .select(["media_asset_id", "target_path"]) + .where("organization_record_id", "=", plan.recordId!) + .where("role", "=", "main") + .executeTakeFirstOrThrow(); + } + const asset = (id: string) => + h.db.selectFrom("media_asset").selectAll().where("id", "=", id).executeTakeFirstOrThrow(); + const locations = (id: string) => + h.db.selectFrom("asset_location").select(["status", "size_bytes"]).where("media_asset_id", "=", id).execute(); + + let newAsset = ""; + let target = ""; + + it("非洗版替换:退役旧位置与旧资产,新建资产记新文件的大小与版本", async () => { + const first = await organize(OLD); + const second = await organize(NEW, { onExisting: "replace_with_verify", grantReplace: true }); + expect(second.target_path).toBe(first.target_path); + expect(second.media_asset_id).not.toBe(first.media_asset_id); + newAsset = second.media_asset_id!; + target = second.target_path!; + + expect((await asset(first.media_asset_id!)).status).toBe("retired"); + expect(await locations(first.media_asset_id!)).toEqual([{ status: "removed", size_bytes: expect.anything() }]); + const a = await asset(newAsset); + expect(a).toMatchObject({ status: "active", media_entity_id: entity }); + expect(Number(a.size_bytes)).toBe(300_000); + expect(JSON.stringify(a.delivery)).toContain("BBB"); + expect((await locations(newAsset)).map((l) => l.status)).toEqual(["available"]); + }); + + it("复用判为缺失的位置:位置恢复可用,资产恢复 active 且事实换成新文件", async () => { + await h.db + .updateTable("asset_location") + .set({ status: "confirmed_missing" }) + .where("media_asset_id", "=", newAsset) + .execute(); + await h.db.updateTable("media_asset").set({ status: "missing" }).where("id", "=", newAsset).execute(); + await rm(lib.path(target)); + const again = await organize(OLD); + expect(again.media_asset_id).toBe(newAsset); + const a = await asset(newAsset); + expect(a.status).toBe("active"); + expect(Number(a.size_bytes)).toBe(200_000); + expect(JSON.stringify(a.delivery)).toContain("AAA"); + const [loc] = await locations(newAsset); + expect(loc).toMatchObject({ status: "available" }); + expect(Number(loc!.size_bytes)).toBe(200_000); + }); +}); diff --git a/apps/server/test/acquisition-organize/organize-not-video.test.ts b/apps/server/test/acquisition-organize/organize-not-video.test.ts new file mode 100644 index 00000000..ccccb370 --- /dev/null +++ b/apps/server/test/acquisition-organize/organize-not-video.test.ts @@ -0,0 +1,85 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { createTempTree, type TempTree } from "@umt/testkit"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts"; +import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts"; + +// 收尾时 ffprobe 判定「不是视频」:已拷 / 搬进库的新文件与它的附属文件移入回收站(不留未登记文件), +// 附属文件结果改为跳过(main_failed),开待处理;重放不重复处理。 + +const hasPg = await pgAvailable(); +const FAKE = "Fake.2020.1080p.BluRay.x264-GRP.mkv"; +const SUB = "Fake.2020.1080p.BluRay.x264-GRP.zh.srt"; + +describe.skipIf(!hasPg)("整理:不是视频的文件不留在媒体库", () => { + let h: Harness; + let lib: TempTree; + let src: TempTree; + + beforeAll(async () => { + lib = await createTempTree({}, { sentinel: true, prefix: "umt-nvlib-" }); + src = await createTempTree( + { [FAKE]: 200_000, [SUB]: "1\n00:00:01,000 --> 00:00:02,000\n字幕\n" }, + { prefix: "umt-nvsrc-" }, + ); + h = await createHarness("not_video", { + probe: async (p: string) => + p.endsWith(".mkv") + ? ({ status: "ok", video: [], audio: [], subtitle: [] } as never) + : ({ status: "unknown" } as never), + }); + await seedBase(h.db, { libraryPath: lib.root, cachePath: src.root, qbUrl: "http://127.0.0.1:9" }); + }, 60_000); + afterAll(async () => { + await h?.close(); + for (const t of [lib, src]) await t?.cleanup(); + }); + + it("Move:新文件与字幕移入回收站,不登记资产,开待处理;重放不重复", async () => { + const entity = await seedEntity(h.db, { kind: "movie", title: "伪装片", year: 2020 }); + const plan = await planImport(h.d, { + sourcePaths: [src.path(FAKE)], + mediaEntityId: entity, + action: "move", + trigger: "manual", + actorType: "user", + }); + expect(plan.status).toBe("running"); + const ex = await executeRecord(h.d, plan.recordId!); + expect(ex.status).toBe("failed"); + + const files = await h.db + .selectFrom("organization_file_result") + .select(["role", "status", "skip_reason", "target_path", "error", "media_asset_id"]) + .where("organization_record_id", "=", plan.recordId!) + .orderBy("ordinal") + .execute(); + const main = files.find((f) => f.role === "main")!; + const sub = files.find((f) => f.role === "subtitle")!; + expect(main).toMatchObject({ status: "failed", media_asset_id: null }); + expect((main.error as { code: string }).code).toBe("NOT_VIDEO"); + expect(sub).toMatchObject({ status: "skipped", skip_reason: "main_failed", media_asset_id: null }); + + // 库里不留文件,回收站里各一份(Move 时源已删,回收站里是唯一一份) + expect(existsSync(join(lib.root, main.target_path!))).toBe(false); + expect(existsSync(join(lib.root, sub.target_path!))).toBe(false); + expect(existsSync(src.path(FAKE))).toBe(false); + const trash = await h.db.selectFrom("trash_item").select(["original_path", "trash_path"]).execute(); + expect(trash.map((t) => t.original_path).sort()).toEqual([main.target_path, sub.target_path].sort()); + for (const t of trash) expect(existsSync(t.trash_path)).toBe(true); + expect(await h.db.selectFrom("media_asset").select("id").where("media_entity_id", "=", entity).execute()).toEqual( + [], + ); + const decisions = await h.db + .selectFrom("decision") + .select(["title", "status"]) + .where("dedupe_key", "=", `organize_not_video:${plan.recordId}`) + .execute(); + expect(decisions).toEqual([{ title: "1 个文件不是视频,没有入库", status: "open" }]); + + // 重放:已结束记录不重复移动 + await executeRecord(h.d, plan.recordId!); + expect(await h.db.selectFrom("trash_item").select("id").execute()).toHaveLength(2); + }); +}); diff --git a/apps/server/test/acquisition-organize/organize-season-inference.test.ts b/apps/server/test/acquisition-organize/organize-season-inference.test.ts new file mode 100644 index 00000000..44085d21 --- /dev/null +++ b/apps/server/test/acquisition-organize/organize-season-inference.test.ts @@ -0,0 +1,51 @@ +import { createTempTree, type TempTree } from "@umt/testkit"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { planImport } from "../../src/modules/acquisition-organize/services/organize.ts"; +import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts"; + +// 季号推断(审视 ④ 风险 9):解析器对有集号没季号的文件已推定第 1 季(seasonInferred),服务端与域层用同一个值; +// 带季号与不带季号的文件混在一起时,不带季号的不会因为「季目录不唯一」被静默排除(回归用例)。 + +const hasPg = await pgAvailable(); +const SIZE = 200_000; + +describe.skipIf(!hasPg)("整理:季号推断收敛到一处", () => { + let h: Harness; + let lib: TempTree; + let src: TempTree; + + beforeAll(async () => { + lib = await createTempTree({}, { sentinel: true, prefix: "umt-silib-" }); + src = await createTempTree( + { + "Pack/Show.S02E01.1080p.WEB-DL.mkv": SIZE, + "Pack/Show.E05.1080p.WEB-DL.mkv": SIZE, + }, + { prefix: "umt-sisrc-" }, + ); + h = await createHarness("season_inference"); + await seedBase(h.db, { libraryPath: lib.root, cachePath: src.root, qbUrl: "http://127.0.0.1:9" }); + }, 60_000); + afterAll(async () => { + await h?.close(); + for (const t of [lib, src]) await t?.cleanup(); + }); + + it("没季号的集按第 1 季入库,不以 no_target_dir 排除", async () => { + const entity = await seedEntity(h.db, { kind: "series", title: "剧", year: 2020 }); + const plan = await planImport(h.d, { + sourcePaths: [src.path("Pack")], + mediaEntityId: entity, + action: "copy", + trigger: "manual", + actorType: "user", + dryRun: true, + }); + const o = plan.plan!.organize; + expect(o.excluded.filter((x) => x.reason === "no_target_dir")).toEqual([]); + const e05 = o.files.find((f) => f.src.endsWith("Show.E05.1080p.WEB-DL.mkv"))!; + expect(e05).toMatchObject({ season: 1, episodes: { start: 5, end: 5 } }); + expect(e05.dst).toContain("Season 1"); + expect(o.files.find((f) => f.src.endsWith("S02E01.1080p.WEB-DL.mkv"))!.dst).toContain("Season 2"); + }); +}); diff --git a/apps/server/test/acquisition-organize/organize-sha-cache.test.ts b/apps/server/test/acquisition-organize/organize-sha-cache.test.ts new file mode 100644 index 00000000..b5f0ec3d --- /dev/null +++ b/apps/server/test/acquisition-organize/organize-sha-cache.test.ts @@ -0,0 +1,38 @@ +import { createHash } from "node:crypto"; +import { mkdtemp, rm, stat, utimes, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, describe, expect, it } from "vitest"; +import { cachedSha256 } from "../../src/modules/acquisition-organize/services/organize-plan.ts"; + +// 计划期全量 sha256 按 (路径, 大小, 修改时间, inode) 缓存:同一文件没变时不重算,变了就重算。 + +const sha = (s: string) => createHash("sha256").update(s).digest("hex"); + +describe("计划期 sha256 缓存", () => { + let dir = ""; + afterAll(async () => { + if (dir) await rm(dir, { recursive: true, force: true }); + }); + + it("事实不变命中缓存;修改时间变了重算", async () => { + dir = await mkdtemp(join(tmpdir(), "umt-shacache-")); + const p = join(dir, "a.mkv"); + await writeFile(p, "AAAA"); + const t = new Date("2024-01-01T00:00:00Z"); + await utimes(p, t, t); + const facts = async () => { + const s = await stat(p); + return { size: s.size, mtimeMs: s.mtimeMs, ino: s.ino }; + }; + expect(await cachedSha256(p, await facts())).toBe(sha("AAAA")); + // 同大小、同修改时间、同 inode 的改写:按缓存(这正是缓存键的语义) + await writeFile(p, "BBBB"); + await utimes(p, t, t); + expect(await cachedSha256(p, await facts())).toBe(sha("AAAA")); + // 修改时间变了:重算 + const t2 = new Date("2024-01-02T00:00:00Z"); + await utimes(p, t2, t2); + expect(await cachedSha256(p, await facts())).toBe(sha("BBBB")); + }); +}); diff --git a/apps/server/test/acquisition-organize/organize-title-check.test.ts b/apps/server/test/acquisition-organize/organize-title-check.test.ts new file mode 100644 index 00000000..83f3876d --- /dev/null +++ b/apps/server/test/acquisition-organize/organize-title-check.test.ts @@ -0,0 +1,84 @@ +import { existsSync } from "node:fs"; +import { createTempTree, type TempTree } from "@umt/testkit"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts"; +import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts"; + +// 文件级标题粗核:合集 / 混合包里标题明显不是本作品的视频不按本作品命名入库, +// 降为待确认(排除原因 title_mismatch),记录部分完成并开待处理;用户逐文件指定过的不核。 + +const hasPg = await pgAvailable(); +const SIZE = 200_000; +const PACK = "Nolan.Pack"; + +describe.skipIf(!hasPg)("整理:文件标题明显不是本作品时降为待确认", () => { + let h: Harness; + let lib: TempTree; + let src: TempTree; + let entity = ""; + + beforeAll(async () => { + lib = await createTempTree({}, { sentinel: true, prefix: "umt-tclib-" }); + src = await createTempTree( + { + [`${PACK}/Inception.2010.1080p.BluRay.x264-GRP.mkv`]: SIZE, + [`${PACK}/Interstellar.2014.1080p.BluRay.x264-GRP.mkv`]: SIZE, + }, + { prefix: "umt-tcsrc-" }, + ); + h = await createHarness("title_check"); + await seedBase(h.db, { libraryPath: lib.root, cachePath: src.root, qbUrl: "http://127.0.0.1:9" }); + await h.setSettings("organize", { scrapeAfterImport: false }); + entity = await seedEntity(h.db, { kind: "movie", title: "盗梦空间", year: 2010 }); + await h.db.updateTable("media_entity").set({ original_title: "Inception" }).where("id", "=", entity).execute(); + }, 60_000); + afterAll(async () => { + await h?.close(); + for (const t of [lib, src]) await t?.cleanup(); + }); + + it("合集里的别的电影:排除 title_mismatch,不入库,记录部分完成并开待处理", async () => { + const plan = await planImport(h.d, { + sourcePaths: [src.path(PACK)], + mediaEntityId: entity, + action: "copy", + trigger: "auto", + actorType: "system", + }); + expect(plan.status).toBe("running"); + const ex = await executeRecord(h.d, plan.recordId!); + expect(ex.status).toBe("partial"); + const files = await h.db + .selectFrom("organization_file_result") + .select(["source_path", "status", "skip_reason", "target_path"]) + .where("organization_record_id", "=", plan.recordId!) + .execute(); + const other = files.find((f) => f.source_path.includes("Interstellar"))!; + expect(other).toMatchObject({ status: "skipped", skip_reason: "excluded:title_mismatch", target_path: null }); + const mine = files.find((f) => f.source_path.includes("Inception"))!; + expect(mine.status).toBe("succeeded"); + expect(existsSync(src.path(`${PACK}/Interstellar.2014.1080p.BluRay.x264-GRP.mkv`))).toBe(true); + const decision = await h.db + .selectFrom("decision") + .select(["summary", "status"]) + .where("dedupe_key", "=", `organize_excluded:${plan.recordId}`) + .executeTakeFirstOrThrow(); + expect(decision.status).toBe("open"); + expect(decision.summary).toContain("标题不是本作品"); + }); + + it("用户逐文件指定过的不核", async () => { + const plan = await planImport(h.d, { + sourcePaths: [src.path(PACK)], + mediaEntityId: entity, + action: "copy", + trigger: "manual", + actorType: "user", + dryRun: true, + fileOverrides: { + [src.path(`${PACK}/Interstellar.2014.1080p.BluRay.x264-GRP.mkv`)]: { contentUnitIds: [] }, + }, + }); + expect(plan.plan!.organize.excluded.some((x) => x.reason === "title_mismatch")).toBe(false); + }); +}); diff --git a/apps/server/test/acquisition-organize/scrape-after-import.test.ts b/apps/server/test/acquisition-organize/scrape-after-import.test.ts new file mode 100644 index 00000000..683f19ae --- /dev/null +++ b/apps/server/test/acquisition-organize/scrape-after-import.test.ts @@ -0,0 +1,125 @@ +import { createTempTree, type TempTree } from "@umt/testkit"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts"; +import { SCRAPE_TASK, scrapeAfterImport } from "../../src/modules/acquisition-organize/services/scrape.ts"; +import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts"; + +// 入库后自动刮削(缺省开):只对有 TMDB id 或本地元数据的作品排;没排上(库根只登记、作品没有元数据、排队失败)时 +// 在整理记录上留一条「未刮削(原因)」的刮削记录;设置关闭是预期,不留。 + +const hasPg = await pgAvailable(); +const SIZE = 200_000; + +describe.skipIf(!hasPg)("入库后自动刮削:缺省开启,没排上时整理记录上可见", () => { + let h: Harness; + let lib: TempTree; + let src: TempTree; + let rootId = ""; + + beforeAll(async () => { + lib = await createTempTree({}, { sentinel: true, prefix: "umt-sailib-" }); + src = await createTempTree( + { + "Tenet.2020.1080p.BluRay.x264-GRP.mkv": SIZE, + "Dune.2021.1080p.BluRay.x264-GRP.mkv": SIZE, + "Arrival.2016.1080p.BluRay.x264-GRP.mkv": SIZE, + "Heat.1995.1080p.BluRay.x264-GRP.mkv": SIZE, + }, + { prefix: "umt-saisrc-" }, + ); + h = await createHarness("scrape_after_import"); + rootId = (await seedBase(h.db, { libraryPath: lib.root, cachePath: src.root, qbUrl: "http://127.0.0.1:9" })).rootId; + }, 60_000); + afterAll(async () => { + await h?.close(); + for (const t of [lib, src]) await t?.cleanup(); + }); + + async function movie(title: string, opts: { tmdb?: string; metadata?: object } = {}) { + const id = await seedEntity(h.db, { kind: "movie", title, year: 2020 }); + if (opts.metadata) + await h.db + .updateTable("media_entity") + .set({ metadata: JSON.stringify(opts.metadata) }) + .where("id", "=", id) + .execute(); + if (opts.tmdb) + await h.db + .insertInto("external_identity") + .values({ + media_entity_id: id, + provider: "tmdb", + entity_type: "movie", + external_id: opts.tmdb, + status: "accepted", + }) + .execute(); + return id; + } + async function organize(entity: string, file: string) { + const plan = await planImport(h.d, { + sourcePaths: [src.path(file)], + mediaEntityId: entity, + action: "copy", + trigger: "manual", + actorType: "user", + }); + const ex = await executeRecord(h.d, plan.recordId!); + expect(ex.status).toBe("succeeded"); + return plan.recordId!; + } + const recordsOf = (orgId: string) => + h.db + .selectFrom("scrape_record") + .select(["status", "reason", "trigger"]) + .where("organization_record_id", "=", orgId) + .execute(); + + it("缺省设置:有 TMDB id 的作品入库后排上刮削", async () => { + const id = await movie("信条", { tmdb: "577922" }); + const org = await organize(id, "Tenet.2020.1080p.BluRay.x264-GRP.mkv"); + expect(await recordsOf(org)).toEqual([{ status: "queued", reason: null, trigger: "auto" }]); + expect(h.engine.enqueued.some((x) => x.task === SCRAPE_TASK)).toBe(true); + }); + + it("没有 TMDB id 也没有本地元数据:不排任务,记「未刮削」scrape_no_metadata;重放不重复记", async () => { + const before = h.engine.enqueued.filter((x) => x.task === SCRAPE_TASK).length; + const id = await movie("沙丘"); + const org = await organize(id, "Dune.2021.1080p.BluRay.x264-GRP.mkv"); + expect(await recordsOf(org)).toEqual([{ status: "skipped", reason: "scrape_no_metadata", trigger: "auto" }]); + expect(h.engine.enqueued.filter((x) => x.task === SCRAPE_TASK).length).toBe(before); + await executeRecord(h.d, org); + expect(await recordsOf(org)).toHaveLength(1); + }); + + it("库根是只登记模式:记 scrape_root_read_only;排队失败:记 task_enqueue_failed", async () => { + const id = await movie("降临", { tmdb: "329865" }); + const org = await organize(id, "Arrival.2016.1080p.BluRay.x264-GRP.mkv"); + await h.db.deleteFrom("scrape_record").where("organization_record_id", "=", org).execute(); + await h.db.updateTable("library_root").set({ management_mode: "register_only" }).where("id", "=", rootId).execute(); + try { + await scrapeAfterImport(h.d, id, org); + expect(await recordsOf(org)).toEqual([{ status: "skipped", reason: "scrape_root_read_only", trigger: "auto" }]); + } finally { + await h.db.updateTable("library_root").set({ management_mode: "managed" }).where("id", "=", rootId).execute(); + } + await h.db.deleteFrom("scrape_record").where("organization_record_id", "=", org).execute(); + const enqueue = h.d.ctx.engine.enqueue; + h.d.ctx.engine.enqueue = (async () => { + throw new Error("引擎不可用"); + }) as never; + try { + await scrapeAfterImport(h.d, id, org); + } finally { + h.d.ctx.engine.enqueue = enqueue; + } + expect(await recordsOf(org)).toEqual([{ status: "failed", reason: "task_enqueue_failed", trigger: "auto" }]); + }); + + it("设置关闭:不排也不记", async () => { + await h.setSettings("organize", { scrapeAfterImport: false }); + const id = await movie("盗火线", { tmdb: "949" }); + const org = await organize(id, "Heat.1995.1080p.BluRay.x264-GRP.mkv"); + expect(await recordsOf(org)).toEqual([]); + }); +}); diff --git a/apps/server/test/acquisition-organize/scrape-episodes.test.ts b/apps/server/test/acquisition-organize/scrape-episodes.test.ts new file mode 100644 index 00000000..1be7bc5b --- /dev/null +++ b/apps/server/test/acquisition-organize/scrape-episodes.test.ts @@ -0,0 +1,377 @@ +import { readFile, stat } from "node:fs/promises"; +import type { TmdbRichSeasonDetail } from "@umt/adapters"; +import type { ScrapeFileResult, ScrapeMatch } from "@umt/contracts"; +import { createTempTree, type TempTree } from "@umt/testkit"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { queueScrape, runScrapeRecord, scrapeEntity } from "../../src/modules/acquisition-organize/services/scrape.ts"; +import { createHarness, type Harness, pgAvailable, seedBase } from "./harness.ts"; + +// 刮削的集与布局: +// - 多集文件(文件名 E01-E02 / 覆盖多个集单元)按 Kodi 约定在一个 NFO 里写多个 ; +// - 综艺按播出日期命名的期数按日期对上集(本地集单元或 TMDB 季详情)写分集 NFO; +// - 作品重刮只刮该作品的视频;同一目录里有别的作品时电影图片按 <视频名>-poster 命名; +// - 认不出集号的跳过算部分完成;图片扩展名按实际类型;取消时记录不停在进行中。 + +const hasPg = await pgAvailable(); +const at = new Date().toISOString(); +const f = (value: unknown) => ({ value, source: "tmdb", language: "zh-CN", at, locked: false }); +const enc = (s: string) => new TextEncoder().encode(s); +const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +function seasonOf(n: number, eps: { ep: number; name: string; airDate: string }[]): TmdbRichSeasonDetail { + return { + tvId: "777", + seasonNumber: n, + name: `第 ${n} 季`, + nameLanguage: "zh-CN", + overview: null, + overviewLanguage: null, + airDate: eps[0]?.airDate ?? null, + posterPath: null, + fetchedLanguages: ["zh-CN"], + episodes: eps.map((e) => ({ + seasonNumber: n, + episodeNumber: e.ep, + name: e.name, + nameLanguage: "zh-CN", + overview: `${e.name}简介`, + overviewLanguage: "zh-CN", + airDate: e.airDate, + runtime: null, + stillPath: null, + voteAverage: null, + voteCount: null, + })), + }; +} + +describe.skipIf(!hasPg)("刮削:多集文件、综艺日期期数、平铺目录、状态", () => { + let h: Harness; + let lib: TempTree; + let rootId = ""; + let seasonDetail: (n: number) => Promise = async () => null; + let imageMime = "image/jpeg"; + + beforeAll(async () => { + lib = await createTempTree( + { + "TV/双集剧 (2020)/Season 1/Double.S01E01-E02.mkv": 100, + "TV/双集剧 (2020)/Season 1/Double.S01E03.mkv": 100, + "TV/综艺 (2024)/Season 1/综艺 - 2024-05-03.mkv": 100, + "TV/综艺 (2024)/Season 1/综艺 - 2024-05-10.mkv": 100, + "TV/综艺 (2024)/Season 1/综艺 - 2024-06-01.mkv": 100, + "Movies/盗梦空间 (2010).mkv": 100, + "Movies/星际穿越 (2014).mkv": 100, + "Single/阿凡达 (2009)/阿凡达 (2009).mkv": 100, + }, + { sentinel: true, prefix: "umt-screps-" }, + ); + h = await createHarness("scrape_eps", { + scrapeImage: async (p) => ({ content: imageMime === "image/png" ? PNG : enc(p), mime: imageMime }), + scrapeTmdbSeason: async (_tv, n) => seasonDetail(n), + }); + rootId = (await seedBase(h.db, { libraryPath: lib.root, cachePath: lib.root, qbUrl: "http://127.0.0.1:9" })).rootId; + }, 60_000); + afterAll(async () => { + await h?.close(); + await lib?.cleanup(); + }); + + async function entity(kind: string, title: string, meta: object, tmdb?: { id: string; type: "movie" | "tv" }) { + const id = ( + await h.db + .insertInto("media_entity") + .values({ kind: kind as never, title, year: 2020, status: "active", metadata: JSON.stringify(meta) }) + .returning("id") + .executeTakeFirstOrThrow() + ).id; + if (tmdb) + await h.db + .insertInto("external_identity") + .values({ + media_entity_id: id, + provider: "tmdb", + entity_type: tmdb.type, + external_id: tmdb.id, + status: "accepted", + }) + .execute(); + return id; + } + async function unit(entityId: string, season: number, ep: number | null, meta: object, airDate?: string) { + return ( + await h.db + .insertInto("content_unit") + .values({ + media_entity_id: entityId, + kind: ep === null ? "season" : "episode", + season_number: season, + episode_number: ep, + status: "confirmed", + title: (meta as { name?: { value?: string } }).name?.value ?? null, + air_date: airDate ?? null, + metadata: JSON.stringify(meta), + }) + .returning("id") + .executeTakeFirstOrThrow() + ).id; + } + async function scrape(entityId: string | null, kind: "movie" | "tv", path: string, externalId: string | null = null) { + const match: ScrapeMatch = { + mediaEntityId: entityId, + provider: externalId ? "tmdb" : null, + externalId, + kind, + title: "x", + year: null, + source: entityId ? "library" : "chosen", + }; + const q = await queueScrape(h.d, { + rootId: `lib:${rootId}`, + rootPath: lib.root, + libraryRootId: rootId, + path, + match, + mode: "overwrite", + trigger: "manual", + }); + return runScrapeRecord(h.d, q.recordId); + } + const byPath = (files: ScrapeFileResult[], suffix: string) => files.find((x) => x.path.endsWith(suffix)); + const count = (xml: string, re: RegExp) => xml.match(re)?.length ?? 0; + + it("多集文件(文件名 E01-E02):一个 NFO 里每集一个 ,按集号顺序", async () => { + const id = await entity("series", "双集剧", { title: f("双集剧") }, { id: "500", type: "tv" }); + const s = await unit(id, 1, null, { name: f("第 1 季") }); + for (const [ep, name] of [ + [1, "开端"], + [2, "发展"], + [3, "结局"], + ] as const) + await h.db + .insertInto("content_unit") + .values({ + media_entity_id: id, + parent_unit_id: s, + kind: "episode", + season_number: 1, + episode_number: ep, + status: "confirmed", + title: name, + metadata: JSON.stringify({ name: f(name), overview: f(`${name}简介`) }), + }) + .execute(); + const r = await scrape(id, "tv", "TV/双集剧 (2020)", "500"); + expect(r.status).toBe("succeeded"); + expect(byPath(r.files, "S01E01-E02.nfo")).toMatchObject({ action: "written" }); + const xml = await readFile(lib.path("TV/双集剧 (2020)/Season 1/Double.S01E01-E02.nfo"), "utf8"); + expect(count(xml, /<\?xml /g)).toBe(1); + expect(count(xml, //g)).toBe(2); + expect(xml.indexOf("开端")).toBeLessThan(xml.indexOf("发展")); + const single = await readFile(lib.path("TV/双集剧 (2020)/Season 1/Double.S01E03.nfo"), "utf8"); + expect(count(single, //g)).toBe(1); + }); + + it("多集文件(已登记、覆盖两个集单元):按覆盖单元写两块", async () => { + const id = await entity("series", "覆盖剧", { title: f("覆盖剧") }, { id: "501", type: "tv" }); + const s = await unit(id, 1, null, { name: f("第 1 季") }); + const eps: string[] = []; + for (const [ep, name] of [ + [1, "甲"], + [2, "乙"], + ] as const) + eps.push( + ( + await h.db + .insertInto("content_unit") + .values({ + media_entity_id: id, + parent_unit_id: s, + kind: "episode", + season_number: 1, + episode_number: ep, + status: "confirmed", + title: name, + metadata: JSON.stringify({ name: f(name) }), + }) + .returning("id") + .executeTakeFirstOrThrow() + ).id, + ); + // 文件名没有集号区间,覆盖单元是 E01 + E02 + const path = "TV/覆盖剧 (2020)/Season 1/覆盖剧 - S01E01.mkv"; + await (await import("node:fs/promises")).mkdir(lib.path("TV/覆盖剧 (2020)/Season 1"), { recursive: true }); + await (await import("node:fs/promises")).writeFile(lib.path(path), "video"); + const asset = await h.db + .insertInto("media_asset") + .values({ media_entity_id: id, kind: "video", status: "active" }) + .returning("id") + .executeTakeFirstOrThrow(); + await h.db + .insertInto("asset_location") + .values({ + media_asset_id: asset.id, + location_kind: "library", + library_root_id: rootId, + relative_path: path, + status: "available", + }) + .execute(); + for (const u of eps) + await h.db + .insertInto("asset_unit_coverage") + .values({ media_asset_id: asset.id, content_unit_id: u, coverage: "full" }) + .execute(); + const r = await scrape(id, "tv", "TV/覆盖剧 (2020)", "501"); + expect(r.status).toBe("succeeded"); + const xml = await readFile(lib.path(path.replace(/\.mkv$/, ".nfo")), "utf8"); + expect(count(xml, //g)).toBe(2); + expect(xml).toContain("乙"); + }); + + let variety = ""; + it("综艺按日期命名:按集单元的播出日期对上集,写 与集号;对不上的跳过", async () => { + const id = await entity("variety", "综艺", { title: f("综艺") }, { id: "777", type: "tv" }); + variety = id; + const s = await unit(id, 1, null, { name: f("第 1 季") }); + for (const [ep, name, day] of [ + [1, "第一期", "2024-05-03"], + [2, "第二期", "2024-05-10"], + ] as const) + await h.db + .insertInto("content_unit") + .values({ + media_entity_id: id, + parent_unit_id: s, + kind: "episode", + season_number: 1, + episode_number: ep, + status: "confirmed", + title: name, + air_date: day, + metadata: JSON.stringify({ name: f(name), airDate: f(day) }), + }) + .execute(); + seasonDetail = async () => null; + const r = await scrape(id, "tv", "TV/综艺 (2024)", "777"); + const nfo = await readFile(lib.path("TV/综艺 (2024)/Season 1/综艺 - 2024-05-10.nfo"), "utf8"); + expect(nfo).toContain("第二期"); + expect(nfo).toContain("2024-05-10"); + expect(nfo).toContain("2"); + expect(byPath(r.files, "2024-05-03.nfo")).toMatchObject({ action: "written" }); + // 6 月 1 日这期季表里没有(TMDB 也取不到):跳过,算部分完成 + expect(byPath(r.files, "2024-06-01.mkv")).toMatchObject({ action: "skipped", reason: "scrape_no_episode" }); + expect(r.status).toBe("partial"); + }); + + it("综艺按日期命名:本地没有这一期时按 TMDB 季详情的播出日期对集", async () => { + seasonDetail = async (n) => + n === 1 + ? seasonOf(1, [ + { ep: 1, name: "第一期", airDate: "2024-05-03" }, + { ep: 2, name: "第二期", airDate: "2024-05-10" }, + { ep: 5, name: "六月特辑", airDate: "2024-06-01" }, + ]) + : null; + const r = await scrape(variety, "tv", "TV/综艺 (2024)", "777"); + expect(byPath(r.files, "2024-06-01.nfo")).toMatchObject({ action: "written" }); + const nfo = await readFile(lib.path("TV/综艺 (2024)/Season 1/综艺 - 2024-06-01.nfo"), "utf8"); + expect(nfo).toContain("六月特辑"); + expect(nfo).toContain("5"); + expect(r.status).toBe("succeeded"); + }); + + async function registerVideo(entityId: string, path: string) { + const a = await h.db + .insertInto("media_asset") + .values({ media_entity_id: entityId, kind: "video", status: "active" }) + .returning("id") + .executeTakeFirstOrThrow(); + await h.db + .insertInto("asset_location") + .values({ + media_asset_id: a.id, + location_kind: "library", + library_root_id: rootId, + relative_path: path, + status: "available", + }) + .execute(); + } + + it("平铺模板:作品重刮只刮本作品的视频,图片按 <视频名>-poster 命名,不写目录共用的 poster", async () => { + const meta = (t: string) => ({ title: f(t), posterPath: f(`/${t}.jpg`), backdropPath: f(`/${t}-bg.jpg`) }); + const inception = await entity("movie", "盗梦空间", meta("盗梦空间"), { id: "27205", type: "movie" }); + const interstellar = await entity("movie", "星际穿越", meta("星际穿越"), { id: "157336", type: "movie" }); + await registerVideo(inception, "Movies/盗梦空间 (2010).mkv"); + await registerVideo(interstellar, "Movies/星际穿越 (2014).mkv"); + const q = await scrapeEntity(h.d, inception, { trigger: "manual", mode: "overwrite" }); + expect(q).toHaveLength(1); + const r = await runScrapeRecord(h.d, q[0]!.recordId); + expect(r.status).toBe("succeeded"); + expect(r.videos).toEqual(["Movies/盗梦空间 (2010).mkv"]); + expect(r.files.map((x) => x.path).sort()).toEqual([ + "Movies/盗梦空间 (2010)-fanart.jpg", + "Movies/盗梦空间 (2010)-poster.jpg", + "Movies/盗梦空间 (2010).nfo", + ]); + await expect(stat(lib.path("Movies/星际穿越 (2014).nfo"))).rejects.toThrow(); + await expect(stat(lib.path("Movies/poster.jpg"))).rejects.toThrow(); + expect(await readFile(lib.path("Movies/盗梦空间 (2010).nfo"), "utf8")).toContain("盗梦空间"); + }); + + it("单部电影的目录照旧写 poster / fanart;图片扩展名按实际类型", async () => { + const avatar = await entity( + "movie", + "阿凡达", + { title: f("阿凡达"), posterPath: f("https://img.example.test/avatar.webp") }, + { id: "19995", type: "movie" }, + ); + await registerVideo(avatar, "Single/阿凡达 (2009)/阿凡达 (2009).mkv"); + imageMime = "image/png"; + try { + const q = await scrapeEntity(h.d, avatar, { trigger: "manual", mode: "overwrite" }); + const r = await runScrapeRecord(h.d, q[0]!.recordId); + expect(r.status).toBe("succeeded"); + expect(r.files.find((x) => x.role === "poster")).toMatchObject({ + path: "Single/阿凡达 (2009)/poster.png", + action: "written", + }); + await expect(stat(lib.path("Single/阿凡达 (2009)/poster.webp"))).rejects.toThrow(); + expect(new Uint8Array(await readFile(lib.path("Single/阿凡达 (2009)/poster.png")))).toEqual(PNG); + } finally { + imageMime = "image/jpeg"; + } + }); + + it("任务被取消:记录收尾为失败(scrape_canceled),不停在进行中", async () => { + const id = await entity("movie", "取消", { title: f("取消") }, { id: "1", type: "movie" }); + const match: ScrapeMatch = { + mediaEntityId: id, + provider: "tmdb", + externalId: "1", + kind: "movie", + title: "取消", + year: null, + source: "library", + }; + const q = await queueScrape(h.d, { + rootId: `lib:${rootId}`, + rootPath: lib.root, + libraryRootId: rootId, + path: "Single/阿凡达 (2009)", + match, + trigger: "manual", + }); + const ac = new AbortController(); + ac.abort(); + await expect(runScrapeRecord(h.d, q.recordId, ac.signal)).rejects.toThrow(); + const row = await h.db + .selectFrom("scrape_record") + .select(["status", "reason", "finished_at"]) + .where("id", "=", q.recordId) + .executeTakeFirstOrThrow(); + expect(row).toMatchObject({ status: "failed", reason: "scrape_canceled" }); + expect(row.finished_at).not.toBeNull(); + }); +}); diff --git a/apps/server/test/file-manager/organize-in-place.test.ts b/apps/server/test/file-manager/organize-in-place.test.ts index 74cadfc3..bcd453b5 100644 --- a/apps/server/test/file-manager/organize-in-place.test.ts +++ b/apps/server/test/file-manager/organize-in-place.test.ts @@ -205,9 +205,14 @@ describe.skipIf(skip)("file manager in-place import", () => { try { const r = await queue(s.items, { scrapeAfterImport: true }); expect((await executeRecord(h.module.deps().ao, r.recordId)).status).toBe("succeeded"); + // 不刮削,只在整理记录上留一条「未刮削(只登记模式)」 expect( - await h.db.selectFrom("scrape_record").select("id").where("organization_record_id", "=", r.recordId).execute(), - ).toEqual([]); + await h.db + .selectFrom("scrape_record") + .select(["status", "reason"]) + .where("organization_record_id", "=", r.recordId) + .execute(), + ).toEqual([{ status: "skipped", reason: "scrape_root_read_only" }]); expect(await readdir(join(h.lib, s.path.split("/")[0]!))).toEqual(["original.name.mkv"]); } finally { await h.db.updateTable("library_root").set({ management_mode: "managed" }).where("id", "=", rootId).execute(); diff --git a/apps/server/test/file-manager/trash.test.ts b/apps/server/test/file-manager/trash.test.ts index 8711c8cd..86595d7f 100644 --- a/apps/server/test/file-manager/trash.test.ts +++ b/apps/server/test/file-manager/trash.test.ts @@ -1,4 +1,4 @@ -import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { access, mkdir, mkdtemp, readFile, rm, symlink, utimes, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { findCrossDeviceBase } from "@umt/testkit"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; @@ -171,6 +171,21 @@ describe.skipIf(skip)("files 删除与回收站", () => { expect(left.map((x) => x.id)).not.toContain(oldId); }); + it("purge 过期项时,过期且没有登记的 .umt-trash/replaced-* 一并清掉", async () => { + const oldBin = join(h.lib, ".umt-trash", "replaced-2020-01-01T00-00-00-000Z-aaaaaa"); + const newBin = join(h.lib, ".umt-trash", "replaced-2099-01-01T00-00-00-000Z-bbbbbb"); + await mkdir(oldBin, { recursive: true }); + await mkdir(newBin, { recursive: true }); + await writeFile(join(oldBin, "old.mkv"), "o"); + await writeFile(join(newBin, "new.mkv"), "n"); + const past = new Date(Date.now() - 30 * 86_400_000); + await utimes(oldBin, past, past); + const r = await h.api("POST", "/files/trash/purge", { idempotencyKey: key() }, SESSION); + expect(r.status).toBe(200); + expect(await exists(oldBin)).toBe(false); + expect(await exists(join(newBin, "new.mkv"))).toBe(true); + }); + it("永久删除默认关闭;API Key 删除需一次性审批", async () => { await writeFile(join(h.lib, "perm.txt"), "p"); const p = await del({ items: [{ root: h.libRoot, path: "perm.txt" }], mode: "permanent", dryRun: false }); diff --git a/apps/server/test/scrape/nfo-deviations.json b/apps/server/test/scrape/nfo-deviations.json index 7f110843..f5f6fa0e 100644 --- a/apps/server/test/scrape/nfo-deviations.json +++ b/apps/server/test/scrape/nfo-deviations.json @@ -65,5 +65,11 @@ "ours": "<视频名>-thumb.jpg", "reason": "Kodi 约定 -thumb 后缀;Jellyfin / Emby 同样识别,且不会和同名海报规则冲突" } - ] + ], + "multiEpisode": { + "case": "s01e01-e02(一个文件覆盖第 1、2 集)", + "mp": "只按首集(begin_episode)写一个 ,与 s01e01 相同", + "ours": "按 Kodi 多集约定写两个 块:第一块同 s01e01,第二块同 s01e02(各自的已登记差异照旧)", + "reason": "只写首集时 Kodi 只显示第一集,第二集在媒体库里缺失;Jellyfin / Emby 也读多块 NFO" + } } diff --git a/apps/server/test/scrape/nfo-mp-diff.test.ts b/apps/server/test/scrape/nfo-mp-diff.test.ts index 1ccabbfa..8756e616 100644 --- a/apps/server/test/scrape/nfo-mp-diff.test.ts +++ b/apps/server/test/scrape/nfo-mp-diff.test.ts @@ -1,6 +1,6 @@ import { readFileSync } from "node:fs"; import { fetchRichDetail, fetchRichSeason } from "@umt/adapters"; -import { episodeNfo, planScrapeTargets, seasonNfo, seasonPosterName, workNfo } from "@umt/domain"; +import { episodeNfo, multiEpisodeNfo, planScrapeTargets, seasonNfo, seasonPosterName, workNfo } from "@umt/domain"; import { describe, expect, it } from "vitest"; import { nfoEpisode, nfoSeason, nfoWork } from "../../src/modules/acquisition-organize/services/scrape-input.ts"; import { richEntityFields } from "../../src/modules/sources-identity/service/metadata-rich.ts"; @@ -12,7 +12,8 @@ import { richEntityFields } from "../../src/modules/sources-identity/service/met const read = (f: string) => JSON.parse(readFileSync(new URL(f, import.meta.url), "utf8")); const raw = read("./tmdb-raw.json") as Record; const mp = read("./scrape-mp.json") as Record; -const devs = read("./nfo-deviations.json").deviations as { case: string; tag: string }[]; +const devFile = read("./nfo-deviations.json"); +const devs = devFile.deviations as { case: string; tag: string }[]; const LANGS = ["zh-CN", "zh-SG", "zh-TW", "zh-HK", "en-US"]; const client = { @@ -63,23 +64,23 @@ async function ours(): Promise> { metadata: { name: { value: sd.name }, overview: { value: sd.overview }, airDate: { value: sd.airDate } }, }), ); - for (const e of sd.episodes) { - out[`s01e${String(e.episodeNumber).padStart(2, "0")}`] = episodeNfo( - nfoEpisode(1, e.episodeNumber, { - season_number: 1, - episode_number: e.episodeNumber, - title: e.name, - air_date: e.airDate, - metadata: { - name: { value: e.name }, - overview: { value: e.overview }, - airDate: { value: e.airDate }, - runtime: { value: e.runtime }, - voteAverage: { value: e.voteAverage }, - }, - }), - ); - } + const eps = sd.episodes.map((e) => + nfoEpisode(1, e.episodeNumber, { + season_number: 1, + episode_number: e.episodeNumber, + title: e.name, + air_date: e.airDate, + metadata: { + name: { value: e.name }, + overview: { value: e.overview }, + airDate: { value: e.airDate }, + runtime: { value: e.runtime }, + voteAverage: { value: e.voteAverage }, + }, + }), + ); + for (const e of eps) out[`s01e${String(e.episode).padStart(2, "0")}`] = episodeNfo(e); + out["s01e01-e02"] = multiEpisodeNfo(eps.slice(0, 2)); return out; } @@ -109,6 +110,24 @@ describe("NFO 与 MP 差分", async () => { } }); + it("多集文件:按登记的偏离写多个 块,每块与 MP 对应单集一致(MP 只写首集)", () => { + expect(devFile.multiEpisode?.reason).toBeTruthy(); + const xml = mine["s01e01-e02"]!; + const [decl, ...body] = xml.split("\n"); + expect(decl).toBe(mp.s01e01!.split("\n")[0]); + const blocks = body + .join("\n") + .split(/(?=)/) + .filter((b) => b.trim()); + expect(blocks).toHaveLength(2); + for (const [i, c] of ["s01e01", "s01e02"].entries()) { + const skip = new Set(devs.filter((d) => d.case === c).map((d) => d.tag)); + const a = normalize(mp[c]!).filter((l) => !skip.has(tagOf(l) ?? "") && !l.startsWith(" !skip.has(tagOf(l) ?? "")); + expect(b).toEqual(a); + } + }); + it("编码与声明:UTF-8、两空格缩进、末尾换行,和 MP 一致", () => { for (const c of cases) { expect(mine[c]!.split("\n")[0]).toBe(mp[c]!.split("\n")[0]); diff --git a/apps/web/app/components/library/scrape-panel.tsx b/apps/web/app/components/library/scrape-panel.tsx index fe6e0429..c6071968 100644 --- a/apps/web/app/components/library/scrape-panel.tsx +++ b/apps/web/app/components/library/scrape-panel.tsx @@ -21,6 +21,7 @@ export const SCRAPE_STATUS: Record = { @@ -82,6 +83,8 @@ function FileRow({ f }: { f: ScrapeFileResult }) { /** 不必逐条列出的跳过:已存在(不是本工具写的 / 只补缺失)、内容没变 */ const QUIET_SKIPS = new Set(["scrape_exists", "scrape_unchanged"]); +/** 预期内的跳过(已存在、没变、被人改过);其余跳过(缺元数据、认不出集号)让记录算部分完成 */ +export const EXPECTED_SCRAPE_SKIPS = new Set([...QUIET_SKIPS, "scrape_user_modified"]); /** 一条刮削记录:状态、目录与识别结果、计数、逐文件结果(缺省不列"已存在"跳过的) */ export function ScrapeRecordView({ record: s, showPath = false }: { record: ScrapeRecord; showPath?: boolean }) { diff --git a/apps/web/app/components/organize-wizard/model.ts b/apps/web/app/components/organize-wizard/model.ts index 805aa934..07b29b5e 100644 --- a/apps/web/app/components/organize-wizard/model.ts +++ b/apps/web/app/components/organize-wizard/model.ts @@ -410,6 +410,7 @@ export const EXCLUDE_REASON: Record = { extra_content: "额外内容", no_target_dir: "没有目标目录", naming_failed: "命名失败", + title_mismatch: "标题不是本作品", }; export const SKIP_REASON: Record = { diff --git a/apps/web/app/components/organize/organize-scrape-history.tsx b/apps/web/app/components/organize/organize-scrape-history.tsx index d589043a..0b053ec2 100644 --- a/apps/web/app/components/organize/organize-scrape-history.tsx +++ b/apps/web/app/components/organize/organize-scrape-history.tsx @@ -1,14 +1,18 @@ import { useQuery } from "@tanstack/react-query"; -import { FilesScrapeRecordsResult } from "@umt/contracts"; +import { FilesScrapeRecordsResult, type ScrapeFileResult } from "@umt/contracts"; import { Link } from "react-router"; import { apiParsed } from "~/api/parse.ts"; import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; import { PaginatedList } from "~/components/console/paginated-list.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx"; -import { SCRAPE_STATUS, scrapeCounts } from "~/components/library/scrape-panel.tsx"; +import { EXPECTED_SCRAPE_SKIPS, SCRAPE_STATUS, scrapeCounts } from "~/components/library/scrape-panel.tsx"; import { reasonLine } from "~/lib/reasons.ts"; import { formatDateTime } from "~/lib/utils.ts"; +/** 失败的文件,以及缺元数据、认不出集号等不在预期内的跳过 */ +const unfinished = (files: ScrapeFileResult[]) => + files.filter((f) => f.action === "failed" || (f.action === "skipped" && !EXPECTED_SCRAPE_SKIPS.has(f.reason ?? ""))); + /** 整理与刮削分别展示结果,避免将刮削失败当作文件搬运失败。 */ export function OrganizeScrapeHistory({ recordId }: { recordId: string }) { const query = useQuery({ @@ -39,21 +43,36 @@ export function OrganizeScrapeHistory({ recordId }: { recordId: string }) {

{record.match?.title ?? record.path} · {record.path || "/"}

-

{scrapeCounts(record) || "等待写入 NFO 与图片"}

- {record.reason ?

{reasonLine(record.reason)}

: null} - {record.files.some((file) => file.action === "failed") ? ( + {record.status === "skipped" ? null : ( +

{scrapeCounts(record) || "等待写入 NFO 与图片"}

+ )} + {record.reason ? ( +

+ {reasonLine(record.reason)} +

+ ) : null} + {unfinished(record.files).length ? (
- 失败文件 + 失败或没写的文件 file.action === "failed")} - label="刮削失败文件" + rows={unfinished(record.files)} + label="刮削失败或跳过的文件" resetKey={record.id} as="ul" className="mt-2 space-y-1" > {(file) => ( -
  • - {file.path} · {reasonLine(file.reason) || "写入失败"} +
  • + {file.path} · {reasonLine(file.reason) || (file.action === "failed" ? "写入失败" : "跳过")}
  • )}
    diff --git a/docs/runbook/organize.md b/docs/runbook/organize.md index 7ee9e8fa..cb462d4a 100644 --- a/docs/runbook/organize.md +++ b/docs/runbook/organize.md @@ -50,6 +50,21 @@ 洗版在本次冻结计划中保存选中的旧文件。新文件核验登记成功后才清理所选旧版本,剧集只处理成功对应的季集与 Part;覆盖不完整的合并集保留。相同目标路径会先暂存旧文件,失败后恢复。旧版的私有字幕等随之清理,共享 NFO/图片、未选版本及下载缓存保留;按现有文件替换设置移入回收站或删除。 +## 收尾核验与资产登记 + +- 目标路径已有位置时:本次是「替换」(向导 / 待处理选替换,或洗版选中的旧版本),或旧位置属于别的作品,退役旧位置(没有别的位置时旧资产退役)并新建资产,大小、校验、版本信息都是新文件的;否则沿用同一资产。沿用的位置之前被对账判为缺失 / 不可用时恢复可用,资产事实换成这次入库的文件。 +- 文件级标题粗核:同一批视频(合集、混合包)里,文件名解析出的标题和作品标题 / 原名 / 别名明显不是同一部的(没有足够共同词; + 剧集只看带 SxxEyy 的文件),排除为待确认 `title_mismatch`,不按本作品命名入库,记录部分完成并开待处理「有 N 个视频没有整理」, + Move 时下载任务保留。只在同一批里至少有一个文件对得上作品时才判断别的文件(全都对不上多半是作品名的另一种写法,交给资源级识别); + 用户在向导里逐文件指定过的不核。 +- ffprobe 判定「不是视频」(没有视频流,伪装或损坏)的文件:核验失败,已拷 / 搬进库的新文件和它的字幕等附属文件移入回收站(Move 时源已删,回收站里是唯一一份),附属文件结果记跳过 `main_failed`,开待处理「N 个文件不是视频,没有入库」。原地入库不动原文件;洗版替换照旧回滚旧文件。 + +- 跨卷 Move(复制 → 校验 → 删源)删源前一律全量 sha256 校验,不用抽样;复制与同卷移动仍按 `verifyHash`。 +- 计划期判断「目标已存在且同尺寸是不是同一文件」的全量 sha256 按(路径、大小、修改时间、inode)缓存,向导预览 / 复核 / 执行不再各算一遍。 +- 整理时补全元数据(`metadata.resolve_organize`,模型 + 网页搜索)带 3 分钟超时,上游挂住不会一直占着文件道。 +- 季号:解析器对有集号没季号的文件推定第 1 季,服务端与域层用同一个值(`organize-season-inference.test.ts`)。 +- 回收站清除先删文件再删行;清过期项时顺带删各库根 `.umt-trash/replaced-*` 里过期、没有回收站登记的目录。 + 关联迁移 `0060_manual_acquisition`,已于 2026-10-01 随 v0.3.10 发布执行。业务依据与验收见 [MP 对照 §5.6](../porting/moviepilot-v3-comparison.md#56-本轮实现与验收已部署-v0310)。 ## 与 MP 的对照 diff --git a/docs/runbook/scrape.md b/docs/runbook/scrape.md index f2c62522..0afa2b30 100644 --- a/docs/runbook/scrape.md +++ b/docs/runbook/scrape.md @@ -28,7 +28,7 @@ SCR-2(用户 2026-09-30:"已经有的也可以重新刮削……如果是直接 | --- | --- | | 文件管理「刮削…」(右键 / ⋯ 菜单、多选批量栏、详情按钮) | `POST /files/scrape {items[{root,path,mediaEntityId?,external?}], mode?, addToLibrary?}`;任意视频文件或目录,每项一条记录,请求内最多等 `FILES_TRANSFER_WAIT_SEC`(缺省 20 s),没刮完的 `pending=true`,前端按 `GET /files/scrape-records?ids=` 轮询 | | 作品详情「重新刮削」「覆盖重写」 | `POST /library/{id}/scrape {mode?}`(202):对作品目录刮削,每个目录一条记录;没有已入库文件 412(到文件管理里对目录刮削) | -| 整理入库成功(`finalizeRecord` 结果为已入库 / 部分入库) | 设置 `organize.scrapeAfterImport` 打开时对作品目录排刮削,幂等键 `scrapeMedia:record:<整理记录 id>:<序号>` | +| 整理入库成功(`finalizeRecord` 结果为已入库 / 部分入库) | 设置 `organize.scrapeAfterImport`(缺省开)打开时对作品目录排刮削,幂等键 `scrapeMedia:record:<整理记录 id>:<目录>`;只对有 TMDB id 或本地元数据的作品排,没排上见下「入库后没排上」 | | 元数据刷新后 NFO 字段有变化 | 只对刮削过的作品(有成功 / 部分成功的记录):对作品目录排「更新」模式刮削(`trigger=auto`),任务延后 2 分钟开始;见下「刷新后自动重刮」 | | 改作品的 TMDB 映射 | 清掉旧作品的 tmdb 来源字段 → 排 full 刷新(幂等键 `refreshMetadata:identity:<新身份 id>`)→ 刷新写回新字段后按上一行自动重刮 | @@ -81,6 +81,19 @@ SCR-2(用户 2026-09-30:"已经有的也可以重新刮削……如果是直接 TMDB 有这一集但没有译名 / 简介(只有占位名和播出日期)不算缺,照常写(和 MP 一样)。未登记目录直接取 TMDB 时同一规则:季详情失败的季跳过。 +### 入库后没排上 + +入库后该刮却没排上任务时,在整理记录上留一条 `status = skipped` 的刮削记录(迁移 0074),整理详情「整理后刮削」显示「未刮削」与原因; +同一整理记录同一原因只记一次。设置关闭(或本次整理取消勾选)是预期,不记。 + +| 原因 | 情况 | +| --- | --- | +| `scrape_not_library` | 整理目标不在媒体库目录里 | +| `scrape_root_read_only` | 库根是只登记模式 | +| `scrape_no_metadata` | 作品没有 TMDB id,也没有本地元数据(Local 补全的字段) | +| `task_enqueue_failed` | 排任务失败(记录状态 failed) | +| `scrape_internal_error` | 查库等意外错误 | + ### 识别(文件管理) 顺序:请求指定 > 目录里已登记文件所属的唯一作品(`source=library`)> 按路径识别(`source=auto`)。 @@ -101,8 +114,17 @@ TMDB 有这一集但没有译名 / 简介(只有占位名和播出日期)不算 | 电影目录 | 目录里每个视频 `<视频名>.nfo`(不进子目录)+ 目录 `poster.jpg` / `fanart.jpg`;蓝光原盘目录(含 `BDMV` / `VIDEO_TS`)写 `<目录名>.nfo` | | 电影文件 | `<视频名>.nfo` + 同目录 `poster.jpg` / `fanart.jpg`;文件直接在登记目录根时图片改为 `<视频名>-poster.jpg` / `-fanart.jpg` | +- 自定义平铺模板(多部电影放在同一目录):目录里有登记在别的作品下的视频,或只刮部分视频时目录里还有没登记到本作品的视频, + 图片改为 `<视频名>-poster.jpg` / `-fanart.jpg`,不写整个目录共用的 `poster.jpg`。作品详情「重新刮削」对电影只刮该作品已入库的视频, + 不给同目录别的电影写本作品的 NFO(同一作品的多个版本照旧共用一张 poster)。 +- 图片扩展名按下载到的实际类型(jpeg / png / webp),URL 认不出或与内容不符时以内容为准。 + - 集号:已登记视频按覆盖的集单元;其余按路径解析(季号缺失时取季目录名,再缺按第 1 季;同 MP 对每集重新 `MetaInfoPath`)。 - 认不出集号的视频记 `scrape_no_episode` 跳过。多集文件按首集。 + 认不出集号的视频记 `scrape_no_episode` 跳过,记录算部分完成。 +- 多集文件(文件名 `E01-E02`,或已登记文件覆盖多个集单元):一个 `<视频名>.nfo` 里按 Kodi 约定每集写一个 `` 块 + (MP 只写首集,偏离登记在 `nfo-deviations.json` 的 `multiEpisode`);剧照按首集。某一集缺元数据时只写有元数据的那几块,全缺才跳过。 +- 综艺按播出日期命名的期数(默认综艺模板没有集号时用日期):按集单元的播出日期对上集(本地季表,或按 TMDB id 现取的季详情), + 写该集的季号、集号与 ``;日期对不上(或同一天有多集)的跳过 `scrape_no_episode`。 - 字段:本地作品有元数据时用最终字段(字段锁、人工覆盖优先,见 `metadata.md`);否则按 TMDB id 直接取 TMDB(与刷新元数据同一映射)。 TMDB id 只认与刮削类型一致的映射(同一作品同时有 movie / tv 两个 accepted 映射时不串)。 - 作品类型:剧集 / 综艺按剧集刮,电影 / 合集 / 其他按电影刮(`<视频名>.nfo`,根元素 ``),范围判定与 NFO 生成同一个函数 `scrapeKindOf`。 @@ -163,7 +185,7 @@ TMDB 有这一集但没有译名 / 简介(只有占位名和播出日期)不算 | 字段 | 缺省 | 说明 | | --- | --- | --- | -| `organize.scrapeAfterImport` | 关 | 入库后自动刮削(MP 目录「刮削」缺省同样关) | +| `organize.scrapeAfterImport` | 开 | 入库后自动刮削(MP 目录「刮削」缺省关;我们缺省开,因为「更新」方式不动别人写的文件、缺元数据不写占位,且只对有 TMDB id 或本地元数据的作品排)。以前保存过设置的照旧按保存的值 | | `organize.scrapeMode` | `update` | `update` 更新 / `missing` 只补缺失(MP「仅缺失」)/ `overwrite` 全部覆盖,见上「覆盖方式」;手动刮削时对话框可临时改。以前保存过的 `missing` / `overwrite` 照旧生效,要用更新到设置里改 | | `organize.scrapeImages` | 开 | 海报、背景图、季海报 | | `organize.scrapeEpisodeThumbs` | 开 | 每集剧照;`scrapeImages` 关时不生效 | @@ -177,6 +199,7 @@ TMDB 有这一集但没有译名 / 简介(只有占位名和播出日期)不算 `title`、`originaltitle`、`premiered`、`year`;剧集另有 `season=-1`、`episode=-1`。 - 季:`plot`、`outline`、`title`(单元有简介 / 日期但没名称时「季 N」)、`premiered`、`releasedate`、`year`、`seasonnumber`。 - 集:`title`(TMDB 占位名时「第 N 集」)、`plot`、`outline`、`aired`、`year`、`season`、`episode`、`rating`。 + 多集文件每集一个 `` 块。 季 / 集单元整个缺元数据时不写(见「季 / 集缺元数据」)。 - 取值:标题 / 简介 / 标语按元数据语言链(见 `metadata.md`);字段锁与人工覆盖优先(用的是投影后的最终值)。 @@ -189,6 +212,7 @@ TMDB 有这一集但没有译名 / 简介(只有占位名和播出日期)不算 | 制片国取 TMDB `origin_country`(代码转中文名);剧集 `studio` 写播出平台 | 本地不存 `production_countries` / `production_companies` | | 电影不写 `studio` | 同上 | | 集 NFO 不写 `uniqueid` / `tmdbid` | 本地不存 TMDB 集 id;媒体服务器按季号集号匹配 | +| 多集文件每集一个 `` 块(MP 只写首集) | 只写首集时 Kodi 只显示第一集 | | 简介含 `]]>` 时拆成两段 CDATA | MP 的 minidom 直接抛错 | 重新生成 MP 输出(改了样例或跟进上游后): @@ -213,14 +237,15 @@ cd /opt/uhd_media_tool && deno run -A npm:@biomejs/biome check --write apps/serv - 原因码(`packages/contracts/src/reasons.ts`):`scrape_no_metadata`、`scrape_root_read_only`、`scrape_root_offline`、`scrape_not_video`、 `scrape_no_videos`、`scrape_path_missing`、`scrape_tmdb_unavailable`、`scrape_no_episode`、`scrape_exists`、`scrape_unchanged`、 `scrape_user_modified`、`scrape_metadata_missing`、`scrape_write_failed`、`scrape_image_failed`、`scrape_internal_error`(查库等非写文件错误, - 已写的文件结果保留)、`path_escape`、`task_enqueue_failed`。 + 已写的文件结果保留)、`scrape_canceled`(任务被取消,记录收尾为失败,已写的文件结果保留)、`scrape_not_library`、`path_escape`、`task_enqueue_failed`。 | 现象 | 看哪里 | | --- | --- | -| 入库后没有 NFO | 设置里「入库后自动刮削」是否打开;作品详情「刮削」是否有记录;引擎运行 `scrapeMedia` | +| 入库后没有 NFO | 整理详情「整理后刮削」是否有「未刮削」及原因;设置里「入库后自动刮削」是否打开;引擎运行 `scrapeMedia` | | 刮削对话框一直让选作品 | 文件名解析不出标题,或 TMDB 有多个同名候选;选一次即可,打开「同时加入媒体库」后下次按已入库作品识别 | | 全部「跳过」 | 覆盖方式是只补缺失,或更新时文件不是本工具写的 / 被人改过(`scrape_exists` / `scrape_user_modified`);要替换选「全部覆盖」或作品详情「覆盖重写」 | | 改了映射 / 刷新了元数据,NFO 没变 | 作品以前没刮削过(不自动重刮);重刮任务要等 2 分钟;NFO 被人改过(`scrape_user_modified`) | +| 记录「部分完成」、原因认不出集号 / 集数待确认 | 文件名没有集号(综艺日期对不上季表),或集数映射待确认;确认集数或刷新元数据后重新刮削 | | 记录「部分成功」、原因缺元数据 | 季 / 集还没取到元数据且 TMDB 季详情取不到;刷新元数据(作品详情「刷新」带 full)后会自动重刮 | | 图片失败、NFO 正常 | TMDB 图片访问(出口代理);稍后重新刮削,只补缺失的图片 | | 「没有可用的元数据」 | 作品没有 TMDB 字段也没有 TMDB id;在文件管理里刮削并指定 TMDB 条目 | diff --git a/packages/contracts/src/reasons.ts b/packages/contracts/src/reasons.ts index 505f4897..3b8d0cdb 100644 --- a/packages/contracts/src/reasons.ts +++ b/packages/contracts/src/reasons.ts @@ -119,6 +119,8 @@ const REASONS: Record = { scrape_image_failed: { text: "图片没下载成功", action: "稍后重新刮削" }, scrape_no_episode: { text: "文件名里认不出集号" }, scrape_no_work_dir: { text: "视频直接放在媒体库目录下,不写剧集和季的文件" }, + scrape_canceled: { text: "刮削任务被取消", action: "重新刮削" }, + scrape_not_library: { text: "整理目标不在媒体库目录里,不自动刮削", action: "到文件管理里对目录刮削" }, }; /** diff --git a/packages/contracts/src/scrape.ts b/packages/contracts/src/scrape.ts index d5c9ec09..d86078ce 100644 --- a/packages/contracts/src/scrape.ts +++ b/packages/contracts/src/scrape.ts @@ -26,7 +26,8 @@ export type ScrapeFileResult = z.infer; export const ScrapeMode = z.enum(["missing", "overwrite", "update"]); export type ScrapeMode = z.infer; -export const ScrapeStatus = z.enum(["queued", "running", "succeeded", "partial", "failed"]); +/** skipped = 入库后自动刮削没排上(原因在 reason;0074),整理详情显示「未刮削」 */ +export const ScrapeStatus = z.enum(["queued", "running", "succeeded", "partial", "failed", "skipped"]); export type ScrapeStatus = z.infer; export const ScrapeTrigger = z.enum(["auto", "manual"]); export type ScrapeTrigger = z.infer; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 3f810a2b..cadbf484 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -644,7 +644,8 @@ export const DEFAULT_SETTINGS: GlobalSettings = { defaultMode: "copy", allowCrossVolumeMove: true, minSizeBytes: 50 * 1024 * 1024, - scrapeAfterImport: false, + // 缺省开:只对有 TMDB id 或本地元数据的作品排刮削(否则在整理记录上记「未刮削」),「更新」方式不动别人写的文件 + scrapeAfterImport: true, scrapeMode: "update", scrapeImages: true, scrapeEpisodeThumbs: true, diff --git a/packages/db/migrations/0074_scrape_record_skipped.ts b/packages/db/migrations/0074_scrape_record_skipped.ts new file mode 100644 index 00000000..3a452cf7 --- /dev/null +++ b/packages/db/migrations/0074_scrape_record_skipped.ts @@ -0,0 +1,24 @@ +import type { Kysely } from "kysely"; +import { execDdl, oneOf } from "../src/migration-helpers.ts"; + +// 入库后自动刮削没排上(库根不是可写管理模式、作品没有 TMDB id 也没有本地元数据、排队失败)时在整理记录上可见: +// 写一条 status = skipped 的刮削记录(原因码在 reason),整理详情的「整理后刮削」里显示「未刮削(原因)」。 +// 设置关闭属预期,不写记录。 + +const OLD = ["queued", "running", "succeeded", "partial", "failed"]; +const NEW = [...OLD, "skipped"]; + +const swap = (values: readonly string[]) => ` +alter table scrape_record drop constraint if exists scrape_record_status_check; +alter table scrape_record add constraint scrape_record_status_check ${oneOf("status", values)}; +`; + +// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型 +export async function up(db: Kysely): Promise { + await execDdl(db, swap(NEW)); +} + +// biome-ignore lint/suspicious/noExplicitAny: 同上 +export async function down(db: Kysely): Promise { + await execDdl(db, `delete from scrape_record where status = 'skipped'; ${swap(OLD)}`); +} diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index b9801dfc..4daaf3b8 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -233,7 +233,7 @@ export type ScheduledJobKind = "task" | "agent_check"; export type ScheduledJobLastStatus = "ok" | "failed" | "skipped" | "running"; export type ScheduledJobRunTrigger = "schedule" | "manual" | "skip"; export type ScheduledJobRunStatus = "ok" | "failed" | "skipped" | "running"; -export type ScrapeRecordStatus = "queued" | "running" | "succeeded" | "partial" | "failed"; +export type ScrapeRecordStatus = "queued" | "running" | "succeeded" | "partial" | "failed" | "skipped"; export type ScrapeRecordTrigger = "auto" | "manual"; export type ScrapeRecordMode = "missing" | "overwrite" | "update"; export type SecretPurpose = diff --git a/packages/domain/src/organize/index.ts b/packages/domain/src/organize/index.ts index 344304bf..b615fc24 100644 --- a/packages/domain/src/organize/index.ts +++ b/packages/domain/src/organize/index.ts @@ -17,4 +17,5 @@ export { } from "./plan.ts"; export * from "./replacement.ts"; export { type DefaultSubtitleLang, sidecarLanguageSuffix, sidecarStem } from "./sidecars.ts"; +export { type TitleCheckFile, titleMatchesNames, titleMismatches } from "./title-check.ts"; export { findOtherVersions } from "./versions.ts"; diff --git a/packages/domain/src/organize/plan.ts b/packages/domain/src/organize/plan.ts index 82ede68a..7f1a4000 100644 --- a/packages/domain/src/organize/plan.ts +++ b/packages/domain/src/organize/plan.ts @@ -69,9 +69,9 @@ export interface FileOverride { episodes?: { start: number; end: number }; /** * 不入库(调用方按作品集数映射判定:集数待确认 episode_unresolved、映射规则标为特别篇 / 花絮 / 忽略 special_extra); - * 只按精确路径生效,目录前缀覆写不带它 + * 只按精确路径生效,目录前缀覆写不带它。title_mismatch = 文件标题明显不是本作品(混合包里别的作品),电影也生效 */ - exclude?: { reason: "episode_unresolved" | "special_extra"; detail?: string }; + exclude?: { reason: "episode_unresolved" | "special_extra" | "title_mismatch"; detail?: string }; } /** 源路径的覆写:精确命中优先,否则取最长的目录前缀 */ @@ -121,7 +121,8 @@ export interface ExcludedFile { | "special_extra" | "extra_content" | "no_target_dir" - | "naming_failed"; + | "naming_failed" + | "title_mismatch"; detail?: string; } @@ -232,7 +233,8 @@ export function planOrganize(input: OrganizeInput): OrganizePlan { } const parsed = parseReleasePath(segmentsFor(f.path, input.sourceRoot), input.parse ?? {}); const ov = input.mediaType === "tv" ? overrideFor(input.overrides, f.path) : undefined; - const ex = input.mediaType === "tv" ? input.overrides?.[f.path]?.exclude : undefined; + const exact = input.overrides?.[f.path]?.exclude; + const ex = input.mediaType === "tv" || exact?.reason === "title_mismatch" ? exact : undefined; if (ex) { excluded.push({ src: f.path, kind, reason: ex.reason, ...(ex.detail ? { detail: ex.detail } : {}) }); continue; diff --git a/packages/domain/src/organize/title-check.ts b/packages/domain/src/organize/title-check.ts new file mode 100644 index 00000000..e3b92dbe --- /dev/null +++ b/packages/domain/src/organize/title-check.ts @@ -0,0 +1,77 @@ +// 整理的文件级标题粗核(审视 ④ 风险 7):资源级认定了作品,但混合包 / 合集里混着别的作品时, +// 按文件名解析出的标题和作品标题 / 别名对一下,明显不是同一部的不按本作品命名入库(降为待确认 title_mismatch)。 +// 只拦明显不同的: +// - 只比文件名本身(不看目录名);文件名里解析不出标题的不判断; +// - 剧集只看带 SxxEyy 的文件(只有集标题的文件名常被解析成「标题」); +// - 同一批里至少有一个文件的标题对得上作品才判断别的文件(全都对不上时多半是作品名的另一种写法, +// 例如罗马音、英文译名,交给资源级识别,不拦); +// - 「对得上」= 有足够多的共同词:拉丁词(≥3 个字母,去掉常见虚词)或中日韩相邻两字, +// 文件标题的词至少一半出现在作品某个名字里。 +import { parseRelease } from "../release-parser/index.ts"; + +const STOP = new Set(["the", "and", "of", "a", "an", "no", "to", "in", "on", "season", "part", "complete", "series"]); +const CJK = /[぀-ヿ㐀-䶿一-鿿가-힯]/; + +function tokensOf(s: string): Set { + const out = new Set(); + const t = s.normalize("NFKC").toLowerCase(); + for (const w of t.match(/[a-z0-9]+(?:'[a-z]+)?/g) ?? []) { + const word = w.replace(/'[a-z]+$/, ""); + if (word.length >= 3 && !STOP.has(word) && !/^\d+$/.test(word)) out.add(word); + } + // 中日韩:按连续段取相邻两字(单字段整段算一个) + for (const run of t.match(new RegExp(`${CJK.source}+`, "gu")) ?? []) { + const chars = [...run]; + if (chars.length === 1) out.add(run); + for (let i = 0; i + 1 < chars.length; i++) out.add(chars[i]! + chars[i + 1]!); + } + return out; +} + +/** 一个文件标题和作品名字对得上(共同词占文件标题的一半以上);文件标题没有可比的词时返回 null */ +export function titleMatchesNames(fileTitle: string, names: readonly string[]): boolean | null { + const ft = tokensOf(fileTitle); + if (!ft.size) return null; + for (const n of names) { + const nt = tokensOf(n); + if (!nt.size) continue; + let shared = 0; + for (const w of ft) if (nt.has(w)) shared++; + if (shared > 0 && shared * 2 >= ft.size) return true; + } + return false; +} + +export interface TitleCheckFile { + path: string; + /** 文件名(不含目录) */ + name: string; +} + +/** + * 一批视频文件里标题明显不是本作品的(混合包里别的作品)。返回 路径 → 解析出的文件标题。 + * names:作品标题、原名、别名。 + */ +export function titleMismatches( + files: readonly TitleCheckFile[], + names: readonly string[], + mediaType: "movie" | "tv", +): Map { + const usable = names.filter((n) => n?.trim()); + const judged: { path: string; title: string; ok: boolean }[] = []; + for (const f of files) { + const p = parseRelease(f.name, { isFile: true }); + if (mediaType === "tv" && (p.season === undefined || p.seasonInferred || !p.episodes)) continue; + const titles = [ + ...new Set([p.titleCn, p.titleEn, p.title, ...(p.altTitles ?? [])].filter((t): t is string => !!t)), + ]; + if (!titles.length) continue; + const results = titles.map((t) => titleMatchesNames(t, usable)); + if (results.every((r) => r === null)) continue; + judged.push({ path: f.path, title: titles[0]!, ok: results.some((r) => r === true) }); + } + const out = new Map(); + if (!judged.some((j) => j.ok)) return out; + for (const j of judged) if (!j.ok) out.set(j.path, j.title); + return out; +} diff --git a/packages/domain/src/scrape/layout.ts b/packages/domain/src/scrape/layout.ts index 69d84a70..1c51ccc5 100644 --- a/packages/domain/src/scrape/layout.ts +++ b/packages/domain/src/scrape/layout.ts @@ -7,7 +7,7 @@ // - 剧集:作品目录 tvshow.nfo、poster.jpg、fanart.jpg;季目录 season.nfo;季海报同时写作品目录 // `season01-poster.jpg`(特别篇 `season-specials-poster.jpg`)与季目录 `poster.jpg`(MP 同); // - 每集:`<视频名>.nfo`;剧照 `<视频名>-thumb.jpg`(Kodi / Jellyfin / Emby 都认;MP 写 `<视频名>.jpg`)。 -// 视频直接在库根(没有作品目录)时,电影图片改用 `<视频名>-poster.jpg` / `<视频名>-fanart.jpg`, +// 视频直接在库根(没有作品目录)或电影目录里还有别的作品时,电影图片改用 `<视频名>-poster.jpg` / `<视频名>-fanart.jpg`, // 剧集不写 tvshow / 季文件(不能往库根里放 tvshow.nfo)。 /** @@ -49,8 +49,13 @@ export interface ScrapeVideo { /** 库根内相对路径 */ path: string; season?: number | null; - /** 首集集号(多集文件按首集,同 MP begin_episode) */ + /** 首集集号(同 MP begin_episode;剧照、文件命名按首集) */ episode?: number | null; + /** + * 文件覆盖的全部集号(多集文件 E01-E02 / 覆盖多个集单元),含首集、升序;缺省 = 只有首集。 + * 集 NFO 按 Kodi 约定每集写一个 `` 块(MP 只写首集)。 + */ + episodes?: readonly number[] | null; } export interface ScrapeImages { @@ -74,6 +79,11 @@ export interface ScrapeLayoutInput { * 电影海报 / 背景图);season = 季级(season.nfo / 季海报)。缺省都写。选中单集文件时两者都关,选中季目录时只关 work。 */ parts?: { work?: boolean; season?: boolean }; + /** + * 电影:作品目录里还有别的作品的视频(自定义平铺模板把多部电影放在同一目录)。图片改用 `<视频名>-poster` / + * `<视频名>-fanart`,不写整个目录共用的 poster.jpg / fanart.jpg。 + */ + sharedDir?: boolean; } export interface ScrapeTarget { @@ -83,6 +93,8 @@ export interface ScrapeTarget { role: ScrapeTargetRole; season?: number; episode?: number; + /** 集 NFO:文件覆盖的全部集号(多集文件多于一个) */ + episodes?: number[]; /** 这个文件对应的视频(电影 / 集 NFO、剧照) */ video?: string; /** 图片:TMDB 图片路径 */ @@ -152,7 +164,7 @@ export function planScrapeTargets(input: ScrapeLayoutInput): ScrapeTarget[] { add({ path: join(dir, `${stemOf(v.path)}.nfo`), kind: "nfo", role: "movie_nfo", video: v.path }); } if (!input.options.images || input.parts?.work === false) return out; - const inWork = workDir && videos.some((v) => isUnder(v.path, workDir)); + const inWork = workDir && !input.sharedDir && videos.some((v) => isUnder(v.path, workDir)); const art = (role: "poster" | "fanart", p: string | null | undefined) => { if (!p) return; if (inWork) add({ path: join(workDir, `${role}${extOf(p)}`), kind: "image", role, imagePath: p }); @@ -227,12 +239,15 @@ export function planScrapeTargets(input: ScrapeLayoutInput): ScrapeTarget[] { if (v.season === null || v.season === undefined || v.episode === null || v.episode === undefined) continue; const dir = normDir(posix.dirname(v.path)); const stem = stemOf(v.path); + const first = v.episode; + const eps = [...new Set([first, ...(v.episodes ?? [])])].sort((a, b) => a - b); add({ path: join(dir, `${stem}.nfo`), kind: "nfo", role: "episode_nfo", season: v.season, episode: v.episode, + episodes: eps, video: v.path, }); const still = img.stills?.get(`${v.season}:${v.episode}`); diff --git a/packages/domain/src/scrape/nfo.ts b/packages/domain/src/scrape/nfo.ts index 5c6d31d7..95ebc2b5 100644 --- a/packages/domain/src/scrape/nfo.ts +++ b/packages/domain/src/scrape/nfo.ts @@ -6,7 +6,8 @@ // - 缺值不写元素(MP 写空元素 ``、评分缺省写 0、空 CDATA); // - 简介里的 `]]>` 拆成两段 CDATA(MP 的 minidom 直接抛错); // - 额外写 豆瓣 id(doubanid + uniqueid type=douban)、标语 tagline、集时长 runtime; -// - 集 NFO 没有 TMDB 集 id(本地不存),不写 uniqueid / tmdbid,媒体服务器按季号集号匹配。 +// - 集 NFO 没有 TMDB 集 id(本地不存),不写 uniqueid / tmdbid,媒体服务器按季号集号匹配; +// - 多集文件(E01-E02)按 Kodi 约定每集一个 `` 块写进同一个 NFO(MP 只写首集)。 export const TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p/original"; @@ -92,9 +93,9 @@ function render(n: Node, indent: string, out: string[]) { else out.push(`${indent}<${n.tag}${attrs}/>`); } -function toXml(root: Node): string { +function toXml(...roots: Node[]): string { const out = ['']; - render(root, "", out); + for (const root of roots) render(root, "", out); return `${out.join("\n")}\n`; } @@ -248,6 +249,19 @@ export function seasonNfo(s: NfoSeason): string { /** 每集 `<视频名>.nfo` */ export function episodeNfo(e: NfoEpisode): string { + return toXml(episodeNode(e)); +} + +/** + * 多集文件的 `<视频名>.nfo`:Kodi 约定(Jellyfin / Emby 同样读取)每集一个 `` 块,依次写在同一个文件里, + * 只有一个 XML 声明。只有一集时与 episodeNfo 相同。 + */ +export function multiEpisodeNfo(list: readonly NfoEpisode[]): string { + if (!list.length) throw new Error("multiEpisodeNfo: 至少一集"); + return toXml(...list.map(episodeNode)); +} + +function episodeNode(e: NfoEpisode): Node { const b = new Builder(); b.text("title", has(e.title) ? e.title : `第 ${e.episode} 集`); b.plot(e.overview); @@ -259,5 +273,5 @@ export function episodeNfo(e: NfoEpisode): string { b.directors(e.directors); b.actors(e.actors, false); if (num(e.runtime) && e.runtime > 0) b.text("runtime", e.runtime); - return toXml({ tag: "episodedetails", children: b.nodes }); + return { tag: "episodedetails", children: b.nodes }; } diff --git a/packages/domain/test/organize.test.ts b/packages/domain/test/organize.test.ts index dcd0b360..307eff4e 100644 --- a/packages/domain/test/organize.test.ts +++ b/packages/domain/test/organize.test.ts @@ -5,6 +5,8 @@ import { planOrganize, sidecarLanguageSuffix, sidecarStem, + titleMatchesNames, + titleMismatches, } from "../src/index.ts"; const MiB = 1024 ** 2; @@ -407,3 +409,48 @@ describe("原地入库计划", () => { expect(p.requiresApproval).toBe(false); }); }); + +describe("文件级标题粗核", () => { + const names = ["盗梦空间", "Inception"]; + it("合集里别的电影判为不符;对得上的、解析不出标题的不拦", () => { + const r = titleMismatches( + [ + { path: "a", name: "Inception.2010.1080p.BluRay.x264-GRP.mkv" }, + { path: "b", name: "Interstellar.2014.1080p.BluRay.x264-GRP.mkv" }, + { path: "c", name: "盗梦空间.2010.2160p.mkv" }, + ], + names, + "movie", + ); + expect([...r]).toEqual([["b", "Interstellar"]]); + }); + it("全都对不上时不拦(多半是作品名的另一种写法)", () => { + expect(titleMismatches([{ path: "a", name: "Sousou.no.Frieren.S01E01.mkv" }], ["葬送的芙莉莲"], "tv").size).toBe(0); + expect( + titleMismatches( + [ + { path: "a", name: "Frieren.S01E01.1080p.WEB-DL.mkv" }, + { path: "b", name: "Sousou.no.Frieren.S01E02.1080p.WEB-DL.mkv" }, + ], + ["葬送的芙莉莲", "Frieren: Beyond Journey's End"], + "tv", + ).size, + ).toBe(0); + }); + it("剧集只看带 SxxEyy 的文件:只有集标题的文件名不拦", () => { + const r = titleMismatches( + [ + { path: "a", name: "Severance.S01E01.1080p.WEB-DL.H264-GRP.mkv" }, + { path: "b", name: "Other.Show.S01E01.1080p.WEB-DL.mkv" }, + { path: "c", name: "01 - The Beginning.mkv" }, + ], + ["人生切割术", "Severance"], + "tv", + ); + expect([...r.keys()]).toEqual(["b"]); + }); + it("中文标题按相邻两字比较:同系列相近的标题不拦", () => { + expect(titleMatchesNames("哈利波特与密室", ["哈利波特与魔法石"])).toBe(true); + expect(titleMatchesNames("星际穿越", ["盗梦空间"])).toBe(false); + }); +}); diff --git a/packages/domain/test/scrape.test.ts b/packages/domain/test/scrape.test.ts index c9ae6ea3..417da9cb 100644 --- a/packages/domain/test/scrape.test.ts +++ b/packages/domain/test/scrape.test.ts @@ -3,6 +3,7 @@ import { episodeNfo, inferWorkDir, isSeasonDirName, + multiEpisodeNfo, planScrapeTargets, scrapeKindOf, seasonNfo, @@ -255,3 +256,37 @@ describe("作品类型 → 刮削类型", () => { expect(["movie", "collection", "other"].map(scrapeKindOf)).toEqual(["movie", "movie", "movie"]); }); }); + +describe("多集文件", () => { + it("集 NFO 每集一个 块,只有一个 XML 声明;一集时与 episodeNfo 相同", () => { + const xml = multiEpisodeNfo([ + { season: 1, episode: 1, title: "上", airDate: "2022-02-17" }, + { season: 1, episode: 2, title: "下", airDate: "2022-02-17" }, + ]); + expect(xml.match(/<\?xml /g)).toHaveLength(1); + expect(xml.match(//g)).toHaveLength(2); + expect(xml).toContain(" 上\n 2022-02-17"); + expect(xml.indexOf("1")).toBeLessThan(xml.indexOf("2")); + const one = { season: 1, episode: 3, title: "单集" }; + expect(multiEpisodeNfo([one])).toBe(episodeNfo(one)); + }); + + it("布局:多集文件只写一个 NFO(按视频名)与首集剧照,目标带全部集号", () => { + const t = planScrapeTargets({ + kind: "tv", + workDir: "剧 (2020)", + videos: [{ path: "剧 (2020)/Season 1/剧 - S01E01-E02.mkv", season: 1, episode: 1, episodes: [2, 1] }], + images: { stills: new Map([["1:1", "/e1.jpg"]]) }, + options: { images: true, episodeThumbs: true }, + parts: { work: false, season: false }, + }); + expect(t).toEqual([ + expect.objectContaining({ + role: "episode_nfo", + path: "剧 (2020)/Season 1/剧 - S01E01-E02.nfo", + episodes: [1, 2], + }), + expect.objectContaining({ role: "episode_thumb", path: "剧 (2020)/Season 1/剧 - S01E01-E02-thumb.jpg" }), + ]); + }); +}); diff --git a/packages/mapping/src/organize.ts b/packages/mapping/src/organize.ts index 8a3a99e8..cfee7ea2 100644 --- a/packages/mapping/src/organize.ts +++ b/packages/mapping/src/organize.ts @@ -143,10 +143,15 @@ export function organizePlanToFileResults( } /** - * 被排除但"本该入库"的视频:季集没定下来、没有对应季目录、命名缺字段。文件留在原处没人管, + * 被排除但"本该入库"的视频:季集没定下来、没有对应季目录、命名缺字段、文件标题明显不是本作品(待确认)。文件留在原处没人管, * 不能算整理成功(Move 成功后会移除下载任务)。样例、过小、已在库等预期跳过不在此列。 */ -export const UNRESOLVED_EXCLUSIONS = ["episode_unresolved", "no_target_dir", "naming_failed"] as const; +export const UNRESOLVED_EXCLUSIONS = [ + "episode_unresolved", + "no_target_dir", + "naming_failed", + "title_mismatch", +] as const; const UNRESOLVED_SKIP = new Set(UNRESOLVED_EXCLUSIONS.map((r) => `excluded:${r}`)); /** 文件结果的 skip_reason 是否属于"本该入库却被排除" */