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:
1 parent
1ade49fe8e
commit
12f8fc59c3
17 files changed
+136
-20
No files matched your search
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@umt/web",
|
||||
"version": "0.3.41",
|
||||
"version": "0.3.42",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Reference in new issue
Block a user