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:
1 parent
0085e45ca7
commit
8c4c7d028a
8 files changed
+513
-61
No files matched your search
@@ -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}`);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user