merge: fix/r-organize-group

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-06 16:55:51 +08:00
commit d51bb95e5f
19 files changed
+897 -13

No files matched your search

+3
View File
@@ -4,6 +4,9 @@
## [Unreleased]
### 新增
- 整理时指定剧集组(参考 MoviePilot 重新整理选剧集组):整理弹窗可临时按 TMDB 默认季或作品的其它剧集组换算季集与命名,作品设置不变,资产仍登记到作品当前结构的对应集;可勾选同时设为作品的季集结构。
## [0.3.30] - 2026-10-06
迁移 `0075`、`0077`。
@@ -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<PlanRes
const seasons = new Set<number>();
// 集数映射(批次 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<string, DomainFileOverride> = {};
const unresolvedEpisodes: NonNullable<FrozenPlan["unresolvedEpisodes"]> = {};
// 文件级标题粗核:混合包 / 合集里标题明显不是本作品的视频不按本作品命名入库(待确认 title_mismatch);
@@ -683,16 +698,26 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
}
const season = parsed.season ?? (parsed.episodes || parsed.airDate ? 1 : undefined);
if (mediaType === "tv" && season !== undefined) seasons.add(season);
// 临时结构:命名 / 季目录按指定结构的季集;资产覆盖的单元换回作品当前结构(换不回就不登记集覆盖)
const named: EpisodeSpan | null =
episodes?.toEntity && season !== undefined && parsed.episodes ? { season, episodes: parsed.episodes } : null;
const unit = named ? episodes!.toEntity!(named) : undefined;
const unitParsed =
unit === undefined ? parsed : unit ? { ...parsed, season: unit.season, episodes: unit.episodes } : null;
const unitSeason = unit === undefined ? season : unit?.season;
facts[f.path] = {
quality: qualityOf(parsed),
unitKeys: unitKeysOf({
...parsed,
...(season !== undefined ? { season } : {}),
}),
...(season !== undefined ? { season } : {}),
...(parsed.episodes ? { episodes: parsed.episodes } : {}),
unitKeys: unitParsed
? unitKeysOf({
...unitParsed,
...(unitSeason !== undefined ? { season: unitSeason } : {}),
})
: [],
...(unitSeason !== undefined ? { season: unitSeason } : {}),
...(unitParsed?.episodes ? { episodes: unitParsed.episodes } : {}),
...(ov?.contentUnitIds?.length ? { unitIds: ov.contentUnitIds } : {}),
...(resolution && resolution.method !== "skipped" ? { episodeResolution: episodeFacts(resolution) } : {}),
...(named ? { structureEpisodes: named } : {}),
};
}
const overrides =
@@ -924,6 +949,7 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
sourceRoot,
facts,
...(Object.keys(unresolvedEpisodes).length ? { unresolvedEpisodes } : {}),
...(structure ? { episodeStructure: structure } : {}),
...(downgradedFromMove ? { downgradedFromMove } : {}),
...(req.slotId ? { slotId: req.slotId } : {}),
naming: {
@@ -1073,8 +1099,9 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
return { recordId: rec.id, status: rec.status, reason };
});
// 文件集数待确认:并入关联资源的 episode_mapping 待处理(待处理按资源归集;没有关联资源只记整理失败原因)
// 临时结构下的待确认是相对指定结构的,不并进作品的集数待确认
const pending = episodes?.unresolved[0];
if (pending && episodes?.release) {
if (pending && episodes?.release && !episodes.toEntity) {
await settleEpisodeDecision(d.ctx, db, {
sourceReleaseId: episodes.release.id,
entityId: entity.id,
@@ -1088,6 +1115,8 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
interface FileEpisodes {
map: EntityEpisodeMapper;
opts: FileEpisodeOptions;
/** 临时季集结构:指定结构的季集 → 作品当前结构的单元季集;跟随作品时没有 */
toEntity?: (span: EpisodeSpan) => 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<FileEpisodes> {
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;
@@ -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<string, EpisodeFacts>;
/** 整理时指定了季集结构(向导);缺省跟随作品时没有 */
episodeStructure?: FrozenEpisodeStructure;
downgradedFromMove?: string;
/** W5-A2:手动整理指定的 Slot */
slotId?: string;
@@ -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<TmdbClient | null>;
/** sources-identity 运行时(与 tmdb 同一实例):整理时指定季集结构 / 切换作品季集结构 */
sources: () => SourcesRuntime;
}
const envNum = (k: string, dflt: number) => {
@@ -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;
@@ -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<string, StructureChoice>();
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<string> {
if (planIds.length === 0) throw new FilesError("bad_request", "执行需要 dryRun 返回的 planIds");
@@ -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 原季集原地迁移集单元、重建季表与总集数、记下选择,按作品全量重算已绑定资源;
@@ -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<StructureEpisodeMapper> {
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<number, { episode: number; airDate: string | null }[]>();
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<string, { season: number; episode: number }>();
const originByUnit = new Map<string, { season: number; episode: number }>();
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;
@@ -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,
};
@@ -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<string> {
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<string, unknown> = {}, 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 } },
});
});
});
@@ -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);
});
});
@@ -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<string, StructureChoice>;
target: WizardTarget;
onExisting: FilesOnExisting;
naming: "template" | "keep";
@@ -276,6 +283,7 @@ export const DEFAULT_MIN_SIZE = 50 * MIB;
export const defaultOptions = (o: Partial<WizardOptions> = {}): 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<string, WizardEntity>();
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),
};
}
@@ -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
/>
</div>
) : null}
{showEntities.slice(0, 5).map((e) => (
<StructurePicker
key={e.id}
entity={e}
showTitle={showEntities.length > 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 };
})
}
/>
))}
<div className="flex items-center justify-between gap-3 rounded-xl border border-border px-3 py-2">
<span className="text-caption">
整理后刮削
@@ -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
? "(仅本次)"
: ""
}`
: ""}
</span>
{result && g.recordId ? (
<Link className="ml-auto text-meta underline underline-offset-2" to={`/console/organize/${g.recordId}`}>
@@ -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 (
<div className="space-y-2">
<FormSelect
label={showTitle ? `季集结构 · ${entityLabel(entity)}` : "季集结构"}
value={selected}
onChange={(v) =>
onChange(v === FOLLOW ? undefined : { groupId: v === DEFAULT ? null : v, apply: value?.apply ?? false })
}
choices={choices}
/>
{value ? (
<div className="flex items-center justify-between gap-3 rounded-xl border border-border px-3 py-2">
<span className="text-caption">
同时设为作品的季集结构
<span className="block text-meta text-muted-foreground">
{value.apply
? "执行时先改作品设置,已有集按 TMDB 原季集迁移,再按新结构整理"
: "只这次按所选结构换算季集和命名,作品设置不变"}
</span>
</span>
<Switch
checked={value.apply}
onCheckedChange={(apply) => onChange({ ...value, apply })}
aria-label="同时设为作品的季集结构"
/>
</div>
) : null}
{list.error ? <p className="text-meta text-warning">剧集组列表暂时取不到,只能跟随作品。</p> : null}
</div>
);
}
+1 -1
View File
@@ -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 调整了组内顺序也按原季集挪单元。
+3 -1
View File
@@ -195,7 +195,9 @@ TMDB 默认季有时和发布者、媒体服务器的分季不一样(最常见:
**刷新**:剧集组作品的元数据刷新拉 `/tv/episode_group/{id}` 而不是默认季;TMDB 编辑调整过组内顺序时,集单元按记下的原季集挪到新位置(规则同切换)。组取不到(404 / 网络)时本次不动季集结构,只刷作品字段。刮削缺季集元数据时也按组取。
**没做**:识别词 / 单条资源指定剧集组(MP 的 `g=`)、整理时按剧集组(MP `TransferInfo.episode_group`)——季集结构是作品级的一个选择。
**整理时指定**:整理弹窗可以只这次按另一套结构整理(不改作品设置),或勾选同时设为作品结构,见 `organize.md`「整理时指定剧集组」。
**没做**:识别词 / 单条资源指定剧集组(MP 的 `g=`)——季集结构是作品级的一个选择,整理时可临时指定。
## 池的治理
+23
View File
@@ -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:<groupId|default>`。
不做:切换结构后自动改名已整理的文件(仍用「重新整理」)、剧集组季的独立海报。测试:`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`)。
+23
View File
@@ -381,6 +381,18 @@ export const FilesOrganizeItem = FileRef.extend({
});
export type FilesOrganizeItem = z.infer<typeof FilesOrganizeItem>;
/**
* 整理时指定季集结构(对照 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<typeof FilesOrganizeEpisodeStructure>;
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<typeof FilesOrganizeRequest>;
@@ -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(),
});