diff --git a/CHANGELOG.md b/CHANGELOG.md index 416ef68a..a13a6467 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ## [Unreleased] +### 新增 +- 整理时指定剧集组(参考 MoviePilot 重新整理选剧集组):整理弹窗可临时按 TMDB 默认季或作品的其它剧集组换算季集与命名,作品设置不变,资产仍登记到作品当前结构的对应集;可勾选同时设为作品的季集结构。 + ## [0.3.30] - 2026-10-06 迁移 `0075`、`0077`。 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..2d17a25c 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, @@ -505,6 +553,18 @@ export async function organizeFiles( `下载任务 ${block.downloadTaskId} 的做种义务${block.hnr === "unknown" ? "未知" : "未满足"},不能移动,也不会自动改为复制。已建待处理事项 ${block.decisionId}`, ); } + // 同时设为作品的季集结构:所有执行前检查(计划一致、Move 授权、H&R)通过后才切换作品 + // (单元迁移 + 重算 + 刷新),同一作品的多组只切一次;切换后按作品(新结构)再算一遍,前后路径须与预览一致 + 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 after = pairsOf(await planImport(d.ao, importReq(true))).map(({ src, dst }) => ({ src, dst })); + if (hashJson(after) !== hashJson(frozen.pairs)) + throw new FilesError("plan_stale", "切换作品季集结构后目标路径与预览不一致。重新预览"); + } await retirePlan(d, planId); const taskId = await ensureTaskRow(db, { kind: "verifyAndImport", @@ -534,6 +594,7 @@ export async function organizeFiles( planId, // 执行时 planImport 不回传计划体;冻结计划与刚重算的 dryRun 一致(上面已校验),直接回显 files: filesOf(again.plan), + ...structureOf(again.plan, st), truncated: false, recordId: plan.recordId, taskId, @@ -607,6 +668,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..214ae06f --- /dev/null +++ b/apps/server/test/file-manager/organize-episode-structure.test.ts @@ -0,0 +1,309 @@ +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, EXEC_KEY, type FmHarness, key, pgAvailable, seedDownload, 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("勾「同时设为作品的季集结构」但 Move 授权不通过:不执行,作品结构不变", async () => { + const body = { + action: "move", + items: [ + { + root: h.dlRoot, + path: "Split Cour S01E14 [2160p].mkv", + mediaEntityId: show, + targetDir: { root: h.libRoot, path: "alt" }, + }, + ], + episodeStructures: [{ mediaEntityId: show, groupId: "grp-production", applyToEntity: true }], + }; + const dry = await h.api("POST", "/files/organize", { ...body, idempotencyKey: key(), dryRun: true }, EXEC_KEY); + expect(dry.status, JSON.stringify(dry.body)).toBe(200); + expect(dry.body.data.groups[0].error).toBeNull(); + const exec = await h.api( + "POST", + "/files/organize", + { ...body, idempotencyKey: key(), dryRun: false, planIds: [dry.body.data.groups[0].planId] }, + EXEC_KEY, + ); + expect(exec.status).toBe(403); + expect(exec.body.error.message).toMatch(/^approval_required:/); + expect(await entityGroup()).toBeNull(); + expect(await unitAt(1, 14)).toBe(unitE14); + }); + + it("勾「同时设为作品的季集结构」但做种义务未满足、不能移动:该组不执行,作品结构不变", async () => { + const name = "Split Cour S01E14 [2160p].mkv"; + const task = await seedDownload(h.db, h, { name, status: "completed" }); + const body = { + action: "move", + items: [{ root: h.dlRoot, path: name, mediaEntityId: show, targetDir: { root: h.libRoot, path: "alt" } }], + episodeStructures: [{ mediaEntityId: show, groupId: "grp-production", applyToEntity: true }], + }; + const dry = await h.api("POST", "/files/organize", { ...body, idempotencyKey: key(), dryRun: true }); + expect(dry.status, JSON.stringify(dry.body)).toBe(200); + expect(dry.body.data.groups[0].error).toBeNull(); + const exec = await h.api("POST", "/files/organize", { + ...body, + idempotencyKey: key(), + dryRun: false, + planIds: [dry.body.data.groups[0].planId], + }); + expect(exec.status).toBe(200); + expect(exec.body.data.groups[0].error?.code).toBe("download_active"); + expect(exec.body.data.recordIds).toEqual([]); + expect(await entityGroup()).toBeNull(); + expect(await unitAt(1, 14)).toBe(unitE14); + await h.db.deleteFrom("download_task").where("id", "=", task).execute(); + }); + + 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/apps/web/app/components/organize-wizard/model.test.ts b/apps/web/app/components/organize-wizard/model.test.ts index 5e8d3053..f79e0344 100644 --- a/apps/web/app/components/organize-wizard/model.test.ts +++ b/apps/web/app/components/organize-wizard/model.test.ts @@ -20,6 +20,7 @@ import { rowProblem, rowsFromIdentify, setRowEntity, + structureEntities, type WizardEntity, type WizardRow, wizardStats, @@ -489,3 +490,48 @@ describe("季集只在用户改过时发送(集数映射交给服务端)", () => expect(merged[0]?.season).toBe("3"); }); }); + +describe("季集结构", () => { + const rowsOf = () => { + const rows = rowsFromIdentify([item("a.mkv"), item("b.mkv"), item("c.mkv"), item("d.mkv")], new Set()); + return [ + rows[0] as WizardRow, + { ...(rows[1] as WizardRow), entity: series }, + { ...(rows[2] as WizardRow), entity: tmdbMovie }, + { ...(rows[3] as WizardRow), entity: { ...series, id: "e5" }, excluded: "manual" as const }, + ]; + }; + + it("缺省跟随作品:不发 episodeStructures;只列本次涉及、未排除的本地剧集", () => { + const rows = rowsOf(); + expect(defaultOptions().episodeStructures).toEqual({}); + const body = organizeRequest(rows, defaultOptions(), { dryRun: true, idempotencyKey: "k-123456" }); + expect(body.episodeStructures).toBeUndefined(); + expect(structureEntities(rows).map((e) => e.id)).toEqual(["e1", "e9"]); + }); + + it("临时选剧集组 / 默认季:按作品发 groupId,不带 applyToEntity;不在本次范围的作品不发", () => { + const options = defaultOptions({ + episodeStructures: { + e1: { groupId: "grp-production", apply: false }, + e9: { groupId: null, apply: false }, + e5: { groupId: "grp-x", apply: true }, + }, + }); + const body = organizeRequest(rowsOf(), options, { dryRun: true, idempotencyKey: "k-123456" }); + expect(body.episodeStructures).toEqual([ + { mediaEntityId: "e1", groupId: "grp-production" }, + { mediaEntityId: "e9", groupId: null }, + ]); + }); + + it("勾「同时设为作品的季集结构」:带 applyToEntity,预览与执行同一请求体", () => { + const options = defaultOptions({ episodeStructures: { e1: { groupId: "grp-production", apply: true } } }); + const preview = organizeRequest(rowsOf(), options, { dryRun: true, idempotencyKey: "preview" }); + const queue = organizeRequest(rowsOf(), options, { dryRun: false, idempotencyKey: "q", planIds: ["p"] }); + expect(preview.episodeStructures).toEqual([ + { mediaEntityId: "e1", groupId: "grp-production", applyToEntity: true }, + ]); + expect(queue.episodeStructures).toEqual(preview.episodeStructures); + }); +}); diff --git a/apps/web/app/components/organize-wizard/model.ts b/apps/web/app/components/organize-wizard/model.ts index 07b29b5e..4e5ebca5 100644 --- a/apps/web/app/components/organize-wizard/model.ts +++ b/apps/web/app/components/organize-wizard/model.ts @@ -260,8 +260,15 @@ export function groupRows(rows: WizardRow[]): { group: string; dir: string; rows export type WizardTarget = { mode: "auto" } | { mode: "root"; rootId: string } | { mode: "dir"; ref: FileRef }; +/** + * 整理时指定的季集结构(按作品;没有条目 = 跟随作品)。groupId null = TMDB 默认季; + * apply = 同时设为作品的季集结构(执行时先切换作品) + */ +export type StructureChoice = { groupId: string | null; apply: boolean }; + export type WizardOptions = { action: "copy" | "move" | "in_place"; + episodeStructures: Record; target: WizardTarget; onExisting: FilesOnExisting; naming: "template" | "keep"; @@ -276,6 +283,7 @@ export const DEFAULT_MIN_SIZE = 50 * MIB; export const defaultOptions = (o: Partial = {}): WizardOptions => ({ action: "copy", + episodeStructures: {}, target: { mode: "auto" }, onExisting: "conflict", naming: "template", @@ -308,6 +316,26 @@ export function organizeItems(rows: WizardRow[], o: WizardOptions): FilesOrganiz }); } +/** 本次涉及的本地剧集作品(可指定季集结构):去重,保持首次出现顺序 */ +export function structureEntities(rows: WizardRow[]): WizardEntity[] { + const out = new Map(); + for (const r of rows) + if (!r.excluded && r.entity?.id && !isMovieKind(r.entity.kind) && !out.has(r.entity.id)) + out.set(r.entity.id, r.entity); + return [...out.values()]; +} + +/** 季集结构的请求体:只发本次涉及的本地剧集作品;都跟随作品时不发 */ +export function structureRequest(rows: WizardRow[], o: WizardOptions): FilesOrganizeRequest["episodeStructures"] { + const list = structureEntities(rows) + .filter((e) => o.episodeStructures[e.id!]) + .map((e) => { + const c = o.episodeStructures[e.id!]!; + return { mediaEntityId: e.id!, groupId: c.groupId, ...(c.apply ? { applyToEntity: true } : {}) }; + }); + return list.length ? list : undefined; +} + /** dryRun 与执行必须同一请求体(服务端按请求指纹比对计划),只差 dryRun/planIds/幂等键 */ export function organizeRequest( rows: WizardRow[], @@ -329,6 +357,7 @@ export function organizeRequest( refreshMetadata: o.refreshMetadata, scrapeAfterImport: o.scrapeAfterImport, skipSuccessfulRecords: o.skipSuccessfulRecords, + episodeStructures: structureRequest(rows, o), }; } diff --git a/apps/web/app/components/organize-wizard/organize-wizard.tsx b/apps/web/app/components/organize-wizard/organize-wizard.tsx index 51a7c2cc..a70dbbcf 100644 --- a/apps/web/app/components/organize-wizard/organize-wizard.tsx +++ b/apps/web/app/components/organize-wizard/organize-wizard.tsx @@ -42,6 +42,7 @@ import { previewStats, rowProblem, rowsFromIdentify, + structureEntities, type WizardEntity, type WizardOptions, type WizardRow, @@ -49,6 +50,7 @@ import { } from "./model.ts"; import { type OrganizeSource, useResolvedSource } from "./source.ts"; import { PreviewList, PreviewStatsBar } from "./step-preview.tsx"; +import { StructurePicker } from "./structure-picker.tsx"; export type { OrganizeSource } from "./source.ts"; @@ -218,6 +220,7 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on [identified, manual, season, options.skipSuccessfulRecords], ); const active = effectiveRows.filter((r) => !r.excluded); + const showEntities = structureEntities(effectiveRows); const stats = wizardStats(effectiveRows); const hnrCount = active.filter((r) => r.hnrBlocked).length; const successfulCount = rows.filter((r) => (r.item.records?.successfulCount ?? 0) > 0).length; @@ -689,6 +692,22 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on /> ) : null} + {showEntities.slice(0, 5).map((e) => ( + 1} + value={options.episodeStructures[e.id!]} + onChange={(v) => + setOptions((o) => { + const next = { ...o.episodeStructures }; + if (v) next[e.id!] = v; + else delete next[e.id!]; + return { ...o, episodeStructures: next }; + }) + } + /> + ))}
整理后刮削 diff --git a/apps/web/app/components/organize-wizard/step-preview.tsx b/apps/web/app/components/organize-wizard/step-preview.tsx index 0ec681c4..303dd6b7 100644 --- a/apps/web/app/components/organize-wizard/step-preview.tsx +++ b/apps/web/app/components/organize-wizard/step-preview.tsx @@ -191,6 +191,15 @@ export function PreviewGroupCard({ {g.files.length} 个文件 · {formatBytes(size, 1)} {g.action ? ` · ${g.action === "move" ? "移动" : "复制"}` : ""} {g.naming ? ` · 命名:${TEMPLATE_SOURCE[g.naming.templateSource] ?? g.naming.templateSource}` : ""} + {g.episodeStructure + ? ` · 季集结构:${g.episodeStructure.name}${ + g.episodeStructure.appliedToEntity + ? "(同时设为作品结构)" + : g.episodeStructure.temporary + ? "(仅本次)" + : "" + }` + : ""} {result && g.recordId ? ( diff --git a/apps/web/app/components/organize-wizard/structure-picker.tsx b/apps/web/app/components/organize-wizard/structure-picker.tsx new file mode 100644 index 00000000..271d9103 --- /dev/null +++ b/apps/web/app/components/organize-wizard/structure-picker.tsx @@ -0,0 +1,70 @@ +import { useEpisodeGroups } from "~/api/episode-mapping.ts"; +import { Switch } from "~/components/ui/switch.tsx"; +import { FormSelect } from "./form-select.tsx"; +import { entityLabel, type StructureChoice, type WizardEntity } from "./model.ts"; + +const FOLLOW = "follow"; +const DEFAULT = "default"; + +/** + * 整理时指定季集结构(docs/runbook/organize.md「整理时指定剧集组」):缺省跟随作品,可临时选 TMDB 默认季或作品的其它剧集组; + * 勾「同时设为作品的季集结构」时执行前先改作品设置。作品没有剧集组可选时不显示。 + */ +export function StructurePicker({ + entity, + value, + onChange, + showTitle, +}: { + entity: WizardEntity; + value: StructureChoice | undefined; + onChange: (v: StructureChoice | undefined) => void; + /** 本次有多部剧集时在标签里带作品名 */ + showTitle: boolean; +}) { + const q = useEpisodeGroups(entity.id ?? undefined); + const list = q.data; + if (!list?.applicable || (!list.groups.length && !list.current)) return null; + const current = list.current; + const choices = [ + { value: FOLLOW, label: `跟随作品(当前:${current?.name ?? "TMDB 默认季"})` }, + ...(current ? [{ value: DEFAULT, label: "TMDB 默认季" }] : []), + ...list.groups + .filter((g) => g.id !== current?.groupId) + .map((g) => ({ + value: g.id, + label: `${g.name}${g.typeLabel ? ` · ${g.typeLabel}` : ""} · ${g.groupCount} 季 ${g.episodeCount} 集`, + })), + ]; + const selected = value ? (value.groupId ?? DEFAULT) : FOLLOW; + return ( +
+ + onChange(v === FOLLOW ? undefined : { groupId: v === DEFAULT ? null : v, apply: value?.apply ?? false }) + } + choices={choices} + /> + {value ? ( +
+ + 同时设为作品的季集结构 + + {value.apply + ? "执行时先改作品设置,已有集按 TMDB 原季集迁移,再按新结构整理" + : "只这次按所选结构换算季集和命名,作品设置不变"} + + + onChange({ ...value, apply })} + aria-label="同时设为作品的季集结构" + /> +
+ ) : null} + {list.error ?

剧集组列表暂时取不到,只能跟随作品。

: null} +
+ ); +} diff --git a/docs/porting/moviepilot-episode-groups.md b/docs/porting/moviepilot-episode-groups.md index 4c264a27..5daccab4 100644 --- a/docs/porting/moviepilot-episode-groups.md +++ b/docs/porting/moviepilot-episode-groups.md @@ -11,7 +11,7 @@ | `api/endpoints/media.py groups/{tmdbid}`、`group/seasons/{groupId}` | `GET /media-entities/{id}/episode-groups`、`GET /media-entities/{id}/episode-structure?groupId=` | 季卡片额外带本地已入库(按 TMDB 原季集对照)与当前结构下的季单元 id | | `schemas/subscribe.py episode_group`、`SubscribeSeasonDialog.vue`(剧集组横向轨道 + 季卡片:海报、集数、首播、已入库) | 订阅表单 `SubscriptionSeasonPicker` | 订阅不单独存组:选了非当前结构的季要确认改作品设置 | | `scraper.py`:NFO / 剧照按 `get_tv_group_detail` 取季 | 刮削 NFO 用单元元数据(本来就按组);缺元数据时 `scrapeTmdbSeason(…, episodeGroupId)` 按组取 | 季海报 MP 仍取默认季;我们取组内首集所在默认季的海报 | -| `schemas/transfer.py episode_group`、`ReorganizeDialog.vue` | 不做 | 整理按作品当前季集结构映射 | +| `schemas/transfer.py episode_group`、`ReorganizeDialog.vue` | 整理弹窗「季集结构」:`POST /files/organize` 的 `episodeStructures`(`createStructureEpisodeMapper`) | 缺省跟随作品;临时选的结构只这次换算季集与命名,资产覆盖按 TMDB 原季集换回作品结构的单元;可勾「同时设为作品的季集结构」先切换作品。见 `runbook/organize.md`「整理时指定剧集组」 | | `metabase.py` 识别词 `g=` 指定剧集组 | 不做 | 单条资源不指定组;规则的目标季集写组内的即可 | 我们多出来的:切换结构时已有单元按 TMDB 原季集原地迁移(引用不动,对不上的标临时并列出);集数解析多一步「按 TMDB 默认季集换算」(`tmdb_season`),发布者按默认季编号时自动换到组内季集;刷新时 TMDB 调整了组内顺序也按原季集挪单元。 diff --git a/docs/runbook/identity.md b/docs/runbook/identity.md index 0028aeb7..62980476 100644 --- a/docs/runbook/identity.md +++ b/docs/runbook/identity.md @@ -195,7 +195,9 @@ TMDB 默认季有时和发布者、媒体服务器的分季不一样(最常见: **刷新**:剧集组作品的元数据刷新拉 `/tv/episode_group/{id}` 而不是默认季;TMDB 编辑调整过组内顺序时,集单元按记下的原季集挪到新位置(规则同切换)。组取不到(404 / 网络)时本次不动季集结构,只刷作品字段。刮削缺季集元数据时也按组取。 -**没做**:识别词 / 单条资源指定剧集组(MP 的 `g=`)、整理时按剧集组(MP `TransferInfo.episode_group`)——季集结构是作品级的一个选择。 +**整理时指定**:整理弹窗可以只这次按另一套结构整理(不改作品设置),或勾选同时设为作品结构,见 `organize.md`「整理时指定剧集组」。 + +**没做**:识别词 / 单条资源指定剧集组(MP 的 `g=`)——季集结构是作品级的一个选择,整理时可临时指定。 ## 池的治理 diff --git a/docs/runbook/organize.md b/docs/runbook/organize.md index cb462d4a..e1759d25 100644 --- a/docs/runbook/organize.md +++ b/docs/runbook/organize.md @@ -22,6 +22,29 @@ 确认预览后只提供「加入整理队列」。后台受理成功后弹窗自动关闭,可以继续操作,进度到队列查看,整理后的刮削任务也显示在队列,整理详情单独展示刮削结果;部分受理时已受理文件从本次范围移除,剩余文件可修改后再预览提交。队列复用现有后台调度,不要求浏览器停留到文件处理结束。记录、队列及弹窗明细均复用统一分页。 +## 整理时指定剧集组 + +对照 MP 的整理 / 重新整理选剧集组(`TransferInfo.episode_group`、`ReorganizeDialog.vue`)。作品的季集结构(TMDB 默认季 / 某个剧集组,见 `identity.md`「剧集组」)是作品级设置;整理弹窗里本次涉及的本地剧集作品如果有剧集组可选,多一项「季集结构」: + +- **缺省「跟随作品(当前:X)」**:请求不带 `episodeStructures`,冻结计划和以前一样(不记季集结构)。 +- **临时选 TMDB 默认季或作品的其它剧集组**(列表同 `GET /media-entities/{id}/episode-groups`):只这次按所选结构换算季集、定季目录与命名,作品设置不变。选的就是作品当前结构时等同跟随作品。 +- **勾「同时设为作品的季集结构」**(缺省不勾):预览不改作品;执行时等计划一致、Move 授权、做种义务这些执行前检查都通过后,才走作品级切换(`setEpisodeGroup`:单元按 TMDB 原季集原地迁移、全量重算、刷新元数据,写审计),检查失败时作品结构不变;切换后按新结构(此时就是作品结构)再算一遍,前后路径须与预览一致,再整理。同一作品多组只切一次。 + +请求:`POST /files/organize` 的 `episodeStructures: [{ mediaEntityId, groupId | null, applyToEntity? }]`(预览与执行同一请求体,进请求指纹)。组响应带 `episodeStructure { groupId, name, temporary, appliedToEntity? }`,预览头部显示「季集结构:制作季(仅本次)」。 + +**临时结构下的集数映射**(`episode-resolution.ts createStructureEpisodeMapper`): + +1. 季表换成所选结构的季与集数;选剧集组时带每集播出日期与原季集对照(发布者按 TMDB 默认季编号的 `S01E14` 换到组内 `S02E02`)。 +2. 作品的映射规则是按作品当前结构写的:先按作品季表 + 规则跑一遍,命中规则(含特别篇 / 花絮 / 忽略)就用规则结果,季集再经 TMDB 原季集换到所选结构(换不过去的按待确认排除);没命中规则时按所选结构的季表判定。 +3. 资源的集数解析是按作品结构做的,临时结构下不沿用,只取发布时间;按所选结构判不出的文件照常排除为集数待确认,但不并进作品的「集数待确认」待处理。 +4. 向导里人工填的季集按所选结构理解(决定命名)。 + +**资产覆盖落到哪个单元**:作品的集单元是按作品当前结构编号的,所以文件的季集(所选结构)逐集换回作品结构再登记覆盖:所选结构季集 → TMDB 原季集(剧集组按组内位置,默认季即自身)→ 作品结构(作品是默认季时原季集即单元季集;作品是剧集组时按单元上记的 `tmdbSeason / tmdbEpisode` 对照)。区间里每一集都要换得到、落在同一季且连续;换不回(比如作品的剧集组不含这一集,或单元还没记原季集)时文件照常按所选结构命名入库,只是不登记集覆盖。例:作品是默认季(S1 = 25 集),临时按「制作季」整理 `S01E14` → 命名 `S02E02`,覆盖挂作品的 S01E14 单元;勾「同时设为作品结构」→ 先切换(S01E14 单元原地变成 S02E02),命名 `S02E02`,覆盖挂迁移后的同一单元。 + +**排查**:冻结计划 `plan.episodeStructure { groupId, name, entityGroupId, temporary, appliedToEntity? }` 记这次用了哪套结构与当时作品的结构;临时结构下每个文件的 `facts.structureEpisodes` 是命名用的季集,`facts.season / episodes` 是换回作品结构后的单元季集(换不回时没有),`episodeResolution.evidence` 带 `structure:`。 + +不做:切换结构后自动改名已整理的文件(仍用「重新整理」)、剧集组季的独立海报。测试:`apps/server/test/file-manager/organize-episode-structure.test.ts`、`apps/web/app/components/organize-wizard/model.test.ts`「季集结构」。 + ## 来源、分类与存量整理 设置 → 媒体库与目录中维护分类 YAML 和整理来源。新功能已于 2026-10-01 部署为 v0.3.9,迁移 `0059_organize_source_snapshot` 已执行,见 发布记录(已删除,见 git `ceb66f8`)。 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(), });