// 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 写空元素 ``、评分缺省写 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, ">"); 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) => `/g, "]]]]>")}]]>`; 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}`); } else if (n.cdata !== undefined) out.push(`${indent}<${n.tag}${attrs}>${cdata(clean(n.cdata))}`); else if (n.text !== undefined) out.push(`${indent}<${n.tag}${attrs}>${escText(clean(n.text))}`); else out.push(`${indent}<${n.tag}${attrs}/>`); } function toXml(root: Node): string { const out = ['']; 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(); 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 }); }