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

@@ -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)