From 680419bb64a3f2455d2d6dad86d1e3f88b07958e Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 19:37:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(scrape):=20TMDB=20=E6=A0=87=E5=BF=97?= =?UTF-8?q?=E5=86=99=20clearlogo.png?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 富详情请求加 images(include_image_language=zh,en,null),作品原语言不是中英文时另取 /images; 选一张 PNG 标志(原语言 → 中文 → 英文 → 无文字,同语言按评分)存作品字段 logoPath,变化时触发重刮 - 刮削布局新角色 clearlogo:电影写影片目录 clearlogo.png、剧集写剧集根目录,库根 / 平铺电影 <视频名>-clearlogo.png; 覆盖方式与 hash 规则同其它图片 - 布局金样差分登记偏离 tmdb_clearlogo;mock TMDB 支持 logos 与 /images Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- .../services/scrape-input.ts | 3 +- .../sources-identity/service/metadata-rich.ts | 1 + .../sources-identity/service/metadata.ts | 1 + .../scrape-clearlogo.test.ts | 126 ++++++++++++++++++ .../test/scrape/scrape-layout-mp.test.ts | 19 ++- apps/web/app/components/library/labels.ts | 1 + .../app/components/library/scrape-panel.tsx | 1 + docs/runbook/scrape.md | 13 +- packages/adapters/src/tmdb/details.test.ts | 68 ++++++++++ packages/adapters/src/tmdb/details.ts | 58 +++++++- packages/contracts/src/content.ts | 2 + packages/contracts/src/scrape.ts | 2 +- packages/domain/src/scrape/layout.ts | 15 ++- packages/domain/test/scrape.test.ts | 54 ++++++++ packages/testkit/src/tmdb/fixtures.ts | 2 + packages/testkit/src/tmdb/mock-tmdb.ts | 25 +++- 16 files changed, 379 insertions(+), 12 deletions(-) create mode 100644 apps/server/test/acquisition-organize/scrape-clearlogo.test.ts diff --git a/apps/server/src/modules/acquisition-organize/services/scrape-input.ts b/apps/server/src/modules/acquisition-organize/services/scrape-input.ts index dd4c0a97..5100965a 100644 --- a/apps/server/src/modules/acquisition-organize/services/scrape-input.ts +++ b/apps/server/src/modules/acquisition-organize/services/scrape-input.ts @@ -152,11 +152,12 @@ export function episodeMetadataMissing(u: UnitForNfo | undefined): boolean { return !u || (!filled(u.metadata) && !str(u.title) && !u.air_date); } -/** 图片路径:作品海报 / 背景图,季海报,集剧照 */ +/** 图片路径:作品海报 / 背景图 / 标志,季海报,集剧照 */ export function imagePathsOf(entityMeta: unknown) { return { posterPath: str(val(entityMeta, "posterPath")) ?? str(val(entityMeta, "posterUrl")), backdropPath: str(val(entityMeta, "backdropPath")), + logoPath: str(val(entityMeta, "logoPath")), }; } export const unitPosterPath = (m: unknown) => str(val(m, "posterPath")); diff --git a/apps/server/src/modules/sources-identity/service/metadata-rich.ts b/apps/server/src/modules/sources-identity/service/metadata-rich.ts index 3bfcd8c7..6a4ae35a 100644 --- a/apps/server/src/modules/sources-identity/service/metadata-rich.ts +++ b/apps/server/src/modules/sources-identity/service/metadata-rich.ts @@ -72,6 +72,7 @@ export function richEntityFields(r: TmdbRichDetail): Partial = new Set([ "externalIds", "posterPath", "backdropPath", + "logoPath", ]); /** diff --git a/apps/server/test/acquisition-organize/scrape-clearlogo.test.ts b/apps/server/test/acquisition-organize/scrape-clearlogo.test.ts new file mode 100644 index 00000000..84921226 --- /dev/null +++ b/apps/server/test/acquisition-organize/scrape-clearlogo.test.ts @@ -0,0 +1,126 @@ +import { readFile, writeFile } from "node:fs/promises"; +import type { ScrapeFileResult, ScrapeMatch } from "@umt/contracts"; +import { createTempTree, type TempTree } from "@umt/testkit"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { queueScrape, runScrapeRecord } from "../../src/modules/acquisition-organize/services/scrape.ts"; +import { createHarness, type Harness, pgAvailable, seedBase } from "./harness.ts"; + +// TMDB 标志 → clearlogo.png:电影写影片目录、剧集写剧集根目录;覆盖方式与其它图片一致 +// (更新:没变不重写、本工具写过且没被改过的按新图覆盖、被人改过的跳过;只补缺失;全部覆盖)。 + +const hasPg = await pgAvailable(); +const at = new Date().toISOString(); +const f = (value: unknown) => ({ value, source: "tmdb", language: null, at, locked: false }); +const enc = (s: string) => new TextEncoder().encode(s); + +describe.skipIf(!hasPg)("刮削:TMDB 标志写 clearlogo.png", () => { + let h: Harness; + let lib: TempTree; + let rootId = ""; + + beforeAll(async () => { + lib = await createTempTree( + { + "Movies/星际穿越 (2014)/Interstellar.2014.mkv": 100, + "TV/人生切割术 (2022)/Season 1/Severance.S01E01.mkv": 100, + }, + { sentinel: true, prefix: "umt-scrlogo-" }, + ); + h = await createHarness("scrape_logo", { + // 内容 = 路径,方便看出写的是哪张 + scrapeImage: async (p) => ({ content: enc(p), mime: p.endsWith(".png") ? "image/png" : "image/jpeg" }), + }); + rootId = (await seedBase(h.db, { libraryPath: lib.root, cachePath: lib.root, qbUrl: "http://127.0.0.1:9" })).rootId; + }, 60_000); + afterAll(async () => { + await h?.close(); + await lib?.cleanup(); + }); + + async function entity(kind: "movie" | "series", title: string, meta: object) { + return ( + await h.db + .insertInto("media_entity") + .values({ kind, title, year: 2020, status: "active", metadata: JSON.stringify(meta) }) + .returning("id") + .executeTakeFirstOrThrow() + ).id; + } + const setMeta = (id: string, meta: object) => + h.db + .updateTable("media_entity") + .set({ metadata: JSON.stringify(meta) }) + .where("id", "=", id) + .execute(); + async function scrape( + entityId: string, + kind: "movie" | "tv", + path: string, + mode?: "missing" | "overwrite" | "update", + ) { + const match: ScrapeMatch = { + mediaEntityId: entityId, + provider: null, + externalId: null, + kind, + title: "x", + year: null, + source: "library", + }; + const q = await queueScrape(h.d, { + rootId: `lib:${rootId}`, + rootPath: lib.root, + libraryRootId: rootId, + path, + match, + mode, + trigger: "manual", + }); + return runScrapeRecord(h.d, q.recordId); + } + const logo = (files: ScrapeFileResult[]) => files.find((x) => x.role === "clearlogo"); + const MOVIE_DIR = "Movies/星际穿越 (2014)"; + const LOGO = `${MOVIE_DIR}/clearlogo.png`; + + it("电影:写在影片目录 clearlogo.png;更新模式内容没变不重写,换了标志按新图覆盖", async () => { + const movie = await entity("movie", "星际穿越", { title: f("星际穿越"), logoPath: f("/logo-a.png") }); + const r = await scrape(movie, "movie", MOVIE_DIR); + expect(logo(r.files)).toMatchObject({ + path: LOGO, + action: "written", + hash: expect.stringMatching(/^[0-9a-f]{64}$/), + }); + expect(await readFile(lib.path(LOGO), "utf8")).toBe("/logo-a.png"); + const same = await scrape(movie, "movie", MOVIE_DIR, "update"); + expect(logo(same.files)).toMatchObject({ action: "skipped", reason: "scrape_unchanged" }); + await setMeta(movie, { title: f("星际穿越"), logoPath: f("/logo-b.png") }); + const upd = await scrape(movie, "movie", MOVIE_DIR, "update"); + expect(logo(upd.files)).toMatchObject({ action: "written" }); + expect(await readFile(lib.path(LOGO), "utf8")).toBe("/logo-b.png"); + + // 被人换掉的标志:更新跳过、只补缺失跳过、全部覆盖改写 + await writeFile(lib.path(LOGO), "我自己的标志"); + await setMeta(movie, { title: f("星际穿越"), logoPath: f("/logo-c.png") }); + expect(logo((await scrape(movie, "movie", MOVIE_DIR, "update")).files)).toMatchObject({ + action: "skipped", + reason: "scrape_user_modified", + }); + expect(logo((await scrape(movie, "movie", MOVIE_DIR, "missing")).files)).toMatchObject({ + action: "skipped", + reason: "scrape_exists", + }); + expect(await readFile(lib.path(LOGO), "utf8")).toBe("我自己的标志"); + expect(logo((await scrape(movie, "movie", MOVIE_DIR, "overwrite")).files)).toMatchObject({ action: "written" }); + expect(await readFile(lib.path(LOGO), "utf8")).toBe("/logo-c.png"); + }); + + it("剧集:写在剧集根目录,季目录不写;没有标志不写", async () => { + const show = await entity("series", "人生切割术", { title: f("人生切割术"), logoPath: f("/sev.png") }); + const r = await scrape(show, "tv", "TV/人生切割术 (2022)"); + expect(logo(r.files)).toMatchObject({ path: "TV/人生切割术 (2022)/clearlogo.png", action: "written" }); + expect(r.files.filter((x) => x.role === "clearlogo")).toHaveLength(1); + await setMeta(show, { title: f("人生切割术") }); + const none = await scrape(show, "tv", "TV/人生切割术 (2022)", "overwrite"); + expect(logo(none.files)).toBeUndefined(); + }); +}); diff --git a/apps/server/test/scrape/scrape-layout-mp.test.ts b/apps/server/test/scrape/scrape-layout-mp.test.ts index 37952076..dad8d895 100644 --- a/apps/server/test/scrape/scrape-layout-mp.test.ts +++ b/apps/server/test/scrape/scrape-layout-mp.test.ts @@ -40,6 +40,19 @@ const DEVIATIONS: { id: string; apply: (files: string[], scenario: string) => st ? fs : fs.map((f) => (f === "poster.jpg" || f === "fanart.jpg" ? `${LOOSE.replace(/\.mkv$/, "")}-${f}` : f)), }, + // 4. 作品级标志 clearlogo.png(TMDB logos;MP 只在接 fanart.tv 时写 logo,我们不接 fanart.tv): + // 写在作品海报旁边——电影影片目录 / 剧集根目录 clearlogo.png,库根的电影 <视频名>-clearlogo.png;季目录不写 + { + id: "tmdb_clearlogo", + apply: (fs) => [ + ...fs, + ...fs.flatMap((f) => { + const m = /^(.*?)(\/|^|-)poster\.jpg$/.exec(f); + if (!m || /(^|\/)Season \d+$/.test(m[1]!) || /(^|\/)season(\d+|-specials)$/.test(m[1]!)) return []; + return [`${m[1]}${m[2]}clearlogo.png`]; + }), + ], + }, ]; const detail = (entityType: "movie" | "tv", externalId: string): TmdbRichDetail => @@ -69,6 +82,7 @@ const detail = (entityType: "movie" | "tv", externalId: string): TmdbRichDetail originCountry: [], posterPath: "/poster.jpg", backdropPath: "/backdrop.jpg", + logoPath: "/logo.png", cast: [], crew: [], externalIds: { imdb: null, tvdb: null, wikidata: null }, @@ -124,7 +138,10 @@ describe.skipIf(skip)("刮削文件布局与 MP 差分", () => { "scrlayout", { ao: { - scrapeImage: async () => ({ content: new Uint8Array([0xff, 0xd8, 0xff]), mime: "image/jpeg" }), + scrapeImage: async (p) => + p.endsWith(".png") + ? { content: new Uint8Array([0x89, 0x50, 0x4e, 0x47, 1]), mime: "image/png" } + : { content: new Uint8Array([0xff, 0xd8, 0xff]), mime: "image/jpeg" }, scrapeTmdbDetail: async (type, id) => detail(type, id), scrapeTmdbSeason: async (_tv, n) => (n === 1 ? season1 : null), }, diff --git a/apps/web/app/components/library/labels.ts b/apps/web/app/components/library/labels.ts index bc9789e9..958ce731 100644 --- a/apps/web/app/components/library/labels.ts +++ b/apps/web/app/components/library/labels.ts @@ -260,6 +260,7 @@ export const ENTITY_FIELD_LABEL: Record = { cast: "演员", crew: "导演 / 主创", externalIds: "外部 ID", + logoPath: "标志", }; /** 季 / 集字段(contracts UnitMetadataField) */ diff --git a/apps/web/app/components/library/scrape-panel.tsx b/apps/web/app/components/library/scrape-panel.tsx index 6ad70372..2e18a13a 100644 --- a/apps/web/app/components/library/scrape-panel.tsx +++ b/apps/web/app/components/library/scrape-panel.tsx @@ -37,6 +37,7 @@ const ROLE: Record = { episode_nfo: "集 NFO", poster: "海报", fanart: "背景图", + clearlogo: "标志", season_poster: "季海报", episode_thumb: "剧照", }; diff --git a/docs/runbook/scrape.md b/docs/runbook/scrape.md index 0afa2b30..d2bfe6c8 100644 --- a/docs/runbook/scrape.md +++ b/docs/runbook/scrape.md @@ -108,16 +108,20 @@ TMDB 有这一集但没有译名 / 简介(只有占位名和播出日期)不算 | 选中的 | 写的文件 | | --- | --- | -| 剧集目录 | `tvshow.nfo`、`poster.jpg`、`fanart.jpg`、`seasonNN-poster.jpg`;每个季目录(`Season 1` / `S01` / `Specials` / `SPs` / `第 1 季`)`season.nfo` + `poster.jpg`;每集 `<视频名>.nfo` + `<视频名>-thumb.jpg`。只进季目录一层,其他子目录不管 | +| 剧集目录 | `tvshow.nfo`、`poster.jpg`、`fanart.jpg`、`clearlogo.png`(有 TMDB 标志时)、`seasonNN-poster.jpg`;每个季目录(`Season 1` / `S01` / `Specials` / `SPs` / `第 1 季`)`season.nfo` + `poster.jpg`;每集 `<视频名>.nfo` + `<视频名>-thumb.jpg`。只进季目录一层,其他子目录不管 | | 季目录 | `season.nfo` + 季目录 `poster.jpg` + 上级 `seasonNN-poster.jpg`;每集 NFO 与剧照。不写 `tvshow.nfo` | | 单集文件 | 该集 `<视频名>.nfo` + 剧照 | -| 电影目录 | 目录里每个视频 `<视频名>.nfo`(不进子目录)+ 目录 `poster.jpg` / `fanart.jpg`;蓝光原盘目录(含 `BDMV` / `VIDEO_TS`)写 `<目录名>.nfo` | -| 电影文件 | `<视频名>.nfo` + 同目录 `poster.jpg` / `fanart.jpg`;文件直接在登记目录根时图片改为 `<视频名>-poster.jpg` / `-fanart.jpg` | +| 电影目录 | 目录里每个视频 `<视频名>.nfo`(不进子目录)+ 目录 `poster.jpg` / `fanart.jpg` / `clearlogo.png`;蓝光原盘目录(含 `BDMV` / `VIDEO_TS`)写 `<目录名>.nfo` | +| 电影文件 | `<视频名>.nfo` + 同目录 `poster.jpg` / `fanart.jpg` / `clearlogo.png`;文件直接在登记目录根时图片改为 `<视频名>-poster.jpg` / `-fanart.jpg` / `-clearlogo.png` | - 自定义平铺模板(多部电影放在同一目录):目录里有登记在别的作品下的视频,或只刮部分视频时目录里还有没登记到本作品的视频, 图片改为 `<视频名>-poster.jpg` / `-fanart.jpg`,不写整个目录共用的 `poster.jpg`。作品详情「重新刮削」对电影只刮该作品已入库的视频, 不给同目录别的电影写本作品的 NFO(同一作品的多个版本照旧共用一张 poster)。 - 图片扩展名按下载到的实际类型(jpeg / png / webp),URL 认不出或与内容不符时以内容为准。 +- 标志(clearlogo,2026-10-06):刷新元数据时 TMDB 详情带 `images`(`include_image_language=zh,en,null`,作品原语言不是中英文时另取原语言), + 选一张 PNG 标志存为作品字段 `logoPath`,语言优先 作品原语言 → 中文 → 英文 → 无文字,同语言取评分最高;SVG 标志不用。 + 文件名按 Kodi / Emby / Jellyfin 通用约定 `clearlogo.png`:电影写影片目录、剧集写剧集根目录(季目录不写);覆盖方式与 hash 规则同其它图片。 + 标志变化算 NFO 相关字段,刮削过的作品会延后排一次「更新」模式重刮。不接 fanart.tv。 - 集号:已登记视频按覆盖的集单元;其余按路径解析(季号缺失时取季目录名,再缺按第 1 季;同 MP 对每集重新 `MetaInfoPath`)。 认不出集号的视频记 `scrape_no_episode` 跳过,记录算部分完成。 @@ -166,6 +170,7 @@ TMDB 有这一集但没有译名 / 简介(只有占位名和播出日期)不算 | 作品 / 电影目录背景图 | `backdrop.jpg` + `fanart.jpg` | `fanart.jpg` | 三家都认 fanart.jpg,副本没用 | | 每集剧照 | `<视频名>.jpg` | `<视频名>-thumb.jpg` | Kodi 约定,Jellyfin / Emby 同样识别;不和同名封面混 | | 视频直接在登记目录根 | 根里写 `poster.jpg` / `backdrop.jpg` / `fanart.jpg` | `<视频名>-poster.jpg` / `<视频名>-fanart.jpg` | 根里放一张 poster.jpg 会被当成整个库的封面 | +| 作品级标志 | 只在接 fanart.tv 时写 logo | 作品海报旁写 `clearlogo.png`(根下电影 `<视频名>-clearlogo.png`),来源 TMDB logos | 用户定:不接 fanart.tv,TMDB 标志够用(差分登记 `tmdb_clearlogo`) | | 其余(tvshow / season / 集 NFO、季海报两处、电影 NFO、蓝光 `<目录名>.nfo`、季目录只写季级、单集只写该集) | — | 同 MP | — | 行为上的差异: @@ -177,7 +182,7 @@ TMDB 有这一集但没有译名 / 简介(只有占位名和播出日期)不算 | 识别 | 按路径识别,认不出直接失败;对话框可手填 TMDB / 豆瓣 id | 按路径识别,认不准时返回前 5 候选让用户选(也可搜索);已登记的目录直接用作品 | 选一次比手填 id 省事;已登记的不必再识别 | | 执行 | 请求内同步 | 后台任务 `scrapeMedia`,请求内最多等 20 s | 大季包图片多;超时不丢 | | 记录 | 只有日志 | 每次刮削一条记录(目录、识别结果、逐文件结果、原因),作品详情显示最近一条 | 可回看、可排查 | -| 图片种类 | poster / backdrop / logo / banner / disc / clearart / thumb(按图片源) | poster / fanart / 季海报 / 剧照 | 本地只有 TMDB 海报、背景图、剧照;其余要 fanart.tv 源 | +| 图片种类 | poster / backdrop / logo / banner / disc / clearart / thumb(按图片源) | poster / fanart / clearlogo / 季海报 / 剧照 | 本地只有 TMDB 海报、背景图、标志、剧照;其余要 fanart.tv 源(不接) | | 批量 | 多选共用一个识别条件 | 多选逐项各自识别,各自选候选 | 多选里常是不同作品 | | 音乐 | 支持 | 不做 | 范围外 | diff --git a/packages/adapters/src/tmdb/details.test.ts b/packages/adapters/src/tmdb/details.test.ts index 71551fe8..1dc8518e 100644 --- a/packages/adapters/src/tmdb/details.test.ts +++ b/packages/adapters/src/tmdb/details.test.ts @@ -9,6 +9,7 @@ import { parseAltTitles, parseTranslations, pickChineseTitle, + pickLogo, pickText, pickTitle, } from "./index.ts"; @@ -223,3 +224,70 @@ describe("fetchRichDetail / fetchRichSeason(mock TMDB)", () => { expect(mock.calls.length).toBe(n); }); }); + +describe("TMDB 标志(clearlogo)", () => { + const logos = (rows: { file_path: string; iso_639_1: string | null; vote_average?: number }[]) => ({ logos: rows }); + + it("语言优先 原语言 → 中文 → 英文 → 无文字;同语言按评分;只要 PNG", () => { + const all = logos([ + { file_path: "/none.png", iso_639_1: null, vote_average: 9 }, + { file_path: "/en.png", iso_639_1: "en", vote_average: 5 }, + { file_path: "/zh-low.png", iso_639_1: "zh", vote_average: 3 }, + { file_path: "/zh-high.png", iso_639_1: "zh", vote_average: 6 }, + { file_path: "/ja.svg", iso_639_1: "ja", vote_average: 9 }, + { file_path: "/ja.png", iso_639_1: "ja", vote_average: 1 }, + ]); + expect(pickLogo([all], "ja")).toBe("/ja.png"); + expect(pickLogo([all], "ko")).toBe("/zh-high.png"); + expect( + pickLogo( + [ + logos([ + { file_path: "/en.png", iso_639_1: "en" }, + { file_path: "/n.png", iso_639_1: null }, + ]), + ], + "zh", + ), + ).toBe("/en.png"); + expect(pickLogo([logos([{ file_path: "/n.png", iso_639_1: null }])], "en")).toBe("/n.png"); + expect(pickLogo([logos([{ file_path: "/x.svg", iso_639_1: "en" }])], "en")).toBeNull(); + expect(pickLogo([undefined], null)).toBeNull(); + }); + + it("详情请求带 images(zh,en,null);原语言不是中英文时另取原语言标志", async () => { + const fx = TMDB_FIXTURES.map((f) => + f.id === 872585 + ? { + ...f, + logos: [ + { file_path: "/oppen-en.png", iso_639_1: "en" }, + { file_path: "/oppen-zh.png", iso_639_1: "zh" }, + ], + } + : f.id === 95396 + ? { + ...f, + original_language: "ja", + logos: [ + { file_path: "/sev-ja.png", iso_639_1: "ja" }, + { file_path: "/sev-en.png", iso_639_1: "en" }, + ], + } + : f, + ); + mock = await createMockTmdb({ fixtures: fx }); + const tmdb = createTmdbClient({ baseUrl: `${mock.url}/3`, apiKey: "test-tmdb-key", retries: 0, timeoutMs: 1000 }); + const movie = await fetchRichDetail(tmdb, "movie", 872585, CHAIN); + expect(movie.logoPath).toBe("/oppen-en.png"); + const call = mock.calls.find((c) => c.path === "/3/movie/872585")!; + expect(call.query).toMatchObject({ include_image_language: "zh,en,null" }); + expect(call.query.append_to_response).toContain("images"); + expect(mock.calls.some((c) => c.path === "/3/movie/872585/images")).toBe(false); + const tv = await fetchRichDetail(tmdb, "tv", 95396, CHAIN); + expect(tv.logoPath).toBe("/sev-ja.png"); + expect(mock.calls.find((c) => c.path === "/3/tv/95396/images")?.query).toMatchObject({ + include_image_language: "ja", + }); + }); +}); diff --git a/packages/adapters/src/tmdb/details.ts b/packages/adapters/src/tmdb/details.ts index 332f1995..13f8303e 100644 --- a/packages/adapters/src/tmdb/details.ts +++ b/packages/adapters/src/tmdb/details.ts @@ -82,6 +82,8 @@ export interface TmdbRichDetail { originCountry: string[]; posterPath: string | null; backdropPath: string | null; + /** 标志(透明 PNG,刮削写 clearlogo.png);语言优先 原语言 → 中文 → 英文 → 无文字;没有为 null(旧数据可缺省) */ + logoPath?: string | null; cast: TmdbCredit[]; crew: TmdbCredit[]; externalIds: { imdb: string | null; tvdb: string | null; wikidata: string | null }; @@ -163,9 +165,48 @@ function credits(raw: unknown, type: MediaEntityType, createdBy: unknown): { cas return { cast, crew: crew.slice(0, 6) }; } +/** 详情请求带回的图片语言:中文、英文、无文字;作品原语言不在其中时另取一次 /images */ +export const DETAIL_IMAGE_LANGUAGES = "zh,en,null"; + +interface LogoRow { + path: string; + lang: string | null; + vote: number; + count: number; +} + +const logoRows = (images: unknown): LogoRow[] => { + const logos = ((images ?? {}) as Json).logos; + return ((Array.isArray(logos) ? logos : []) as Json[]).flatMap((x) => { + const path = s(x.file_path); + // 只要 PNG:Kodi / Emby / Jellyfin 的 clearlogo 都是 png,TMDB 的 svg 标志不用 + if (!path || !/\.png$/i.test(path)) return []; + return [{ path, lang: s(x.iso_639_1), vote: n(x.vote_average) ?? 0, count: n(x.vote_count) ?? 0 }]; + }); +}; + /** - * 富详情:一次请求 `/{type}/{id}?language=链首&append_to_response=alternative_titles,credits,external_ids,translations`, - * 标题 / 简介 / 标语按链从 translations(+ 同地区别名)取。不存在抛 TmdbError(notfound)。 + * 选一张标志:语言优先 作品原语言 → 中文 → 英文 → 无文字;同语言按评分、票数从高到低。没有 PNG 标志返回 null。 + * images 可以是多份(详情带回的 + 原语言单独取的)。 + */ +export function pickLogo(images: unknown[], originalLanguage: string | null): string | null { + const rows = images.flatMap(logoRows); + const orig = originalLanguage?.toLowerCase().split("-")[0] ?? null; + const order = [orig, "zh", "en", null].filter((x, i, a) => a.indexOf(x) === i && (x !== null || i === a.length - 1)); + for (const lang of order) { + const hit = rows + .filter((r) => (r.lang?.toLowerCase() ?? null) === lang) + .sort((a, b) => b.vote - a.vote || b.count - a.count)[0]; + if (hit) return hit.path; + } + return null; +} + +/** + * 富详情:一次请求 `/{type}/{id}?language=链首&append_to_response=alternative_titles,credits,external_ids,translations,images` + * (`include_image_language=zh,en,null`),标题 / 简介 / 标语按链从 translations(+ 同地区别名)取。 + * 作品原语言不是中文 / 英文时另取 `/{type}/{id}/images?include_image_language=<原语言>` 选标志(失败忽略)。 + * 不存在抛 TmdbError(notfound)。 */ export async function fetchRichDetail( client: Pick, @@ -177,7 +218,8 @@ export async function fetchRichDetail( const requested = chainOf(languages); const j = await client.getJson(`/${type}/${id}`, { language: requested[0], - append_to_response: "alternative_titles,credits,external_ids,translations", + append_to_response: "alternative_titles,credits,external_ids,translations,images", + include_image_language: DETAIL_IMAGE_LANGUAGES, }); const titleKey = type === "movie" ? "title" : "name"; const originalTitle = s(type === "movie" ? j.original_title : j.original_name); @@ -212,6 +254,15 @@ export async function fetchRichDetail( const ext = (j.external_ids ?? {}) as Json; const runtimes = Array.isArray(j.episode_run_time) ? (j.episode_run_time as unknown[]) : []; const { cast, crew } = credits(j.credits, type, j.created_by); + const logoSources: unknown[] = [j.images]; + const origLang = originalLanguage?.toLowerCase().split("-")[0] ?? null; + if (origLang && origLang !== "zh" && origLang !== "en") { + try { + logoSources.unshift(await client.getJson(`/${type}/${id}/images`, { include_image_language: origLang })); + } catch { + // 原语言标志取不到就用中文 / 英文 / 无文字的 + } + } return { entityType: type, externalId: String(id), @@ -240,6 +291,7 @@ export async function fetchRichDetail( originCountry, posterPath: s(j.poster_path), backdropPath: s(j.backdrop_path), + logoPath: pickLogo(logoSources, originalLanguage), cast, crew, externalIds: { diff --git a/packages/contracts/src/content.ts b/packages/contracts/src/content.ts index 473b985f..b4090ff3 100644 --- a/packages/contracts/src/content.ts +++ b/packages/contracts/src/content.ts @@ -220,6 +220,8 @@ export const EntityMetadataField = z.enum([ "cast", "crew", "externalIds", + // 统一元数据缓存一轮:TMDB 标志(刮削写 clearlogo.png) + "logoPath", ]); export type EntityMetadataField = z.infer; diff --git a/packages/contracts/src/scrape.ts b/packages/contracts/src/scrape.ts index d86078ce..614d02a3 100644 --- a/packages/contracts/src/scrape.ts +++ b/packages/contracts/src/scrape.ts @@ -9,7 +9,7 @@ import { Id, NonNegInt, Timestamp } from "./common.ts"; export const ScrapeFileResult = z.object({ path: z.string(), kind: z.enum(["nfo", "image"]), - /** movie_nfo / tvshow_nfo / season_nfo / episode_nfo / poster / fanart / season_poster / episode_thumb */ + /** movie_nfo / tvshow_nfo / season_nfo / episode_nfo / poster / fanart / clearlogo / season_poster / episode_thumb */ role: z.string(), action: z.enum(["written", "skipped", "failed"]), /** 跳过 / 失败原因码(reasons.ts:scrape_exists / scrape_write_failed / scrape_image_failed …) */ diff --git a/packages/domain/src/scrape/layout.ts b/packages/domain/src/scrape/layout.ts index 1c51ccc5..0d10eb0b 100644 --- a/packages/domain/src/scrape/layout.ts +++ b/packages/domain/src/scrape/layout.ts @@ -7,6 +7,8 @@ // - 剧集:作品目录 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`)。 +// - 标志(TMDB logos,MP 只在接 fanart.tv 时写):电影写在影片目录、剧集写在剧集根目录,文件名 `clearlogo.png` +// (Kodi / Emby / Jellyfin 通用);平铺 / 库根的电影用 `<视频名>-clearlogo.png`。 // 视频直接在库根(没有作品目录)或电影目录里还有别的作品时,电影图片改用 `<视频名>-poster.jpg` / `<视频名>-fanart.jpg`, // 剧集不写 tvshow / 季文件(不能往库根里放 tvshow.nfo)。 @@ -42,6 +44,7 @@ export type ScrapeTargetRole = | "episode_nfo" | "poster" | "fanart" + | "clearlogo" | "season_poster" | "episode_thumb"; @@ -61,6 +64,8 @@ export interface ScrapeVideo { export interface ScrapeImages { posterPath?: string | null; backdropPath?: string | null; + /** TMDB 标志(PNG) */ + logoPath?: string | null; /** 季号 → 季海报路径 */ seasonPosters?: ReadonlyMap; /** "季:集" → 剧照路径 */ @@ -165,7 +170,7 @@ export function planScrapeTargets(input: ScrapeLayoutInput): ScrapeTarget[] { } if (!input.options.images || input.parts?.work === false) return out; const inWork = workDir && !input.sharedDir && videos.some((v) => isUnder(v.path, workDir)); - const art = (role: "poster" | "fanart", p: string | null | undefined) => { + const art = (role: "poster" | "fanart" | "clearlogo", p: string | null | undefined) => { if (!p) return; if (inWork) add({ path: join(workDir, `${role}${extOf(p)}`), kind: "image", role, imagePath: p }); else @@ -182,6 +187,7 @@ export function planScrapeTargets(input: ScrapeLayoutInput): ScrapeTarget[] { }; art("poster", img.posterPath); art("fanart", img.backdropPath); + art("clearlogo", img.logoPath); return out; } @@ -206,6 +212,13 @@ export function planScrapeTargets(input: ScrapeLayoutInput): ScrapeTarget[] { role: "fanart", imagePath: img.backdropPath, }); + if (img.logoPath) + add({ + path: join(workDir, `clearlogo${extOf(img.logoPath)}`), + kind: "image", + role: "clearlogo", + imagePath: img.logoPath, + }); } } // 季:按视频所在目录(不等于作品目录的才算季目录);没有作品目录时只写季目录里的 season.nfo / poster diff --git a/packages/domain/test/scrape.test.ts b/packages/domain/test/scrape.test.ts index 417da9cb..7908cae9 100644 --- a/packages/domain/test/scrape.test.ts +++ b/packages/domain/test/scrape.test.ts @@ -199,6 +199,60 @@ describe("刮削目标布局", () => { expect(atRoot.map((x) => x.path)).toEqual(["x.nfo", "x-poster.jpg", "x-fanart.png"]); }); + it("标志 clearlogo.png:电影写影片目录、剧集写剧集根目录;库根 / 平铺的电影用 <视频名>-clearlogo.png;选中季 / 集不写", () => { + const withLogo = { ...images, logoPath: "/logo.png" }; + const movie = planScrapeTargets({ + kind: "movie", + workDir: "星际穿越 (2014)", + videos: [{ path: "星际穿越 (2014)/星际穿越 (2014) - 1080p.mkv" }], + images: withLogo, + options: { images: true, episodeThumbs: true }, + }); + expect(movie.filter((x) => x.role === "clearlogo").map((x) => [x.path, x.imagePath])).toEqual([ + ["星际穿越 (2014)/clearlogo.png", "/logo.png"], + ]); + const atRoot = planScrapeTargets({ + kind: "movie", + workDir: null, + videos: [{ path: "x.mkv" }], + images: withLogo, + options: { images: true, episodeThumbs: true }, + }); + expect(atRoot.map((x) => x.path)).toEqual(["x.nfo", "x-poster.jpg", "x-fanart.png", "x-clearlogo.png"]); + const tv = planScrapeTargets({ + kind: "tv", + workDir: "S", + videos: [{ path: "S/Season 1/a.mkv", season: 1, episode: 1 }], + images: withLogo, + options: { images: true, episodeThumbs: true }, + }); + expect(tv.slice(0, 4).map((x) => x.path)).toEqual([ + "S/tvshow.nfo", + "S/poster.jpg", + "S/fanart.png", + "S/clearlogo.png", + ]); + const seasonOnly = planScrapeTargets({ + kind: "tv", + workDir: "S", + videos: [{ path: "S/Season 1/a.mkv", season: 1, episode: 1 }], + images: withLogo, + options: { images: true, episodeThumbs: true }, + parts: { work: false }, + }); + expect(seasonOnly.some((x) => x.role === "clearlogo")).toBe(false); + // 关图片不写;没有标志不写 + const noImages = planScrapeTargets({ + kind: "tv", + workDir: "S", + videos: [{ path: "S/Season 1/a.mkv", season: 1, episode: 1 }], + images: withLogo, + options: { images: false, episodeThumbs: false }, + }); + expect(noImages.some((x) => x.role === "clearlogo")).toBe(false); + expect(movie.length).toBe(4); + }); + it("剧集视频在库根:不写 tvshow / 季文件,只写集 NFO", () => { const t = planScrapeTargets({ kind: "tv", diff --git a/packages/testkit/src/tmdb/fixtures.ts b/packages/testkit/src/tmdb/fixtures.ts index 515c0983..2939e56e 100644 --- a/packages/testkit/src/tmdb/fixtures.ts +++ b/packages/testkit/src/tmdb/fixtures.ts @@ -60,6 +60,8 @@ export interface TmdbFixture { genre_ids?: number[]; /** 剧集组(`/tv/{id}/episode_groups`、`/tv/episode_group/{id}`);组内集按 TMDB 默认季集号引用 seasons 里的集 */ episode_groups?: TmdbFixtureEpisodeGroup[]; + /** 标志(append_to_response=images 与 `/{type}/{id}/images` 按 include_image_language 过滤);缺省没有 */ + logos?: { file_path: string; iso_639_1: string | null; vote_average?: number; vote_count?: number }[]; } export interface TmdbFixtureEpisodeGroup { diff --git a/packages/testkit/src/tmdb/mock-tmdb.ts b/packages/testkit/src/tmdb/mock-tmdb.ts index f0fb16e6..12026530 100644 --- a/packages/testkit/src/tmdb/mock-tmdb.ts +++ b/packages/testkit/src/tmdb/mock-tmdb.ts @@ -22,7 +22,7 @@ const notFound: MockReply = { /** 列表接口一页的条数(同 TMDB) */ const LIST_PAGE = 20; -/** mock TMDB v3:/3/trending、/3/discover、电影 / 剧集榜单(按热度,一页 20 条)、/3/search/{movie,tv}、/3/find/:imdbId、/3/{movie,tv}/:id(append_to_response)、/3/tv/:id/season/:n、/3/tv/:id/episode_groups、/3/tv/episode_group/:id */ +/** mock TMDB v3:/3/trending、/3/discover、电影 / 剧集榜单(按热度,一页 20 条)、/3/search/{movie,tv}、/3/find/:imdbId、/3/{movie,tv}/:id(append_to_response)、/3/{movie,tv}/:id/images、/3/tv/:id/season/:n、/3/tv/:id/episode_groups、/3/tv/episode_group/:id */ export async function createMockTmdb(opts: MockTmdbOptions = {}): Promise { const fx = opts.fixtures ?? TMDB_FIXTURES; const apiKey = opts.apiKey ?? "test-tmdb-key"; @@ -59,6 +59,21 @@ export async function createMockTmdb(opts: MockTmdbOptions = {}): Promise) => { + const langs = q.include_image_language ? q.include_image_language.split(",") : null; + const logos = (f.logos ?? []) + .filter((l) => !langs || langs.includes(l.iso_639_1 ?? "null")) + .map((l) => ({ + ...l, + vote_average: l.vote_average ?? 5, + vote_count: l.vote_count ?? 1, + width: 500, + height: 200, + })); + return { backdrops: [], posters: [], logos }; + }; + const listItem = (f: TmdbFixture, lang?: string) => ({ ...brief(f, lang), genre_ids: f.genre_ids ?? [], @@ -222,6 +237,13 @@ export async function createMockTmdb(opts: MockTmdbOptions = {}): Promise { + const f = find(m[1]!, m[2]!); + return f ? { json: { id: f.id, ...images(f, q) } } : notFound; + }, + ], [ /^\/3\/tv\/(\d+)\/season\/(\d+)$/, (m, q) => { @@ -298,6 +320,7 @@ export async function createMockTmdb(opts: MockTmdbOptions = {}): Promise