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:
1 parent
d55fa4b240
commit
6d4af00339
5 files changed
+173
No files matched your search
@@ -20,6 +20,7 @@ import {
|
|||||||
rowProblem,
|
rowProblem,
|
||||||
rowsFromIdentify,
|
rowsFromIdentify,
|
||||||
setRowEntity,
|
setRowEntity,
|
||||||
|
structureEntities,
|
||||||
type WizardEntity,
|
type WizardEntity,
|
||||||
type WizardRow,
|
type WizardRow,
|
||||||
wizardStats,
|
wizardStats,
|
||||||
@@ -489,3 +490,48 @@ describe("季集只在用户改过时发送(集数映射交给服务端)", () =>
|
|||||||
expect(merged[0]?.season).toBe("3");
|
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 };
|
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 = {
|
export type WizardOptions = {
|
||||||
action: "copy" | "move" | "in_place";
|
action: "copy" | "move" | "in_place";
|
||||||
|
episodeStructures: Record<string, StructureChoice>;
|
||||||
target: WizardTarget;
|
target: WizardTarget;
|
||||||
onExisting: FilesOnExisting;
|
onExisting: FilesOnExisting;
|
||||||
naming: "template" | "keep";
|
naming: "template" | "keep";
|
||||||
@@ -276,6 +283,7 @@ export const DEFAULT_MIN_SIZE = 50 * MIB;
|
|||||||
|
|
||||||
export const defaultOptions = (o: Partial<WizardOptions> = {}): WizardOptions => ({
|
export const defaultOptions = (o: Partial<WizardOptions> = {}): WizardOptions => ({
|
||||||
action: "copy",
|
action: "copy",
|
||||||
|
episodeStructures: {},
|
||||||
target: { mode: "auto" },
|
target: { mode: "auto" },
|
||||||
onExisting: "conflict",
|
onExisting: "conflict",
|
||||||
naming: "template",
|
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/幂等键 */
|
/** dryRun 与执行必须同一请求体(服务端按请求指纹比对计划),只差 dryRun/planIds/幂等键 */
|
||||||
export function organizeRequest(
|
export function organizeRequest(
|
||||||
rows: WizardRow[],
|
rows: WizardRow[],
|
||||||
@@ -329,6 +357,7 @@ export function organizeRequest(
|
|||||||
refreshMetadata: o.refreshMetadata,
|
refreshMetadata: o.refreshMetadata,
|
||||||
scrapeAfterImport: o.scrapeAfterImport,
|
scrapeAfterImport: o.scrapeAfterImport,
|
||||||
skipSuccessfulRecords: o.skipSuccessfulRecords,
|
skipSuccessfulRecords: o.skipSuccessfulRecords,
|
||||||
|
episodeStructures: structureRequest(rows, o),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
previewStats,
|
previewStats,
|
||||||
rowProblem,
|
rowProblem,
|
||||||
rowsFromIdentify,
|
rowsFromIdentify,
|
||||||
|
structureEntities,
|
||||||
type WizardEntity,
|
type WizardEntity,
|
||||||
type WizardOptions,
|
type WizardOptions,
|
||||||
type WizardRow,
|
type WizardRow,
|
||||||
@@ -49,6 +50,7 @@ import {
|
|||||||
} from "./model.ts";
|
} from "./model.ts";
|
||||||
import { type OrganizeSource, useResolvedSource } from "./source.ts";
|
import { type OrganizeSource, useResolvedSource } from "./source.ts";
|
||||||
import { PreviewList, PreviewStatsBar } from "./step-preview.tsx";
|
import { PreviewList, PreviewStatsBar } from "./step-preview.tsx";
|
||||||
|
import { StructurePicker } from "./structure-picker.tsx";
|
||||||
|
|
||||||
export type { OrganizeSource } from "./source.ts";
|
export type { OrganizeSource } from "./source.ts";
|
||||||
|
|
||||||
@@ -218,6 +220,7 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
|
|||||||
[identified, manual, season, options.skipSuccessfulRecords],
|
[identified, manual, season, options.skipSuccessfulRecords],
|
||||||
);
|
);
|
||||||
const active = effectiveRows.filter((r) => !r.excluded);
|
const active = effectiveRows.filter((r) => !r.excluded);
|
||||||
|
const showEntities = structureEntities(effectiveRows);
|
||||||
const stats = wizardStats(effectiveRows);
|
const stats = wizardStats(effectiveRows);
|
||||||
const hnrCount = active.filter((r) => r.hnrBlocked).length;
|
const hnrCount = active.filter((r) => r.hnrBlocked).length;
|
||||||
const successfulCount = rows.filter((r) => (r.item.records?.successfulCount ?? 0) > 0).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>
|
</div>
|
||||||
) : null}
|
) : 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">
|
<div className="flex items-center justify-between gap-3 rounded-xl border border-border px-3 py-2">
|
||||||
<span className="text-caption">
|
<span className="text-caption">
|
||||||
整理后刮削
|
整理后刮削
|
||||||
|
|||||||
@@ -191,6 +191,15 @@ export function PreviewGroupCard({
|
|||||||
{g.files.length} 个文件 · {formatBytes(size, 1)}
|
{g.files.length} 个文件 · {formatBytes(size, 1)}
|
||||||
{g.action ? ` · ${g.action === "move" ? "移动" : "复制"}` : ""}
|
{g.action ? ` · ${g.action === "move" ? "移动" : "复制"}` : ""}
|
||||||
{g.naming ? ` · 命名:${TEMPLATE_SOURCE[g.naming.templateSource] ?? g.naming.templateSource}` : ""}
|
{g.naming ? ` · 命名:${TEMPLATE_SOURCE[g.naming.templateSource] ?? g.naming.templateSource}` : ""}
|
||||||
|
{g.episodeStructure
|
||||||
|
? ` · 季集结构:${g.episodeStructure.name}${
|
||||||
|
g.episodeStructure.appliedToEntity
|
||||||
|
? "(同时设为作品结构)"
|
||||||
|
: g.episodeStructure.temporary
|
||||||
|
? "(仅本次)"
|
||||||
|
: ""
|
||||||
|
}`
|
||||||
|
: ""}
|
||||||
</span>
|
</span>
|
||||||
{result && g.recordId ? (
|
{result && g.recordId ? (
|
||||||
<Link className="ml-auto text-meta underline underline-offset-2" to={`/console/organize/${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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user