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,
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user