feat(scrape): TMDB 标志写 clearlogo.png
- 富详情请求加 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 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
1dca95f883
commit
680419bb64
16 files changed
+379
-12
No files matched your search
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<TmdbClient, "getJson">,
|
||||
@@ -177,7 +218,8 @@ export async function fetchRichDetail(
|
||||
const requested = chainOf(languages);
|
||||
const j = await client.getJson<Json>(`/${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<Json>(`/${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: {
|
||||
|
||||
@@ -220,6 +220,8 @@ export const EntityMetadataField = z.enum([
|
||||
"cast",
|
||||
"crew",
|
||||
"externalIds",
|
||||
// 统一元数据缓存一轮:TMDB 标志(刮削写 clearlogo.png)
|
||||
"logoPath",
|
||||
]);
|
||||
export type EntityMetadataField = z.infer<typeof EntityMetadataField>;
|
||||
|
||||
|
||||
@@ -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 …) */
|
||||
|
||||
@@ -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<number, string | null>;
|
||||
/** "季:集" → 剧照路径 */
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<MockTmdb> {
|
||||
const fx = opts.fixtures ?? TMDB_FIXTURES;
|
||||
const apiKey = opts.apiKey ?? "test-tmdb-key";
|
||||
@@ -59,6 +59,21 @@ export async function createMockTmdb(opts: MockTmdbOptions = {}): Promise<MockTm
|
||||
};
|
||||
};
|
||||
|
||||
/** images.logos 按 include_image_language(逗号分隔,null = 无文字)过滤;不给参数全给 */
|
||||
const images = (f: TmdbFixture, q: Record<string, string>) => {
|
||||
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<MockTm
|
||||
};
|
||||
},
|
||||
],
|
||||
[
|
||||
/^\/3\/(movie|tv)\/(\d+)\/images$/,
|
||||
(m, q) => {
|
||||
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<MockTm
|
||||
f.type === "movie" ? { titles: f.alternative_titles } : { results: f.alternative_titles };
|
||||
}
|
||||
if (append.has("external_ids")) out.external_ids = f.external_ids;
|
||||
if (append.has("images")) out.images = images(f, q);
|
||||
if (append.has("translations")) {
|
||||
const key = f.type === "movie" ? "title" : "name";
|
||||
out.translations = {
|
||||
|
||||
Reference in new issue
Block a user