fix(organize-wizard): 整理向导只发用户改过的季集,不再绕过集数映射
- 行上新增 episodeTouched / mappedFor:只有行内改季集、批量季号、集号偏移、弹窗指定季号的行才发季集; 未改动的预填只作展示,交给服务端按最终作品映射 - 改选作品后,按旧作品映射的预填清空(未映射的解析值保留);补识别刷新未改动行的预填 - 识别结果新增 episodeUnresolved(集数待确认),向导里该行未决,需填季集或排除 - pg 测试:向导整理 `[ANi] X - 25` 不带季集 → S02E13,显式季集按用户值 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
00045a24cb
commit
c16f8a51e9
6 files changed
+259
-17
No files matched your search
@@ -4,15 +4,18 @@ import {
|
||||
applyGroupEntity,
|
||||
applyMinSize,
|
||||
applyOffsetTo,
|
||||
applySeasonTo,
|
||||
defaultOptions,
|
||||
dialogRows,
|
||||
displayPath,
|
||||
downloadContentRef,
|
||||
groupRows,
|
||||
MIB,
|
||||
mergeIdentified,
|
||||
nestSidecars,
|
||||
organizeRequest,
|
||||
pageOf,
|
||||
patchRowEpisodes,
|
||||
previewStats,
|
||||
rowProblem,
|
||||
rowsFromIdentify,
|
||||
@@ -119,12 +122,13 @@ describe("识别结果 → 行", () => {
|
||||
it("剧集缺季号或集号非法时未决;做种路径标 H&R", () => {
|
||||
const rows = rowsFromIdentify([item("seed/a.mkv")], new Set(["seed"]));
|
||||
expect(rows[0]?.hnrBlocked).toBe(true);
|
||||
expect(rowProblem({ ...(rows[0] as WizardRow), season: "" })).toMatch(/季号/);
|
||||
expect(rowProblem({ ...(rows[0] as WizardRow), episodes: "1-x" })).toMatch(/集号/);
|
||||
const touched = { ...(rows[0] as WizardRow), episodeTouched: true };
|
||||
expect(rowProblem({ ...touched, season: "" })).toMatch(/季号/);
|
||||
expect(rowProblem({ ...touched, episodes: "1-x" })).toMatch(/集号/);
|
||||
});
|
||||
|
||||
it("只允许单集或连续集号,不让人工覆写被后端忽略", () => {
|
||||
const r = rowsFromIdentify([item("show/a.mkv")], new Set())[0] as WizardRow;
|
||||
const r = { ...(rowsFromIdentify([item("show/a.mkv")], new Set())[0] as WizardRow), episodeTouched: true };
|
||||
expect(rowProblem({ ...r, episodes: "3,5" })).toBe("集号需要连续,如 3-5");
|
||||
expect(wizardStats([{ ...r, episodes: "3,5" }]).pending).toBe(1);
|
||||
expect(rowProblem({ ...r, episodes: "4,3,4" })).toBeNull();
|
||||
@@ -166,13 +170,15 @@ describe("批量设置", () => {
|
||||
});
|
||||
|
||||
describe("统一整理弹窗范围", () => {
|
||||
it("指定作品覆盖整批,清除后恢复逐文件识别,季集解析保留", () => {
|
||||
it("指定作品覆盖整批,清除后恢复逐文件识别与季集预填", () => {
|
||||
const rows = rowsFromIdentify([item("a.mkv"), item("b.mkv", { id: "e2" })], new Set());
|
||||
const selected = dialogRows(rows, tmdbMovie, "", false);
|
||||
expect(selected.map((r) => r.entity)).toEqual([tmdbMovie, tmdbMovie]);
|
||||
expect(dialogRows(rows, null, "", false).map((r) => r.entity?.id)).toEqual(["e1", "e2"]);
|
||||
expect(rows.map((r) => r.entity?.id)).toEqual(["e1", "e2"]);
|
||||
expect(selected.map((r) => r.episodes)).toEqual(["2", "2"]);
|
||||
// 预填是按 e1 / e2 映射的,改选作品后不沿用
|
||||
expect(selected.map((r) => r.episodes)).toEqual(["", ""]);
|
||||
expect(dialogRows(rows, null, "", false).map((r) => r.episodes)).toEqual(["2", "2"]);
|
||||
});
|
||||
|
||||
it("成功历史默认参与;开启跳过后未识别的成功文件也不阻挡预览", () => {
|
||||
@@ -228,7 +234,7 @@ describe("请求体", () => {
|
||||
{ dryRun: true, idempotencyKey: "k-123456" },
|
||||
);
|
||||
expect(body.items).toEqual([
|
||||
{ root: "lib:x", path: "a.mkv", mediaEntityId: "e1", season: 1, episodes: [2] },
|
||||
{ root: "lib:x", path: "a.mkv", mediaEntityId: "e1" },
|
||||
{ root: "lib:x", path: "b.mkv", external: { provider: "tmdb", externalId: "872585", kind: "movie" } },
|
||||
]);
|
||||
expect(body.targetLibraryRootId).toBe("0e0e0e0e-0000-0000-0000-000000000000");
|
||||
@@ -405,3 +411,81 @@ it("原地入库请求不携带旧搬运目标、分类来源与替换策略,
|
||||
expect(exec.naming).toBe(dry.naming);
|
||||
expect(exec.onExisting).toBe(dry.onExisting);
|
||||
});
|
||||
|
||||
describe("季集只在用户改过时发送(集数映射交给服务端)", () => {
|
||||
const anime = (path: string, over: Partial<FileIdentifyItem> = {}): FileIdentifyItem => ({
|
||||
...item(path),
|
||||
parsed: { ...item(path).parsed, season: 2, episodes: [13] },
|
||||
...over,
|
||||
});
|
||||
const body = (rows: WizardRow[]) =>
|
||||
organizeRequest(rows, defaultOptions(), { dryRun: true, idempotencyKey: "k-123456" }).items;
|
||||
|
||||
it("未改动的行只带作品,不带季集;预填仍用于展示", () => {
|
||||
const rows = dialogRows(rowsFromIdentify([anime("[ANi] X - 25.mkv")], new Set()), null, "", false);
|
||||
expect(rows[0]).toMatchObject({ season: "2", episodes: "13", episodeTouched: false, mappedFor: "e1" });
|
||||
expect(rowProblem(rows[0] as WizardRow)).toBeNull();
|
||||
expect(body(rows)).toEqual([{ root: "lib:x", path: "[ANi] X - 25.mkv", mediaEntityId: "e1" }]);
|
||||
});
|
||||
|
||||
it("行内改季或集、批量季号、集号偏移、弹窗季号都算用户指定,按显示值发送", () => {
|
||||
const base = rowsFromIdentify([anime("a.mkv"), anime("b.mkv"), anime("c.mkv")], new Set());
|
||||
const [a, b, c] = base.map((r) => r.key) as [string, string, string];
|
||||
let rows = patchRowEpisodes(base, a, { season: "1", episodes: "13" });
|
||||
rows = applySeasonTo(rows, new Set([b]), "3");
|
||||
rows = applyOffsetTo(rows, new Set([c]), 1).rows;
|
||||
expect(body(rows)).toEqual([
|
||||
{ root: "lib:x", path: "a.mkv", mediaEntityId: "e1", season: 1, episodes: [13] },
|
||||
{ root: "lib:x", path: "b.mkv", mediaEntityId: "e1", season: 3, episodes: [13] },
|
||||
{ root: "lib:x", path: "c.mkv", mediaEntityId: "e1", season: 2, episodes: [14] },
|
||||
]);
|
||||
expect(body(dialogRows(base, null, "4", false)).map((x) => [x.season, x.episodes])).toEqual([
|
||||
[4, [13]],
|
||||
[4, [13]],
|
||||
[4, [13]],
|
||||
]);
|
||||
// 改过的行补识别时保留用户值
|
||||
const merged = mergeIdentified(rows, [anime("a.mkv", { parsed: { ...anime("a.mkv").parsed, season: 5 } })]);
|
||||
expect(merged[0]?.season).toBe("1");
|
||||
});
|
||||
|
||||
it("集数待确认的行未决,填季集或排除后放行;改选作品后不再按旧作品判定", () => {
|
||||
const pending = anime("[ANi] X - 99.mkv", {
|
||||
parsed: { ...anime("x").parsed, season: 1, episodes: [99] },
|
||||
episodeUnresolved: true,
|
||||
warnings: ["集数对不上作品的季集,需要人工指定"],
|
||||
});
|
||||
const rows = rowsFromIdentify([pending], new Set());
|
||||
const r = dialogRows(rows, null, "", false)[0] as WizardRow;
|
||||
expect(rowProblem(r)).toMatch(/集数待确认/);
|
||||
expect(wizardStats([r]).pending).toBe(1);
|
||||
expect(rowProblem({ ...r, excluded: "manual" })).toBeNull();
|
||||
const fixed = patchRowEpisodes(rows, r.key, { season: "2", episodes: "13" });
|
||||
expect(rowProblem(dialogRows(fixed, null, "", false)[0] as WizardRow)).toBeNull();
|
||||
const other = dialogRows(rows, series, "", false)[0] as WizardRow;
|
||||
expect(rowProblem(other)).toBeNull();
|
||||
expect(other).toMatchObject({ season: "", episodes: "" });
|
||||
expect(body([other])).toEqual([{ root: "lib:x", path: "[ANi] X - 99.mkv", mediaEntityId: "e9" }]);
|
||||
});
|
||||
|
||||
it("改选作品后清空旧作品的映射预填;未映射的解析值保留;补识别刷新未改动行的预填", () => {
|
||||
const rows = rowsFromIdentify([anime("a.mkv"), anime("b.mkv", { candidates: [] })], new Set());
|
||||
const changed = dialogRows(setRowEntity(rows, rows[0]?.key as string, series, false), series, "", false);
|
||||
expect(changed.map((r) => [r.season, r.episodes])).toEqual([
|
||||
["", ""],
|
||||
["2", "13"],
|
||||
]);
|
||||
// 只改作品不改季集 → 都不发季集
|
||||
expect(body(changed).every((x) => x.season === undefined && x.episodes === undefined)).toBe(true);
|
||||
// 改选后用户在清空的格里填集号 → 只按用户填的发
|
||||
const typed = patchRowEpisodes(rows, rows[0]?.key as string, { season: "", episodes: "7" });
|
||||
expect(body(dialogRows(typed, series, "", false))[0]).toEqual({
|
||||
root: "lib:x",
|
||||
path: "a.mkv",
|
||||
mediaEntityId: "e9",
|
||||
episodes: [7],
|
||||
});
|
||||
const merged = mergeIdentified(rows, [anime("a.mkv", { parsed: { ...anime("a.mkv").parsed, season: 3 } })]);
|
||||
expect(merged[0]?.season).toBe("3");
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,13 @@ export type WizardRow = {
|
||||
touched: boolean;
|
||||
season: string;
|
||||
episodes: string;
|
||||
/**
|
||||
* 用户改过季 / 集(行内输入、批量季号、集号偏移、弹窗指定季号)。只有改过的行才把季集发给服务端;
|
||||
* 没改过的由服务端按最终作品做集数映射(docs/runbook/identity.md「集数映射怎么用」)。
|
||||
*/
|
||||
episodeTouched: boolean;
|
||||
/** 识别结果的季集预填是按哪个作品映射的(entityKey);"" = 未映射的文件名解析值 */
|
||||
mappedFor: string;
|
||||
slotId: string;
|
||||
excluded: ExcludeReason | null;
|
||||
/** 同目录分组键 */
|
||||
@@ -56,6 +63,7 @@ export function rowProblem(r: WizardRow): string | null {
|
||||
if (r.excluded) return null;
|
||||
if (!r.entity) return "未选择作品";
|
||||
if (isMovieKind(r.entity.kind)) return null;
|
||||
if (!r.episodeTouched) return episodeUnresolved(r) ? "集数待确认,请填写季集或排除" : null;
|
||||
const eps = parseEpisodes(r.episodes);
|
||||
if (eps === null) return "集号格式不对,请填写单集或连续区间,如 3、3-5";
|
||||
const unique = [...new Set(eps)].sort((a, b) => a - b);
|
||||
@@ -66,6 +74,13 @@ export function rowProblem(r: WizardRow): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 识别结果对当前作品是「集数待确认」(映射不出季集),且用户还没指定 */
|
||||
export const episodeUnresolved = (r: WizardRow) =>
|
||||
!r.episodeTouched && !!r.item.episodeUnresolved && !!r.mappedFor && r.mappedFor === entityKey(r.entity);
|
||||
|
||||
/** 识别项上季集预填对应的作品:服务端只按首个本地候选映射 */
|
||||
const mappedForOf = (it: FileIdentifyItem) => it.candidates[0]?.mediaEntityId ?? "";
|
||||
|
||||
/** 识别项 → 行:高置信本地候选(或服务端建议)自动选中;低于最小体积的项预先排除 */
|
||||
export function rowsFromIdentify(items: FileIdentifyItem[], hnrPaths: Set<string>): WizardRow[] {
|
||||
return items.map((it) => {
|
||||
@@ -83,6 +98,8 @@ export function rowsFromIdentify(items: FileIdentifyItem[], hnrPaths: Set<string
|
||||
touched: false,
|
||||
season: it.parsed.season != null ? String(it.parsed.season) : "",
|
||||
episodes: formatEpisodes(it.parsed.episodes),
|
||||
episodeTouched: false,
|
||||
mappedFor: mappedForOf(it),
|
||||
slotId: "",
|
||||
excluded: it.skipped === "below_min_size" ? "below_min_size" : null,
|
||||
group: `${it.root}\u0000${parentRel(it.path)}`,
|
||||
@@ -120,9 +137,18 @@ export function applyEntityTo(rows: WizardRow[], keys: Set<string>, e: WizardEnt
|
||||
return rows.map((r) => (keys.has(r.key) && !r.excluded ? { ...r, entity: e, touched: true, slotId: "" } : r));
|
||||
}
|
||||
|
||||
/** 行内改季号 / 集号:标记为用户指定 */
|
||||
export function patchRowEpisodes(
|
||||
rows: WizardRow[],
|
||||
key: string,
|
||||
patch: Partial<Pick<WizardRow, "season" | "episodes">>,
|
||||
): WizardRow[] {
|
||||
return rows.map((r) => (r.key === key ? { ...r, ...patch, episodeTouched: true } : r));
|
||||
}
|
||||
|
||||
/** 批量:季号应用到选中行 */
|
||||
export function applySeasonTo(rows: WizardRow[], keys: Set<string>, season: string): WizardRow[] {
|
||||
return rows.map((r) => (keys.has(r.key) && !r.excluded ? { ...r, season: season.trim() } : r));
|
||||
return rows.map((r) => (keys.has(r.key) && !r.excluded ? { ...r, season: season.trim(), episodeTouched: true } : r));
|
||||
}
|
||||
|
||||
/** 批量:集号偏移 ±n(选中行);偏移后小于 1 的行不改并计入 failed */
|
||||
@@ -137,7 +163,7 @@ export function applyOffsetTo(rows: WizardRow[], keys: Set<string>, n: number):
|
||||
failed++;
|
||||
return r;
|
||||
}
|
||||
return { ...r, episodes: formatEpisodes(next) };
|
||||
return { ...r, episodes: formatEpisodes(next), episodeTouched: true };
|
||||
});
|
||||
return { rows: out, failed };
|
||||
}
|
||||
@@ -169,12 +195,14 @@ export function mergeIdentified(rows: WizardRow[], items: FileIdentifyItem[]): W
|
||||
const it = byKey.get(r.key);
|
||||
if (!it) return r;
|
||||
const f = fresh.get(r.key);
|
||||
if (r.episodeTouched) return { ...r, item: it, entity: r.entity ?? f?.entity ?? null };
|
||||
return {
|
||||
...r,
|
||||
item: it,
|
||||
entity: r.entity ?? f?.entity ?? null,
|
||||
season: r.season || f?.season || "",
|
||||
episodes: r.episodes || f?.episodes || "",
|
||||
season: f?.season ?? "",
|
||||
episodes: f?.episodes ?? "",
|
||||
mappedFor: f?.mappedFor ?? "",
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -190,6 +218,15 @@ export function wizardStats(rows: WizardRow[]): WizardStats {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 最终作品与季集预填所依据的作品不同(改选了作品)时,未经用户修改的预填不再沿用旧作品的映射:
|
||||
* 清空,交给服务端按新作品映射。未映射的文件名解析值(mappedFor 为空)保留作参考。
|
||||
*/
|
||||
export function withEntityPrefill(row: WizardRow): WizardRow {
|
||||
if (row.episodeTouched || !row.mappedFor || row.mappedFor === entityKey(row.entity)) return row;
|
||||
return { ...row, season: "", episodes: "", mappedFor: "" };
|
||||
}
|
||||
|
||||
/** 共用弹窗的作品、季号作用于整个本次范围;成功历史跳过独立于目标冲突。 */
|
||||
export function dialogRows(
|
||||
rows: WizardRow[],
|
||||
@@ -202,13 +239,13 @@ export function dialogRows(
|
||||
!row.excluded && skipSuccessfulRecords && (row.item.records?.successfulCount ?? 0) > 0
|
||||
? ("successful_record" as const)
|
||||
: row.excluded;
|
||||
return {
|
||||
const next = withEntityPrefill({
|
||||
...row,
|
||||
excluded,
|
||||
entity: manual ?? row.entity,
|
||||
slotId: manual && entityKey(manual) !== entityKey(row.entity) ? "" : row.slotId,
|
||||
season: season.trim() || row.season,
|
||||
};
|
||||
});
|
||||
return season.trim() ? { ...next, season: season.trim(), episodeTouched: true } : next;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -262,8 +299,9 @@ export function organizeItems(rows: WizardRow[], o: WizardOptions): FilesOrganiz
|
||||
const item: FilesOrganizeItem = { root: r.root, path: r.path };
|
||||
if (e.id) item.mediaEntityId = e.id;
|
||||
else if (e.external) item.external = e.external;
|
||||
if (!movie && r.season.trim()) item.season = Number(r.season.trim());
|
||||
if (!movie && eps?.length) item.episodes = eps;
|
||||
// 只发用户指定过的季集;未改动的预填只是展示,由服务端按最终作品映射(否则会被当作人工指定而跳过映射)
|
||||
if (!movie && r.episodeTouched && r.season.trim()) item.season = Number(r.season.trim());
|
||||
if (!movie && r.episodeTouched && eps?.length) item.episodes = eps;
|
||||
if (r.slotId && e.id) item.slotId = r.slotId;
|
||||
if (o.action !== "in_place" && o.target.mode === "dir") item.targetDir = o.target.ref;
|
||||
return item;
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
MIB,
|
||||
mergeIdentified,
|
||||
organizeRequest,
|
||||
patchRowEpisodes,
|
||||
previewStats,
|
||||
rowProblem,
|
||||
rowsFromIdentify,
|
||||
@@ -413,7 +414,10 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
|
||||
value={r.season}
|
||||
disabled={!!season || !!r.excluded}
|
||||
className="h-8 w-16"
|
||||
onChange={(e) => setRows((rs) => rs.map((x) => (x.key === r.key ? { ...x, season: e.target.value } : x)))}
|
||||
placeholder="自动"
|
||||
onChange={(e) =>
|
||||
setRows((rs) => patchRowEpisodes(rs, r.key, { season: e.target.value, episodes: r.episodes }))
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -429,7 +433,10 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
|
||||
value={r.episodes}
|
||||
disabled={!!r.excluded}
|
||||
className="h-8 w-20"
|
||||
onChange={(e) => setRows((rs) => rs.map((x) => (x.key === r.key ? { ...x, episodes: e.target.value } : x)))}
|
||||
placeholder="自动"
|
||||
onChange={(e) =>
|
||||
setRows((rs) => patchRowEpisodes(rs, r.key, { season: r.season, episodes: e.target.value }))
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user