feat(domain): 刮削 NFO 生成与目标文件布局(SCR-1)
电影 / 剧集 / 季 / 集 NFO(元素与顺序对照 MP TmdbScraper,缺值不写), poster / fanart / 季海报 / 剧照命名(Emby / Jellyfin / Kodi 兼容)。 Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
b3775dff1b
commit
861f799615
5 files changed
+690
No files matched your search
@@ -0,0 +1,263 @@
|
||||
// SCR-1 刮削:作品 / 季 / 集元数据 → NFO XML(Emby / Jellyfin / Kodi 通用)。纯函数,无 IO。
|
||||
// 对照 MP app/modules/themoviedb/scraper.py(__gen_common_nfo / __gen_movie_nfo_file / __gen_tv_nfo_file /
|
||||
// __gen_tv_season_nfo_file / __gen_tv_episode_nfo_file)的元素名、顺序与属性;输出格式对照 minidom
|
||||
// toprettyxml(indent=" ", encoding="utf-8"):UTF-8 声明、两空格缩进、只有文本的元素写在一行、简介用 CDATA。
|
||||
// 与 MP 的差异(差分登记见 apps/server/test/scrape/nfo-deviations.json):
|
||||
// - 缺值不写元素(MP 写空元素 `<premiered/>`、评分缺省写 0、空 CDATA);
|
||||
// - 简介里的 `]]>` 拆成两段 CDATA(MP 的 minidom 直接抛错);
|
||||
// - 额外写 豆瓣 id(doubanid + uniqueid type=douban)、标语 tagline、集时长 runtime;
|
||||
// - 集 NFO 没有 TMDB 集 id(本地不存),不写 uniqueid / tmdbid,媒体服务器按季号集号匹配。
|
||||
|
||||
export const TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p/original";
|
||||
|
||||
/** 人物:演员(role = 角色名)或导演 */
|
||||
export interface NfoPerson {
|
||||
tmdbId?: number | null;
|
||||
name: string;
|
||||
role?: string | null;
|
||||
/** TMDB profile_path(`/xx.jpg`)或完整 URL */
|
||||
profilePath?: string | null;
|
||||
}
|
||||
|
||||
export interface NfoIds {
|
||||
tmdb?: string | null;
|
||||
imdb?: string | null;
|
||||
tvdb?: string | null;
|
||||
douban?: string | null;
|
||||
}
|
||||
|
||||
/** 电影 / 剧集(tvshow.nfo) */
|
||||
export interface NfoWork {
|
||||
kind: "movie" | "tvshow";
|
||||
title: string;
|
||||
originalTitle?: string | null;
|
||||
year?: number | null;
|
||||
/** 首映 / 首播日期 YYYY-MM-DD */
|
||||
premiered?: string | null;
|
||||
overview?: string | null;
|
||||
tagline?: string | null;
|
||||
/** 0–10 */
|
||||
rating?: number | null;
|
||||
genres?: readonly string[];
|
||||
/** 制片国(显示名) */
|
||||
countries?: readonly string[];
|
||||
/** 出品 / 播出平台 */
|
||||
studios?: readonly string[];
|
||||
/** 分钟 */
|
||||
runtime?: number | null;
|
||||
directors?: readonly NfoPerson[];
|
||||
actors?: readonly NfoPerson[];
|
||||
ids: NfoIds;
|
||||
}
|
||||
|
||||
export interface NfoSeason {
|
||||
season: number;
|
||||
title?: string | null;
|
||||
overview?: string | null;
|
||||
airDate?: string | null;
|
||||
}
|
||||
|
||||
export interface NfoEpisode {
|
||||
season: number;
|
||||
episode: number;
|
||||
title?: string | null;
|
||||
overview?: string | null;
|
||||
airDate?: string | null;
|
||||
rating?: number | null;
|
||||
runtime?: number | null;
|
||||
directors?: readonly NfoPerson[];
|
||||
actors?: readonly NfoPerson[];
|
||||
}
|
||||
|
||||
// ── 极简 XML 树(只覆盖 NFO 用到的形态) ──
|
||||
|
||||
type Node = { tag: string; attrs?: [string, string][]; text?: string; cdata?: string; children?: Node[] };
|
||||
|
||||
const escText = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
const escAttr = (s: string) =>
|
||||
escText(s).replace(/"/g, """).replace(/\r/g, " ").replace(/\n/g, " ").replace(/\t/g, "	");
|
||||
/** XML 1.0 不允许的控制字符去掉(TMDB 简介偶见) */
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: 就是要删控制字符
|
||||
const clean = (s: string) => s.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, "");
|
||||
const cdata = (s: string) => `<![CDATA[${s.replace(/]]>/g, "]]]]><![CDATA[>")}]]>`;
|
||||
|
||||
function render(n: Node, indent: string, out: string[]) {
|
||||
const attrs = (n.attrs ?? []).map(([k, v]) => ` ${k}="${escAttr(clean(v))}"`).join("");
|
||||
if (n.children?.length) {
|
||||
out.push(`${indent}<${n.tag}${attrs}>`);
|
||||
for (const c of n.children) render(c, `${indent} `, out);
|
||||
out.push(`${indent}</${n.tag}>`);
|
||||
} else if (n.cdata !== undefined) out.push(`${indent}<${n.tag}${attrs}>${cdata(clean(n.cdata))}</${n.tag}>`);
|
||||
else if (n.text !== undefined) out.push(`${indent}<${n.tag}${attrs}>${escText(clean(n.text))}</${n.tag}>`);
|
||||
else out.push(`${indent}<${n.tag}${attrs}/>`);
|
||||
}
|
||||
|
||||
function toXml(root: Node): string {
|
||||
const out = ['<?xml version="1.0" encoding="utf-8"?>'];
|
||||
render(root, "", out);
|
||||
return `${out.join("\n")}\n`;
|
||||
}
|
||||
|
||||
const has = (v: string | null | undefined): v is string => typeof v === "string" && v.trim().length > 0;
|
||||
const num = (v: number | null | undefined): v is number => typeof v === "number" && Number.isFinite(v);
|
||||
|
||||
/** TMDB 图片路径 → 完整 URL(MP settings.TMDB_IMAGE_URL,原图) */
|
||||
export function tmdbImageFullUrl(path: string): string {
|
||||
return /^https?:\/\//.test(path) ? path : `${TMDB_IMAGE_BASE}/${path.replace(/^\/+/, "")}`;
|
||||
}
|
||||
|
||||
/** 评分:MP 写 round(x, 1) 的 Python 字符串形式(8.0 → "8.0") */
|
||||
const ratingText = (v: number) => {
|
||||
const r = Math.round(v * 10) / 10;
|
||||
return Number.isInteger(r) ? r.toFixed(1) : String(r);
|
||||
};
|
||||
|
||||
class Builder {
|
||||
readonly nodes: Node[] = [];
|
||||
text(tag: string, v: string | number | null | undefined, attrs?: [string, string][]) {
|
||||
if (typeof v === "number") {
|
||||
if (num(v)) this.nodes.push({ tag, text: String(v), ...(attrs ? { attrs } : {}) });
|
||||
} else if (has(v)) this.nodes.push({ tag, text: v.trim(), ...(attrs ? { attrs } : {}) });
|
||||
return this;
|
||||
}
|
||||
plot(v: string | null | undefined) {
|
||||
if (has(v)) {
|
||||
this.nodes.push({ tag: "plot", cdata: v.trim() });
|
||||
this.nodes.push({ tag: "outline", cdata: v.trim() });
|
||||
}
|
||||
return this;
|
||||
}
|
||||
directors(list: readonly NfoPerson[] | undefined) {
|
||||
for (const d of list ?? []) {
|
||||
if (!has(d.name)) continue;
|
||||
this.nodes.push({ tag: "director", text: d.name, attrs: [["tmdbid", num(d.tmdbId) ? String(d.tmdbId) : ""]] });
|
||||
}
|
||||
return this;
|
||||
}
|
||||
actors(list: readonly NfoPerson[] | undefined, withRole: boolean) {
|
||||
for (const a of list ?? []) {
|
||||
if (!has(a.name)) continue;
|
||||
const children: Node[] = [
|
||||
{ tag: "name", text: a.name },
|
||||
{ tag: "type", text: "Actor" },
|
||||
];
|
||||
if (withRole && has(a.role)) children.push({ tag: "role", text: a.role });
|
||||
if (num(a.tmdbId)) children.push({ tag: "tmdbid", text: String(a.tmdbId) });
|
||||
if (has(a.profilePath)) children.push({ tag: "thumb", text: tmdbImageFullUrl(a.profilePath) });
|
||||
if (num(a.tmdbId)) children.push({ tag: "profile", text: `https://www.themoviedb.org/person/${a.tmdbId}` });
|
||||
this.nodes.push({ tag: "actor", children });
|
||||
}
|
||||
return this;
|
||||
}
|
||||
list(tag: string, values: readonly string[] | undefined) {
|
||||
const seen = new Set<string>();
|
||||
for (const v of values ?? []) {
|
||||
if (!has(v) || seen.has(v.trim())) continue;
|
||||
seen.add(v.trim());
|
||||
this.nodes.push({ tag, text: v.trim() });
|
||||
}
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 外部 id 块(MP __gen_common_nfo):tmdbid + uniqueid(tmdb);有 TVDB / IMDb 时追加,IMDb 存在时 IMDb 为 default。
|
||||
* 豆瓣是我们加的(uniqueid type=douban,不做 default)。
|
||||
*/
|
||||
function idBlock(b: Builder, ids: NfoIds) {
|
||||
const tmdb: Node | null = has(ids.tmdb)
|
||||
? {
|
||||
tag: "uniqueid",
|
||||
text: ids.tmdb,
|
||||
attrs: [
|
||||
["type", "tmdb"],
|
||||
["default", "true"],
|
||||
],
|
||||
}
|
||||
: null;
|
||||
if (tmdb) {
|
||||
b.text("tmdbid", ids.tmdb);
|
||||
b.nodes.push(tmdb);
|
||||
}
|
||||
if (has(ids.tvdb)) {
|
||||
b.text("tvdbid", ids.tvdb);
|
||||
b.nodes.push({ tag: "uniqueid", text: ids.tvdb, attrs: [["type", "tvdb"]] });
|
||||
}
|
||||
if (has(ids.imdb)) {
|
||||
b.text("imdbid", ids.imdb);
|
||||
b.nodes.push({
|
||||
tag: "uniqueid",
|
||||
text: ids.imdb,
|
||||
attrs: [
|
||||
["type", "imdb"],
|
||||
["default", "true"],
|
||||
],
|
||||
});
|
||||
if (tmdb)
|
||||
tmdb.attrs = [
|
||||
["type", "tmdb"],
|
||||
["default", "false"],
|
||||
];
|
||||
}
|
||||
if (has(ids.douban)) {
|
||||
b.text("doubanid", ids.douban);
|
||||
b.nodes.push({ tag: "uniqueid", text: ids.douban, attrs: [["type", "douban"]] });
|
||||
}
|
||||
}
|
||||
|
||||
/** movie.nfo(`<视频名>.nfo`)/ tvshow.nfo */
|
||||
export function workNfo(w: NfoWork): string {
|
||||
const b = new Builder();
|
||||
idBlock(b, w.ids);
|
||||
b.plot(w.overview);
|
||||
b.directors(w.directors);
|
||||
b.actors(w.actors, true);
|
||||
b.list("genre", w.genres);
|
||||
if (num(w.rating) && w.rating > 0) b.text("rating", ratingText(w.rating));
|
||||
b.list("studio", w.studios);
|
||||
b.list("country", w.countries);
|
||||
if (num(w.runtime) && w.runtime > 0) b.text("runtime", w.runtime);
|
||||
b.text("title", w.title);
|
||||
b.text("originaltitle", w.originalTitle);
|
||||
b.text("premiered", w.premiered);
|
||||
const year = num(w.year)
|
||||
? w.year
|
||||
: has(w.premiered) && /^\d{4}/.test(w.premiered)
|
||||
? Number(w.premiered.slice(0, 4))
|
||||
: null;
|
||||
b.text("year", year);
|
||||
if (w.kind === "tvshow") {
|
||||
b.text("season", "-1");
|
||||
b.text("episode", "-1");
|
||||
}
|
||||
b.text("tagline", w.tagline);
|
||||
return toXml({ tag: w.kind === "movie" ? "movie" : "tvshow", children: b.nodes });
|
||||
}
|
||||
|
||||
/** season.nfo(季目录内) */
|
||||
export function seasonNfo(s: NfoSeason): string {
|
||||
const b = new Builder();
|
||||
b.plot(s.overview);
|
||||
b.text("title", has(s.title) ? s.title : `季 ${s.season}`);
|
||||
b.text("premiered", s.airDate);
|
||||
b.text("releasedate", s.airDate);
|
||||
b.text("year", has(s.airDate) && /^\d{4}/.test(s.airDate) ? s.airDate.slice(0, 4) : null);
|
||||
b.text("seasonnumber", String(s.season));
|
||||
return toXml({ tag: "season", children: b.nodes });
|
||||
}
|
||||
|
||||
/** 每集 `<视频名>.nfo` */
|
||||
export function episodeNfo(e: NfoEpisode): string {
|
||||
const b = new Builder();
|
||||
b.text("title", has(e.title) ? e.title : `第 ${e.episode} 集`);
|
||||
b.plot(e.overview);
|
||||
b.text("aired", e.airDate);
|
||||
b.text("year", has(e.airDate) && /^\d{4}/.test(e.airDate) ? e.airDate.slice(0, 4) : null);
|
||||
b.text("season", String(e.season));
|
||||
b.text("episode", String(e.episode));
|
||||
if (num(e.rating) && e.rating > 0) b.text("rating", ratingText(e.rating));
|
||||
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 });
|
||||
}
|
||||
Reference in new issue
Block a user