From d55fa4b24028a136c679d3f33f5a53dbdd03eeb2 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 16:28:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(organize):=20=E6=95=B4=E7=90=86=E6=97=B6?= =?UTF-8?q?=E6=8C=87=E5=AE=9A=E5=AD=A3=E9=9B=86=E7=BB=93=E6=9E=84(?= =?UTF-8?q?=E4=B8=B4=E6=97=B6=E5=89=A7=E9=9B=86=E7=BB=84=20/=20=E5=90=8C?= =?UTF-8?q?=E6=97=B6=E8=AE=BE=E4=B8=BA=E4=BD=9C=E5=93=81=E7=BB=93=E6=9E=84?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 契约:FilesOrganizeRequest.episodeStructures(按作品 groupId / applyToEntity),组响应带 episodeStructure - 集数映射:createStructureEpisodeMapper 按指定结构的季表(剧集组带原季集对照)换算;作品规则先按作品结构命中,再经 TMDB 原季集换到指定结构 - 资产覆盖:指定结构季集 → TMDB 原季集 → 作品当前结构的单元,换不回不登记集覆盖;冻结计划记 episodeStructure 与 facts.structureEpisodes - 向导执行勾选「同时设为作品的季集结构」时先走 setEpisodeGroup 切换作品再整理 - 测试:organize-episode-structure(SPLIT_COUR_FIXTURE) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- .../services/organize-plan.ts | 79 +++++- .../services/organize-types.ts | 30 ++ .../server/src/modules/file-manager/common.ts | 3 + apps/server/src/modules/file-manager/index.ts | 4 + .../src/modules/file-manager/organize.ts | 71 +++++ .../service/episode-groups.ts | 30 ++ .../service/episode-resolution.ts | 133 ++++++++- .../service/episode-structure.ts | 19 ++ .../organize-episode-structure.test.ts | 256 ++++++++++++++++++ packages/contracts/src/files.ts | 23 ++ 10 files changed, 637 insertions(+), 11 deletions(-) create mode 100644 apps/server/test/file-manager/organize-episode-structure.test.ts 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 4df36557..b2d5871b 100644 --- a/apps/server/src/modules/acquisition-organize/services/organize-plan.ts +++ b/apps/server/src/modules/acquisition-organize/services/organize-plan.ts @@ -29,11 +29,14 @@ import { import { organizePlanToFileResults, toDomainPathBinding } from "@umt/mapping"; import { createEntityEpisodeMapper, + createStructureEpisodeMapper, type EntityEpisodeMapper, + type EpisodeSpan, type FileEpisodeOptions, publishedAtOf, settleEpisodeDecision, } from "../../sources-identity/service/episode-resolution.ts"; +import type { Layout } from "../../sources-identity/service/episode-structure.ts"; import { errMessage, hashJson, jsonb, toApi } from "../util.ts"; import { publishedAcquisitionPolicies, unitKeysOf } from "./acquisition.ts"; import { classifyEntity } from "./classification.ts"; @@ -46,7 +49,14 @@ import { successfulOrganizeCounts } from "./organize-history.ts"; import { planTransfer } from "./organize-transfer.ts"; // 入库:冻结整理计划(organization_record + 文件明细)→ 逐文件可恢复执行(操作账本 fencing)→ // 资产/位置/路径绑定同事务登记 → Slot 推进 → Move 后移除 qB 任务记录(不删数据)。 -import type { EpisodeFacts, FrozenPlan, ImportRequest, PlanResult } from "./organize-types.ts"; +import type { + EpisodeFacts, + FrozenEpisodeStructure, + FrozenPlan, + ImportRequest, + OrganizeEpisodeStructure, + PlanResult, +} from "./organize-types.ts"; import { type FrozenReplacement, freezeReplacement } from "./replacement.ts"; import { hnrSatisfied } from "./retention.ts"; import { subscriptionLibraryRoot } from "./subscriptions.ts"; @@ -636,7 +646,12 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise(); // 集数映射(批次 3):文件名解析出的是发布者声称的季集,按作品季表 + 规则换成规范季集再定季目录、命名与单元; // 用户逐文件指定的季 / 集优先,不再映射。结果以精确路径覆写交给 planOrganize(待确认 / special 规则的文件不入库) - const episodes = mediaType === "tv" ? await planFileEpisodes(d, entity, rel, dt) : null; + // 整理时指定了季集结构(向导):与作品不同的结构只这次用,季集按它换算与命名,单元覆盖换回作品结构(见 planFileEpisodes) + const structure = mediaType === "tv" ? frozenStructure(entity, req.episodeStructure) : undefined; + const episodes = + mediaType === "tv" + ? await planFileEpisodes(d, entity, rel, dt, structure?.temporary ? req.episodeStructure!.layout : null) + : null; const mappedOverrides: Record = {}; const unresolvedEpisodes: NonNullable = {}; // 文件级标题粗核:混合包 / 合集里标题明显不是本作品的视频不按本作品命名入库(待确认 title_mismatch); @@ -683,16 +698,26 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise EpisodeSpan | null; /** 下载关联的资源(已绑定本作品) */ release: SourceReleaseRow | null; unresolved: { path: string; resolution: EpisodeResolution; parsed: ParsedRelease }[]; @@ -1102,6 +1131,8 @@ async function planFileEpisodes( entity: MediaEntityRow, rel: SourceReleaseRow | undefined, dt: DownloadTaskRow | undefined, + /** 临时季集结构(与作品当前结构不同) */ + layout: Layout | null, ): Promise { const db = d.ctx.db; let linked = rel; @@ -1114,8 +1145,19 @@ async function planFileEpisodes( .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; + if (layout) { + // 资源的集数解析是按作品结构做的,临时结构下不沿用,只取发布时间 + const s = await createStructureEpisodeMapper(db, entity.id, entity.kind, layout); + return { + map: s.map, + toEntity: s.toEntity, + opts: linked ? { publishedAt: publishedAtOf(linked) } : {}, + release: own, + unresolved: [], + }; + } + const map = await createEntityEpisodeMapper(db, entity.id, entity.kind); const res = own?.episode_resolution as EpisodeResolution | null | undefined; return { map, @@ -1128,6 +1170,23 @@ async function planFileEpisodes( }; } +/** 冻结进计划的季集结构;请求没指定时为 undefined(跟随作品,计划与以前一致) */ +function frozenStructure( + entity: MediaEntityRow, + s: OrganizeEpisodeStructure | undefined, +): FrozenEpisodeStructure | undefined { + if (!s) return undefined; + const entityGroupId = entity.episode_group_id ?? null; + const temporary = !!s.layout && s.groupId !== entityGroupId; + return { + groupId: s.groupId, + name: temporary ? s.layout!.name : s.groupId ? (entity.episode_group_name ?? s.groupId) : "TMDB 默认季", + entityGroupId, + temporary, + ...(s.appliedToEntity ? { appliedToEntity: true } : {}), + }; +} + const sameRange = (a: { start: number; end: number }, b: { start: number; end: number } | undefined) => !!b && a.start === b.start && a.end === b.end; 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 a7d4c9ef..8ccde11a 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,6 @@ import type { ActorType, OrganizationRecord, OrganizationTrigger } from "@umt/contracts"; import type { EpisodeResolution, ExistingPolicy, NewBinding, OrganizePlan, QualityFacts } from "@umt/domain"; +import type { Layout } from "../../sources-identity/service/episode-structure.ts"; import type { FrozenTransfer, TransferSpec } from "./organize-transfer.ts"; import type { FrozenReplacement } from "./replacement.ts"; @@ -52,6 +53,28 @@ export interface ImportRequest { namingMode?: NamingMode | undefined; /** W9-H 调用方声明的命名模板来源(本次覆写等);缺省按模板层级推断,冻结进 plan.naming */ namingSource?: NamingSourceTag | undefined; + /** 整理时指定的季集结构(向导「季集结构」);缺省跟随作品 */ + episodeStructure?: OrganizeEpisodeStructure | undefined; +} + +/** 整理时指定的季集结构:groupId null = TMDB 默认季 */ +export interface OrganizeEpisodeStructure { + groupId: string | null; + /** 与作品当前结构不同时的目标结构(调用方按 TMDB 取);null = 就是作品当前结构,按作品整理 */ + layout: Layout | null; + /** 整理前已把它设为作品的季集结构(向导勾「同时设为作品的季集结构」) */ + appliedToEntity?: boolean | undefined; +} + +/** 冻结进计划的季集结构(排查用):这次按哪套结构换算季集与命名 */ +export interface FrozenEpisodeStructure { + groupId: string | null; + name: string; + /** 计划时作品的季集结构 */ + entityGroupId: string | null; + /** 与作品结构不同、只这次使用(资产覆盖换回作品结构的单元) */ + temporary: boolean; + appliedToEntity?: boolean; } export type NamingMode = "template" | "keep"; @@ -112,10 +135,17 @@ export interface FrozenPlan { unitIds?: string[]; /** 按作品做的集数映射(方式 + 文件名原始季集);人工指定季集或非剧集时没有 */ episodeResolution?: EpisodeFacts; + /** + * 临时季集结构下命名用的季集(指定结构的);此时 season / episodes 是换回作品当前结构的单元季集。 + * 换不回(作品结构里没有这几集)时 season / episodes 不给,不登记集覆盖 + */ + structureEpisodes?: { season: number; episodes: { start: number; end: number } }; } >; /** 集数待确认 / 映射规则标为特别篇 · 花絮 · 忽略、因而不入库的视频:源路径 → 集数映射摘要 */ unresolvedEpisodes?: Record; + /** 整理时指定了季集结构(向导);缺省跟随作品时没有 */ + episodeStructure?: FrozenEpisodeStructure; downgradedFromMove?: string; /** W5-A2:手动整理指定的 Slot */ slotId?: string; diff --git a/apps/server/src/modules/file-manager/common.ts b/apps/server/src/modules/file-manager/common.ts index 93361c60..5cdf6157 100644 --- a/apps/server/src/modules/file-manager/common.ts +++ b/apps/server/src/modules/file-manager/common.ts @@ -7,6 +7,7 @@ import type { LibraryRootRow } from "@umt/db"; import type { Principal } from "../../http/auth.ts"; import type { AoDeps, Exec } from "../acquisition-organize/services/common.ts"; import { loadInstances } from "../acquisition-organize/services/common.ts"; +import type { SourcesRuntime } from "../sources-identity/runtime.ts"; import type { ServerContext } from "../types.ts"; // 文件管理模块共用:依赖、错误、根定义、路径守卫、计划/幂等/审计小工具。 @@ -45,6 +46,8 @@ export interface FmDeps { options: FmOptions; /** W9-H(G1):TMDB 客户端(按 TMDB 候选建作品);未配置返回 null */ tmdb: () => Promise; + /** sources-identity 运行时(与 tmdb 同一实例):整理时指定季集结构 / 切换作品季集结构 */ + sources: () => SourcesRuntime; } const envNum = (k: string, dflt: number) => { diff --git a/apps/server/src/modules/file-manager/index.ts b/apps/server/src/modules/file-manager/index.ts index b3d9e1b2..b9b12a0c 100644 --- a/apps/server/src/modules/file-manager/index.ts +++ b/apps/server/src/modules/file-manager/index.ts @@ -42,6 +42,10 @@ export function createFileManagerModule(overrides: FmOverrides = {}): ServerModu sources ??= new SourcesRuntime(ctx, overrides.tmdb ? { tmdb: overrides.tmdb } : {}); return sources.tmdb(); }, + sources: () => { + sources ??= new SourcesRuntime(ctx, overrides.tmdb ? { tmdb: overrides.tmdb } : {}); + return sources; + }, }; } return deps; diff --git a/apps/server/src/modules/file-manager/organize.ts b/apps/server/src/modules/file-manager/organize.ts index 3d126bd1..09da6654 100644 --- a/apps/server/src/modules/file-manager/organize.ts +++ b/apps/server/src/modules/file-manager/organize.ts @@ -3,6 +3,7 @@ import { stat } from "node:fs/promises"; import { join, relative } from "node:path"; import type { FilesOrganizeGroup, FilesOrganizeRequest, FilesOrganizeResult } from "@umt/contracts"; import type { Principal } from "../../http/auth.ts"; +import { ApiException } from "../../http/errors.ts"; import { ensureTaskRow, markTask } from "../acquisition-organize/services/common.ts"; import { type FileOverrideInput, type FrozenPlan, planImport } from "../acquisition-organize/services/organize.ts"; import { @@ -13,6 +14,8 @@ import { requestKey, sourceGuard, } from "../acquisition-organize/services/organize-guard.ts"; +import { organizeStructure, setEpisodeGroup } from "../sources-identity/service/episode-groups.ts"; +import type { Layout } from "../sources-identity/service/episode-structure.ts"; import { entityFromCandidate, externalKey } from "./candidate.ts"; import { actorOfP, errMessage, FilesError, type FmDeps, hashJson, isAdmin, resolveIn } from "./common.ts"; import { audit, filesChanged, loadPlan, retirePlan, savePlan } from "./ledger.ts"; @@ -164,6 +167,24 @@ function episodeRange(eps: number[]): { start: number; end: number } | null { const BUSY_SLOT = new Set(["reserved", "downloading", "verifying"]); +/** 整理时指定的季集结构(按作品):layout = 与作品当前结构不同的目标结构;switched = 已按勾选切换了作品结构 */ +interface StructureChoice { + groupId: string | null; + apply: boolean; + layout: Layout | null; + switched: boolean; +} + +/** 取 TMDB / 校验季集结构时的错误 → 文件管理错误 */ +function structureError(e: unknown): never { + if (e instanceof ApiException) + throw new FilesError( + e.code === "upstream_unavailable" ? "upstream" : e.code === "not_found" ? "not_found" : "bad_request", + e.message, + ); + throw e; +} + export async function organizeFiles( d: FmDeps, principal: Principal | undefined, @@ -234,6 +255,20 @@ export async function organizeFiles( ).map((u) => [u.id, u]) : [], ); + // 季集结构(向导「季集结构」):只对本次涉及的本地剧集作品;与作品当前结构相同等同跟随作品 + const structures = new Map(); + for (const x of body.episodeStructures ?? []) { + if (structures.has(x.mediaEntityId) || !entityIds.includes(x.mediaEntityId)) continue; + const kind = entities.get(x.mediaEntityId); + if (kind !== "series" && kind !== "variety") continue; + const r = await organizeStructure(d.sources(), x.mediaEntityId, x.groupId).catch(structureError); + structures.set(x.mediaEntityId, { + groupId: x.groupId, + apply: x.applyToEntity === true && !!r.layout, + layout: r.layout, + switched: false, + }); + } const slotIds = [...new Set(body.items.map((i) => i.slotId).filter((x): x is string => !!x))]; const slots = new Map( slotIds.length @@ -383,6 +418,7 @@ export async function organizeFiles( for (const g of groups.values()) { const req = reqOf(g); const hasOverrides = Object.keys(g.overrides).length > 0; + const st = structures.get(g.entity); const reqHash = hashJson({ key: requestKey(req), ...(body.sourceConfigId && g.action !== "in_place" ? { sourceConfigId: body.sourceConfigId } : {}), @@ -395,6 +431,7 @@ export async function organizeFiles( ...(namingMode !== "template" ? { naming: namingMode } : {}), ...(body.skipSuccessfulRecords ? { skipSuccessfulRecords: true } : {}), ...(body.scrapeAfterImport !== undefined ? { scrapeAfterImport: body.scrapeAfterImport } : {}), + ...(st ? { episodeStructure: { groupId: st.groupId, apply: st.apply } } : {}), }); const ext = fromExternal.get(g.entity); const base = { @@ -423,6 +460,16 @@ export async function organizeFiles( namingMode, skipSuccessfulRecords: body.skipSuccessfulRecords, scrapeAfterImport: body.scrapeAfterImport, + // 勾了「同时设为作品的季集结构」:执行前已切换作品,之后按作品(新结构)整理;预览按临时结构算,命名相同 + ...(st + ? { + episodeStructure: { + groupId: st.groupId, + layout: st.switched ? null : st.layout, + ...(st.switched ? { appliedToEntity: true } : {}), + }, + } + : {}), trigger: "manual" as const, actorType: actorType as "api_key" | "user", actorId: actor, @@ -455,6 +502,7 @@ export async function organizeFiles( ...planExtras(plan.plan, !!g.targetDir), planId: saved.id, files: filesOf(plan.plan), + ...structureOf(plan.plan, st), truncated: plan.truncated === true, recordId: null, taskId: null, @@ -469,6 +517,14 @@ export async function organizeFiles( const now = await factsOf(frozen.facts.map((f) => f.path)); const changed = now.find((f, i) => f.size !== frozen.facts[i]!.size || f.mtimeMs !== frozen.facts[i]!.mtimeMs); if (changed) throw new FilesError("plan_stale", `源文件在预览后发生变化:${changed.path}`); + // 同时设为作品的季集结构:先走作品级切换(单元迁移 + 重算 + 刷新),同一作品的多组只切一次 + if (st?.apply && !st.switched) { + await setEpisodeGroup(d.sources(), g.entity, st.groupId, { + type: principal?.kind === "api_key" ? "api_key" : "user", + id: principal?.id ?? null, + }).catch(structureError); + st.switched = true; + } const again = await planImport(d.ao, importReq(true)); const nowPairs = pairsOf(again).map(({ src, dst }) => ({ src, dst })); if (hashJson(nowPairs) !== hashJson(frozen.pairs)) @@ -534,6 +590,7 @@ export async function organizeFiles( planId, // 执行时 planImport 不回传计划体;冻结计划与刚重算的 dryRun 一致(上面已校验),直接回显 files: filesOf(again.plan), + ...structureOf(again.plan, st), truncated: false, recordId: plan.recordId, taskId, @@ -607,6 +664,20 @@ function planExtras(plan: FrozenPlan | undefined, overrideDir: boolean) { }; } +/** 组按哪套季集结构整理(请求指定了才有) */ +function structureOf(plan: FrozenPlan | undefined, st: StructureChoice | undefined) { + const s = plan?.episodeStructure; + if (!s) return {}; + return { + episodeStructure: { + groupId: s.groupId, + name: s.name, + temporary: s.temporary, + ...(s.appliedToEntity || st?.apply ? { appliedToEntity: true } : {}), + }, + }; +} + /** 执行请求携带全部组的 planId:找与本组请求一致的那个 */ async function pickPlan(d: FmDeps, planIds: string[], reqHash: string): Promise { if (planIds.length === 0) throw new FilesError("bad_request", "执行需要 dryRun 返回的 planIds"); diff --git a/apps/server/src/modules/sources-identity/service/episode-groups.ts b/apps/server/src/modules/sources-identity/service/episode-groups.ts index 012d1771..4d642228 100644 --- a/apps/server/src/modules/sources-identity/service/episode-groups.ts +++ b/apps/server/src/modules/sources-identity/service/episode-groups.ts @@ -249,6 +249,36 @@ export async function episodeStructureSeasons( return { groupId, name: target.layout.name, current: isCurrent, seasons }; } +/** + * 整理时临时指定的季集结构(docs/runbook/organize.md「整理时指定剧集组」):返回作品当前选择与目标结构; + * 目标就是作品当前结构时 layout 为 null(按作品整理,不必取 TMDB)。 + */ +export async function organizeStructure( + rt: SourcesRuntime, + entityId: string, + groupId: string | null, +): Promise<{ current: { id: string; name: string } | null; layout: Layout | null }> { + const cur = await entityEpisodeGroup(rt.ctx.db, entityId); + const current = cur ? { id: cur.id, name: cur.name } : null; + if ((cur?.id ?? null) === groupId) return { current, layout: null }; + const tv = await tvContext(rt, entityId); + if ("error" in tv) { + throw new ApiException( + "bad_request", + tv.error === "not_episodic" + ? "只有剧集 / 综艺能指定季集结构" + : tv.error === "no_tmdb_identity" + ? "作品还没有 TMDB 剧集身份,不能指定季集结构" + : "TMDB 未配置,不能指定季集结构", + ); + } + try { + return { current, layout: (await layoutOf(tv, groupId)).layout }; + } catch (e) { + return tmdbFailure(e); + } +} + /** * 切换作品的季集结构(TMDB 默认季 ⇄ 剧集组 / 剧集组之间): * 事务内按 TMDB 原季集原地迁移集单元、重建季表与总集数、记下选择,按作品全量重算已绑定资源; 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 72afde45..8eb5ba3f 100644 --- a/apps/server/src/modules/sources-identity/service/episode-resolution.ts +++ b/apps/server/src/modules/sources-identity/service/episode-resolution.ts @@ -24,7 +24,7 @@ import type { ServerContext } from "../../types.ts"; import { jsonb } from "../rows.ts"; import { closeDecisions, type Exec, openDecision } from "./common.ts"; import { ensureUnits } from "./entities.ts"; -import { seasonOrigins } from "./episode-structure.ts"; +import { type Layout, seasonOrigins } from "./episode-structure.ts"; // 集数解析接入(docs/plan/episode-resolution.md 批次 2):识别绑定作品后、建集单元前,按作品季表 + 映射规则把 // 「发布者声称的季集」换成「媒体库应当使用的季集」。判定本身在 domain resolveEpisodes,这里只装配数据与落库: @@ -190,6 +190,137 @@ export async function createEntityEpisodeMapper( }; } +/** 季集区间(含两端) */ +export interface EpisodeSpan { + season: number; + episodes: { start: number; end: number }; +} + +/** 按指定季集结构整理时的文件映射器(整理时临时选了与作品不同的结构) */ +export interface StructureEpisodeMapper { + /** 映射结果的季集是指定结构的(命名、季目录按它) */ + map: EntityEpisodeMapper; + /** + * 指定结构的季集 → 作品当前结构的单元季集(资产覆盖挂在作品单元上):逐集求 TMDB 原季集,再按作品当前结构换算; + * 区间里有一集对不上、或换算后不在同一季 / 不连续时为 null(不登记覆盖) + */ + toEntity(span: EpisodeSpan): EpisodeSpan | null; +} + +/** + * 整理时临时指定季集结构(docs/runbook/organize.md「整理时指定剧集组」):不改作品设置,只这次按 target 换算季集与命名。 + * - 季表:target 的季与集数(剧集组另带每集播出日期与原季集对照 origins,发布者按 TMDB 默认季编号时换到组内季集) + * - 规则:作品的映射规则是按作品当前结构写的。先按作品季表 + 规则跑一遍,命中规则(含特别篇 / 忽略)时用规则的结果, + * 季集再经 TMDB 原季集换到 target;没命中规则时按 target 季表单独判定(不带规则) + * - 单元:toEntity 把 target 季集换回作品当前结构(作品是剧集组时按单元上记的原季集对照,默认季时原季集即单元季集) + */ +export async function createStructureEpisodeMapper( + exec: Exec, + entityId: string, + kind: MediaKind, + target: Layout, +): Promise { + const current = await createEntityEpisodeMapper(exec, entityId, kind); + const entity = await exec + .selectFrom("media_entity") + .select(["metadata", "episode_group_id"]) + .where("id", "=", entityId) + .executeTakeFirst(); + const airs = new Map(); + for (const e of target.episodes ?? []) airs.set(e.season, [...(airs.get(e.season) ?? []), e]); + const seasons: SeasonInfo[] = target.seasons + .filter((s) => s.seasonNumber >= 0) + .map((s) => ({ + season: s.seasonNumber, + total: s.episodeCount > 0 ? s.episodeCount : null, + episodes: (airs.get(s.seasonNumber) ?? []) + .filter((e) => e.airDate) + .map((e) => ({ episode: e.episode, airDate: e.airDate })) + .sort((a, b) => a.episode - b.episode), + })) + .sort((a, b) => a.season - b.season); + const main = seasons.filter((s) => s.season >= 1).map((s) => s.season); + const origins = target.groupId + ? (target.episodes ?? []).map((e) => ({ + tmdbSeason: e.tmdbSeason, + tmdbEpisode: e.tmdbEpisode, + season: e.season, + episode: e.episode, + })) + : []; + const table: SeasonTable = { + seasons, + latestSeason: main.length ? Math.max(...main) : null, + ended: ENDED_STATUSES.has(airStatusOf(entity?.metadata).toLowerCase()), + ...(origins.length ? { origins } : {}), + }; + // 作品当前结构:默认季 = 原季集即单元季集;剧集组 = 单元上记的原季集对照 + const grouped = !!entity?.episode_group_id; + const unitByOrigin = new Map(); + const originByUnit = new Map(); + if (grouped) + for (const o of await seasonOrigins(exec, entityId)) { + unitByOrigin.set(`${o.tmdbSeason}:${o.tmdbEpisode}`, { season: o.season, episode: o.episode }); + originByUnit.set(`${o.season}:${o.episode}`, { season: o.tmdbSeason, episode: o.tmdbEpisode }); + } + const currentToOrigin = (s: number, e: number) => + grouped ? (originByUnit.get(`${s}:${e}`) ?? null) : { season: s, episode: e }; + const originToCurrent = (s: number, e: number) => + grouped ? (unitByOrigin.get(`${s}:${e}`) ?? null) : { season: s, episode: e }; + const tag = `structure:${target.groupId ?? "default"}`; + + /** 逐集换算并要求落在同一季、连续 */ + const convert = ( + span: EpisodeSpan, + step: (s: number, e: number) => { season: number; episode: number } | null, + ): EpisodeSpan | null => { + const { start, end } = span.episodes; + if (end < start || end - start > 200) return null; + const hits: { season: number; episode: number }[] = []; + for (let e = start; e <= end; e++) { + const h = step(span.season, e); + if (!h) return null; + hits.push(h); + } + const first = hits[0]!; + if (hits.some((h, i) => h.season !== first.season || h.episode !== first.episode + i)) return null; + return { season: first.season, episodes: { start: first.episode, end: hits.at(-1)!.episode } }; + }; + const toTarget = (span: EpisodeSpan) => + convert(span, (s, e) => { + const o = currentToOrigin(s, e); + return o ? target.fromOrigin(o.season, o.episode) : null; + }); + const toEntity = (span: EpisodeSpan) => + convert(span, (s, e) => { + const o = target.toOrigin(s, e); + return o ? originToCurrent(o.season, o.episode) : null; + }); + + const map: EntityEpisodeMapper = (parsed, opts = {}) => { + const p = opts.publishedAt; + const publishedAt = p instanceof Date ? p.toISOString() : (p ?? null); + const byRule = current(parsed, { publishedAt }).resolution; + if (byRule.method === "rule" || byRule.ruleFailed) { + if (!byRule.canonical) return { resolution: { ...byRule, evidence: [...byRule.evidence, tag] }, parsed }; + const moved = toTarget(byRule.canonical); + const resolution: EpisodeResolution = moved + ? { ...byRule, canonical: moved, evidence: [...byRule.evidence, tag] } + : { + source: byRule.source, + method: "unresolved", + canonical: null, + evidence: [...byRule.evidence, `${tag}:not_in_target`], + }; + return { resolution, parsed: canonicalParsed(parsed, resolution) }; + } + const r = resolveEpisodes({ parsed, kind, table, rules: [], publishedAt }); + const resolution = { ...r, evidence: [...r.evidence, tag] }; + return { resolution, parsed: canonicalParsed(parsed, resolution) }; + }; + return { map, toEntity }; +} + /** 资源的原始季集与文件解析结果相同(季号缺省都按未给出比较,集区间与播出日期逐项相等) */ function sameSource(src: EpisodeResolution["source"], p: ParsedRelease): boolean { if (!src.episodes && !src.airDate) return false; diff --git a/apps/server/src/modules/sources-identity/service/episode-structure.ts b/apps/server/src/modules/sources-identity/service/episode-structure.ts index 541f2c7a..dea8acc6 100644 --- a/apps/server/src/modules/sources-identity/service/episode-structure.ts +++ b/apps/server/src/modules/sources-identity/service/episode-structure.ts @@ -34,10 +34,20 @@ export interface Layout { groupId: string | null; name: string; seasons: StructureSeason[]; + /** 剧集组的每集(组内季集 + 播出日期 + TMDB 原季集);默认季不给 */ + episodes?: LayoutEpisode[]; toOrigin(season: number, episode: number): Coord | null; fromOrigin(season: number, episode: number): Coord | null; } +export interface LayoutEpisode { + season: number; + episode: number; + airDate: string | null; + tmdbSeason: number; + tmdbEpisode: number; +} + const key = (s: number, e: number) => `${s}:${e}`; export function defaultLayout(seasons: readonly StructureSeason[]): Layout { @@ -69,6 +79,15 @@ export function groupLayout(detail: TmdbEpisodeGroupDetail): Layout { episodeCount: s.episodes.length, airDate: s.airDate, })), + episodes: detail.seasons.flatMap((s) => + s.episodes.map((e) => ({ + season: e.seasonNumber, + episode: e.episodeNumber, + airDate: e.airDate, + tmdbSeason: e.tmdbSeason, + tmdbEpisode: e.tmdbEpisode, + })), + ), toOrigin: (s, e) => toO.get(key(s, e)) ?? null, fromOrigin: (s, e) => fromO.get(key(s, e)) ?? null, }; diff --git a/apps/server/test/file-manager/organize-episode-structure.test.ts b/apps/server/test/file-manager/organize-episode-structure.test.ts new file mode 100644 index 00000000..f6380217 --- /dev/null +++ b/apps/server/test/file-manager/organize-episode-structure.test.ts @@ -0,0 +1,256 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createTmdbClient } from "@umt/adapters"; +import { createMockTmdb, type MockTmdb, SPLIT_COUR_FIXTURE, TMDB_FIXTURES } from "@umt/testkit"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import type { FrozenPlan } from "../../src/modules/acquisition-organize/services/organize.ts"; +import { executeRecord } from "../../src/modules/acquisition-organize/services/organize.ts"; +import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts"; +import { createFmHarness, type FmHarness, key, pgAvailable, seedEntity } from "./harness.ts"; + +// 整理时指定季集结构(docs/runbook/organize.md「整理时指定剧集组」):作品是 TMDB 默认季(S1 = 25 集), +// 剧集组「制作季」拆成 S1(1–12)、S2(13–25)。临时按组整理 S01E14 → 命名 S02E02,资产覆盖仍挂作品的 S01E14 单元; +// 勾「同时设为作品的季集结构」→ 先切换作品(单元原地迁移),再按新结构整理;缺省跟随作品时行为不变。 + +const skip = !(await pgAvailable()); + +describe.skipIf(skip)("整理时指定季集结构", () => { + let h: FmHarness; + let tmdb: MockTmdb; + let show: string; + let unitE14: string; + + beforeAll(async () => { + tmdb = await createMockTmdb({ fixtures: [...TMDB_FIXTURES, SPLIT_COUR_FIXTURE] }); + h = await createFmHarness("organize_episode_structure", { + tmdb: () => createTmdbClient({ apiKey: "test-tmdb-key", baseUrl: `${tmdb.url}/3`, retries: 0 }), + }); + show = await seedEntity(h.db, { kind: "series", title: "Split Cour", year: 2024 }); + await h.db + .insertInto("external_identity") + .values({ + media_entity_id: show, + provider: "tmdb", + entity_type: "tv", + external_id: String(SPLIT_COUR_FIXTURE.id), + status: "accepted", + }) + .execute(); + expect((await refreshMetadata(h.module.deps().sources(), show, { full: true })).status).toBe("refreshed"); + unitE14 = await unitAt(1, 14); + for (const name of [ + "Split Cour S01E13 [1080p].mkv", + "Split Cour S01E14 [1080p].mkv", + "Split Cour S01E14 [2160p].mkv", + ]) + await writeFile(join(h.dl, name), name.padEnd(64, "x")); + await mkdir(join(h.lib, "alt"), { recursive: true }); + }); + afterAll(async () => { + await h?.close(); + await tmdb?.close(); + }); + + async function unitAt(season: number, episode: number): Promise { + return ( + await h.db + .selectFrom("content_unit") + .select("id") + .where("media_entity_id", "=", show) + .where("kind", "=", "episode") + .where("season_number", "=", season) + .where("episode_number", "=", episode) + .where("status", "<>", "retired") + .executeTakeFirstOrThrow() + ).id; + } + const entityGroup = async () => + (await h.db.selectFrom("media_entity").select("episode_group_id").where("id", "=", show).executeTakeFirstOrThrow()) + .episode_group_id; + const coverage = async (recordId: string) => + h.db + .selectFrom("organization_file_result as f") + .innerJoin("asset_unit_coverage as c", "c.media_asset_id", "f.media_asset_id") + .innerJoin("content_unit as u", "u.id", "c.content_unit_id") + .select(["u.id", "u.season_number", "u.episode_number"]) + .where("f.organization_record_id", "=", recordId) + .execute(); + const frozen = async (recordId: string) => + (await h.db.selectFrom("organization_record").select("plan").where("id", "=", recordId).executeTakeFirstOrThrow()) + .plan as unknown as FrozenPlan; + + async function organize(path: string, extra: Record = {}, targetDir?: string) { + const body = { + action: "copy", + items: [ + { + root: h.dlRoot, + path, + mediaEntityId: show, + ...(targetDir ? { targetDir: { root: h.libRoot, path: targetDir } } : {}), + }, + ], + ...extra, + }; + const dry = await h.api("POST", "/files/organize", { ...body, idempotencyKey: key(), dryRun: true }); + expect(dry.status, JSON.stringify(dry.body)).toBe(200); + const preview = dry.body.data.groups[0]; + expect(preview.error, JSON.stringify(preview)).toBeNull(); + const groupAfterPreview = await entityGroup(); + const exec = await h.api("POST", "/files/organize", { + ...body, + idempotencyKey: key(), + dryRun: false, + planIds: [preview.planId], + }); + expect(exec.status, JSON.stringify(exec.body)).toBe(200); + expect(exec.body.data.groups[0].error, JSON.stringify(exec.body.data.groups[0])).toBeNull(); + const recordId = exec.body.data.recordIds[0] as string; + expect((await executeRecord(h.module.deps().ao, recordId)).status).toBe("succeeded"); + return { preview, groupAfterPreview, exec: exec.body.data.groups[0], recordId }; + } + + it("缺省跟随作品:按作品默认季命名与登记,计划里不记季集结构(回归)", async () => { + const r = await organize("Split Cour S01E13 [1080p].mkv"); + expect(r.preview.files[0].target).toMatch(/Season 1\/.*S01E13/); + expect(r.preview.episodeStructure).toBeUndefined(); + expect(await coverage(r.recordId)).toEqual([expect.objectContaining({ season_number: 1, episode_number: 13 })]); + expect((await frozen(r.recordId)).episodeStructure).toBeUndefined(); + }); + + it("指定与作品相同的结构等同跟随作品", async () => { + const dry = await h.api("POST", "/files/organize", { + idempotencyKey: key(), + action: "copy", + dryRun: true, + items: [{ root: h.dlRoot, path: "Split Cour S01E14 [1080p].mkv", mediaEntityId: show }], + episodeStructures: [{ mediaEntityId: show, groupId: null }], + }); + expect(dry.status).toBe(200); + const g = dry.body.data.groups[0]; + expect(g.files[0].target).toMatch(/Season 1\/.*S01E14/); + expect(g.episodeStructure).toEqual({ groupId: null, name: "TMDB 默认季", temporary: false }); + }); + + it("临时按剧集组整理:S01E14 命名为 S02E02,作品设置不变,资产覆盖挂作品的 S01E14 单元", async () => { + const r = await organize("Split Cour S01E14 [1080p].mkv", { + episodeStructures: [{ mediaEntityId: show, groupId: "grp-production" }], + }); + expect(r.preview.files[0].target).toMatch(/Season 2\/.*S02E02/); + expect(r.preview.episodeStructure).toEqual({ groupId: "grp-production", name: "制作季", temporary: true }); + expect(await entityGroup()).toBeNull(); + expect(await coverage(r.recordId)).toEqual([{ id: unitE14, season_number: 1, episode_number: 14 }]); + const plan = await frozen(r.recordId); + expect(plan.episodeStructure).toEqual({ + groupId: "grp-production", + name: "制作季", + entityGroupId: null, + temporary: true, + }); + const fx = Object.values(plan.facts)[0]!; + expect(fx).toMatchObject({ + season: 1, + episodes: { start: 14, end: 14 }, + structureEpisodes: { season: 2, episodes: { start: 2, end: 2 } }, + }); + expect(fx.episodeResolution?.evidence).toContain("structure:grp-production"); + }); + + it("勾「同时设为作品的季集结构」:预览不切换,执行时先切换作品、单元原地迁移,命名 S02E02,覆盖挂迁移后的单元", async () => { + const r = await organize( + "Split Cour S01E14 [2160p].mkv", + { episodeStructures: [{ mediaEntityId: show, groupId: "grp-production", applyToEntity: true }] }, + "alt", + ); + expect(r.groupAfterPreview).toBeNull(); + expect(r.preview.episodeStructure).toEqual({ + groupId: "grp-production", + name: "制作季", + temporary: true, + appliedToEntity: true, + }); + expect(r.preview.files[0].target).toMatch(/alt\/.*S02E02/); + expect(r.exec.files[0].target).toMatch(/alt\/.*S02E02/); + expect(await entityGroup()).toBe("grp-production"); + // 单元 id 不变,原地迁到组的 S2E2 + expect(await unitAt(2, 2)).toBe(unitE14); + expect(await coverage(r.recordId)).toEqual([{ id: unitE14, season_number: 2, episode_number: 2 }]); + expect((await frozen(r.recordId)).episodeStructure).toEqual({ + groupId: "grp-production", + name: "制作季", + entityGroupId: "grp-production", + temporary: false, + appliedToEntity: true, + }); + }); + + it("作品是剧集组时临时按默认季:S01E13 命名 S01E13,覆盖换回组的 S02E01 单元", async () => { + const dry = await h.api("POST", "/files/organize", { + idempotencyKey: key(), + action: "copy", + dryRun: true, + items: [ + { + root: h.dlRoot, + path: "Split Cour S01E13 [1080p].mkv", + mediaEntityId: show, + targetDir: { root: h.libRoot, path: "alt" }, + }, + ], + episodeStructures: [{ mediaEntityId: show, groupId: null }], + }); + expect(dry.status, JSON.stringify(dry.body)).toBe(200); + const g = dry.body.data.groups[0]; + expect(g.files[0].target).toMatch(/alt\/.*S01E13/); + expect(g.episodeStructure).toEqual({ groupId: null, name: "TMDB 默认季", temporary: true }); + const art = await h.db + .selectFrom("artifact") + .select("content") + .where("id", "=", g.planId) + .executeTakeFirstOrThrow(); + const plan = (art.content as { plan: FrozenPlan }).plan; + expect(Object.values(plan.facts)[0]).toMatchObject({ + season: 2, + episodes: { start: 1, end: 1 }, + structureEpisodes: { season: 1, episodes: { start: 13, end: 13 } }, + }); + }); + + it("作品的映射规则按作品结构写:临时结构下规则结果经 TMDB 原季集换到指定结构", async () => { + // 作品现在是「制作季」:ABS 组按 EP-24 换到组的第 2 季(- 26 → 组 S02E02 = 默认 S01E14) + await h.db + .insertInto("episode_mapping_rule") + .values({ + media_entity_id: show, + season: 1, + release_group: "ABS", + kind: "expression", + expression: "EP-24", + target_season: 2, + created_by_type: "user", + }) + .execute(); + await writeFile(join(h.dl, "[ABS] Split Cour - 26 [1080p].mkv"), "abs".padEnd(64, "x")); + const dry = await h.api("POST", "/files/organize", { + idempotencyKey: key(), + action: "copy", + dryRun: true, + items: [{ root: h.dlRoot, path: "[ABS] Split Cour - 26 [1080p].mkv", mediaEntityId: show }], + episodeStructures: [{ mediaEntityId: show, groupId: null }], + }); + expect(dry.status, JSON.stringify(dry.body)).toBe(200); + const g = dry.body.data.groups[0]; + expect(g.files[0].target).toMatch(/Season 1\/.*S01E14/); + const art = await h.db + .selectFrom("artifact") + .select("content") + .where("id", "=", g.planId) + .executeTakeFirstOrThrow(); + const fx = Object.values((art.content as { plan: FrozenPlan }).plan.facts)[0]!; + expect(fx).toMatchObject({ season: 2, episodes: { start: 2, end: 2 } }); + expect(fx.episodeResolution).toMatchObject({ + method: "rule", + canonical: { season: 1, episodes: { start: 14, end: 14 } }, + }); + }); +}); diff --git a/packages/contracts/src/files.ts b/packages/contracts/src/files.ts index 25f19601..301d51d4 100644 --- a/packages/contracts/src/files.ts +++ b/packages/contracts/src/files.ts @@ -381,6 +381,18 @@ export const FilesOrganizeItem = FileRef.extend({ }); export type FilesOrganizeItem = z.infer; +/** + * 整理时指定季集结构(对照 MP TransferInfo.episode_group):这次整理按 groupId 的季集换算与命名,不改作品设置; + * groupId null = TMDB 默认季。applyToEntity = 同时设为作品的季集结构(执行时先切换作品,再按新结构整理;预览不切换)。 + * 与作品当前结构相同等同跟随作品。只对本地剧集作品生效 + */ +export const FilesOrganizeEpisodeStructure = z.object({ + mediaEntityId: Id, + groupId: z.string().trim().min(1).max(64).nullable(), + applyToEntity: z.boolean().optional(), +}); +export type FilesOrganizeEpisodeStructure = z.infer; + export const FilesOrganizeRequest = z.object({ sourceConfigId: Id.optional(), idempotencyKey: IdempotencyKey, @@ -402,6 +414,8 @@ export const FilesOrganizeRequest = z.object({ skipSuccessfulRecords: z.boolean().optional(), /** 本次入库后写 NFO/图片;缺省沿用已有设置,Local 保留自动补全行为。 */ scrapeAfterImport: z.boolean().optional(), + /** 整理时指定季集结构(按作品,缺省跟随作品),见 FilesOrganizeEpisodeStructure */ + episodeStructures: z.array(FilesOrganizeEpisodeStructure).max(50).optional(), }); export type FilesOrganizeRequest = z.infer; @@ -463,6 +477,15 @@ export const FilesOrganizeGroup = z.object({ targetSegments: z .array(z.object({ text: z.string(), role: z.enum(["root", "season", "dir"]), bound: z.boolean() })) .optional(), + /** 本组按哪套季集结构整理(请求指定了季集结构时才有);temporary = 与作品结构不同、只这次使用 */ + episodeStructure: z + .object({ + groupId: z.string().nullable(), + name: z.string(), + temporary: z.boolean(), + appliedToEntity: z.boolean().optional(), + }) + .optional(), /** W9-H(G5):执行后排的元数据刷新 run id(refreshMetadata=true 且该组已提交) */ refreshTaskId: z.string().nullable().optional(), });