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

+11 -1
View File
@@ -49,8 +49,13 @@ export interface ScrapeVideo {
/** 库根内相对路径 */
path: string;
season?: number | null;
/** 首集集号(多集文件按首集,同 MP begin_episode) */
/** 首集集号(同 MP begin_episode;剧照、文件命名按首集) */
episode?: number | null;
/**
* 文件覆盖的全部集号(多集文件 E01-E02 / 覆盖多个集单元),含首集、升序;缺省 = 只有首集。
* 集 NFO 按 Kodi 约定每集写一个 `<episodedetails>` 块(MP 只写首集)。
*/
episodes?: readonly number[] | null;
}
export interface ScrapeImages {
@@ -83,6 +88,8 @@ export interface ScrapeTarget {
role: ScrapeTargetRole;
season?: number;
episode?: number;
/** 集 NFO:文件覆盖的全部集号(多集文件多于一个) */
episodes?: number[];
/** 这个文件对应的视频(电影 / 集 NFO、剧照) */
video?: string;
/** 图片:TMDB 图片路径 */
@@ -227,12 +234,15 @@ export function planScrapeTargets(input: ScrapeLayoutInput): ScrapeTarget[] {
if (v.season === null || v.season === undefined || v.episode === null || v.episode === undefined) continue;
const dir = normDir(posix.dirname(v.path));
const stem = stemOf(v.path);
const first = v.episode;
const eps = [...new Set([first, ...(v.episodes ?? [])])].sort((a, b) => a - b);
add({
path: join(dir, `${stem}.nfo`),
kind: "nfo",
role: "episode_nfo",
season: v.season,
episode: v.episode,
episodes: eps,
video: v.path,
});
const still = img.stills?.get(`${v.season}:${v.episode}`);
+18 -4
View File
@@ -6,7 +6,8 @@
// - 缺值不写元素(MP 写空元素 `<premiered/>`、评分缺省写 0、空 CDATA);
// - 简介里的 `]]>` 拆成两段 CDATA(MP 的 minidom 直接抛错);
// - 额外写 豆瓣 id(doubanid + uniqueid type=douban)、标语 tagline、集时长 runtime;
// - 集 NFO 没有 TMDB 集 id(本地不存),不写 uniqueid / tmdbid,媒体服务器按季号集号匹配。
// - 集 NFO 没有 TMDB 集 id(本地不存),不写 uniqueid / tmdbid,媒体服务器按季号集号匹配;
// - 多集文件(E01-E02)按 Kodi 约定每集一个 `<episodedetails>` 块写进同一个 NFO(MP 只写首集)。
export const TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p/original";
@@ -92,9 +93,9 @@ function render(n: Node, indent: string, out: string[]) {
else out.push(`${indent}<${n.tag}${attrs}/>`);
}
function toXml(root: Node): string {
function toXml(...roots: Node[]): string {
const out = ['<?xml version="1.0" encoding="utf-8"?>'];
render(root, "", out);
for (const root of roots) render(root, "", out);
return `${out.join("\n")}\n`;
}
@@ -248,6 +249,19 @@ export function seasonNfo(s: NfoSeason): string {
/** 每集 `<视频名>.nfo` */
export function episodeNfo(e: NfoEpisode): string {
return toXml(episodeNode(e));
}
/**
* 多集文件的 `<视频名>.nfo`:Kodi 约定(Jellyfin / Emby 同样读取)每集一个 `<episodedetails>` 块,依次写在同一个文件里,
* 只有一个 XML 声明。只有一集时与 episodeNfo 相同。
*/
export function multiEpisodeNfo(list: readonly NfoEpisode[]): string {
if (!list.length) throw new Error("multiEpisodeNfo: 至少一集");
return toXml(...list.map(episodeNode));
}
function episodeNode(e: NfoEpisode): Node {
const b = new Builder();
b.text("title", has(e.title) ? e.title : `第 ${e.episode} 集`);
b.plot(e.overview);
@@ -259,5 +273,5 @@ export function episodeNfo(e: NfoEpisode): string {
b.directors(e.directors);
b.actors(e.actors, false);
if (num(e.runtime) && e.runtime > 0) b.text("runtime", e.runtime);
return toXml({ tag: "episodedetails", children: b.nodes });
return { tag: "episodedetails", children: b.nodes };
}
+35
View File
@@ -3,6 +3,7 @@ import {
episodeNfo,
inferWorkDir,
isSeasonDirName,
multiEpisodeNfo,
planScrapeTargets,
scrapeKindOf,
seasonNfo,
@@ -255,3 +256,37 @@ describe("作品类型 → 刮削类型", () => {
expect(["movie", "collection", "other"].map(scrapeKindOf)).toEqual(["movie", "movie", "movie"]);
});
});
describe("多集文件", () => {
it("集 NFO 每集一个 <episodedetails> 块,只有一个 XML 声明;一集时与 episodeNfo 相同", () => {
const xml = multiEpisodeNfo([
{ season: 1, episode: 1, title: "上", airDate: "2022-02-17" },
{ season: 1, episode: 2, title: "下", airDate: "2022-02-17" },
]);
expect(xml.match(/<\?xml /g)).toHaveLength(1);
expect(xml.match(/<episodedetails>/g)).toHaveLength(2);
expect(xml).toContain(" <title>上</title>\n <aired>2022-02-17</aired>");
expect(xml.indexOf("<episode>1</episode>")).toBeLessThan(xml.indexOf("<episode>2</episode>"));
const one = { season: 1, episode: 3, title: "单集" };
expect(multiEpisodeNfo([one])).toBe(episodeNfo(one));
});
it("布局:多集文件只写一个 NFO(按视频名)与首集剧照,目标带全部集号", () => {
const t = planScrapeTargets({
kind: "tv",
workDir: "剧 (2020)",
videos: [{ path: "剧 (2020)/Season 1/剧 - S01E01-E02.mkv", season: 1, episode: 1, episodes: [2, 1] }],
images: { stills: new Map([["1:1", "/e1.jpg"]]) },
options: { images: true, episodeThumbs: true },
parts: { work: false, season: false },
});
expect(t).toEqual([
expect.objectContaining({
role: "episode_nfo",
path: "剧 (2020)/Season 1/剧 - S01E01-E02.nfo",
episodes: [1, 2],
}),
expect.objectContaining({ role: "episode_thumb", path: "剧 (2020)/Season 1/剧 - S01E01-E02-thumb.jpg" }),
]);
});
});