From 12f8fc59c388dbe405c5dc50982786cd2222b2d0 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 8 Oct 2026 15:27:21 +0800 Subject: [PATCH] =?UTF-8?q?fix(identity):=20=E5=AD=A3=E8=8C=83=E5=9B=B4?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E5=8F=AA=E5=BB=BA=E7=AC=AC=E4=B8=80=E5=AD=A3?= =?UTF-8?q?;=E5=88=B7=E6=96=B0=E6=97=B6=E9=80=80=E5=BD=B9=E7=A9=BA?= =?UTF-8?q?=E7=9A=84=E4=B8=B4=E6=97=B6=E5=AD=A3;v0.3.42?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 药屋少女的呢喃:一条「S01-S24 季度全24集」的资源按季范围建出 S04–S24 二十一个空季。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- CHANGELOG.md | 6 +++ apps/server/deno.json | 2 +- .../sources-identity/service/entities.ts | 30 ++++++++--- .../service/episode-structure.ts | 53 ++++++++++++++++++- .../sources-identity/service/metadata.ts | 13 ++++- .../sources-identity/data-hygiene.test.ts | 30 +++++++++++ .../sources-identity/episode-groups.test.ts | 2 +- apps/web/package.json | 2 +- deno.json | 2 +- packages/adapters/deno.json | 2 +- packages/agent/deno.json | 2 +- packages/contracts/deno.json | 2 +- packages/db/deno.json | 2 +- packages/domain/deno.json | 2 +- packages/mapping/deno.json | 2 +- packages/tasks/deno.json | 2 +- packages/testkit/deno.json | 2 +- 17 files changed, 136 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11e0f0de..430cc7be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ 版本号遵循 [SemVer](https://semver.org/lang/zh-CN/)。所有成员包(`apps/*`、`packages/*`)与根 `deno.json` 使用同一个版本号;`/api/v1/health` 与备份清单里的版本取自 `apps/server/deno.json`。 +## [0.3.42] - 2026-10-08 + +### 修复 +- 资源把「全 24 集」写成 `S01-S24` 这类季范围时,不再给作品每一季都建一个空的临时季(药屋少女的呢喃因此多出第 4~24 季):季范围只建第一季,后面的季只挂已有的。 +- 刷新元数据时,把不在 TMDB 季集结构里、下面没有集、也没被资源或文件引用的临时季退役;季范围误建的空季和切换季集结构后留下的孤儿季会在下次刷新时消失。 + ## [0.3.41] - 2026-10-08 迁移 `0093`。 diff --git a/apps/server/deno.json b/apps/server/deno.json index 38515425..3aac2da4 100644 --- a/apps/server/deno.json +++ b/apps/server/deno.json @@ -1,6 +1,6 @@ { "name": "@umt/server", - "version": "0.3.41", + "version": "0.3.42", "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 9984c48b..67d5e6a1 100644 --- a/apps/server/src/modules/sources-identity/service/entities.ts +++ b/apps/server/src/modules/sources-identity/service/entities.ts @@ -219,12 +219,30 @@ export async function ensureUnits( parsed: ParsedRelease | null, ): Promise<{ unitIds: string[]; primary: string | null }> { if (!parsed || kind === "movie" || parsed.season === undefined) return { unitIds: [], primary: null }; - const seasons: number[] = []; - const last = parsed.seasonEnd ?? parsed.season; - for (let s = parsed.season; s <= last && seasons.length < 50; s++) seasons.push(s); - const seasonIds: string[] = []; - for (const s of seasons) seasonIds.push(await findOrCreateUnit(exec, entityId, "season", s, null, null)); - if (parsed.episodes && seasons.length === 1) { + // 季范围(S01-S03 季包)只给第一季找或建单元,后面的季只挂已有的(元数据建的或单季资源建的): + // 发布者把「全 24 集」写成 S01-S24 时,不再凭空建出 23 个空季(药屋少女的呢喃 S04–S24 即此) + const seasonIds: string[] = [await findOrCreateUnit(exec, entityId, "season", parsed.season, null, null)]; + const last = Math.min(parsed.seasonEnd ?? parsed.season, parsed.season + 49); + if (last > parsed.season) { + const rest = await exec + .selectFrom("content_unit") + .select(["id", "season_number"]) + .where("media_entity_id", "=", entityId) + .where("kind", "=", "season") + .where("status", "<>", "retired") + .where("season_number", ">", parsed.season) + .where("season_number", "<=", last) + .orderBy("season_number") + .orderBy("created_at") + .execute(); + const seen = new Set(); + for (const r of rest) { + if (seen.has(r.season_number!)) continue; + seen.add(r.season_number!); + seasonIds.push(r.id); + } + } + if (parsed.episodes && (parsed.seasonEnd ?? parsed.season) === parsed.season) { const ids: string[] = []; const end = Math.min(parsed.episodes.end, parsed.episodes.start + 199); for (let e = parsed.episodes.start; e <= end; e++) { 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 dea8acc6..2d6ea134 100644 --- a/apps/server/src/modules/sources-identity/service/episode-structure.ts +++ b/apps/server/src/modules/sources-identity/service/episode-structure.ts @@ -198,7 +198,7 @@ export interface RelayoutResult { } /** 单元除自身元数据外有没有被引用(资产覆盖、资源、下载、订阅覆盖…) */ -async function referencedUnits(exec: Exec, ids: readonly string[]): Promise> { +export async function referencedUnits(exec: Exec, ids: readonly string[]): Promise> { if (!ids.length) return new Set(); const r = await sql<{ id: string }>` select x.id from ( @@ -377,3 +377,54 @@ async function writeOrigin(exec: Exec, unitId: string, origin: Coord, now: Date) set metadata = metadata || ${jsonb({ tmdbSeason: fv(origin.season), tmdbEpisode: fv(origin.episode) })}::jsonb where id = ${unitId}`.execute(exec); } + +/** + * 退役空的临时季:不在 TMDB 当前结构里、下面没有未退役的集、没被资源 / 文件 / 收录版本引用的临时季单元。 + * 来源是旧数据(资源把「全 24 集」写成 S01-S24 时按季范围建出的空季)和切换季集结构后留下的孤儿季;刷新元数据时顺带清掉。 + * 返回退役的季号。 + */ +export async function retireOrphanSeasons( + exec: Exec, + entityId: string, + 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"), + ), + ), + ) + .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 retire = candidates.filter((r) => !refs.has(r.id)); + if (!retire.length) return []; + await exec + .updateTable("content_unit") + .set({ status: "retired", updated_at: now }) + .where( + "id", + "in", + retire.map((r) => r.id), + ) + .execute(); + return retire.map((r) => r.season_number!).sort((a, b) => a - b); +} diff --git a/apps/server/src/modules/sources-identity/service/metadata.ts b/apps/server/src/modules/sources-identity/service/metadata.ts index 520f3c3c..759fcc4e 100644 --- a/apps/server/src/modules/sources-identity/service/metadata.ts +++ b/apps/server/src/modules/sources-identity/service/metadata.ts @@ -26,7 +26,13 @@ import type { SourcesRuntime } from "../runtime.ts"; import { type Actor, audit, type Exec } from "./common.ts"; import { resolveEntityId, seedAliases } from "./entities.ts"; import { reresolveEntityEpisodes } from "./episode-resolution.ts"; -import { entityEpisodeGroup, groupLayout, type Layout, relayoutUnits } from "./episode-structure.ts"; +import { + entityEpisodeGroup, + groupLayout, + type Layout, + relayoutUnits, + retireOrphanSeasons, +} from "./episode-structure.ts"; import { emitMetadataRefreshed } from "./lifecycle-events.ts"; import { applySeasonMetadata, @@ -417,6 +423,11 @@ export async function refreshMetadata( const seasonList = groupSeasons ?? detail.seasons ?? []; const totals = ident.entity_type === "tv" && seasonList.length ? await applySeasonTotals(trx, id, seasonList, now) : null; + // 不在 TMDB 结构里、没有集也没被引用的临时季退役(季范围误建的空季、切换结构留下的孤儿季) + if (ident.entity_type === "tv" && seasonList.length) { + const retired = await retireOrphanSeasons(trx, id, new Set(seasonList.map((s) => s.seasonNumber)), now); + if (retired.length) ctx.logger.info({ entityId: id, seasons: retired }, "退役空的临时季"); + } for (const sd of airSeasons) await applyEpisodeAirDates(trx, id, sd); const episodes: SeasonMetadataResult[] = []; for (const sd of richSeasons) diff --git a/apps/server/test/sources-identity/data-hygiene.test.ts b/apps/server/test/sources-identity/data-hygiene.test.ts index 8a833f4b..e838bef3 100644 --- a/apps/server/test/sources-identity/data-hygiene.test.ts +++ b/apps/server/test/sources-identity/data-hygiene.test.ts @@ -235,6 +235,36 @@ describe.skipIf(!up)("DH-1 内容单元数据治理", () => { expect((await units()).filter((u) => label(u) === "S1E20")).toHaveLength(1); }); + it("季范围资源(把全 24 集写成 S01-S24)只建第一季、挂已有的季;刷新时退役空的临时季", async () => { + const seasonsNow = async () => + ( + await h.db + .selectFrom("content_unit") + .select(["id", "season_number", "status"]) + .where("media_entity_id", "=", id) + .where("kind", "=", "season") + .where("status", "<>", "retired") + .orderBy("season_number") + .execute() + ).map((s) => s.season_number); + const before = await seasonsNow(); + const parsed = { season: 1, seasonEnd: 24 } as ParsedRelease; + const r = await h.db.transaction().execute((trx) => ensureUnits(trx, id, "series", parsed)); + expect(await seasonsNow()).toEqual(before); + expect(r.unitIds.length).toBe(before.filter((n) => n !== null && n >= 1).length); + + // 空的临时季:S7 没人引用 → 退役;S8 被资源引用 → 留着;S3 下面有集 → 留着 + const s7 = await unit("season", 7, null); + const s8 = await unit("season", 8, null); + 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"); + }); + it("identity-cleanup --merge-duplicates:默认 dry-run 列清单不改库,--apply 只合并重复、不删孤立单元", async () => { const schema = (await sql<{ s: string }>`select current_schema() as s`.execute(h.db)).rows[0]!.s; const u = new URL(PG_URL); diff --git a/apps/server/test/sources-identity/episode-groups.test.ts b/apps/server/test/sources-identity/episode-groups.test.ts index 8bd90162..45673ea9 100644 --- a/apps/server/test/sources-identity/episode-groups.test.ts +++ b/apps/server/test/sources-identity/episode-groups.test.ts @@ -305,10 +305,10 @@ describe.skipIf(!pg)("剧集组", () => { const u14 = await unit(unitE14); expect(u14).toMatchObject({ season_number: 1, episode_number: 14, status: "confirmed" }); expect(u14.metadata).not.toHaveProperty("tmdbSeason"); + // 组里的第 2 季在默认结构里没有,切回后成了没有集、没被引用的空季:随刷新退役,不再挂着临时季 expect(await seasonTotals()).toEqual([ [0, null, "confirmed"], [1, 25, "confirmed"], - [2, null, "provisional"], ]); expect((await loadSeasonTable(h.db, entityId)).origins).toBeUndefined(); expect((await release(abs13)).episode_resolution).toMatchObject({ diff --git a/apps/web/package.json b/apps/web/package.json index f13781ae..26dda20c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@umt/web", - "version": "0.3.41", + "version": "0.3.42", "private": true, "type": "module", "scripts": { diff --git a/deno.json b/deno.json index 143f6e3e..278caec6 100644 --- a/deno.json +++ b/deno.json @@ -1,5 +1,5 @@ { - "version": "0.3.41", + "version": "0.3.42", "workspace": [ "./packages/adapters", "./packages/agent", diff --git a/packages/adapters/deno.json b/packages/adapters/deno.json index ba6d379b..4c0edd2d 100644 --- a/packages/adapters/deno.json +++ b/packages/adapters/deno.json @@ -1,6 +1,6 @@ { "name": "@umt/adapters", - "version": "0.3.41", + "version": "0.3.42", "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 ae649588..8162d03a 100644 --- a/packages/agent/deno.json +++ b/packages/agent/deno.json @@ -1,6 +1,6 @@ { "name": "@umt/agent", - "version": "0.3.41", + "version": "0.3.42", "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 8d824809..d460b2c1 100644 --- a/packages/contracts/deno.json +++ b/packages/contracts/deno.json @@ -1,6 +1,6 @@ { "name": "@umt/contracts", - "version": "0.3.41", + "version": "0.3.42", "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 e81752dc..dd2beb51 100644 --- a/packages/db/deno.json +++ b/packages/db/deno.json @@ -1,6 +1,6 @@ { "name": "@umt/db", - "version": "0.3.41", + "version": "0.3.42", "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 504e889b..7c6107a7 100644 --- a/packages/domain/deno.json +++ b/packages/domain/deno.json @@ -1,6 +1,6 @@ { "name": "@umt/domain", - "version": "0.3.41", + "version": "0.3.42", "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 1b42b3c4..e6033b2d 100644 --- a/packages/mapping/deno.json +++ b/packages/mapping/deno.json @@ -1,6 +1,6 @@ { "name": "@umt/mapping", - "version": "0.3.41", + "version": "0.3.42", "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 af8a8131..ab5d61a1 100644 --- a/packages/tasks/deno.json +++ b/packages/tasks/deno.json @@ -1,6 +1,6 @@ { "name": "@umt/tasks", - "version": "0.3.41", + "version": "0.3.42", "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 cec4b794..e937dc5f 100644 --- a/packages/testkit/deno.json +++ b/packages/testkit/deno.json @@ -1,6 +1,6 @@ { "name": "@umt/testkit", - "version": "0.3.41", + "version": "0.3.42", "exports": { ".": "./src/index.ts" }, "tasks": { "test": "deno run -A npm:vitest run",