diff --git a/apps/server/src/modules/acquisition-organize/services/scrape.ts b/apps/server/src/modules/acquisition-organize/services/scrape.ts index 11ca7fe3..35335ea7 100644 --- a/apps/server/src/modules/acquisition-organize/services/scrape.ts +++ b/apps/server/src/modules/acquisition-organize/services/scrape.ts @@ -57,7 +57,8 @@ import { // - 覆盖:update(缺省)只覆盖本工具上次写入且没被改过的文件(逐文件 sha256 记在 scrape_record.files, // 改过的记 scrape_user_modified)/ missing 只补缺失 / overwrite 全部覆盖;写入走同目录临时文件 + rename; // 目标必须在登记目录内。 -// - 季 / 集缺元数据时先按 TMDB id 现取季详情补齐,仍缺就跳过(scrape_metadata_missing,记录 partial),不写占位 NFO。 +// - 季 / 集缺元数据时先按 TMDB id 现取季详情补齐,仍缺就跳过(scrape_metadata_missing,记录 partial),不写占位 NFO; +// 没有 TMDB id 的作品(Local)无处可补,照旧写占位。 // - 每次刮削一条 scrape_record(目录、识别结果、逐文件结果、原因);审计 organize.scrape;SSE files.changed。 export const SCRAPE_TASK = "scrapeMedia"; @@ -957,6 +958,8 @@ export async function runScrapeRecord( } }; + // 没有 TMDB id 的作品(Local)没有别的来源可补,季 / 集缺元数据时照旧写「季 N」「第 N 集」占位 + const hasTmdbId = !!(match.externalId ?? fields.ids.tmdb); // NFO 先写(不依赖网络) for (const t of targets.filter((x) => x.kind === "nfo")) { if (signal?.aborted) throw new Error("aborted"); @@ -969,14 +972,14 @@ export async function runScrapeRecord( if (t.role === "movie_nfo" || t.role === "tvshow_nfo") xml = workNfo(work); else if (t.role === "season_nfo") { const u = seasonUnit.get(t.season!); - if (seasonMetadataMissing(u)) { + if (seasonMetadataMissing(u) && hasTmdbId) { files.push(missingMeta(t)); continue; } xml = seasonNfo(nfoSeason(t.season!, u)); } else { const u = episodeUnit.get(`${t.season}:${t.episode}`); - if (episodeMetadataMissing(u)) { + if (episodeMetadataMissing(u) && hasTmdbId) { files.push(missingMeta(t)); continue; } diff --git a/apps/server/test/acquisition-organize/scrape-accuracy.test.ts b/apps/server/test/acquisition-organize/scrape-accuracy.test.ts index 68a8ce28..06b8abcb 100644 --- a/apps/server/test/acquisition-organize/scrape-accuracy.test.ts +++ b/apps/server/test/acquisition-organize/scrape-accuracy.test.ts @@ -1,4 +1,4 @@ -import { readFile, stat, utimes, writeFile } from "node:fs/promises"; +import { mkdir, readFile, stat, utimes, writeFile } from "node:fs/promises"; import type { TmdbRichSeasonDetail } from "@umt/adapters"; import type { ScrapeFileResult, ScrapeMatch } from "@umt/contracts"; import { createTempTree, type TempTree } from "@umt/testkit"; @@ -295,6 +295,19 @@ describe.skipIf(!hasPg)("刮削准确度:更新模式、缺元数据不写占位 expect(r2.status).toBe("partial"); }); + it("没有 TMDB id 的作品(Local)季 / 集缺元数据时照旧写占位", async () => { + const local = await entity("series", "本地剧", { title: f("本地剧") }); + const dir = "TV/本地剧 (2020)"; + await mkdir(lib.path(`${dir}/Season 1`), { recursive: true }); + await writeFile(lib.path(`${dir}/Season 1/Local.S01E01.mkv`), "video"); + const calls = seasonCalls.length; + const r = await scrape(local, "tv", dir, "update", null); + expect(seasonCalls.length).toBe(calls); + expect(r.files.find((x) => x.path.endsWith("S01E01.nfo"))).toMatchObject({ action: "written" }); + expect(await readFile(lib.path(`${dir}/Season 1/Local.S01E01.nfo`), "utf8")).toContain("第 1 集"); + expect(r.files.some((x) => x.reason === "scrape_metadata_missing")).toBe(false); + }); + it("合集 / 其他类作品按电影刮削,NFO 根元素是 ", async () => { expect(nfoWork({ kind: "collection", title: "x", original_title: null, year: null, metadata: {} }, {}).kind).toBe( "movie", diff --git a/docs/porting/moviepilot-metadata-recognition.md b/docs/porting/moviepilot-metadata-recognition.md index 96f69731..374f6696 100644 --- a/docs/porting/moviepilot-metadata-recognition.md +++ b/docs/porting/moviepilot-metadata-recognition.md @@ -434,7 +434,7 @@ export interface TmdbPort { searchMovie(q: string, year?: number): Promise`,也不写它** | | `:356 _save_file` | 先写临时文件,chmod `0666 & ~umask`,再经存储链上传 | 同目录临时文件 + rename(`services/scrape.ts` `atomicWrite`),目标必须在登记目录内 | | `app/modules/themoviedb/scraper.py:35 get_metadata_nfo` | 电影、剧、季、集四种 NFO;标签包括 `uniqueid(tmdb default, imdb, tvdb)`、`plot`、`genre`、`actor`、`director`、`studio`、`country`、`mpaa`、`rating`、`runtime` | `packages/domain/src/scrape/nfo.ts`(纯函数,与 MP 元素顺序一致,金样差分 `apps/server/test/scrape/nfo-mp-diff.test.ts`,偏离登记 `nfo-deviations.json`);`uniqueid` 只有 tmdb / imdb / tvdb / douban,**没有 `type="uhd"`**;不写 `mpaa`、`episodeguide`、`lockdata`。合集 / 其他类作品按电影写(根元素 ``,判定函数 `scrapeKindOf`) | -| 季 / 集元数据 | `get_tv_season_detail` 现取;集名缺时写占位 | 本地作品用投影后的季 / 集字段;单元缺元数据时按 TMDB id 现取季详情补齐,仍缺则该文件跳过(`scrape_metadata_missing`)、记录 `partial`,**不写「季 N」「第 N 集」占位** | +| 季 / 集元数据 | `get_tv_season_detail` 现取;集名缺时写占位 | 本地作品用投影后的季 / 集字段;单元缺元数据时按 TMDB id 现取季详情补齐,仍缺则该文件跳过(`scrape_metadata_missing`)、记录 `partial`,**不写「季 N」「第 N 集」占位**;没有 TMDB id 的作品(Local)无处可补,照旧写占位 | | 元数据变化后 | 不会自动重刮 | 刷新元数据后 NFO 相关字段有变化、且作品刮削过 → 延后 2 分钟排一次 `update` 模式重刮(同目录排队记录合批防抖);改 TMDB 映射时先清旧作品的 tmdb 来源字段与没锁的 IMDb 映射,再 full 刷新,刷新后按同一规则重刮 | | 失败语义 | 刮削在整理后执行,失败只记日志,不回滚转移 | 不重做搬运;刮削是独立任务 `scrapeMedia`(不重试,失败的重新刮削),每次一条 `scrape_record`(逐文件结果与原因);非写文件的意外错误记 `scrape_internal_error` 并保留已写文件结果 | diff --git a/docs/runbook/scrape.md b/docs/runbook/scrape.md index 752843eb..f2c62522 100644 --- a/docs/runbook/scrape.md +++ b/docs/runbook/scrape.md @@ -76,7 +76,7 @@ SCR-2(用户 2026-09-30:"已经有的也可以重新刮削……如果是直接 典型是整理懒建的集、改身份后被清空的单元)时,先按作品的 TMDB id 现取该季详情补齐(映射同刷新元数据,只补缺的单元,本地已有的字段不动, 不落库)。仍然缺(没有 TMDB id、季详情失败、TMDB 没有这一集)时: -- 该季 `season.nfo` / 该集 NFO 跳过,原因 `scrape_metadata_missing`,**不写「季 N」「第 N 集」占位**; +- 该季 `season.nfo` / 该集 NFO 跳过,原因 `scrape_metadata_missing`,**不写「季 N」「第 N 集」占位**;没有 TMDB id 的作品(Local)无处可补,照旧写占位; - 记录状态是 `partial`(原因 `scrape_metadata_missing`),不再报成功;刷新元数据后会自动重刮(上一节),或手动重新刮削。 TMDB 有这一集但没有译名 / 简介(只有占位名和播出日期)不算缺,照常写(和 MP 一样)。未登记目录直接取 TMDB 时同一规则:季详情失败的季跳过。 @@ -151,7 +151,7 @@ TMDB 有这一集但没有译名 / 简介(只有占位名和播出日期)不算 | 项 | MP | 我们 | 理由 | | --- | --- | --- | --- | | 已有文件 | 手动刮削一律覆盖(`overwrite=True`),自动刮削按每类文件的策略 | 缺省「更新」(只覆盖本工具写过且没被改过的,按 hash 判断),也可选「只补缺失 / 全部覆盖」,刮削对话框里可临时改 | 元数据纠正后已有 NFO 要能跟着更新,又不能覆盖用户手改的、其他工具写的 | -| 季 / 集缺元数据 | 写占位(「第 N 集」) | 现取季详情补齐,仍缺跳过并记 partial | 占位 NFO 在「只补缺失」下永远不会被替换 | +| 季 / 集缺元数据 | 写占位(「第 N 集」) | 现取季详情补齐,仍缺跳过并记 partial;没有 TMDB id 的作品照旧写占位 | 占位 NFO 在「只补缺失」下永远不会被替换 | | 识别 | 按路径识别,认不出直接失败;对话框可手填 TMDB / 豆瓣 id | 按路径识别,认不准时返回前 5 候选让用户选(也可搜索);已登记的目录直接用作品 | 选一次比手填 id 省事;已登记的不必再识别 | | 执行 | 请求内同步 | 后台任务 `scrapeMedia`,请求内最多等 20 s | 大季包图片多;超时不丢 | | 记录 | 只有日志 | 每次刮削一条记录(目录、识别结果、逐文件结果、原因),作品详情显示最近一条 | 可回看、可排查 |