Files
UHD_Media_Tool/packages/domain/src/scrape/nfo.ts
T
rootandClaude Opus 5.5 861f799615 feat(domain): 刮削 NFO 生成与目标文件布局(SCR-1)
电影 / 剧集 / 季 / 集 NFO(元素与顺序对照 MP TmdbScraper,缺值不写),
poster / fanart / 季海报 / 剧照命名(Emby / Jellyfin / Kodi 兼容)。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 22:36:14 +08:00

264 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const escAttr = (s: string) =>
escText(s).replace(/"/g, "&quot;").replace(/\r/g, "&#13;").replace(/\n/g, "&#10;").replace(/\t/g, "&#9;");
/** 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 });
}