fix(identity): 不凭资源标题造结构外的季;刷新时收掉结构外的临时季;v0.3.43

药屋少女的呢喃:发布者把第二、三期写成 S02/S03,TMDB 连续编在第 1 季,资源懒建出第 2、3 季。

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 16:54:22 +08:00
1 parent 12f8fc59c3
commit ae137fd0ce
15 files changed
+98 -50

No files matched your search

+6
View File
@@ -2,6 +2,12 @@
版本号遵循 [SemVer](https://semver.org/lang/zh-CN/)。所有成员包(`apps/*`、`packages/*`)与根 `deno.json` 使用同一个版本号;`/api/v1/health` 与备份清单里的版本取自 `apps/server/deno.json`。
## [0.3.43] - 2026-10-08
### 修复
- 作品已有 TMDB 季集结构时,资源标题里写的季不在结构里(如 TMDB 把三期连续编在第 1 季,发布者却写 S02、S03),不再按标题建出这一季:资源先按集数映射规则换算,换算不了就停在「待确认」。
- 刷新元数据时,结构外的临时季连同下面的临时集一起退役,挂在上面的资源解开关联(仍属于这部作品);有文件覆盖或收录版本引用的季留着不动,特别篇(第 0 季)不清。
## [0.3.42] - 2026-10-08
### 修复
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@umt/server",
"version": "0.3.42",
"version": "0.3.43",
"exports": { ".": "./src/main.ts" },
"tasks": {
"dev": "deno run -A --watch --env-file=../../.env src/main.ts",
@@ -219,6 +219,18 @@ export async function ensureUnits(
parsed: ParsedRelease | null,
): Promise<{ unitIds: string[]; primary: string | null }> {
if (!parsed || kind === "movie" || parsed.season === undefined) return { unitIds: [], primary: null };
// 作品已有元数据季集结构(TMDB 默认季或剧集组建的正式季)时,只挂结构里的季:资源写的季在结构外
// (发布者把第二期写成 S02、TMDB 却连续编在第 1 季)就不建单元,等规则换算或停在待确认,不凭标题造季
const structured = await exec
.selectFrom("content_unit")
.select(["season_number"])
.where("media_entity_id", "=", entityId)
.where("kind", "=", "season")
.where("status", "<>", "retired")
.where((eb) => eb.or([eb("status", "=", "confirmed"), eb("source", "in", ["tmdb", "user"])]))
.execute();
const known = new Set(structured.map((s) => s.season_number));
if (known.size && !known.has(parsed.season)) return { unitIds: [], primary: null };
// 季范围(S01-S03 季包)只给第一季找或建单元,后面的季只挂已有的(元数据建的或单季资源建的):
// 发布者把「全 24 集」写成 S01-S24 时,不再凭空建出 23 个空季(药屋少女的呢喃 S04–S24 即此)
const seasonIds: string[] = [await findOrCreateUnit(exec, entityId, "season", parsed.season, null, null)];
@@ -232,6 +244,13 @@ export async function ensureUnits(
.where("status", "<>", "retired")
.where("season_number", ">", parsed.season)
.where("season_number", "<=", last)
.$if(known.size > 0, (q) =>
q.where(
"season_number",
"in",
[...known].filter((n): n is number => n !== null),
),
)
.orderBy("season_number")
.orderBy("created_at")
.execute();
@@ -379,9 +379,10 @@ async function writeOrigin(exec: Exec, unitId: string, origin: Coord, now: Date)
}
/**
* 退役空的临时季:不在 TMDB 当前结构里、下面没有未退役的集、没被资源 / 文件 / 收录版本引用的临时季单元。
* 来源是旧数据(资源把「全 24 集」写成 S01-S24 时按季范围建出的空季)和切换季集结构后留下的孤儿季;刷新元数据时顺带清掉。
* 返回退役的季号。
* 收掉结构外的临时季:不在 TMDB 当前结构(默认季或剧集组)里的临时季单元及其临时集单元。
* 来源:资源按标题懒建的季(发布者把第二期写成 S02、TMDB 连续编在第 1 季)、季范围误建的空季、切换结构后留下的孤儿季。
* 资源上指向这些单元的 content_unit_id 解开(资源仍挂在作品上,集数按规则换算或停在待确认);
* 有文件覆盖或收录版本引用的单元留着不动(那是已经落盘的东西,要人看)。返回退役的季号。
*/
export async function retireOrphanSeasons(
exec: Exec,
@@ -389,42 +390,49 @@ export async function retireOrphanSeasons(
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"),
),
),
)
const units = await exec
.selectFrom("content_unit")
.select(["id", "kind", "season_number", "status"])
.where("media_entity_id", "=", entityId)
.where("status", "<>", "retired")
.where("season_number", "is not", null)
.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 orphanSeasons = new Set(
units
// 季 0(特别篇)不收:剧集组常常不含特别篇,切到组时它只是暂时不在结构里
.filter(
(u) =>
u.kind === "season" &&
u.status === "provisional" &&
u.season_number! > 0 &&
!keepSeasons.has(u.season_number!),
)
.map((u) => u.season_number!),
);
const retire = candidates.filter((r) => !refs.has(r.id));
if (!orphanSeasons.size) return [];
// 孤儿季里还有正式集(不该发生)就整季不动
for (const u of units)
if (u.kind === "episode" && u.status !== "provisional" && orphanSeasons.has(u.season_number!))
orphanSeasons.delete(u.season_number!);
const ids = units.filter((u) => orphanSeasons.has(u.season_number!)).map((u) => u.id);
if (!ids.length) return [];
const pinned = await sql<{ id: string; season: number }>`
select u.id, u.season_number as season from content_unit u where u.id = any(${ids}::uuid[]) and (
exists (select 1 from asset_unit_coverage c where c.content_unit_id = u.id)
or exists (select 1 from slot_unit s where s.content_unit_id = u.id)
or exists (select 1 from acquisition_slot a where a.content_unit_id = u.id))`.execute(exec);
for (const p of pinned.rows) orphanSeasons.delete(p.season);
const retire = units.filter((u) => orphanSeasons.has(u.season_number!)).map((u) => u.id);
if (!retire.length) return [];
await exec
.updateTable("source_release")
.set({ content_unit_id: null })
.where("content_unit_id", "in", retire)
.execute();
await exec
.updateTable("content_unit")
.set({ status: "retired", updated_at: now })
.where(
"id",
"in",
retire.map((r) => r.id),
)
.where("id", "in", retire)
.execute();
return retire.map((r) => r.season_number!).sort((a, b) => a - b);
return [...orphanSeasons].sort((a, b) => a - b);
}
@@ -175,8 +175,9 @@ describe.skipIf(!up)("DH-1 内容单元数据治理", () => {
] as const)
expect(byLabel.get(l)).toMatchObject({ id: ids[k], status: "confirmed", source: "tmdb" });
expect(byLabel.get("S1E1")!.title).toBe("第 1 期");
// 对不上的保持临时
for (const l of ["S1E13", "S3", "S3E1"]) expect(byLabel.get(l)).toMatchObject({ status: "provisional" });
// 结构内对不上的集保持临时;结构外的季(TMDB 只有 0–2 季)连同其临时集退役
expect(byLabel.get("S1E13")).toMatchObject({ status: "provisional" });
for (const l of ["S3", "S3E1"]) expect(byLabel.get(l)).toMatchObject({ status: "retired" });
// 季 0:特别篇季与两集按 TMDB 建(confirmed、来源 tmdb)
expect(byLabel.get("S0")).toMatchObject({ status: "confirmed", source: "tmdb" });
expect(list.filter((u) => u.season_number === 0 && u.kind === "episode").map((u) => [u.title, u.status])).toEqual([
@@ -253,16 +254,30 @@ describe.skipIf(!up)("DH-1 内容单元数据治理", () => {
expect(await seasonsNow()).toEqual(before);
expect(r.unitIds.length).toBe(before.filter((n) => n !== null && n >= 1).length);
// 空的临时季:S7 没人引用 → 退役;S8 被资源引用 → 留着;S3 下面有集 → 留着
// 结构外的季:新资源不再建(S05 单季也不建);已有的 S7(空)、S8(被资源挂着)刷新时退役,资源解开;
// S9 下的集有文件覆盖 → 整季留着要人看
const s5 = await h.db
.transaction()
.execute((trx) => ensureUnits(trx, id, "series", { season: 5 } as ParsedRelease));
expect(s5).toEqual({ unitIds: [], primary: null });
const s7 = await unit("season", 7, null);
const s8 = await unit("season", 8, null);
const s9 = await unit("season", 9, null);
const s9e1 = await unit("episode", 9, 1, { parent_unit_id: s9 });
await cover(await asset(), s9e1);
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");
expect(await status(s8)).toBe("retired");
expect(await status(s9)).toBe("provisional");
const pack = await h.db
.selectFrom("source_release")
.select(["content_unit_id", "media_entity_id"])
.where("site_release_id", "=", "s8-pack")
.executeTakeFirstOrThrow();
expect(pack).toEqual({ content_unit_id: null, media_entity_id: id });
});
it("identity-cleanup --merge-duplicates:默认 dry-run 列清单不改库,--apply 只合并重复、不删孤立单元", async () => {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@umt/web",
"version": "0.3.42",
"version": "0.3.43",
"private": true,
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,5 +1,5 @@
{
"version": "0.3.42",
"version": "0.3.43",
"workspace": [
"./packages/adapters",
"./packages/agent",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@umt/adapters",
"version": "0.3.42",
"version": "0.3.43",
"exports": { ".": "./src/index.ts", "./fsworker": "./src/fs/fsworker.ts", "./alist-mock": "./src/alist/mock.ts" },
"tasks": {
"test": "deno run -A npm:vitest run",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@umt/agent",
"version": "0.3.42",
"version": "0.3.43",
"exports": { ".": "./src/index.ts" },
"tasks": {
"test": "deno run -A npm:vitest run",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@umt/contracts",
"version": "0.3.42",
"version": "0.3.43",
"exports": { ".": "./src/index.ts" },
"tasks": {
"test": "deno run -A npm:vitest run",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@umt/db",
"version": "0.3.42",
"version": "0.3.43",
"exports": { ".": "./src/index.ts" },
"tasks": {
"test": "deno run -A npm:vitest run",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@umt/domain",
"version": "0.3.42",
"version": "0.3.43",
"exports": { ".": "./src/index.ts" },
"tasks": {
"test": "deno run -A npm:vitest run",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@umt/mapping",
"version": "0.3.42",
"version": "0.3.43",
"exports": { ".": "./src/index.ts" },
"tasks": {
"test": "deno run -A npm:vitest run",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@umt/tasks",
"version": "0.3.42",
"version": "0.3.43",
"exports": { ".": "./src/index.ts" },
"tasks": {
"test": "deno run -A npm:vitest run",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@umt/testkit",
"version": "0.3.42",
"version": "0.3.43",
"exports": { ".": "./src/index.ts" },
"tasks": {
"test": "deno run -A npm:vitest run",