feat(organize): 整理、季包选文件、刮削、文件管理识别统一套用集数映射;单集沿用资源判定,待确认文件不按原号入库

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-03 18:46:04 +08:00
1 parent 691671a1d2
commit fe597187dd
12 files changed
+848 -21

No files matched your search

@@ -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",
@@ -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<PlanRes
const naming = buildNamingContext(namingEntity);
const facts: FrozenPlan["facts"] = {};
const seasons = new Set<number>();
// 集数映射(批次 3):文件名解析出的是发布者声称的季集,按作品季表 + 规则换成规范季集再定季目录、命名与单元;
// 用户逐文件指定的季 / 集优先,不再映射。结果以精确路径覆写交给 planOrganize(待确认 / special 规则的文件不入库)
const episodes = mediaType === "tv" ? await planFileEpisodes(d, entity, rel, dt) : null;
const mappedOverrides: Record<string, DomainFileOverride> = {};
const unresolvedEpisodes: NonNullable<FrozenPlan["unresolvedEpisodes"]> = {};
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<PlanRes
...(season !== undefined ? { season } : {}),
...(parsed.episodes ? { episodes: parsed.episodes } : {}),
...(ov?.contentUnitIds?.length ? { unitIds: ov.contentUnitIds } : {}),
...(resolution && resolution.method !== "skipped" ? { episodeResolution: episodeFacts(resolution) } : {}),
};
}
const overrides =
Object.keys(mappedOverrides).length > 0 ? { ...req.fileOverrides, ...mappedOverrides } : req.fileOverrides;
const libRoot = { id: root.id, hostPath: root.local_path };
const newBindings: NewBinding[] = [];
const target: { movieDir?: string; seasonDirs?: Record<number, string> } = {};
@@ -755,7 +793,7 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
},
retention: { hnrSatisfied: hnr },
minVideoBytes: req.minSizeBytes ?? orgSettings.minSizeBytes ?? d.ports.options.minVideoBytes,
...(req.fileOverrides ? { overrides: req.fileOverrides } : {}),
...(overrides ? { overrides } : {}),
...(d.ctx.config?.ORGANIZE_DEFAULT_SUBTITLE_LANG
? { defaultSubtitleLang: d.ctx.config.ORGANIZE_DEFAULT_SUBTITLE_LANG }
: {}),
@@ -867,6 +905,7 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
newBindings,
sourceRoot,
facts,
...(Object.keys(unresolvedEpisodes).length ? { unresolvedEpisodes } : {}),
...(downgradedFromMove ? { downgradedFromMove } : {}),
...(req.slotId ? { slotId: req.slotId } : {}),
naming: {
@@ -882,8 +921,20 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
targetLibraryRootId: root.id,
});
const blocking = plan.conflicts.filter((c) => 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<PlanRes
await emitRecord(d, trx, rec.id);
return { recordId: rec.id, status: rec.status, reason };
});
// 文件集数待确认:并入关联资源的 episode_mapping 待处理(待处理按资源归集;没有关联资源只记整理失败原因)
const pending = episodes?.unresolved[0];
if (pending && episodes?.release) {
await settleEpisodeDecision(d.ctx, db, {
sourceReleaseId: episodes.release.id,
entityId: entity.id,
resolution: pending.resolution,
parsed: pending.parsed,
});
}
return result;
}
interface FileEpisodes {
map: EntityEpisodeMapper;
opts: FileEpisodeOptions;
/** 下载关联的资源(已绑定本作品) */
release: SourceReleaseRow | null;
unresolved: { path: string; resolution: EpisodeResolution; parsed: ParsedRelease }[];
}
/**
* 整理计划的集数映射装配:作品季表 + 规则加载一次;下载关联的资源(下载任务直接关联,或经下载 / 导入计划)
* 提供发布时间,原始季集与文件相同时直接沿用它的规范季集
*/
async function planFileEpisodes(
d: AoDeps,
entity: MediaEntityRow,
rel: SourceReleaseRow | undefined,
dt: DownloadTaskRow | undefined,
): Promise<FileEpisodes> {
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));
@@ -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<string, EpisodeFacts>;
downgradedFromMove?: string;
/** W5-A2:手动整理指定的 Slot */
slotId?: string;
@@ -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<string, { season: number; episode: number }>();
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 });
@@ -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<string, IdentityCandidate[]>();
const remoteCache = new Map<string, { list: IdentityCandidate[]; warning: string | null; pending?: boolean }>();
const entityKinds = new Map<string, string>();
const mappers = new Map<string, EntityEpisodeMapper>();
// G8:候选海报(TMDB poster_path;本地作品取投影后的 metadata.posterPath)
const posters = new Map<string, string>();
@@ -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 },
@@ -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)
@@ -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<MediaKind>(["series", "variety"]);
/**
* 文件侧接入(批次 3):整理计划、季包选文件、文件管理识别、刮削对文件名重新解析出的季集,按同一作品的季表 + 规则
* 映射。季表与规则只加载一次;返回的函数纯计算,不停用规则、不开待处理(由调用方决定)。
* 非剧集作品不加载,解析结果原样返回(method = skipped)。
*/
export async function createEntityEpisodeMapper(
exec: Exec,
entityId: string,
kind: MediaKind,
): Promise<EntityEpisodeMapper> {
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;
@@ -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<ReturnType<typeof seedBase>>;
const disk = fakeDisk(10_000_000_000);
const torrents = new Map<string, Uint8Array>();
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<string, number> = {};
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<number, string> = {};
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<Parameters<typeof planImport>[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("<title>第二季终集</title>");
expect(nfo).toMatch(/<season>2<\/season>\n {2}<episode>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)]);
});
});
@@ -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 });
});
});
+1 -1
View File
@@ -54,6 +54,6 @@ ANI-RSS(GPL-2.0,只借思路不抄代码)摸排结论:一条订阅 = 一
- [x] 批次 1
- [x] 批次 2
- [ ] 批次 3
- [x] 批次 3
- [ ] 批次 4
- [ ] 批次 5
+11
View File
@@ -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;
+42
View File
@@ -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",