merge: fix/a-episode

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-03 21:11:22 +08:00
commit e6bc630a4d
10 files changed
+383 -36

No files matched your search

@@ -499,6 +499,7 @@ export async function identifyCore(
suggestion: { mediaEntityId: best?.mediaEntityId ?? null, contentUnitId, contentUnit },
existing: { assetId: existing?.media_asset_id ?? null },
warnings,
...(unresolved ? { episodeUnresolved: true } : {}),
size,
skipped: null,
records,
@@ -35,6 +35,7 @@ import {
looksLikeVariety,
resolveEntityId,
} from "./entities.ts";
import { enqueueNewEntityRefresh } from "./entity-from-candidate.ts";
import {
canonicalParsed,
clearReleaseEpisodes,
@@ -714,8 +715,10 @@ async function bindAuto(
const site = ((release.raw as Record<string, unknown>)?._site ?? {}) as { categoryName?: string };
const variety = looksLikeVariety(parsed, site.categoryName ?? release.category_raw);
for (let attempt = 0; ; attempt++) {
// 本次新建的作品:事务提交后排一次 full 元数据刷新(季表、集单元),刷新完成会重算待确认 / 放行资源的集数
let created: string | null = null;
try {
return await ctx.db.transaction().execute(async (trx) => {
const result = await ctx.db.transaction().execute(async (trx) => {
let entityId: string | null = null;
if (best.candidate.localEntityId) entityId = await resolveEntityId(trx, best.candidate.localEntityId);
if (!entityId && best.candidate.provider === "tmdb") {
@@ -725,19 +728,19 @@ async function bindAuto(
externalId: best.candidate.externalId,
});
if (!entityId && tmdbDetail) {
entityId = (
await createEntityFromTmdb(trx, tmdbDetail, {
level,
evidence: {
sourceReleaseId: release.id,
rules: best.evidence,
titleMatch: best.titleMatch,
yearMatch: best.yearMatch,
},
actor: SYSTEM,
variety,
})
).entityId;
const r = await createEntityFromTmdb(trx, tmdbDetail, {
level,
evidence: {
sourceReleaseId: release.id,
rules: best.evidence,
titleMatch: best.titleMatch,
yearMatch: best.yearMatch,
},
actor: SYSTEM,
variety,
});
entityId = r.entityId;
if (r.created) created = r.entityId;
}
}
if (!entityId)
@@ -848,6 +851,8 @@ async function bindAuto(
reason: resolution.reason,
};
});
if (created) await enqueueNewEntityRefresh(ctx, created);
return result;
} catch (e) {
if (e instanceof IdentityConflictError && attempt < 2) continue;
throw e;
+3 -5
View File
@@ -296,11 +296,9 @@ describe.skipIf(!hasPg)("E7 常规剧集纵切", () => {
.where("season_number", "=", 1)
.orderBy("episode_number")
.execute();
// DH-1:元数据刷新拉了 S1 季详情,对上 TMDB 的 E01 转正式;E02 不在 mock TMDB 的 S1 里,保持临时
expect(eps).toEqual([
{ episode_number: 1, status: "confirmed" },
{ episode_number: 2, status: "provisional" },
]);
// 自动识别新建作品后排了一次 full 元数据刷新:S1 集单元全部来自 TMDB 季详情(mock 为 8 集)并转正式;
// 整季包本身没有建出季表以外的集
expect(eps).toEqual(Array.from({ length: 8 }, (_, i) => ({ episode_number: i + 1, status: "confirmed" })));
// 第三集没出现、总集数未知:Slot 不满足,订阅继续追踪
const s = await slot();
expect(s.status).not.toBe("satisfied");
@@ -0,0 +1,110 @@
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { executeRecord } from "../../src/modules/acquisition-organize/services/organize.ts";
import { createFmHarness, type FmHarness, key, pgAvailable, READ_KEY, seedEntity } from "./harness.ts";
// 整理向导不绕过集数映射:向导只发用户改过的季集;未带季集的项由服务端按最终作品映射(`- 25` → S02E13),
// 带了季集的按人工指定执行。集数待确认的识别项带 episodeUnresolved,向导据此标为未决。
const skip = !(await pgAvailable());
describe.skipIf(skip)("整理向导执行时按作品映射集数", () => {
let h: FmHarness;
let show: string;
const auto = "[ANi] Kusuriya no Hitorigoto - 25 [1080P].mkv";
const manual = "[ANi] Kusuriya no Hitorigoto - 25 [720P].mkv";
const pending = "[ANi] Kusuriya no Hitorigoto - 40 [1080P].mkv";
beforeAll(async () => {
h = await createFmHarness("wizard_episode_mapping");
show = await seedEntity(h.db, { kind: "series", title: "Kusuriya no Hitorigoto", year: 2023 });
await h.db
.updateTable("media_entity")
.set({ metadata: JSON.stringify({ airStatus: { value: "Ended", source: "tmdb" } }) })
.where("id", "=", show)
.execute();
for (const [season, total] of [
[1, 12],
[2, 13],
] as const) {
await h.db
.insertInto("content_unit")
.values({
media_entity_id: show,
kind: "season",
season_number: season,
episode_total: total,
status: "confirmed",
source: "tmdb",
})
.execute();
}
await writeFile(join(h.dl, auto), "a".repeat(64));
await writeFile(join(h.dl, manual), "m".repeat(64));
await writeFile(join(h.dl, pending), "p".repeat(64));
});
afterAll(async () => h?.close());
const organize = async (item: Record<string, unknown>) => {
const items = [{ root: h.dlRoot, mediaEntityId: show, ...item }];
const dry = await h.api("POST", "/files/organize", { idempotencyKey: key(), action: "copy", dryRun: true, items });
expect(dry.status).toBe(200);
const group = dry.body.data.groups[0];
expect(group.error).toBeNull();
const exec = await h.api("POST", "/files/organize", {
idempotencyKey: key(),
action: "copy",
dryRun: false,
items,
planIds: [group.planId],
});
expect(exec.status).toBe(200);
expect(exec.body.data.recordIds).toHaveLength(1);
const result = await executeRecord(h.module.deps().ao, exec.body.data.recordIds[0]);
expect(result.status).toBe("succeeded");
return group.files[0] as { target: string };
};
const unitsOf = async (target: string) =>
h.db
.selectFrom("asset_location as l")
.innerJoin("asset_unit_coverage as au", "au.media_asset_id", "l.media_asset_id")
.innerJoin("content_unit as u", "u.id", "au.content_unit_id")
.select(["u.season_number", "u.episode_number"])
.where("l.relative_path", "like", `%${target.split("/").pop()}`)
.execute();
it("识别:集数待确认的项带 episodeUnresolved,能映射的不带", async () => {
const r = await h.api(
"POST",
"/files/identify",
{
items: [
{ root: h.dlRoot, path: auto },
{ root: h.dlRoot, path: pending },
],
minSizeBytes: 0,
},
READ_KEY,
);
expect(r.status).toBe(200);
const [ok, bad] = r.body.data.items;
expect(ok.parsed).toMatchObject({ season: 2, episodes: [13] });
expect(ok.episodeUnresolved).toBeUndefined();
expect(bad.episodeUnresolved).toBe(true);
});
it("不改季集直接执行:`- 25` 按作品季表入库为 S02E13", async () => {
const file = await organize({ path: auto });
expect(file.target).toMatch(/Season 2\/.*S02E13/);
expect(await readFile(file.target, "utf8")).toBe("a".repeat(64));
expect(await unitsOf(file.target)).toEqual([{ season_number: 2, episode_number: 13 }]);
});
it("用户显式改季集:按用户值入库", async () => {
const file = await organize({ path: manual, season: 2, episodes: [12] });
expect(file.target).toMatch(/Season 2\/.*S02E12/);
expect(await unitsOf(file.target)).toEqual([{ season_number: 2, episode_number: 12 }]);
});
});
@@ -0,0 +1,98 @@
import { createMockTmdb, type MockTmdb, TMDB_FIXTURES, type TmdbFixture } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts";
import { resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts";
import { createHarness, type Harness, pgAvailable, seedSite } from "./harness.ts";
// 自动识别新建作品后排一次 full 元数据刷新:新建档没有季表,绝对集号资源按原号滞后放行(unverified);
// 刷新补齐季表后重算为换算结果。已有作品的再次识别不重复排刷新。
const pg = await pgAvailable();
const season = (n: number, eps: number, start: string) => ({
season_number: n,
air_date: start,
names: { "zh-CN": `第 ${n} 季`, "en-US": `Season ${n}` },
episodes: Array.from({ length: eps }, (_, k) => ({
episode_number: k + 1,
air_date: new Date(Date.parse(start) + k * 7 * 86_400_000).toISOString().slice(0, 10),
names: { "zh-CN": `第 ${k + 1} 集`, "en-US": `Episode ${k + 1}` },
})),
});
// 已完结:S1 = 12、S2 = 13(ANi 连续编号 25 → S02E13)
const SHOW: TmdbFixture = {
type: "tv",
id: 990101,
original_title: "Hoshikuzu Telepath",
original_language: "ja",
origin_country: ["JP"],
date: "2023-10-06",
titles: { "zh-CN": "星屑心灵感应", "en-US": "Hoshikuzu Telepath" },
popularity: 30,
alternative_titles: [],
external_ids: {},
rich: { status: "Ended" },
seasons: [season(1, 12, "2023-10-06"), season(2, 13, "2025-01-10")],
};
describe.skipIf(!pg)("自动识别新建作品排元数据刷新", () => {
let h: Harness;
let tmdb: MockTmdb;
let siteId: string;
let n = 0;
beforeAll(async () => {
tmdb = await createMockTmdb({ fixtures: [...TMDB_FIXTURES, SHOW] });
h = await createHarness("auto_created_entity_refresh", { tmdbUrl: tmdb.url });
siteId = (await seedSite(h, { baseUrl: "https://acgrip.example/" })).site.id;
});
afterAll(async () => {
await h?.close();
await tmdb?.close();
});
const resolve = async (title: string) => {
const r = await h.db
.insertInto("source_release")
.values({ site_id: siteId, site_release_id: String(810000 + ++n), title, subtitle: "星屑心灵感应" })
.returningAll()
.executeTakeFirstOrThrow();
const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "manual" });
expect(res.outcome, JSON.stringify(res)).toBe("auto");
return { id: r.id, entityId: res.mediaEntityId as string };
};
const resolution = async (id: string) =>
(
await h.db
.selectFrom("source_release")
.select("episode_resolution")
.where("id", "=", id)
.executeTakeFirstOrThrow()
).episode_resolution as Record<string, unknown>;
const refreshJobs = () => h.engine.enqueued.filter((e) => e.task === "refreshMetadata");
it("新建作品后入队一次 full 刷新;刷新后 unverified 的 `- 25` 重算为 S02E13;再次识别不重复入队", async () => {
const first = await resolve("[ANi] Hoshikuzu Telepath - 25 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]");
expect(await resolution(first.id)).toMatchObject({ method: "unverified" });
expect(refreshJobs()).toEqual([
{
task: "refreshMetadata",
input: { mediaEntityId: first.entityId, full: true },
key: `refreshMetadata:created:${first.entityId}`,
},
]);
// 执行入队的刷新任务(同 refreshMetadata 任务处理器的调用)
const r = await refreshMetadata(h.rt, first.entityId, { full: true });
expect(r.status).toBe("refreshed");
expect(await resolution(first.id)).toMatchObject({
method: "absolute_cross_season",
canonical: { season: 2, episodes: { start: 13, end: 13 } },
});
const second = await resolve("[ANi] Hoshikuzu Telepath - 24 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]");
expect(second.entityId).toBe(first.entityId);
expect(refreshJobs()).toHaveLength(1);
});
});
@@ -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 }))
}
/>
),
},
+4
View File
@@ -158,6 +158,10 @@ select key, value->>'title' title, value->>'year' y, value->>'source' src from k
处理走 `POST /decisions/{id}/resolve` 的选项,由 sources-identity 直接执行(与身份类事项同一做法)。订阅详情有该作品 + 季的方式统计与待确认数;新建 / 编辑订阅时如果近期资源里有发布组用绝对集号,表单里提示一句。
**整理向导**:识别结果里的季集只是按首个本地候选映射后的预填。向导只把用户改过的季集(行内输入、批量季号、集号偏移、弹窗「指定季号」)发给服务端,当作人工指定;没改过的由服务端按最终作品映射,所以改选作品后预填会清空、改由新作品映射。识别为集数待确认的行(`episodeUnresolved`)在向导里未决,填季集或排除后才能生成预览。
**新建作品的季表**:自动识别新建的作品(和人工指定、整理接管一样)在事务提交后排一次 full 元数据刷新(幂等键 `refreshMetadata:created:<作品>`);新建档没有季表时资源先按原号放行(unverified),刷新补齐季表后自动重算为换算结果。
**智能体兜底**(批次 5):确定性流程(规则 → 原号校验 → 换算)都失败、开了「集数待确认」后,智能体在完整上下文里判断一次,产出一条结构化规则;写入后同类资源由规则自动处理,智能体不逐集调用。
- 何时触发:设置 → 智能体打开「自动调查」时,新开的 `episode_mapping` 事项和下载失败一样进后台调查(`decision.created` → `resolveDecision` → `autoInvestigate`),同一事项只跑一次。对话里也可以直接说「《X》第二季集数对不上」,走同样三个能力(Skill「集数映射」)。
+2
View File
@@ -650,6 +650,8 @@ export const FileIdentifyItem = z.object({
existing: z.object({ assetId: Id.nullable() }),
/** TMDB 不可用等降级提示 */
warnings: z.array(z.string()),
/** 按首个本地候选做集数映射时映射不出季集(集数待确认):parsed 是未映射的解析值,需要人工指定季集 */
episodeUnresolved: z.boolean().optional(),
/** W9-H(G2):文件大小 */
size: NonNegInt.optional(),
/** W9-H(G2):below_min_size = 低于最小体积,未识别(candidates 为空) */