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