feat(organize): 整理时指定季集结构(临时剧集组 / 同时设为作品结构)
- 契约:FilesOrganizeRequest.episodeStructures(按作品 groupId / applyToEntity),组响应带 episodeStructure - 集数映射:createStructureEpisodeMapper 按指定结构的季表(剧集组带原季集对照)换算;作品规则先按作品结构命中,再经 TMDB 原季集换到指定结构 - 资产覆盖:指定结构季集 → TMDB 原季集 → 作品当前结构的单元,换不回不登记集覆盖;冻结计划记 episodeStructure 与 facts.structureEpisodes - 向导执行勾选「同时设为作品的季集结构」时先走 setEpisodeGroup 切换作品再整理 - 测试:organize-episode-structure(SPLIT_COUR_FIXTURE) Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
731336ccb1
commit
d55fa4b240
10 files changed
+637
-11
No files matched your search
@@ -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,
|
||||
@@ -469,6 +517,14 @@ export async function organizeFiles(
|
||||
const now = await factsOf(frozen.facts.map((f) => f.path));
|
||||
const changed = now.find((f, i) => f.size !== frozen.facts[i]!.size || f.mtimeMs !== frozen.facts[i]!.mtimeMs);
|
||||
if (changed) throw new FilesError("plan_stale", `源文件在预览后发生变化:${changed.path}`);
|
||||
// 同时设为作品的季集结构:先走作品级切换(单元迁移 + 重算 + 刷新),同一作品的多组只切一次
|
||||
if (st?.apply && !st.switched) {
|
||||
await setEpisodeGroup(d.sources(), g.entity, st.groupId, {
|
||||
type: principal?.kind === "api_key" ? "api_key" : "user",
|
||||
id: principal?.id ?? null,
|
||||
}).catch(structureError);
|
||||
st.switched = true;
|
||||
}
|
||||
const again = await planImport(d.ao, importReq(true));
|
||||
const nowPairs = pairsOf(again).map(({ src, dst }) => ({ src, dst }));
|
||||
if (hashJson(nowPairs) !== hashJson(frozen.pairs))
|
||||
@@ -534,6 +590,7 @@ export async function organizeFiles(
|
||||
planId,
|
||||
// 执行时 planImport 不回传计划体;冻结计划与刚重算的 dryRun 一致(上面已校验),直接回显
|
||||
files: filesOf(again.plan),
|
||||
...structureOf(again.plan, st),
|
||||
truncated: false,
|
||||
recordId: plan.recordId,
|
||||
taskId,
|
||||
@@ -607,6 +664,20 @@ function planExtras(plan: FrozenPlan | undefined, overrideDir: boolean) {
|
||||
};
|
||||
}
|
||||
|
||||
/** 组按哪套季集结构整理(请求指定了才有) */
|
||||
function structureOf(plan: FrozenPlan | undefined, st: StructureChoice | undefined) {
|
||||
const s = plan?.episodeStructure;
|
||||
if (!s) return {};
|
||||
return {
|
||||
episodeStructure: {
|
||||
groupId: s.groupId,
|
||||
name: s.name,
|
||||
temporary: s.temporary,
|
||||
...(s.appliedToEntity || st?.apply ? { appliedToEntity: true } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 执行请求携带全部组的 planId:找与本组请求一致的那个 */
|
||||
async function pickPlan(d: FmDeps, planIds: string[], reqHash: string): Promise<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,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user