From 861f799615a743d9ce6962d1173717e99a8f508a Mon Sep 17 00:00:00 2001 From: root Date: Wed, 30 Sep 2026 22:36:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(domain):=20=E5=88=AE=E5=89=8A=20NFO=20?= =?UTF-8?q?=E7=94=9F=E6=88=90=E4=B8=8E=E7=9B=AE=E6=A0=87=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=B8=83=E5=B1=80(SCR-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 电影 / 剧集 / 季 / 集 NFO(元素与顺序对照 MP TmdbScraper,缺值不写), poster / fanart / 季海报 / 剧照命名(Emby / Jellyfin / Kodi 兼容)。 Co-Authored-By: Claude Opus 5.5 --- packages/domain/src/index.ts | 1 + packages/domain/src/scrape/index.ts | 3 + packages/domain/src/scrape/layout.ts | 222 ++++++++++++++++++++++ packages/domain/src/scrape/nfo.ts | 263 +++++++++++++++++++++++++++ packages/domain/test/scrape.test.ts | 201 ++++++++++++++++++++ 5 files changed, 690 insertions(+) create mode 100644 packages/domain/src/scrape/index.ts create mode 100644 packages/domain/src/scrape/layout.ts create mode 100644 packages/domain/src/scrape/nfo.ts create mode 100644 packages/domain/test/scrape.test.ts diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index e32c27da..9ab7b1ab 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -11,4 +11,5 @@ export * from "./path/index.ts"; export * from "./release-facets/index.ts"; export * from "./release-parser/index.ts"; export * from "./rules/index.ts"; +export * from "./scrape/index.ts"; export * from "./slot/index.ts"; diff --git a/packages/domain/src/scrape/index.ts b/packages/domain/src/scrape/index.ts new file mode 100644 index 00000000..67e71d1d --- /dev/null +++ b/packages/domain/src/scrape/index.ts @@ -0,0 +1,3 @@ +// SCR-1 刮削:NFO 生成与目标文件布局(纯函数) +export * from "./layout.ts"; +export * from "./nfo.ts"; diff --git a/packages/domain/src/scrape/layout.ts b/packages/domain/src/scrape/layout.ts new file mode 100644 index 00000000..b576a083 --- /dev/null +++ b/packages/domain/src/scrape/layout.ts @@ -0,0 +1,222 @@ +// SCR-1 刮削:算出一部作品要写哪些 NFO / 图片、写到哪里。纯函数,路径都是库根内相对路径(POSIX)。 +// 文件名跟随已入库视频(视频名本身按命名模板生成),图片名按 Emby / Jellyfin / Kodi 通用约定。 +// 对照 MP app/chain/scraping.py `_get_target_fileitem_and_path` / `_get_target_fileitems_and_paths` / +// `_expand_with_aliases` 与 themoviedb/scraper.py `get_metadata_img` / `get_season_poster`: +// - 电影:`<视频名>.nfo` 与视频同目录;海报 / 背景图放作品目录:poster.jpg、fanart.jpg +// (MP 另写 backdrop.jpg 副本,三家都认 fanart,我们不写副本); +// - 剧集:作品目录 tvshow.nfo、poster.jpg、fanart.jpg;季目录 season.nfo;季海报同时写作品目录 +// `season01-poster.jpg`(特别篇 `season-specials-poster.jpg`)与季目录 `poster.jpg`(MP 同); +// - 每集:`<视频名>.nfo`;剧照 `<视频名>-thumb.jpg`(Kodi / Jellyfin / Emby 都认;MP 写 `<视频名>.jpg`)。 +// 视频直接在库根(没有作品目录)时,电影图片改用 `<视频名>-poster.jpg` / `<视频名>-fanart.jpg`, +// 剧集不写 tvshow / 季文件(不能往库根里放 tvshow.nfo)。 + +/** 纯字符串的 POSIX dirname / basename / extname(domain 不依赖 node:path,前端也能打包) */ +const posix = { + dirname(p: string): string { + const i = p.replace(/\/+$/, "").lastIndexOf("/"); + return i < 0 ? "." : i === 0 ? "/" : p.slice(0, i); + }, + basename(p: string): string { + const t = p.replace(/\/+$/, ""); + return t.slice(t.lastIndexOf("/") + 1); + }, + extname(p: string): string { + const b = posix.basename(p); + const i = b.lastIndexOf("."); + return i > 0 ? b.slice(i) : ""; + }, +}; + +export type ScrapeTargetRole = + | "movie_nfo" + | "tvshow_nfo" + | "season_nfo" + | "episode_nfo" + | "poster" + | "fanart" + | "season_poster" + | "episode_thumb"; + +export interface ScrapeVideo { + /** 库根内相对路径 */ + path: string; + season?: number | null; + /** 首集集号(多集文件按首集,同 MP begin_episode) */ + episode?: number | null; +} + +export interface ScrapeImages { + posterPath?: string | null; + backdropPath?: string | null; + /** 季号 → 季海报路径 */ + seasonPosters?: ReadonlyMap; + /** "季:集" → 剧照路径 */ + stills?: ReadonlyMap; +} + +export interface ScrapeLayoutInput { + kind: "movie" | "tv"; + /** 作品目录(库根内相对路径);null / "" = 视频直接放在库根 */ + workDir: string | null; + videos: readonly ScrapeVideo[]; + images: ScrapeImages; + options: { images: boolean; episodeThumbs: boolean }; +} + +export interface ScrapeTarget { + /** 库根内相对路径 */ + path: string; + kind: "nfo" | "image"; + role: ScrapeTargetRole; + season?: number; + episode?: number; + /** 这个文件对应的视频(电影 / 集 NFO、剧照) */ + video?: string; + /** 图片:TMDB 图片路径 */ + imagePath?: string; +} + +const SEASON_DIR_RE = /^(season\s*\d+|s\d{1,3}|specials?|特别篇|第\s*\d+\s*季)$/i; + +/** 没有作品绑定时从视频路径推作品目录:电影取父目录;剧集父目录像季目录时取祖父目录 */ +export function inferWorkDir(kind: "movie" | "tv", videoPath: string): string | null { + const parent = posix.dirname(videoPath); + if (parent === "." || parent === "") return null; + if (kind === "tv" && SEASON_DIR_RE.test(posix.basename(parent))) { + const gp = posix.dirname(parent); + return gp === "." || gp === "" ? null : gp; + } + return parent; +} + +const stemOf = (p: string) => { + const base = posix.basename(p); + const i = base.lastIndexOf("."); + return i > 0 ? base.slice(0, i) : base; +}; +const extOf = (imagePath: string) => { + const e = posix.extname(imagePath.split("?")[0] ?? "").toLowerCase(); + return /^\.(jpe?g|png|webp)$/.test(e) ? e : ".jpg"; +}; +const pad2 = (n: number) => String(n).padStart(2, "0"); + +/** 季海报在作品目录下的文件名(MP get_season_poster) */ +export function seasonPosterName(season: number, ext = ".jpg"): string { + return season === 0 ? `season-specials-poster${ext}` : `season${pad2(season)}-poster${ext}`; +} + +const normDir = (d: string | null) => (d && d !== "." ? d.replace(/\/+$/, "") : null); +const isUnder = (p: string, dir: string) => p === dir || p.startsWith(`${dir}/`); + +/** 一部作品的刮削目标清单(去重,顺序:作品 → 季 → 集) */ +export function planScrapeTargets(input: ScrapeLayoutInput): ScrapeTarget[] { + const out: ScrapeTarget[] = []; + const seen = new Set(); + const add = (t: ScrapeTarget) => { + if (seen.has(t.path)) return; + seen.add(t.path); + out.push(t); + }; + const workDir = normDir(input.workDir); + const videos = [...input.videos].sort((a, b) => a.path.localeCompare(b.path)); + const img = input.images; + const join = (dir: string | null, name: string) => (dir ? `${dir}/${name}` : name); + + if (input.kind === "movie") { + for (const v of videos) { + const dir = normDir(posix.dirname(v.path)); + add({ path: join(dir, `${stemOf(v.path)}.nfo`), kind: "nfo", role: "movie_nfo", video: v.path }); + } + if (!input.options.images) return out; + const inWork = workDir && videos.some((v) => isUnder(v.path, workDir)); + const art = (role: "poster" | "fanart", p: string | null | undefined) => { + if (!p) return; + if (inWork) add({ path: join(workDir, `${role}${extOf(p)}`), kind: "image", role, imagePath: p }); + else + for (const v of videos) { + const dir = normDir(posix.dirname(v.path)); + add({ + path: join(dir, `${stemOf(v.path)}-${role}${extOf(p)}`), + kind: "image", + role, + imagePath: p, + video: v.path, + }); + } + }; + art("poster", img.posterPath); + art("fanart", img.backdropPath); + return out; + } + + // 剧集 + const underWork = workDir ? videos.filter((v) => isUnder(v.path, workDir)) : []; + if (workDir && underWork.length) { + add({ path: join(workDir, "tvshow.nfo"), kind: "nfo", role: "tvshow_nfo" }); + if (input.options.images) { + if (img.posterPath) + add({ + path: join(workDir, `poster${extOf(img.posterPath)}`), + kind: "image", + role: "poster", + imagePath: img.posterPath, + }); + if (img.backdropPath) + add({ + path: join(workDir, `fanart${extOf(img.backdropPath)}`), + kind: "image", + role: "fanart", + imagePath: img.backdropPath, + }); + } + // 季:按视频所在目录(不等于作品目录的才算季目录) + const seasons = new Map>(); + for (const v of underWork) { + if (v.season === null || v.season === undefined) continue; + const dir = normDir(posix.dirname(v.path)); + const set = seasons.get(v.season) ?? new Set(); + if (dir && dir !== workDir) set.add(dir); + seasons.set(v.season, set); + } + for (const [season, dirs] of [...seasons].sort((a, b) => a[0] - b[0])) { + for (const d of [...dirs].sort()) add({ path: `${d}/season.nfo`, kind: "nfo", role: "season_nfo", season }); + const sp = img.seasonPosters?.get(season); + if (input.options.images && sp) { + add({ + path: join(workDir, seasonPosterName(season, extOf(sp))), + kind: "image", + role: "season_poster", + season, + imagePath: sp, + }); + for (const d of [...dirs].sort()) + add({ path: `${d}/poster${extOf(sp)}`, kind: "image", role: "season_poster", season, imagePath: sp }); + } + } + } + for (const v of videos) { + 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); + add({ + path: join(dir, `${stem}.nfo`), + kind: "nfo", + role: "episode_nfo", + season: v.season, + episode: v.episode, + video: v.path, + }); + const still = img.stills?.get(`${v.season}:${v.episode}`); + if (input.options.images && input.options.episodeThumbs && still) + add({ + path: join(dir, `${stem}-thumb${extOf(still)}`), + kind: "image", + role: "episode_thumb", + season: v.season, + episode: v.episode, + video: v.path, + imagePath: still, + }); + } + return out; +} diff --git a/packages/domain/src/scrape/nfo.ts b/packages/domain/src/scrape/nfo.ts new file mode 100644 index 00000000..5c6d31d7 --- /dev/null +++ b/packages/domain/src/scrape/nfo.ts @@ -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 写空元素 ``、评分缺省写 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 }); +} diff --git a/packages/domain/test/scrape.test.ts b/packages/domain/test/scrape.test.ts new file mode 100644 index 00000000..f5fcd31b --- /dev/null +++ b/packages/domain/test/scrape.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from "vitest"; +import { episodeNfo, inferWorkDir, planScrapeTargets, seasonNfo, seasonPosterName, workNfo } from "../src/index.ts"; + +describe("NFO 生成", () => { + it("电影:MP 元素顺序,缺值不写,IMDb 为 default", () => { + const xml = workNfo({ + kind: "movie", + title: "星际穿越", + originalTitle: "Interstellar", + year: 2014, + premiered: "2014-11-05", + overview: "穿越虫洞。", + rating: 8.46, + genres: ["科幻", "剧情", "科幻"], + countries: ["美国"], + runtime: 169, + directors: [{ tmdbId: 525, name: "克里斯托弗·诺兰" }], + actors: [{ tmdbId: 10297, name: "马修·麦康纳", role: "Cooper", profilePath: "/mm.jpg" }], + ids: { tmdb: "157336", imdb: "tt0816692" }, + }); + expect(xml).toBe( + [ + '', + "", + " 157336", + ' 157336', + " tt0816692", + ' tt0816692', + " ", + " ", + ' 克里斯托弗·诺兰', + " ", + " 马修·麦康纳", + " Actor", + " Cooper", + " 10297", + " https://image.tmdb.org/t/p/original/mm.jpg", + " https://www.themoviedb.org/person/10297", + " ", + " 科幻", + " 剧情", + " 8.5", + " 美国", + " 169", + " 星际穿越", + " Interstellar", + " 2014-11-05", + " 2014", + "", + "", + ].join("\n"), + ); + }); + + it("剧集 tvshow.nfo:season/episode = -1,豆瓣 id、标语、平台", () => { + const xml = workNfo({ + kind: "tvshow", + title: "人生切割术", + premiered: "2022-02-17", + tagline: "工作与生活,彻底分开", + studios: ["Apple TV+"], + ids: { tmdb: "95396", tvdb: "371980", douban: "30448896" }, + }); + expect(xml).toContain('95396'); + expect(xml).toContain('371980'); + expect(xml).toContain('30448896'); + expect(xml).toContain("Apple TV+"); + expect(xml).toMatch(/2022<\/year>\n {2}-1<\/season>\n {2}-1<\/episode>\n {2}/); + expect(xml).not.toContain(""); + expect(xml).not.toContain(""); + }); + + it("转义与 CDATA:& < > 转义,]]> 拆段,控制字符删除", () => { + const xml = workNfo({ + kind: "movie", + title: 'A & "c"\u0001', + overview: "x]]>y", + ids: { tmdb: "1" }, + }); + expect(xml).toContain('A & <B> "c"'); + expect(xml).toContain("y]]>"); + }); + + it("季与集", () => { + expect(seasonNfo({ season: 1, title: "第 1 季", airDate: "2022-02-17" })).toBe( + [ + '', + "", + " 第 1 季", + " 2022-02-17", + " 2022-02-17", + " 2022", + " 1", + "", + "", + ].join("\n"), + ); + const ep = episodeNfo({ season: 1, episode: 2, overview: "参观。", airDate: "2022-02-18", rating: 8, runtime: 53 }); + expect(ep).toContain("第 2 集"); + expect(ep).toContain("8.0"); + expect(ep).toMatch(/1<\/season>\n {2}2<\/episode>/); + expect(ep).toContain("53"); + }); +}); + +describe("刮削目标布局", () => { + const images = { + posterPath: "/p.jpg", + backdropPath: "/b.png", + seasonPosters: new Map([[1, "/s1.jpg"]]), + stills: new Map([ + ["1:1", "/e1.jpg"], + ["1:2", null], + ]), + }; + + it("作品目录推断", () => { + expect(inferWorkDir("tv", "人生切割术 (2022)/Season 1/a.mkv")).toBe("人生切割术 (2022)"); + expect(inferWorkDir("tv", "剧集/人生切割术 (2022)/S01/a.mkv")).toBe("剧集/人生切割术 (2022)"); + expect(inferWorkDir("movie", "星际穿越 (2014)/a.mkv")).toBe("星际穿越 (2014)"); + expect(inferWorkDir("movie", "a.mkv")).toBeNull(); + expect(seasonPosterName(0)).toBe("season-specials-poster.jpg"); + }); + + it("剧集:tvshow / 季 / 集,季海报写两处,缺剧照不写", () => { + const t = planScrapeTargets({ + kind: "tv", + workDir: "人生切割术 (2022)", + videos: [ + { path: "人生切割术 (2022)/Season 1/人生切割术 - S01E02 - 第 2 集.mkv", season: 1, episode: 2 }, + { path: "人生切割术 (2022)/Season 1/人生切割术 - S01E01 - 第 1 集.mkv", season: 1, episode: 1 }, + ], + images, + options: { images: true, episodeThumbs: true }, + }); + expect(t.map((x) => x.path)).toEqual([ + "人生切割术 (2022)/tvshow.nfo", + "人生切割术 (2022)/poster.jpg", + "人生切割术 (2022)/fanart.png", + "人生切割术 (2022)/Season 1/season.nfo", + "人生切割术 (2022)/season01-poster.jpg", + "人生切割术 (2022)/Season 1/poster.jpg", + "人生切割术 (2022)/Season 1/人生切割术 - S01E01 - 第 1 集.nfo", + "人生切割术 (2022)/Season 1/人生切割术 - S01E01 - 第 1 集-thumb.jpg", + "人生切割术 (2022)/Season 1/人生切割术 - S01E02 - 第 2 集.nfo", + ]); + }); + + it("关图片 / 关剧照", () => { + const base = { + kind: "tv" as const, + workDir: "S", + videos: [{ path: "S/Season 1/a.mkv", season: 1, episode: 1 }], + images, + }; + expect(planScrapeTargets({ ...base, options: { images: false, episodeThumbs: true } }).map((x) => x.role)).toEqual([ + "tvshow_nfo", + "season_nfo", + "episode_nfo", + ]); + expect( + planScrapeTargets({ ...base, options: { images: true, episodeThumbs: false } }).some( + (x) => x.role === "episode_thumb", + ), + ).toBe(false); + }); + + it("电影:作品目录里写 poster/fanart;在库根时用 <视频名>-poster", () => { + const inDir = planScrapeTargets({ + kind: "movie", + workDir: "星际穿越 (2014)", + videos: [{ path: "星际穿越 (2014)/星际穿越 (2014) - 1080p.mkv" }], + images, + options: { images: true, episodeThumbs: true }, + }); + expect(inDir.map((x) => x.path)).toEqual([ + "星际穿越 (2014)/星际穿越 (2014) - 1080p.nfo", + "星际穿越 (2014)/poster.jpg", + "星际穿越 (2014)/fanart.png", + ]); + const atRoot = planScrapeTargets({ + kind: "movie", + workDir: null, + videos: [{ path: "x.mkv" }], + images, + options: { images: true, episodeThumbs: true }, + }); + expect(atRoot.map((x) => x.path)).toEqual(["x.nfo", "x-poster.jpg", "x-fanart.png"]); + }); + + it("剧集视频在库根:不写 tvshow / 季文件,只写集 NFO", () => { + const t = planScrapeTargets({ + kind: "tv", + workDir: null, + videos: [{ path: "a.S01E01.mkv", season: 1, episode: 1 }], + images, + options: { images: true, episodeThumbs: true }, + }); + expect(t.map((x) => x.path)).toEqual(["a.S01E01.nfo", "a.S01E01-thumb.jpg"]); + }); +});