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 });