diff --git a/apps/server/src/modules/sources-identity/service/episode-resolution.ts b/apps/server/src/modules/sources-identity/service/episode-resolution.ts index f1ccd56a..0453c9dc 100644 --- a/apps/server/src/modules/sources-identity/service/episode-resolution.ts +++ b/apps/server/src/modules/sources-identity/service/episode-resolution.ts @@ -71,9 +71,17 @@ export async function loadSeasonTable(exec: Exec, entityId: string): Promise(); + // 季单元还是临时、但下面已有元数据建的正式集(DH-1 之前的旧数据,季单元没跟着转正)也算元数据覆盖, + // 否则整季从季表里消失,资源一律成了「元数据未覆盖」 + const covered = new Set( + units + .filter((u) => u.kind === "episode" && u.status === "confirmed" && (u.source === "tmdb" || u.source === "user")) + .map((u) => u.season_number), + ); for (const u of units) { if (u.kind !== "season" || u.season_number === null || u.season_number < 0) continue; - if (u.status !== "confirmed" && u.source !== "tmdb" && u.source !== "user") continue; + if (u.status !== "confirmed" && u.source !== "tmdb" && u.source !== "user" && !covered.has(u.season_number)) + continue; const cur = seasons.get(u.season_number); // 重复季单元(并发懒建遗留)取已知的较大总集数 const total = u.episode_total ?? null; diff --git a/apps/server/test/sources-identity/identity-accuracy.test.ts b/apps/server/test/sources-identity/identity-accuracy.test.ts index 159b41b5..9bb39089 100644 --- a/apps/server/test/sources-identity/identity-accuracy.test.ts +++ b/apps/server/test/sources-identity/identity-accuracy.test.ts @@ -284,6 +284,47 @@ describe.skipIf(!pg)("识别准确度与失败可见性", () => { expect(table.latestSeason).toBe(2); }); + it("季表:临时季下已有 TMDB 正式集(DH-1 前的半转正数据)也进季表", async () => { + const e = await h.db + .insertInto("media_entity") + .values({ kind: "series", title: "半转正季表" }) + .returning("id") + .executeTakeFirstOrThrow(); + const s1 = await h.db + .insertInto("content_unit") + .values({ media_entity_id: e.id, kind: "season", season_number: 1, episode_total: 47, status: "provisional" }) + .returning("id") + .executeTakeFirstOrThrow(); + await h.db + .insertInto("content_unit") + .values([ + { + media_entity_id: e.id, + parent_unit_id: s1.id, + kind: "episode", + season_number: 1, + episode_number: 1, + status: "confirmed", + source: "tmdb", + air_date: "2026-09-11", + }, + // 只有资源懒建的临时集:不算覆盖 + { media_entity_id: e.id, kind: "season", season_number: 3, status: "provisional", source: "release" }, + { + media_entity_id: e.id, + kind: "episode", + season_number: 3, + episode_number: 1, + status: "provisional", + source: "release", + }, + ]) + .execute(); + const table = await loadSeasonTable(h.db, e.id); + expect(table.seasons.map((s) => [s.season, s.total])).toEqual([[1, 47]]); + expect(table.latestSeason).toBe(1); + }); + it("快照连续失败 N 次开站点不可达事项(按站点去重),恢复后自动关闭", async () => { // 用有站点定义(支持列表抓取)的 key,适配器换成会失败的假实现 const { site } = await seedSite(h, { key: "hhanclub", baseUrl: "https://hhanclub.example/" }); diff --git a/packages/db/migrations/0091_promote_covered_seasons.ts b/packages/db/migrations/0091_promote_covered_seasons.ts new file mode 100644 index 00000000..d7f1f51c --- /dev/null +++ b/packages/db/migrations/0091_promote_covered_seasons.ts @@ -0,0 +1,37 @@ +import type { Kysely } from "kysely"; +import { execDdl } from "../src/migration-helpers.ts"; + +// DH-1 之前(2026-09-29 v0.3.0 前)刷新的剧集留下的半转正数据:季单元是资源懒建的临时单元, +// TMDB 的集已挂在它下面成了正式单元,季单元本身和当时对上 TMDB 的临时集却没转正。 +// 季表只认正式季,于是整季消失、资源全成「元数据未覆盖」(兰香缘等 26 部)。 +// 这里补做当时漏掉的转正:下面有 TMDB 正式集的临时季 → 正式;这些季里集号在总集数以内、没有同号正式集的临时集 → 正式。 +// 超出总集数的临时集保持临时(对不上 TMDB)。只改状态与来源,不删行;down 不回退。 + +// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型 +export async function up(db: Kysely): Promise { + await execDdl( + db, + ` +create temp table _covered_season on commit drop as + select s.id, s.episode_total from content_unit s + where s.kind = 'season' and s.status = 'provisional' + and exists (select 1 from content_unit c + where c.parent_unit_id = s.id and c.kind = 'episode' + and c.status = 'confirmed' and c.source = 'tmdb'); + +update content_unit c set status = 'confirmed', source = 'tmdb' + from _covered_season s + where c.parent_unit_id = s.id and c.kind = 'episode' and c.status = 'provisional' + and c.episode_number between 1 and coalesce(s.episode_total, 0) + and not exists (select 1 from content_unit d + where d.parent_unit_id = s.id and d.kind = 'episode' + and d.status = 'confirmed' and d.episode_number = c.episode_number); + +update content_unit u set status = 'confirmed', source = 'tmdb' + from _covered_season s where u.id = s.id; +`, + ); +} + +// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型 +export async function down(_db: Kysely): Promise {}