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
@@ -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"));
|
||||
|
||||
@@ -72,6 +72,7 @@ export function richEntityFields(r: TmdbRichDetail): Partial<Record<EntityMetada
|
||||
originCountry: v(r.originCountry),
|
||||
...(r.posterPath ? { posterPath: v(r.posterPath) } : {}),
|
||||
...(r.backdropPath ? { backdropPath: v(r.backdropPath) } : {}),
|
||||
...(r.logoPath ? { logoPath: v(r.logoPath) } : {}),
|
||||
...(r.genres.length ? { genres: v(r.genres) } : {}),
|
||||
...(r.networks.length ? { platform: v(r.networks.join(" / ")) } : {}),
|
||||
...(r.entityType === "tv" && r.episodeCount !== null ? { episodeCount: v(r.episodeCount) } : {}),
|
||||
|
||||
@@ -140,6 +140,7 @@ const ENTITY_NFO_FIELDS: ReadonlySet<string> = new Set([
|
||||
"externalIds",
|
||||
"posterPath",
|
||||
"backdropPath",
|
||||
"logoPath",
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -260,6 +260,7 @@ export const ENTITY_FIELD_LABEL: Record<string, string> = {
|
||||
cast: "演员",
|
||||
crew: "导演 / 主创",
|
||||
externalIds: "外部 ID",
|
||||
logoPath: "标志",
|
||||
};
|
||||
|
||||
/** 季 / 集字段(contracts UnitMetadataField) */
|
||||
|
||||
@@ -37,6 +37,7 @@ const ROLE: Record<string, string> = {
|
||||
episode_nfo: "集 NFO",
|
||||
poster: "海报",
|
||||
fanart: "背景图",
|
||||
clearlogo: "标志",
|
||||
season_poster: "季海报",
|
||||
episode_thumb: "剧照",
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user