feat(web): 整理弹窗加「季集结构」选项

缺省跟随作品(显示当前结构),可临时选 TMDB 默认季或作品的其它剧集组,另有「同时设为作品的季集结构」开关;
请求体只发本次涉及的本地剧集作品,预览头部显示本组用的季集结构。

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:28:34 +08:00
1 parent d55fa4b240
commit 6d4af00339
5 files changed
+173

No files matched your search

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