fix(episode): 半转正的临时季也进季表;迁移 0091 补做 DH-1 前漏掉的季集转正

兰香缘等 26 部:季单元是资源懒建的临时单元,TMDB 集已挂在下面成了正式单元,
季单元没转正,季表丢掉整季,资源全成「元数据未覆盖」。

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-08 12:36:32 +08:00
1 parent 8ff1817845
commit 4866e735c8
3 files changed
+87 -1

No files matched your search

@@ -71,9 +71,17 @@ export async function loadSeasonTable(exec: Exec, entityId: string): Promise<Sea
.orderBy("created_at") .orderBy("created_at")
.execute(); .execute();
const seasons = new Map<number, SeasonInfo>(); const seasons = new Map<number, SeasonInfo>();
// 季单元还是临时、但下面已有元数据建的正式集(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) { for (const u of units) {
if (u.kind !== "season" || u.season_number === null || u.season_number < 0) continue; 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 cur = seasons.get(u.season_number);
// 重复季单元(并发懒建遗留)取已知的较大总集数 // 重复季单元(并发懒建遗留)取已知的较大总集数
const total = u.episode_total ?? null; const total = u.episode_total ?? null;
@@ -284,6 +284,47 @@ describe.skipIf(!pg)("识别准确度与失败可见性", () => {
expect(table.latestSeason).toBe(2); 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 () => { it("快照连续失败 N 次开站点不可达事项(按站点去重),恢复后自动关闭", async () => {
// 用有站点定义(支持列表抓取)的 key,适配器换成会失败的假实现 // 用有站点定义(支持列表抓取)的 key,适配器换成会失败的假实现
const { site } = await seedSite(h, { key: "hhanclub", baseUrl: "https://hhanclub.example/" }); const { site } = await seedSite(h, { key: "hhanclub", baseUrl: "https://hhanclub.example/" });
@@ -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<any>): Promise<void> {
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<any>): Promise<void> {}