fix(identity): 季范围资源只建第一季;刷新时退役空的临时季;v0.3.42

药屋少女的呢喃:一条「S01-S24 季度全24集」的资源按季范围建出 S04–S24 二十一个空季。

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 15:27:21 +08:00
1 parent 1ade49fe8e
commit 12f8fc59c3
17 files changed
+136 -20

No files matched your search

+1 -1
View File
@@ -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",
@@ -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<number>();
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++) {
@@ -198,7 +198,7 @@ export interface RelayoutResult {
}
/** 单元除自身元数据外有没有被引用(资产覆盖、资源、下载、订阅覆盖…) */
async function referencedUnits(exec: Exec, ids: readonly string[]): Promise<Set<string>> {
export async function referencedUnits(exec: Exec, ids: readonly string[]): Promise<Set<string>> {
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<number>,
now: Date,
): Promise<number[]> {
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);
}
@@ -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)
@@ -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);
@@ -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({
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@umt/web",
"version": "0.3.41",
"version": "0.3.42",
"private": true,
"type": "module",
"scripts": {