diff --git a/apps/server/src/modules/acquisition-organize/services/downloads.ts b/apps/server/src/modules/acquisition-organize/services/downloads.ts index dcc0f533..081a5693 100644 --- a/apps/server/src/modules/acquisition-organize/services/downloads.ts +++ b/apps/server/src/modules/acquisition-organize/services/downloads.ts @@ -16,10 +16,12 @@ import { type Transaction, } from "@umt/db"; import { + type EpisodeResolution, episodeKey, fileKindOf, mapQbState, type PlanStage, + parseRelease, parseReleasePath, parseUnitKey, type SlotEvent, @@ -31,6 +33,7 @@ import { } from "@umt/domain"; import { mergeObservedDownloadState, toDomainDownloadStatus } from "@umt/mapping"; import { withAttemptToken } from "@umt/tasks"; +import { createEntityEpisodeMapper, publishedAtOf } from "../../sources-identity/service/episode-resolution.ts"; import { errMessage, jsonb } from "../util.ts"; import { parsedOf } from "./acquisition.ts"; import { @@ -466,17 +469,42 @@ async function selectPackFiles( const u = parseUnitKey(k); return !!u && wildSeasons.has(u.season) && !blocked.has(k) && unitsOverlap([k], [...blocked]).length === 0; }; + // 集数映射(批次 3):文件名的集号按作品季表 + 规则换成规范季集再算单元键;文件没写季号时补资源标题声称的季号 + // (原始值,不是规范值,映射时才不会重复换算)。集数待确认的视频视同认不出集号(整包下载); + // 映射规则标为特别篇 / 花絮 / 忽略的不下 + const entity = rel.media_entity_id + ? await d.ctx.db + .selectFrom("media_entity") + .select(["id", "kind"]) + .where("id", "=", rel.media_entity_id) + .executeTakeFirst() + : undefined; + const mapEpisodes = entity ? await createEntityEpisodeMapper(d.ctx.db, entity.id, entity.kind) : null; + const relRes = rel.episode_resolution as EpisodeResolution | null; + const relRaw = mapEpisodes ? parseRelease(rel.title, rel.subtitle ? { subtitle: rel.subtitle } : {}) : null; + const relSeason = relRaw && !relRaw.seasonInferred ? relRaw.season : undefined; + const mapOpts = { publishedAt: publishedAtOf(rel), ...(relRes?.method ? { release: relRes } : {}) }; const rows = files.map((f) => { const kind = fileKindOf(f.name); - const p = parseReleasePath(f.name); + let p = parseReleasePath(f.name); + let special = false; + if (mapEpisodes && kind === "video" && p.episodes) { + if ((p.season === undefined || p.seasonInferred) && relSeason !== undefined) { + p = { ...p, season: relSeason, seasonInferred: undefined }; + } + const m = mapEpisodes(p, mapOpts); + if (m.resolution.method === "unresolved") p = { ...p, episodes: undefined }; + else if (m.resolution.special) special = true; + else p = m.parsed; + } const keys: string[] = []; - if (p.episodes) + if (p.episodes && !special) for (let e = p.episodes.start; e <= p.episodes.end && keys.length < 200; e++) keys.push(episodeKey(p.season ?? parsed.season ?? 1, e)); - return { index: f.index, name: f.name, kind, keys, wanted: keys.some(wantedKey) }; + return { index: f.index, name: f.name, kind, keys, special, wanted: keys.some(wantedKey) }; }); const videos = rows.filter((r) => r.kind === "video"); - const unmapped = videos.filter((r) => r.keys.length === 0); + const unmapped = videos.filter((r) => r.keys.length === 0 && !r.special); if (videos.length === 0 || unmapped.length > 0) return void (await record({ mode: "whole", diff --git a/apps/server/src/modules/acquisition-organize/services/organize-plan.ts b/apps/server/src/modules/acquisition-organize/services/organize-plan.ts index b053ba6e..4f6c44c6 100644 --- a/apps/server/src/modules/acquisition-organize/services/organize-plan.ts +++ b/apps/server/src/modules/acquisition-organize/services/organize-plan.ts @@ -3,11 +3,13 @@ import { basename, dirname, join, relative } from "node:path"; import { checkRoot, DEFAULT_ROOT_SENTINEL, FsError, sha256Full, statSafe } from "@umt/adapters"; import type { ActorType, OrganizationTrigger, PathBinding } from "@umt/contracts"; import { reasonText } from "@umt/contracts"; -import type { DownloadTaskRow, LibraryRootRow, MediaEntityRow } from "@umt/db"; +import type { DownloadTaskRow, LibraryRootRow, MediaEntityRow, SourceReleaseRow } from "@umt/db"; import { sql } from "@umt/db"; import { buildNamingContext, type PathBinding as DomainBinding, + type FileOverride as DomainFileOverride, + type EpisodeResolution, type ExistingTarget, fileKindOf, type NewBinding, @@ -24,6 +26,13 @@ import { sidecarStem, } from "@umt/domain"; import { organizePlanToFileResults, toDomainPathBinding } from "@umt/mapping"; +import { + createEntityEpisodeMapper, + type EntityEpisodeMapper, + type FileEpisodeOptions, + publishedAtOf, + settleEpisodeDecision, +} from "../../sources-identity/service/episode-resolution.ts"; import { errMessage, hashJson, jsonb, toApi } from "../util.ts"; import { publishedAcquisitionPolicies, unitKeysOf } from "./acquisition.ts"; import { classifyEntity } from "./classification.ts"; @@ -36,7 +45,7 @@ import { successfulOrganizeCounts } from "./organize-history.ts"; import { planTransfer } from "./organize-transfer.ts"; // 入库:冻结整理计划(organization_record + 文件明细)→ 逐文件可恢复执行(操作账本 fencing)→ // 资产/位置/路径绑定同事务登记 → Slot 推进 → Move 后移除 qB 任务记录(不删数据)。 -import type { FrozenPlan, ImportRequest, PlanResult } from "./organize-types.ts"; +import type { EpisodeFacts, FrozenPlan, ImportRequest, PlanResult } from "./organize-types.ts"; import { type FrozenReplacement, freezeReplacement } from "./replacement.ts"; import { hnrSatisfied } from "./retention.ts"; import { subscriptionLibraryRoot } from "./subscriptions.ts"; @@ -623,14 +632,40 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise(); + // 集数映射(批次 3):文件名解析出的是发布者声称的季集,按作品季表 + 规则换成规范季集再定季目录、命名与单元; + // 用户逐文件指定的季 / 集优先,不再映射。结果以精确路径覆写交给 planOrganize(待确认 / special 规则的文件不入库) + const episodes = mediaType === "tv" ? await planFileEpisodes(d, entity, rel, dt) : null; + const mappedOverrides: Record = {}; + const unresolvedEpisodes: NonNullable = {}; for (const f of files) { - const parsed = parseReleasePath(segmentsWithin(f.path, sourceRoot)); + let parsed = parseReleasePath(segmentsWithin(f.path, sourceRoot)); if (parsed.fileKind !== "video") continue; const ov = overrideFor(req.fileOverrides, f.path); - if (mediaType === "tv" && ov?.season !== undefined) { - parsed.season = ov.season; + let resolution: EpisodeResolution | undefined; + if (mediaType === "tv" && (ov?.season !== undefined || ov?.episodes)) { + if (ov?.season !== undefined) parsed.season = ov.season; + if (ov?.episodes) parsed.episodes = ov.episodes; + } else if (episodes) { + const raw = parsed; + const r = episodes.map(raw, episodes.opts); + resolution = r.resolution; + if (r.resolution.method === "unresolved" || r.resolution.special) { + const unresolved = r.resolution.method === "unresolved"; + mappedOverrides[f.path] = { + exclude: unresolved + ? { reason: "episode_unresolved", detail: episodeText(r.resolution) } + : { reason: "special_extra", detail: `rule:${r.resolution.special}` }, + }; + unresolvedEpisodes[f.path] = episodeFacts(r.resolution); + if (unresolved) episodes.unresolved.push({ path: f.path, resolution: r.resolution, parsed: raw }); + continue; + } + parsed = r.parsed; + const c = r.resolution.canonical; + if (c && (c.season !== (raw.season ?? 1) || !sameRange(c.episodes, raw.episodes))) { + mappedOverrides[f.path] = { season: c.season, episodes: { ...c.episodes } }; + } } - if (mediaType === "tv" && ov?.episodes) parsed.episodes = ov.episodes; const season = parsed.season ?? (parsed.episodes || parsed.airDate ? 1 : undefined); if (mediaType === "tv" && season !== undefined) seasons.add(season); facts[f.path] = { @@ -642,8 +677,11 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise 0 ? { ...req.fileOverrides, ...mappedOverrides } : req.fileOverrides; const libRoot = { id: root.id, hostPath: root.local_path }; const newBindings: NewBinding[] = []; const target: { movieDir?: string; seasonDirs?: Record } = {}; @@ -755,7 +793,7 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise c.blocking); + // 没有可入库主视频是因为集数待确认:原因写 episode_unresolved(而不是笼统的 no_main_video) + const blockKinds = [ + ...new Set( + blocking.map((c) => + c.kind === "no_main_video" && plan.excluded.some((x) => x.reason === "episode_unresolved") + ? "episode_unresolved" + : c.kind, + ), + ), + ]; const reason = blocking.length - ? `blocked:${[...new Set(blocking.map((c) => c.kind))].join(",")}` + ? blockKinds.length === 1 && blockKinds[0] === "episode_unresolved" + ? "episode_unresolved" + : `blocked:${blockKinds.join(",")}` : downgradedFromMove ? `move_downgraded_to_copy:${downgradedFromMove}` : null; @@ -1003,9 +1054,88 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise { + const db = d.ctx.db; + let linked = rel; + const planId = dt?.acquisition_plan_id ?? dt?.import_plan_id; + if (!linked && planId) { + linked = await db + .selectFrom("acquisition_plan as p") + .innerJoin("source_release as r", "r.id", "p.source_release_id") + .selectAll("r") + .where("p.id", "=", planId) + .executeTakeFirst(); + } + const map = await createEntityEpisodeMapper(db, entity.id, entity.kind); + const own = linked && linked.media_entity_id === entity.id ? linked : null; + const res = own?.episode_resolution as EpisodeResolution | null | undefined; + return { + map, + opts: { + ...(linked ? { publishedAt: publishedAtOf(linked) } : {}), + ...(res?.method ? { release: res } : {}), + }, + release: own, + unresolved: [], + }; +} + +const sameRange = (a: { start: number; end: number }, b: { start: number; end: number } | undefined) => + !!b && a.start === b.start && a.end === b.end; + +/** 冻结进计划的集数映射摘要(排查用) */ +function episodeFacts(r: EpisodeResolution): EpisodeFacts { + return { + method: r.method, + source: r.source, + canonical: r.canonical, + ...(r.ruleId ? { ruleId: r.ruleId } : {}), + ...(r.special ? { special: r.special } : {}), + evidence: r.evidence, + }; +} + +const pad2 = (n: number) => String(n).padStart(2, "0"); + +/** 排除明细:原始季集 + 判定依据 */ +function episodeText(r: EpisodeResolution): string { + const { season, episodes, airDate } = r.source; + const ep = episodes + ? `E${pad2(episodes.start)}${episodes.end > episodes.start ? `-E${pad2(episodes.end)}` : ""}` + : (airDate ?? ""); + return `${season !== undefined ? `S${pad2(season)}` : ""}${ep} ${r.evidence.join(",")}`.trim(); +} + /** W9-H 保留原文件名:目标目录不变,文件名取源文件名 */ export const keptDst = (f: { src: string; dst: string }) => join(dirname(f.dst), basename(f.src)); diff --git a/apps/server/src/modules/acquisition-organize/services/organize-types.ts b/apps/server/src/modules/acquisition-organize/services/organize-types.ts index f6cdd4af..a7d4c9ef 100644 --- a/apps/server/src/modules/acquisition-organize/services/organize-types.ts +++ b/apps/server/src/modules/acquisition-organize/services/organize-types.ts @@ -1,5 +1,5 @@ import type { ActorType, OrganizationRecord, OrganizationTrigger } from "@umt/contracts"; -import type { ExistingPolicy, NewBinding, OrganizePlan, QualityFacts } from "@umt/domain"; +import type { EpisodeResolution, ExistingPolicy, NewBinding, OrganizePlan, QualityFacts } from "@umt/domain"; import type { FrozenTransfer, TransferSpec } from "./organize-transfer.ts"; import type { FrozenReplacement } from "./replacement.ts"; @@ -69,6 +69,12 @@ export interface FileOverrideInput { contentUnitIds?: string[]; } +/** 冻结计划里的集数映射摘要(排查用) */ +export type EpisodeFacts = Pick< + EpisodeResolution, + "method" | "source" | "canonical" | "evidence" | "ruleId" | "special" +>; + /** organization_record.plan 的冻结内容 */ export interface FrozenPlan { scrapeAfterImport?: boolean; @@ -104,8 +110,12 @@ export interface FrozenPlan { episodes?: { start: number; end: number }; /** 人工指定的内容单元(W5-A2 覆写) */ unitIds?: string[]; + /** 按作品做的集数映射(方式 + 文件名原始季集);人工指定季集或非剧集时没有 */ + episodeResolution?: EpisodeFacts; } >; + /** 集数待确认 / 映射规则标为特别篇 · 花絮 · 忽略、因而不入库的视频:源路径 → 集数映射摘要 */ + unresolvedEpisodes?: Record; downgradedFromMove?: string; /** W5-A2:手动整理指定的 Slot */ slotId?: string; diff --git a/apps/server/src/modules/acquisition-organize/services/scrape.ts b/apps/server/src/modules/acquisition-organize/services/scrape.ts index 3b1968c4..1b028829 100644 --- a/apps/server/src/modules/acquisition-organize/services/scrape.ts +++ b/apps/server/src/modules/acquisition-organize/services/scrape.ts @@ -23,6 +23,10 @@ import { seasonOfDirName, workNfo, } from "@umt/domain"; +import { + createEntityEpisodeMapper, + type EntityEpisodeMapper, +} from "../../sources-identity/service/episode-resolution.ts"; import { errMessage, jsonb } from "../util.ts"; import type { AoDeps } from "./common.ts"; import { @@ -664,6 +668,7 @@ export async function runScrapeRecord( // ── 字段:本地作品(有元数据)优先 ── let fields: Fields | null = null; let coverage = new Map(); + let mapEpisodes: EntityEpisodeMapper | null = null; if (match.mediaEntityId) { const e = await db .selectFrom("media_entity") @@ -671,6 +676,8 @@ export async function runScrapeRecord( .where("id", "=", match.mediaEntityId) .executeTakeFirst(); if (e && e.status !== "merged") { + if (kind === "tv" && (e.kind === "series" || e.kind === "variety")) + mapEpisodes = await createEntityEpisodeMapper(db, e.id, e.kind); const ids: IdentityMap = {}; const idRows = await db .selectFrom("external_identity") @@ -711,6 +718,8 @@ export async function runScrapeRecord( // 季 / 集号:已登记的按覆盖单元;其余按路径解析(季目录名兜底,同 MP 对每集重新 MetaInfoPath) const videos: ScrapeVideo[] = []; const noEpisode: ScrapeFileResult[] = []; + // 集数映射(批次 3):未登记的文件按本地剧集作品的季表 + 规则把文件名季集换成规范季集再对集元数据; + // 集数待确认的不写分集 NFO(原因 episode_unresolved) for (const v of lay.videos) { if (kind === "movie") { videos.push({ path: v }); @@ -722,8 +731,26 @@ export async function runScrapeRecord( continue; } const p = parseReleasePath(v); - const season = p.season ?? lay.dirSeason ?? seasonOfDirName(baseOf(dirOf(v))) ?? (p.episodes ? 1 : null); - const episode = p.episodes?.start ?? null; + let season = p.season ?? lay.dirSeason ?? seasonOfDirName(baseOf(dirOf(v))) ?? (p.episodes ? 1 : null); + let episode = p.episodes?.start ?? null; + if (mapEpisodes && p.episodes) { + const m = mapEpisodes(p); + if (m.resolution.method === "unresolved") { + noEpisode.push({ + path: v, + kind: "nfo", + role: "episode_nfo", + action: "skipped", + reason: "episode_unresolved", + }); + videos.push({ path: v, season, episode: null }); + continue; + } + if (m.resolution.canonical) { + season = m.resolution.canonical.season; + episode = m.resolution.canonical.episodes.start; + } + } if (episode === null) noEpisode.push({ path: v, kind: "nfo", role: "episode_nfo", action: "skipped", reason: "scrape_no_episode" }); videos.push({ path: v, season, episode }); diff --git a/apps/server/src/modules/file-manager/identify.ts b/apps/server/src/modules/file-manager/identify.ts index d669c73e..7fc07240 100644 --- a/apps/server/src/modules/file-manager/identify.ts +++ b/apps/server/src/modules/file-manager/identify.ts @@ -23,6 +23,7 @@ import { localIdentityCandidate } from "@umt/mapping"; import type { Principal } from "../../http/auth.ts"; import { successfulOrganizeCounts } from "../acquisition-organize/services/organize-history.ts"; import { EXTERNAL_INVOKE_TIMEOUT_MS, sharedInvokeLimiter } from "../agent/invoke-limits.ts"; +import { createEntityEpisodeMapper, type EntityEpisodeMapper } from "../sources-identity/service/episode-resolution.ts"; import { actorOfP, errMessage, FilesError, type FmDeps, resolveIn } from "./common.ts"; import { kindOfName } from "./list.ts"; @@ -226,6 +227,7 @@ export async function identifyCore( const localCache = new Map(); const remoteCache = new Map(); const entityKinds = new Map(); + const mappers = new Map(); // G8:候选海报(TMDB poster_path;本地作品取投影后的 metadata.posterPath) const posters = new Map(); @@ -446,10 +448,28 @@ export async function identifyCore( const best = candidates[0]; let contentUnitId: string | null = null; let contentUnit: string | null = null; - const season = parsed.season ?? (parsed.episodes ? 1 : undefined); + // 集数映射(批次 3,只读):识别到本地剧集作品后,展示与建议单元用该作品映射后的季集;待确认的不给单元 + let shown = parsed; + let unresolved = false; + if (best?.mediaEntityId && parsed.episodes) { + const kind = entityKinds.get(best.mediaEntityId); + if (kind === "series" || kind === "variety") { + let map = mappers.get(best.mediaEntityId); + if (!map) { + map = await createEntityEpisodeMapper(db, best.mediaEntityId, kind); + mappers.set(best.mediaEntityId, map); + } + const m = map(parsed); + if (m.resolution.method === "unresolved") { + unresolved = true; + warnings.push("集数对不上作品的季集,需要人工指定"); + } else shown = m.parsed; + } + } + const season = unresolved ? undefined : (shown.season ?? (shown.episodes ? 1 : undefined)); if (season !== undefined) { const pad = (n: number) => String(n).padStart(2, "0"); - contentUnit = parsed.episodes ? `S${pad(season)}E${pad(parsed.episodes.start)}` : `S${pad(season)}`; + contentUnit = shown.episodes ? `S${pad(season)}E${pad(shown.episodes.start)}` : `S${pad(season)}`; if (best?.mediaEntityId) { let q = db .selectFrom("content_unit") @@ -457,7 +477,7 @@ export async function identifyCore( .where("media_entity_id", "=", best.mediaEntityId) .where("season_number", "=", season) .where("status", "<>", "retired"); - q = parsed.episodes ? q.where("episode_number", "=", parsed.episodes.start) : q.where("kind", "=", "season"); + q = shown.episodes ? q.where("episode_number", "=", shown.episodes.start) : q.where("kind", "=", "season"); contentUnitId = (await q.executeTakeFirst())?.id ?? null; } } @@ -474,7 +494,7 @@ export async function identifyCore( items.push({ root: v.root as FileIdentifyItem["root"], path: v.rel, - parsed: parsedOut(parsed), + parsed: parsedOut(shown), candidates, suggestion: { mediaEntityId: best?.mediaEntityId ?? null, contentUnitId, contentUnit }, existing: { assetId: existing?.media_asset_id ?? null }, diff --git a/apps/server/src/modules/file-manager/suggest-name.ts b/apps/server/src/modules/file-manager/suggest-name.ts index 545594c4..2d65e36c 100644 --- a/apps/server/src/modules/file-manager/suggest-name.ts +++ b/apps/server/src/modules/file-manager/suggest-name.ts @@ -2,6 +2,7 @@ import { extname } from "node:path"; import type { FilesSuggestNameRequest, FilesSuggestNameResult } from "@umt/contracts"; import { buildNamingVars, normalizeTitle, parseReleasePath, renderNamingPath } from "@umt/domain"; import { effectiveNaming, loadNamingEntity } from "../acquisition-organize/services/naming.ts"; +import { createEntityEpisodeMapper } from "../sources-identity/service/episode-resolution.ts"; import { FilesError, type FmDeps, resolveIn } from "./common.ts"; // G9 推荐名:按命名模板(作品 > 库根 > 全局)对选中文件/目录渲染名字(与整理用同一引擎与变量来源)。 @@ -76,7 +77,14 @@ export async function suggestName(d: FmDeps, body: FilesSuggestNameRequest): Pro const namingEntity = await loadNamingEntity(db, entity, { libraryRoot: root?.name }); const kind = namingEntity.kind ?? "movie"; const ext = isDir ? "" : extname(r.rel); - const vars = buildNamingVars(namingEntity, parsed, isDir ? {} : { fileExt: ext }); + // 集数映射(批次 3,只读):文件名的季集按作品季表 + 规则换成规范季集再命名;待确认的不给推荐名 + let named = parsed; + if (!isDir && parsed.episodes && (entity.kind === "series" || entity.kind === "variety")) { + const m = (await createEntityEpisodeMapper(db, entity.id, entity.kind))(parsed); + if (m.resolution.method === "unresolved") return empty("episode_unresolved", entityId); + named = m.parsed; + } + const vars = buildNamingVars(namingEntity, named, isDir ? {} : { fileExt: ext }); const out = renderNamingPath(eff.templates[kind], vars, isDir ? { mode: "dirs" } : {}); const segments = out.segments.map((s) => ({ text: s.text, role: s.role })); if (!out.ok) diff --git a/apps/server/src/modules/sources-identity/service/episode-resolution.ts b/apps/server/src/modules/sources-identity/service/episode-resolution.ts index 0f3655fd..dce6f956 100644 --- a/apps/server/src/modules/sources-identity/service/episode-resolution.ts +++ b/apps/server/src/modules/sources-identity/service/episode-resolution.ts @@ -131,6 +131,56 @@ export async function resolveReleaseEpisodes(exec: Exec, input: ReleaseEpisodesI return { resolution, parsed: canonicalParsed(input.parsed, resolution) }; } +export interface FileEpisodeOptions { + /** 资源发布时间:滞后判定用;文件没有关联资源时不传 */ + publishedAt?: Date | string | null; + /** + * 文件所属下载关联的资源的集数解析结果:其原始季集与文件解析出的相同(单集资源的那个文件)时直接沿用, + * 保证下载与整理认同一集 + */ + release?: EpisodeResolution | null; +} + +/** 按作品映射文件 / 资源解析结果的季集(同步,季表与规则已加载) */ +export type EntityEpisodeMapper = (parsed: ParsedRelease, opts?: FileEpisodeOptions) => ReleaseEpisodes; + +const EPISODE_KINDS = new Set(["series", "variety"]); + +/** + * 文件侧接入(批次 3):整理计划、季包选文件、文件管理识别、刮削对文件名重新解析出的季集,按同一作品的季表 + 规则 + * 映射。季表与规则只加载一次;返回的函数纯计算,不停用规则、不开待处理(由调用方决定)。 + * 非剧集作品不加载,解析结果原样返回(method = skipped)。 + */ +export async function createEntityEpisodeMapper( + exec: Exec, + entityId: string, + kind: MediaKind, +): Promise { + const episodic = EPISODE_KINDS.has(kind); + const table: SeasonTable = episodic + ? await loadSeasonTable(exec, entityId) + : { seasons: [], latestSeason: null, ended: false }; + const rules = episodic ? await loadRules(exec, entityId) : []; + return (parsed, opts = {}) => { + const rel = opts.release; + if (rel && sameSource(rel.source, parsed)) return { resolution: rel, parsed: canonicalParsed(parsed, rel) }; + const p = opts.publishedAt; + const publishedAt = p instanceof Date ? p.toISOString() : (p ?? null); + const resolution = resolveEpisodes({ parsed, kind, table, rules, publishedAt }); + return { resolution, parsed: canonicalParsed(parsed, resolution) }; + }; +} + +/** 资源的原始季集与文件解析结果相同(季号缺省都按未给出比较,集区间与播出日期逐项相等) */ +function sameSource(src: EpisodeResolution["source"], p: ParsedRelease): boolean { + if (!src.episodes && !src.airDate) return false; + if (src.season !== p.season) return false; + const a = src.episodes; + const b = p.episodes; + if (!!a !== !!b || (a && b && (a.start !== b.start || a.end !== b.end))) return false; + return (src.airDate ?? null) === (p.airDate ?? null); +} + /** 规范季集叠加到解析结果上(保留其余字段);canonical 为空时原样返回 */ export function canonicalParsed(parsed: ParsedRelease, resolution: EpisodeResolution): ParsedRelease { const c = resolution.canonical; diff --git a/apps/server/test/acquisition-organize/episode-resolution-files.test.ts b/apps/server/test/acquisition-organize/episode-resolution-files.test.ts new file mode 100644 index 00000000..f8dd342b --- /dev/null +++ b/apps/server/test/acquisition-organize/episode-resolution-files.test.ts @@ -0,0 +1,401 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { basename, join } from "node:path"; +import { type EpisodeResolution, parseRelease, resolveEpisodes } from "@umt/domain"; +import { toParsedColumns } from "@umt/mapping"; +import { createMockQb, createTempTree, type MockQb, makeTorrent, type TempTree } from "@umt/testkit"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { submitDownload, syncDownloads } from "../../src/modules/acquisition-organize/services/downloads.ts"; +import { type FrozenPlan, planImport } from "../../src/modules/acquisition-organize/services/organize.ts"; +import { queueScrape, runScrapeRecord } from "../../src/modules/acquisition-organize/services/scrape.ts"; +import { canonicalParsed } from "../../src/modules/sources-identity/service/episode-resolution.ts"; +import { createHarness, fakeDisk, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts"; + +// 文件侧集数映射(docs/plan/episode-resolution.md 批次 3):整理计划与季包选文件对文件名重新解析出的季集, +// 按作品季表 + 映射规则换成规范季集。ANi 连续编号 `- 25` 下载时认作 S02E13,整理时不能又按文件名认成 S01E25。 + +const hasPg = await pgAvailable(); +const SIZE = 300_000; + +// 已完结:S1 = 12、S2 = 13 +const TABLE = { + seasons: [ + { season: 1, total: 12 }, + { season: 2, total: 13 }, + ], + latestSeason: 2, + ended: true, +}; +const ani = (ep: number) => `[ANi] Kusuriya no Hitorigoto - ${ep} [1080P].mkv`; + +describe.skipIf(!hasPg)("整理与季包选文件按作品映射集数(真实 PG + mock qB + 临时目录)", () => { + let h: Harness; + let qb: MockQb; + let lib: TempTree; + let cache: TempTree; + let src: TempTree; + let base: Awaited>; + const disk = fakeDisk(10_000_000_000); + const torrents = new Map(); + let n = 0; + + beforeAll(async () => { + qb = await createMockQb({ addVisibleAfterPolls: 0 }); + lib = await createTempTree({}, { sentinel: true, prefix: "umt-eplib-" }); + cache = await createTempTree({}, { prefix: "umt-epcache-" }); + const pack: Record = {}; + for (let e = 13; e <= 25; e++) pack[`pack/${ani(e)}`] = SIZE; + src = await createTempTree( + { + ...pack, + [`single/${ani(25)}`]: SIZE, + [`over/${ani(40)}`]: SIZE, + "rule/[ANi] Rule Show - 14 [1080P].mkv": SIZE, + "plain/Plain.Show.S01E05.1080p.WEB-DL.x264-GRP.mkv": SIZE, + }, + { prefix: "umt-epsrc-" }, + ); + disk.state.watch = [cache.root]; + disk.setPct(10); + h = await createHarness( + "episode_files", + { + diskUsage: disk.fn, + torrentSource: async ({ release }) => ({ kind: "file", bytes: torrents.get(release.id)! }), + }, + { withSources: true }, + ); + base = await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url }); + }, 60_000); + + afterAll(async () => { + await h?.close(); + await qb?.close(); + for (const t of [lib, cache, src]) await t?.cleanup(); + }); + + /** 剧集作品 + 季表(季单元总集数 + 已完结) */ + async function series(title: string, table = TABLE) { + const id = await seedEntity(h.db, { kind: "series", title, year: 2023 }); + await h.db + .updateTable("media_entity") + .set({ metadata: JSON.stringify({ airStatus: { value: "Ended", source: "tmdb" } }) }) + .where("id", "=", id) + .execute(); + const units: Record = {}; + for (const s of table.seasons) { + units[s.season] = ( + await h.db + .insertInto("content_unit") + .values({ + media_entity_id: id, + kind: "season", + season_number: s.season, + episode_total: s.total, + status: "confirmed", + source: "tmdb", + }) + .returning("id") + .executeTakeFirstOrThrow() + ).id; + } + return { id, units }; + } + + /** 已识别的资源:parsed 为规范季集,episode_resolution 为判定结果(evidence 可追加标记,验证整理沿用的是它) */ + async function release(entityId: string, title: string, opts: { files?: string[]; mark?: string } = {}) { + const raw = parseRelease(title); + const resolution: EpisodeResolution = resolveEpisodes({ parsed: raw, kind: "series", table: TABLE, rules: [] }); + if (opts.mark) resolution.evidence.push(opts.mark); + const cols = toParsedColumns(canonicalParsed(raw, resolution)); + const t = opts.files + ? makeTorrent({ + name: title, + files: opts.files.map((path) => ({ path, length: SIZE })), + pieceSeed: Math.floor(Math.random() * 1e6), + }) + : makeTorrent({ name: `${title}.mkv`, length: SIZE, pieceSeed: Math.floor(Math.random() * 1e6) }); + const id = ( + await h.db + .insertInto("source_release") + .values({ + site_id: base.siteId, + site_release_id: `${Math.floor(Math.random() * 1e9)}`, + title, + size_bytes: SIZE, + infohash_v1: t.hash, + media_entity_id: entityId, + resolution_status: "resolved", + parsed: JSON.stringify(cols.parsed), + parser_version: cols.parserVersion, + episode_resolution: JSON.stringify(resolution), + raw: JSON.stringify({ _site: { hr: false } }), + }) + .returning("id") + .executeTakeFirstOrThrow() + ).id; + torrents.set(id, t.bytes); + return { id, hash: t.hash, resolution }; + } + + /** qB 里已完成的下载任务(接管进来),关联到资源 */ + async function completedTask(releaseId: string, name: string, file: string) { + n++; + const hash = n.toString(16).padStart(40, "b"); + qb.seed({ hash, name, tags: ["umt"], save_path: cache.root }); + await qb.complete(hash, { files: [{ path: `${name}/${file}`, size: SIZE }] }); + await syncDownloads(h.d); + const dt = await h.db + .selectFrom("download_task") + .select("id") + .where("torrent_hash", "=", hash) + .executeTakeFirstOrThrow(); + await h.db.updateTable("download_task").set({ source_release_id: releaseId }).where("id", "=", dt.id).execute(); + return dt.id; + } + + const dryRun = async (entityId: string, dir: string, extra: Partial[1]> = {}) => { + const r = await planImport(h.d, { + sourcePaths: [src.path(dir)], + mediaEntityId: entityId, + action: "copy", + trigger: "manual", + actorType: "user", + dryRun: true, + ...extra, + }); + expect(r.plan, JSON.stringify(r)).toBeDefined(); + return r.plan!; + }; + const mains = (plan: FrozenPlan) => + plan.organize.files + .filter((f) => f.role === "main") + .map((f) => ({ src: basename(f.src), dst: basename(f.dst), season: f.season, episodes: f.episodes })); + + it("关联资源 `- 25` 已认作 S02E13:下载文件 `- 25` 整理命名与单元为 S02E13,沿用资源的判定", async () => { + const { id } = await series("药屋少女的呢喃"); + const rel = await release(id, "[ANi] Kusuriya no Hitorigoto - 25 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]", { + mark: "test:from_release", + }); + expect(rel.resolution.canonical).toEqual({ season: 2, episodes: { start: 13, end: 13 } }); + const dtId = await completedTask(rel.id, "[ANi] Kusuriya no Hitorigoto - 25", ani(25)); + const r = await planImport(h.d, { downloadTaskId: dtId, trigger: "auto", actorType: "system", dryRun: true }); + expect(r.status, JSON.stringify(r)).toBe("queued"); + const plan = r.plan!; + const [m] = mains(plan); + expect(m).toMatchObject({ season: 2, episodes: { start: 13, end: 13 } }); + expect(m!.dst).toMatch(/S02E13/); + expect(m!.dst).not.toMatch(/S01E25/); + const fact = Object.values(plan.facts)[0]!; + expect(fact).toMatchObject({ season: 2, episodes: { start: 13, end: 13 }, unitKeys: ["s2e13"] }); + expect(fact.episodeResolution).toMatchObject({ + method: "absolute_cross_season", + source: { season: 1, episodes: { start: 25, end: 25 } }, + }); + expect(fact.episodeResolution!.evidence).toContain("test:from_release"); + }); + + it("季包 `- 13`…`- 25`(无季号)逐文件映射到 S02E01…S02E13", async () => { + const { id } = await series("季包作品"); + const plan = await dryRun(id, "pack"); + const got = mains(plan).sort((a, b) => a.episodes!.start - b.episodes!.start); + expect(got).toHaveLength(13); + got.forEach((m, i) => { + expect(m.season).toBe(2); + expect(m.episodes).toEqual({ start: i + 1, end: i + 1 }); + expect(m.dst).toMatch(new RegExp(`S02E${String(i + 1).padStart(2, "0")}`)); + }); + expect(Object.values(plan.facts).every((f) => f.season === 2)).toBe(true); + }); + + it("用户逐文件指定的季集优先,不被映射覆盖", async () => { + const { id } = await series("指定集数作品"); + const file = src.path(`single/${ani(25)}`); + const plan = await dryRun(id, "single", { + fileOverrides: { [file]: { season: 1, episodes: { start: 7, end: 7 } } }, + }); + const [m] = mains(plan); + expect(m).toMatchObject({ season: 1, episodes: { start: 7, end: 7 } }); + expect(m!.dst).toMatch(/S01E07/); + expect(plan.facts[file]).toMatchObject({ season: 1, unitKeys: ["s1e7"] }); + expect(plan.facts[file]!.episodeResolution).toBeUndefined(); + }); + + it("文件集数待确认:不按原号命名,整理失败原因 episode_unresolved,并入关联资源的 episode_mapping 待处理", async () => { + const { id } = await series("待确认作品"); + // 没有关联资源:只看计划 + const dry = await planImport(h.d, { + sourcePaths: [src.path("over")], + mediaEntityId: id, + action: "copy", + trigger: "manual", + actorType: "user", + dryRun: true, + }); + expect(dry.status).toBe("failed"); + expect(dry.reason).toBe("episode_unresolved"); + expect(dry.plan!.organize.files).toHaveLength(0); + expect(dry.plan!.organize.excluded).toEqual([ + expect.objectContaining({ reason: "episode_unresolved", detail: expect.stringContaining("S01E40") }), + ]); + expect(Object.values(dry.plan!.unresolvedEpisodes ?? {})[0]).toMatchObject({ + method: "unresolved", + source: { season: 1, episodes: { start: 40, end: 40 } }, + }); + + // 关联资源(同样待确认):记录失败 + 待处理 + const rel = await release(id, "[ANi] Kusuriya no Hitorigoto - 40 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]"); + expect(rel.resolution.method).toBe("unresolved"); + const dtId = await completedTask(rel.id, "[ANi] Kusuriya no Hitorigoto - 40", ani(40)); + const r = await planImport(h.d, { downloadTaskId: dtId, trigger: "auto", actorType: "system" }); + expect(r).toMatchObject({ status: "failed", reason: "episode_unresolved" }); + const files = await h.db + .selectFrom("organization_file_result") + .select(["target_path", "skip_reason"]) + .where("organization_record_id", "=", r.recordId!) + .execute(); + expect(files.every((f) => !f.target_path?.includes("S01E40"))).toBe(true); + expect(files.map((f) => f.skip_reason)).toContain("excluded:episode_unresolved"); + const decision = await h.db + .selectFrom("decision") + .select(["evidence", "status"]) + .where("kind", "=", "episode_mapping") + .where("subject_id", "=", id) + .executeTakeFirstOrThrow(); + expect(decision.evidence).toMatchObject({ sourceReleaseIds: [rel.id] }); + }); + + it("有 active 规则 EP-12(→ 第 2 季)时整理按规则", async () => { + const { id } = await series("规则作品"); + const rule = await h.db + .insertInto("episode_mapping_rule") + .values({ + media_entity_id: id, + season: 1, + release_group: "ANi", + kind: "expression", + expression: "EP-12", + target_season: 2, + created_by_type: "user", + created_by_id: "admin", + }) + .returning("id") + .executeTakeFirstOrThrow(); + const plan = await dryRun(id, "rule"); + const [m] = mains(plan); + expect(m).toMatchObject({ season: 2, episodes: { start: 2, end: 2 } }); + expect(m!.dst).toMatch(/S02E02/); + expect(Object.values(plan.facts)[0]!.episodeResolution).toMatchObject({ method: "rule", ruleId: rule.id }); + }); + + it("原号本来就对(S01E05):整理行为不变", async () => { + const { id } = await series("原号作品"); + const plan = await dryRun(id, "plain"); + const [m] = mains(plan); + expect(m).toMatchObject({ season: 1, episodes: { start: 5, end: 5 } }); + expect(m!.dst).toMatch(/S01E05/); + const fact = Object.values(plan.facts)[0]!; + expect(fact).toMatchObject({ season: 1, unitKeys: ["s1e5"] }); + expect(fact.episodeResolution).toMatchObject({ method: "raw" }); + }); + + it("刮削:未登记的 `- 25` 按作品映射成 S02E13 对集元数据;待确认的 `- 40` 不写分集 NFO", async () => { + const { id } = await series("刮削作品"); + await h.db + .updateTable("media_entity") + .set({ + metadata: JSON.stringify({ + airStatus: { value: "Ended", source: "tmdb" }, + title: { value: "刮削作品", source: "tmdb", language: "zh-CN", at: new Date().toISOString(), locked: false }, + }), + }) + .where("id", "=", id) + .execute(); + await h.db + .insertInto("content_unit") + .values({ + media_entity_id: id, + kind: "episode", + season_number: 2, + episode_number: 13, + status: "confirmed", + source: "tmdb", + title: "第二季终集", + }) + .execute(); + const dir = "刮削作品 (2023)"; + await mkdir(join(lib.root, dir), { recursive: true }); + for (const ep of [25, 40]) await writeFile(join(lib.root, dir, ani(ep)), "v"); + const q = await queueScrape(h.d, { + rootId: `lib:${base.rootId}`, + rootPath: lib.root, + libraryRootId: base.rootId, + path: dir, + match: { + mediaEntityId: id, + provider: null, + externalId: null, + kind: "tv", + title: "刮削作品", + year: 2023, + source: "chosen", + }, + trigger: "manual", + }); + const r = await runScrapeRecord(h.d, q.recordId); + expect(r.files.find((f) => f.path === `${dir}/${ani(40)}`)).toMatchObject({ + action: "skipped", + reason: "episode_unresolved", + }); + const nfo = await readFile(join(lib.root, dir, ani(25).replace(/\.mkv$/, ".nfo")), "utf8"); + expect(nfo).toContain("第二季终集"); + expect(nfo).toMatch(/2<\/season>\n {2}13<\/episode>/); + }); + + it("季包选文件:订阅缺 S02E03,季包 `- 13`…`- 25` 只下 `- 15`", async () => { + const { id } = await series("季包选文件作品"); + const sub = await h.db + .insertInto("subscription") + .values({ media_entity_id: id, media_kind: "series", mode: "continuous", status: "active" }) + .returning("id") + .executeTakeFirstOrThrow(); + const slot = await h.db + .insertInto("acquisition_slot") + .values({ + media_entity_id: id, + subscription_id: sub.id, + name: "1080p", + hard_conditions: JSON.stringify({}), + upgrade_threshold: JSON.stringify({}), + }) + .returning("id") + .executeTakeFirstOrThrow(); + const names: string[] = []; + for (let e = 13; e <= 25; e++) names.push(ani(e)); + const rel = await release(id, "[ANi] Kusuriya no Hitorigoto [13-25][1080P][Baha][WEB-DL]", { files: names }); + const plan = await h.db + .insertInto("acquisition_plan") + .values({ + slot_id: slot.id, + source_release_id: rel.id, + status: "reserved", + evaluation: JSON.stringify({ instanceId: base.instanceId, savePath: cache.root, mediaEntityId: id }), + payload_hash: `ph-${rel.id}`, + idempotency_key: `ik-${rel.id}`, + unit_keys: ["s2e3"], + }) + .returning("id") + .executeTakeFirstOrThrow(); + const s = await submitDownload(h.d, plan.id); + expect(s.status, JSON.stringify(s)).toBe("submitted"); + const row = await h.db + .selectFrom("acquisition_plan") + .select(["file_selection", "unit_keys"]) + .where("id", "=", plan.id) + .executeTakeFirstOrThrow(); + expect(row.file_selection).toMatchObject({ mode: "selected", selectedUnits: ["s2e3"] }); + expect(row.unit_keys).toEqual(["s2e3"]); + const t = qb.get(rel.hash)!; + const on = t.files.filter((f) => f.priority !== 0).map((f) => f.name.split("/").pop()); + expect(on).toEqual([ani(15)]); + }); +}); diff --git a/apps/server/test/file-manager/episode-mapping-identify.test.ts b/apps/server/test/file-manager/episode-mapping-identify.test.ts new file mode 100644 index 00000000..4d96c5e3 --- /dev/null +++ b/apps/server/test/file-manager/episode-mapping-identify.test.ts @@ -0,0 +1,100 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createFmHarness, type FmHarness, pgAvailable, READ_KEY, seedEntity } from "./harness.ts"; + +// 文件管理识别 / 推荐名按作品映射集数(docs/plan/episode-resolution.md 批次 3,只读):识别到本地剧集作品后, +// 展示的季集、建议单元与推荐文件名用映射后的规范季集;集数待确认的不给单元与推荐名,也不开待处理。 + +const skip = !(await pgAvailable()); + +describe.skipIf(skip)("文件管理识别与推荐名按作品映射集数", () => { + let h: FmHarness; + let show: string; + let s2e13: string; + + beforeAll(async () => { + h = await createFmHarness("episode_mapping_identify"); + show = await seedEntity(h.db, { kind: "series", title: "Kusuriya no Hitorigoto", year: 2023 }); + await h.db + .updateTable("media_entity") + .set({ metadata: JSON.stringify({ airStatus: { value: "Ended", source: "tmdb" } }) }) + .where("id", "=", show) + .execute(); + for (const [season, total] of [ + [1, 12], + [2, 13], + ] as const) { + await h.db + .insertInto("content_unit") + .values({ + media_entity_id: show, + kind: "season", + season_number: season, + episode_total: total, + status: "confirmed", + source: "tmdb", + }) + .execute(); + } + s2e13 = ( + await h.db + .insertInto("content_unit") + .values({ + media_entity_id: show, + kind: "episode", + season_number: 2, + episode_number: 13, + status: "confirmed", + source: "tmdb", + }) + .returning("id") + .executeTakeFirstOrThrow() + ).id; + await writeFile(join(h.dl, "[ANi] Kusuriya no Hitorigoto - 25 [1080P].mkv"), "v25"); + await writeFile(join(h.dl, "[ANi] Kusuriya no Hitorigoto - 40 [1080P].mkv"), "v40"); + }); + afterAll(async () => h?.close()); + + it("识别:`- 25` 展示与建议单元为 S02E13;`- 40` 待确认不给单元、带提示,不开待处理", async () => { + const r = await h.api( + "POST", + "/files/identify", + { + items: [ + { root: h.dlRoot, path: "[ANi] Kusuriya no Hitorigoto - 25 [1080P].mkv" }, + { root: h.dlRoot, path: "[ANi] Kusuriya no Hitorigoto - 40 [1080P].mkv" }, + ], + minSizeBytes: 0, + }, + READ_KEY, + ); + expect(r.status).toBe(200); + const [ok, bad] = r.body.data.items; + expect(ok.candidates[0]).toMatchObject({ mediaEntityId: show }); + expect(ok.parsed).toMatchObject({ season: 2, episodes: [13] }); + expect(ok.suggestion).toMatchObject({ mediaEntityId: show, contentUnit: "S02E13", contentUnitId: s2e13 }); + expect(bad.suggestion).toMatchObject({ mediaEntityId: show, contentUnit: null, contentUnitId: null }); + expect(bad.warnings.some((w: string) => w.includes("集数对不上"))).toBe(true); + const decisions = await h.db.selectFrom("decision").select("id").where("kind", "=", "episode_mapping").execute(); + expect(decisions).toHaveLength(0); + }); + + it("推荐名:`- 25` 按 S02E13 命名;`- 40` 不给推荐名(episode_unresolved)", async () => { + const ok = await h.api( + "POST", + "/files/suggest-name", + { root: h.dlRoot, path: "[ANi] Kusuriya no Hitorigoto - 25 [1080P].mkv", mediaEntityId: show }, + READ_KEY, + ); + expect(ok.status).toBe(200); + expect(ok.body.data.name).toMatch(/S02E13/); + const bad = await h.api( + "POST", + "/files/suggest-name", + { root: h.dlRoot, path: "[ANi] Kusuriya no Hitorigoto - 40 [1080P].mkv", mediaEntityId: show }, + READ_KEY, + ); + expect(bad.body.data).toMatchObject({ name: null, reason: "episode_unresolved", mediaEntityId: show }); + }); +}); diff --git a/docs/plan/episode-resolution.md b/docs/plan/episode-resolution.md index 73945c63..b13a4b64 100644 --- a/docs/plan/episode-resolution.md +++ b/docs/plan/episode-resolution.md @@ -54,6 +54,6 @@ ANI-RSS(GPL-2.0,只借思路不抄代码)摸排结论:一条订阅 = 一 - [x] 批次 1 - [x] 批次 2 -- [ ] 批次 3 +- [x] 批次 3 - [ ] 批次 4 - [ ] 批次 5 diff --git a/packages/domain/src/organize/plan.ts b/packages/domain/src/organize/plan.ts index 0c5fd4d1..82ede68a 100644 --- a/packages/domain/src/organize/plan.ts +++ b/packages/domain/src/organize/plan.ts @@ -67,6 +67,11 @@ export interface OrganizeInput { export interface FileOverride { season?: number; episodes?: { start: number; end: number }; + /** + * 不入库(调用方按作品集数映射判定:集数待确认 episode_unresolved、映射规则标为特别篇 / 花絮 / 忽略 special_extra); + * 只按精确路径生效,目录前缀覆写不带它 + */ + exclude?: { reason: "episode_unresolved" | "special_extra"; detail?: string }; } /** 源路径的覆写:精确命中优先,否则取最长的目录前缀 */ @@ -112,6 +117,7 @@ export interface ExcludedFile { | "not_imported_kind" | "orphan_sidecar" | "no_episode" + | "episode_unresolved" | "special_extra" | "extra_content" | "no_target_dir" @@ -226,6 +232,11 @@ 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; + if (ex) { + excluded.push({ src: f.path, kind, reason: ex.reason, ...(ex.detail ? { detail: ex.detail } : {}) }); + continue; + } if (ov?.season !== undefined) parsed.season = ov.season; if (ov?.episodes) { parsed.episodes = ov.episodes; diff --git a/packages/domain/test/organize.test.ts b/packages/domain/test/organize.test.ts index 19d146d7..dcd0b360 100644 --- a/packages/domain/test/organize.test.ts +++ b/packages/domain/test/organize.test.ts @@ -308,6 +308,48 @@ describe("W4-E4:字幕 .default 与 latest 旧版本判定口径(对照 MP)", () expect(movie.files[0]!.dst).toBe("/lib/M (2020)/M (2020).mkv"); }); + it("覆写 exclude:精确路径的视频不入库(记排除原因),目录前缀的 exclude 不生效;全部排除 → no_main_video", () => { + const D = "/dl/Pack"; + const plan = planOrganize({ + operation: "copy", + mediaType: "tv", + sourceRoot: D, + files: [ + { path: `${D}/Show - 40.mkv`, size: GiB }, + { path: `${D}/Show - 40.chs.srt`, size: 1000 }, + { path: `${D}/Show - NCOP.mkv`, size: GiB }, + { path: `${D}/Show.S01E02.mkv`, size: GiB }, + ], + entity: { title: "剧", year: 2020 }, + target: { seasonDirs: { 1: "/lib/剧/Season 1" } }, + overrides: { + [`${D}/Show - 40.mkv`]: { exclude: { reason: "episode_unresolved", detail: "S01E40" } }, + [`${D}/Show - NCOP.mkv`]: { exclude: { reason: "special_extra", detail: "rule:extra" } }, + [D]: { exclude: { reason: "episode_unresolved" } }, + }, + }); + expect(plan.files.filter((f) => f.role === "main").map((f) => f.dst)).toEqual([ + "/lib/剧/Season 1/剧 - S01E02 - 第 2 集.mkv", + ]); + expect(plan.excluded).toEqual( + expect.arrayContaining([ + { src: `${D}/Show - 40.mkv`, kind: "video", reason: "episode_unresolved", detail: "S01E40" }, + { src: `${D}/Show - NCOP.mkv`, kind: "video", reason: "special_extra", detail: "rule:extra" }, + expect.objectContaining({ src: `${D}/Show - 40.chs.srt`, reason: "orphan_sidecar" }), + ]), + ); + const none = planOrganize({ + operation: "copy", + mediaType: "tv", + files: [{ path: "/dl/Show - 40.mkv", size: GiB }], + entity: { title: "剧", year: 2020 }, + target: { seasonDirs: { 1: "/lib/剧/Season 1" } }, + overrides: { "/dl/Show - 40.mkv": { exclude: { reason: "episode_unresolved" } } }, + }); + expect(none.files).toEqual([]); + expect(none.conflicts).toEqual([expect.objectContaining({ kind: "no_main_video", blocking: true })]); + }); + it("findOtherVersions:同季同集同 Part、只看视频扩展名", () => { const sib = [ "/lib/S01/Show - S01E01 - 1080p.mkv",