diff --git a/CHANGELOG.md b/CHANGELOG.md index 96dfb34c..1985585b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,12 @@ - 集数待确认的「采用建议」「忽略」和智能体写规则,都是往作品识别词末尾加一行,上面一行注释写清楚来历;用户注释掉过同范围的行,智能体就不再自动写。 - 发布组比较不分繁简:`綠茶字幕組` 的规则也认 `绿茶字幕组` 的资源。 +## [0.3.43] - 2026-10-08 + +### 修复 +- 作品已有 TMDB 季集结构时,资源标题里写的季不在结构里(如 TMDB 把三期连续编在第 1 季,发布者却写 S02、S03),不再按标题建出这一季:资源先按集数映射规则换算,换算不了就停在「待确认」。 +- 刷新元数据时,结构外的临时季连同下面的临时集一起退役,挂在上面的资源解开关联(仍属于这部作品);有文件覆盖或收录版本引用的季留着不动,特别篇(第 0 季)不清。 + ## [0.3.42] - 2026-10-08 ### 修复 diff --git a/apps/server/deno.json b/apps/server/deno.json index 3aac2da4..eb0335f3 100644 --- a/apps/server/deno.json +++ b/apps/server/deno.json @@ -1,6 +1,6 @@ { "name": "@umt/server", - "version": "0.3.42", + "version": "0.3.43", "exports": { ".": "./src/main.ts" }, "tasks": { "dev": "deno run -A --watch --env-file=../../.env src/main.ts", diff --git a/apps/server/src/modules/sources-identity/service/entities.ts b/apps/server/src/modules/sources-identity/service/entities.ts index 67d5e6a1..c431aca7 100644 --- a/apps/server/src/modules/sources-identity/service/entities.ts +++ b/apps/server/src/modules/sources-identity/service/entities.ts @@ -219,6 +219,18 @@ export async function ensureUnits( parsed: ParsedRelease | null, ): Promise<{ unitIds: string[]; primary: string | null }> { if (!parsed || kind === "movie" || parsed.season === undefined) return { unitIds: [], primary: null }; + // 作品已有元数据季集结构(TMDB 默认季或剧集组建的正式季)时,只挂结构里的季:资源写的季在结构外 + // (发布者把第二期写成 S02、TMDB 却连续编在第 1 季)就不建单元,等规则换算或停在待确认,不凭标题造季 + const structured = await exec + .selectFrom("content_unit") + .select(["season_number"]) + .where("media_entity_id", "=", entityId) + .where("kind", "=", "season") + .where("status", "<>", "retired") + .where((eb) => eb.or([eb("status", "=", "confirmed"), eb("source", "in", ["tmdb", "user"])])) + .execute(); + const known = new Set(structured.map((s) => s.season_number)); + if (known.size && !known.has(parsed.season)) return { unitIds: [], primary: null }; // 季范围(S01-S03 季包)只给第一季找或建单元,后面的季只挂已有的(元数据建的或单季资源建的): // 发布者把「全 24 集」写成 S01-S24 时,不再凭空建出 23 个空季(药屋少女的呢喃 S04–S24 即此) const seasonIds: string[] = [await findOrCreateUnit(exec, entityId, "season", parsed.season, null, null)]; @@ -232,6 +244,13 @@ export async function ensureUnits( .where("status", "<>", "retired") .where("season_number", ">", parsed.season) .where("season_number", "<=", last) + .$if(known.size > 0, (q) => + q.where( + "season_number", + "in", + [...known].filter((n): n is number => n !== null), + ), + ) .orderBy("season_number") .orderBy("created_at") .execute(); diff --git a/apps/server/src/modules/sources-identity/service/episode-structure.ts b/apps/server/src/modules/sources-identity/service/episode-structure.ts index 2d6ea134..d8b8140f 100644 --- a/apps/server/src/modules/sources-identity/service/episode-structure.ts +++ b/apps/server/src/modules/sources-identity/service/episode-structure.ts @@ -379,9 +379,10 @@ async function writeOrigin(exec: Exec, unitId: string, origin: Coord, now: Date) } /** - * 退役空的临时季:不在 TMDB 当前结构里、下面没有未退役的集、没被资源 / 文件 / 收录版本引用的临时季单元。 - * 来源是旧数据(资源把「全 24 集」写成 S01-S24 时按季范围建出的空季)和切换季集结构后留下的孤儿季;刷新元数据时顺带清掉。 - * 返回退役的季号。 + * 收掉结构外的临时季:不在 TMDB 当前结构(默认季或剧集组)里的临时季单元及其临时集单元。 + * 来源:资源按标题懒建的季(发布者把第二期写成 S02、TMDB 连续编在第 1 季)、季范围误建的空季、切换结构后留下的孤儿季。 + * 资源上指向这些单元的 content_unit_id 解开(资源仍挂在作品上,集数按规则换算或停在待确认); + * 有文件覆盖或收录版本引用的单元留着不动(那是已经落盘的东西,要人看)。返回退役的季号。 */ export async function retireOrphanSeasons( exec: Exec, @@ -389,42 +390,49 @@ export async function retireOrphanSeasons( keepSeasons: ReadonlySet, now: Date, ): Promise { - const rows = await exec - .selectFrom("content_unit as s") - .select(["s.id", "s.season_number"]) - .where("s.media_entity_id", "=", entityId) - .where("s.kind", "=", "season") - .where("s.status", "=", "provisional") - .where("s.season_number", "is not", null) - .where(({ not, exists, selectFrom }) => - not( - exists( - selectFrom("content_unit as c") - .select("c.id") - .whereRef("c.media_entity_id", "=", "s.media_entity_id") - .where("c.kind", "=", "episode") - .where("c.status", "<>", "retired") - .whereRef("c.season_number", "=", "s.season_number"), - ), - ), - ) + const units = await exec + .selectFrom("content_unit") + .select(["id", "kind", "season_number", "status"]) + .where("media_entity_id", "=", entityId) + .where("status", "<>", "retired") + .where("season_number", "is not", null) .execute(); - const candidates = rows.filter((r) => !keepSeasons.has(r.season_number!)); - if (!candidates.length) return []; - const refs = await referencedUnits( - exec, - candidates.map((r) => r.id), + const orphanSeasons = new Set( + units + // 季 0(特别篇)不收:剧集组常常不含特别篇,切到组时它只是暂时不在结构里 + .filter( + (u) => + u.kind === "season" && + u.status === "provisional" && + u.season_number! > 0 && + !keepSeasons.has(u.season_number!), + ) + .map((u) => u.season_number!), ); - const retire = candidates.filter((r) => !refs.has(r.id)); + if (!orphanSeasons.size) return []; + // 孤儿季里还有正式集(不该发生)就整季不动 + for (const u of units) + if (u.kind === "episode" && u.status !== "provisional" && orphanSeasons.has(u.season_number!)) + orphanSeasons.delete(u.season_number!); + const ids = units.filter((u) => orphanSeasons.has(u.season_number!)).map((u) => u.id); + if (!ids.length) return []; + const pinned = await sql<{ id: string; season: number }>` + select u.id, u.season_number as season from content_unit u where u.id = any(${ids}::uuid[]) and ( + exists (select 1 from asset_unit_coverage c where c.content_unit_id = u.id) + or exists (select 1 from slot_unit s where s.content_unit_id = u.id) + or exists (select 1 from acquisition_slot a where a.content_unit_id = u.id))`.execute(exec); + for (const p of pinned.rows) orphanSeasons.delete(p.season); + const retire = units.filter((u) => orphanSeasons.has(u.season_number!)).map((u) => u.id); if (!retire.length) return []; + await exec + .updateTable("source_release") + .set({ content_unit_id: null }) + .where("content_unit_id", "in", retire) + .execute(); await exec .updateTable("content_unit") .set({ status: "retired", updated_at: now }) - .where( - "id", - "in", - retire.map((r) => r.id), - ) + .where("id", "in", retire) .execute(); - return retire.map((r) => r.season_number!).sort((a, b) => a - b); + return [...orphanSeasons].sort((a, b) => a - b); } diff --git a/apps/server/test/sources-identity/data-hygiene.test.ts b/apps/server/test/sources-identity/data-hygiene.test.ts index e838bef3..c607067d 100644 --- a/apps/server/test/sources-identity/data-hygiene.test.ts +++ b/apps/server/test/sources-identity/data-hygiene.test.ts @@ -175,8 +175,9 @@ describe.skipIf(!up)("DH-1 内容单元数据治理", () => { ] as const) expect(byLabel.get(l)).toMatchObject({ id: ids[k], status: "confirmed", source: "tmdb" }); expect(byLabel.get("S1E1")!.title).toBe("第 1 期"); - // 对不上的保持临时 - for (const l of ["S1E13", "S3", "S3E1"]) expect(byLabel.get(l)).toMatchObject({ status: "provisional" }); + // 结构内对不上的集保持临时;结构外的季(TMDB 只有 0–2 季)连同其临时集退役 + expect(byLabel.get("S1E13")).toMatchObject({ status: "provisional" }); + for (const l of ["S3", "S3E1"]) expect(byLabel.get(l)).toMatchObject({ status: "retired" }); // 季 0:特别篇季与两集按 TMDB 建(confirmed、来源 tmdb) expect(byLabel.get("S0")).toMatchObject({ status: "confirmed", source: "tmdb" }); expect(list.filter((u) => u.season_number === 0 && u.kind === "episode").map((u) => [u.title, u.status])).toEqual([ @@ -253,16 +254,30 @@ describe.skipIf(!up)("DH-1 内容单元数据治理", () => { expect(await seasonsNow()).toEqual(before); expect(r.unitIds.length).toBe(before.filter((n) => n !== null && n >= 1).length); - // 空的临时季:S7 没人引用 → 退役;S8 被资源引用 → 留着;S3 下面有集 → 留着 + // 结构外的季:新资源不再建(S05 单季也不建);已有的 S7(空)、S8(被资源挂着)刷新时退役,资源解开; + // S9 下的集有文件覆盖 → 整季留着要人看 + const s5 = await h.db + .transaction() + .execute((trx) => ensureUnits(trx, id, "series", { season: 5 } as ParsedRelease)); + expect(s5).toEqual({ unitIds: [], primary: null }); const s7 = await unit("season", 7, null); const s8 = await unit("season", 8, null); + const s9 = await unit("season", 9, null); + const s9e1 = await unit("episode", 9, 1, { parent_unit_id: s9 }); + await cover(await asset(), s9e1); await release("s8-pack", s8); await refreshMetadata(h.rt, id); const status = async (uid: string) => (await h.db.selectFrom("content_unit").select("status").where("id", "=", uid).executeTakeFirstOrThrow()).status; expect(await status(s7)).toBe("retired"); - expect(await status(s8)).toBe("provisional"); - expect(await status(ids.s3!)).toBe("provisional"); + expect(await status(s8)).toBe("retired"); + expect(await status(s9)).toBe("provisional"); + const pack = await h.db + .selectFrom("source_release") + .select(["content_unit_id", "media_entity_id"]) + .where("site_release_id", "=", "s8-pack") + .executeTakeFirstOrThrow(); + expect(pack).toEqual({ content_unit_id: null, media_entity_id: id }); }); it("identity-cleanup --merge-duplicates:默认 dry-run 列清单不改库,--apply 只合并重复、不删孤立单元", async () => { diff --git a/apps/web/package.json b/apps/web/package.json index 26dda20c..f9f18144 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@umt/web", - "version": "0.3.42", + "version": "0.3.43", "private": true, "type": "module", "scripts": { diff --git a/deno.json b/deno.json index 278caec6..6521fdf4 100644 --- a/deno.json +++ b/deno.json @@ -1,5 +1,5 @@ { - "version": "0.3.42", + "version": "0.3.43", "workspace": [ "./packages/adapters", "./packages/agent", diff --git a/packages/adapters/deno.json b/packages/adapters/deno.json index 4c0edd2d..11bbafec 100644 --- a/packages/adapters/deno.json +++ b/packages/adapters/deno.json @@ -1,6 +1,6 @@ { "name": "@umt/adapters", - "version": "0.3.42", + "version": "0.3.43", "exports": { ".": "./src/index.ts", "./fsworker": "./src/fs/fsworker.ts", "./alist-mock": "./src/alist/mock.ts" }, "tasks": { "test": "deno run -A npm:vitest run", diff --git a/packages/agent/deno.json b/packages/agent/deno.json index 8162d03a..00472c84 100644 --- a/packages/agent/deno.json +++ b/packages/agent/deno.json @@ -1,6 +1,6 @@ { "name": "@umt/agent", - "version": "0.3.42", + "version": "0.3.43", "exports": { ".": "./src/index.ts" }, "tasks": { "test": "deno run -A npm:vitest run", diff --git a/packages/contracts/deno.json b/packages/contracts/deno.json index d460b2c1..15f59b14 100644 --- a/packages/contracts/deno.json +++ b/packages/contracts/deno.json @@ -1,6 +1,6 @@ { "name": "@umt/contracts", - "version": "0.3.42", + "version": "0.3.43", "exports": { ".": "./src/index.ts" }, "tasks": { "test": "deno run -A npm:vitest run", diff --git a/packages/db/deno.json b/packages/db/deno.json index dd2beb51..d971c552 100644 --- a/packages/db/deno.json +++ b/packages/db/deno.json @@ -1,6 +1,6 @@ { "name": "@umt/db", - "version": "0.3.42", + "version": "0.3.43", "exports": { ".": "./src/index.ts" }, "tasks": { "test": "deno run -A npm:vitest run", diff --git a/packages/domain/deno.json b/packages/domain/deno.json index 7c6107a7..dbab2ed9 100644 --- a/packages/domain/deno.json +++ b/packages/domain/deno.json @@ -1,6 +1,6 @@ { "name": "@umt/domain", - "version": "0.3.42", + "version": "0.3.43", "exports": { ".": "./src/index.ts" }, "tasks": { "test": "deno run -A npm:vitest run", diff --git a/packages/mapping/deno.json b/packages/mapping/deno.json index e6033b2d..41c8e33f 100644 --- a/packages/mapping/deno.json +++ b/packages/mapping/deno.json @@ -1,6 +1,6 @@ { "name": "@umt/mapping", - "version": "0.3.42", + "version": "0.3.43", "exports": { ".": "./src/index.ts" }, "tasks": { "test": "deno run -A npm:vitest run", diff --git a/packages/tasks/deno.json b/packages/tasks/deno.json index ab5d61a1..ce7fac31 100644 --- a/packages/tasks/deno.json +++ b/packages/tasks/deno.json @@ -1,6 +1,6 @@ { "name": "@umt/tasks", - "version": "0.3.42", + "version": "0.3.43", "exports": { ".": "./src/index.ts" }, "tasks": { "test": "deno run -A npm:vitest run", diff --git a/packages/testkit/deno.json b/packages/testkit/deno.json index e937dc5f..46010f79 100644 --- a/packages/testkit/deno.json +++ b/packages/testkit/deno.json @@ -1,6 +1,6 @@ { "name": "@umt/testkit", - "version": "0.3.42", + "version": "0.3.43", "exports": { ".": "./src/index.ts" }, "tasks": { "test": "deno run -A npm:vitest run",