feat(scrape): 多集文件写多个 episodedetails 块,综艺日期期数按播出日期对集

- 多集文件(文件名 E01-E02 或覆盖多个集单元)按 Kodi 约定在一个 NFO 里每集写一块,MP 只写首集,偏离登记在 nfo-deviations.json;
- 综艺按日期命名的期数按本地季表或 TMDB 季详情的播出日期对上集,写季号、集号与 aired,不再 scrape_no_episode 跳过;
- 认不出集号、集数待确认的跳过让记录记部分完成(已存在 / 没变 / 被人改过仍算完成)。

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-03 23:13:47 +08:00
1 parent 0085e45ca7
commit 8c4c7d028a
8 files changed
+513 -61

No files matched your search

+7 -1
View File
@@ -65,5 +65,11 @@
"ours": "<视频名>-thumb.jpg",
"reason": "Kodi 约定 -thumb 后缀;Jellyfin / Emby 同样识别,且不会和同名海报规则冲突"
}
]
],
"multiEpisode": {
"case": "s01e01-e02(一个文件覆盖第 1、2 集)",
"mp": "只按首集(begin_episode)写一个 <episodedetails>,与 s01e01 相同",
"ours": "按 Kodi 多集约定写两个 <episodedetails> 块:第一块同 s01e01,第二块同 s01e02(各自的已登记差异照旧)",
"reason": "只写首集时 Kodi 只显示第一集,第二集在媒体库里缺失;Jellyfin / Emby 也读多块 NFO"
}
}
+38 -19
View File
@@ -1,6 +1,6 @@
import { readFileSync } from "node:fs";
import { fetchRichDetail, fetchRichSeason } from "@umt/adapters";
import { episodeNfo, planScrapeTargets, seasonNfo, seasonPosterName, workNfo } from "@umt/domain";
import { episodeNfo, multiEpisodeNfo, planScrapeTargets, seasonNfo, seasonPosterName, workNfo } from "@umt/domain";
import { describe, expect, it } from "vitest";
import { nfoEpisode, nfoSeason, nfoWork } from "../../src/modules/acquisition-organize/services/scrape-input.ts";
import { richEntityFields } from "../../src/modules/sources-identity/service/metadata-rich.ts";
@@ -12,7 +12,8 @@ import { richEntityFields } from "../../src/modules/sources-identity/service/met
const read = (f: string) => JSON.parse(readFileSync(new URL(f, import.meta.url), "utf8"));
const raw = read("./tmdb-raw.json") as Record<string, unknown>;
const mp = read("./scrape-mp.json") as Record<string, string>;
const devs = read("./nfo-deviations.json").deviations as { case: string; tag: string }[];
const devFile = read("./nfo-deviations.json");
const devs = devFile.deviations as { case: string; tag: string }[];
const LANGS = ["zh-CN", "zh-SG", "zh-TW", "zh-HK", "en-US"];
const client = {
@@ -63,23 +64,23 @@ async function ours(): Promise<Record<string, string>> {
metadata: { name: { value: sd.name }, overview: { value: sd.overview }, airDate: { value: sd.airDate } },
}),
);
for (const e of sd.episodes) {
out[`s01e${String(e.episodeNumber).padStart(2, "0")}`] = episodeNfo(
nfoEpisode(1, e.episodeNumber, {
season_number: 1,
episode_number: e.episodeNumber,
title: e.name,
air_date: e.airDate,
metadata: {
name: { value: e.name },
overview: { value: e.overview },
airDate: { value: e.airDate },
runtime: { value: e.runtime },
voteAverage: { value: e.voteAverage },
},
}),
);
}
const eps = sd.episodes.map((e) =>
nfoEpisode(1, e.episodeNumber, {
season_number: 1,
episode_number: e.episodeNumber,
title: e.name,
air_date: e.airDate,
metadata: {
name: { value: e.name },
overview: { value: e.overview },
airDate: { value: e.airDate },
runtime: { value: e.runtime },
voteAverage: { value: e.voteAverage },
},
}),
);
for (const e of eps) out[`s01e${String(e.episode).padStart(2, "0")}`] = episodeNfo(e);
out["s01e01-e02"] = multiEpisodeNfo(eps.slice(0, 2));
return out;
}
@@ -109,6 +110,24 @@ describe("NFO 与 MP 差分", async () => {
}
});
it("多集文件:按登记的偏离写多个 <episodedetails> 块,每块与 MP 对应单集一致(MP 只写首集)", () => {
expect(devFile.multiEpisode?.reason).toBeTruthy();
const xml = mine["s01e01-e02"]!;
const [decl, ...body] = xml.split("\n");
expect(decl).toBe(mp.s01e01!.split("\n")[0]);
const blocks = body
.join("\n")
.split(/(?=<episodedetails>)/)
.filter((b) => b.trim());
expect(blocks).toHaveLength(2);
for (const [i, c] of ["s01e01", "s01e02"].entries()) {
const skip = new Set(devs.filter((d) => d.case === c).map((d) => d.tag));
const a = normalize(mp[c]!).filter((l) => !skip.has(tagOf(l) ?? "") && !l.startsWith("<?xml"));
const b = normalize(blocks[i]!).filter((l) => !skip.has(tagOf(l) ?? ""));
expect(b).toEqual(a);
}
});
it("编码与声明:UTF-8、两空格缩进、末尾换行,和 MP 一致", () => {
for (const c of cases) {
expect(mine[c]!.split("\n")[0]).toBe(mp[c]!.split("\n")[0]);