fix(scrape): 没有 TMDB id 的作品季 / 集缺元数据时照旧写占位 NFO
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
5b023ad682
commit
871031d5ed
4 files changed
+23
-7
No files matched your search
@@ -57,7 +57,8 @@ import {
|
|||||||
// - 覆盖:update(缺省)只覆盖本工具上次写入且没被改过的文件(逐文件 sha256 记在 scrape_record.files,
|
// - 覆盖:update(缺省)只覆盖本工具上次写入且没被改过的文件(逐文件 sha256 记在 scrape_record.files,
|
||||||
// 改过的记 scrape_user_modified)/ missing 只补缺失 / overwrite 全部覆盖;写入走同目录临时文件 + rename;
|
// 改过的记 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。
|
// - 每次刮削一条 scrape_record(目录、识别结果、逐文件结果、原因);审计 organize.scrape;SSE files.changed。
|
||||||
|
|
||||||
export const SCRAPE_TASK = "scrapeMedia";
|
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 先写(不依赖网络)
|
// NFO 先写(不依赖网络)
|
||||||
for (const t of targets.filter((x) => x.kind === "nfo")) {
|
for (const t of targets.filter((x) => x.kind === "nfo")) {
|
||||||
if (signal?.aborted) throw new Error("aborted");
|
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);
|
if (t.role === "movie_nfo" || t.role === "tvshow_nfo") xml = workNfo(work);
|
||||||
else if (t.role === "season_nfo") {
|
else if (t.role === "season_nfo") {
|
||||||
const u = seasonUnit.get(t.season!);
|
const u = seasonUnit.get(t.season!);
|
||||||
if (seasonMetadataMissing(u)) {
|
if (seasonMetadataMissing(u) && hasTmdbId) {
|
||||||
files.push(missingMeta(t));
|
files.push(missingMeta(t));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
xml = seasonNfo(nfoSeason(t.season!, u));
|
xml = seasonNfo(nfoSeason(t.season!, u));
|
||||||
} else {
|
} else {
|
||||||
const u = episodeUnit.get(`${t.season}:${t.episode}`);
|
const u = episodeUnit.get(`${t.season}:${t.episode}`);
|
||||||
if (episodeMetadataMissing(u)) {
|
if (episodeMetadataMissing(u) && hasTmdbId) {
|
||||||
files.push(missingMeta(t));
|
files.push(missingMeta(t));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { TmdbRichSeasonDetail } from "@umt/adapters";
|
||||||
import type { ScrapeFileResult, ScrapeMatch } from "@umt/contracts";
|
import type { ScrapeFileResult, ScrapeMatch } from "@umt/contracts";
|
||||||
import { createTempTree, type TempTree } from "@umt/testkit";
|
import { createTempTree, type TempTree } from "@umt/testkit";
|
||||||
@@ -295,6 +295,19 @@ describe.skipIf(!hasPg)("刮削准确度:更新模式、缺元数据不写占位
|
|||||||
expect(r2.status).toBe("partial");
|
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 根元素是 <movie>", async () => {
|
it("合集 / 其他类作品按电影刮削,NFO 根元素是 <movie>", async () => {
|
||||||
expect(nfoWork({ kind: "collection", title: "x", original_title: null, year: null, metadata: {} }, {}).kind).toBe(
|
expect(nfoWork({ kind: "collection", title: "x", original_title: null, year: null, metadata: {} }, {}).kind).toBe(
|
||||||
"movie",
|
"movie",
|
||||||
|
|||||||
@@ -434,7 +434,7 @@ export interface TmdbPort { searchMovie(q: string, year?: number): Promise<TmdbS
|
|||||||
| `:320 _should_scrape` | skip 时不写;文件不存在时写;文件存在时,只有 overwrite 或全局 overwrite 才写 | `missing` / `overwrite` 同 MP。新增 `update`:每次写入在 `scrape_record.files[].hash` 记内容 sha256,目标存在且与本工具上次写入的 hash 一致(没被改过)才用新内容覆盖,内容没变不重写;被改过的跳过(`scrape_user_modified`),不是本工具写的跳过(`scrape_exists`);旧版本没有 hash 的记录按修改时间兜底。**不读 `<lockdata>`,也不写它** |
|
| `:320 _should_scrape` | skip 时不写;文件不存在时写;文件存在时,只有 overwrite 或全局 overwrite 才写 | `missing` / `overwrite` 同 MP。新增 `update`:每次写入在 `scrape_record.files[].hash` 记内容 sha256,目标存在且与本工具上次写入的 hash 一致(没被改过)才用新内容覆盖,内容没变不重写;被改过的跳过(`scrape_user_modified`),不是本工具写的跳过(`scrape_exists`);旧版本没有 hash 的记录按修改时间兜底。**不读 `<lockdata>`,也不写它** |
|
||||||
| `:356 _save_file` | 先写临时文件,chmod `0666 & ~umask`,再经存储链上传 | 同目录临时文件 + rename(`services/scrape.ts` `atomicWrite`),目标必须在登记目录内 |
|
| `: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`。合集 / 其他类作品按电影写(根元素 `<movie>`,判定函数 `scrapeKindOf`) |
|
| `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`。合集 / 其他类作品按电影写(根元素 `<movie>`,判定函数 `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 刷新,刷新后按同一规则重刮 |
|
| 元数据变化后 | 不会自动重刮 | 刷新元数据后 NFO 相关字段有变化、且作品刮削过 → 延后 2 分钟排一次 `update` 模式重刮(同目录排队记录合批防抖);改 TMDB 映射时先清旧作品的 tmdb 来源字段与没锁的 IMDb 映射,再 full 刷新,刷新后按同一规则重刮 |
|
||||||
| 失败语义 | 刮削在整理后执行,失败只记日志,不回滚转移 | 不重做搬运;刮削是独立任务 `scrapeMedia`(不重试,失败的重新刮削),每次一条 `scrape_record`(逐文件结果与原因);非写文件的意外错误记 `scrape_internal_error` 并保留已写文件结果 |
|
| 失败语义 | 刮削在整理后执行,失败只记日志,不回滚转移 | 不重做搬运;刮削是独立任务 `scrapeMedia`(不重试,失败的重新刮削),每次一条 `scrape_record`(逐文件结果与原因);非写文件的意外错误记 `scrape_internal_error` 并保留已写文件结果 |
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ SCR-2(用户 2026-09-30:"已经有的也可以重新刮削……如果是直接
|
|||||||
典型是整理懒建的集、改身份后被清空的单元)时,先按作品的 TMDB id 现取该季详情补齐(映射同刷新元数据,只补缺的单元,本地已有的字段不动,
|
典型是整理懒建的集、改身份后被清空的单元)时,先按作品的 TMDB id 现取该季详情补齐(映射同刷新元数据,只补缺的单元,本地已有的字段不动,
|
||||||
不落库)。仍然缺(没有 TMDB id、季详情失败、TMDB 没有这一集)时:
|
不落库)。仍然缺(没有 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`),不再报成功;刷新元数据后会自动重刮(上一节),或手动重新刮削。
|
- 记录状态是 `partial`(原因 `scrape_metadata_missing`),不再报成功;刷新元数据后会自动重刮(上一节),或手动重新刮削。
|
||||||
|
|
||||||
TMDB 有这一集但没有译名 / 简介(只有占位名和播出日期)不算缺,照常写(和 MP 一样)。未登记目录直接取 TMDB 时同一规则:季详情失败的季跳过。
|
TMDB 有这一集但没有译名 / 简介(只有占位名和播出日期)不算缺,照常写(和 MP 一样)。未登记目录直接取 TMDB 时同一规则:季详情失败的季跳过。
|
||||||
@@ -151,7 +151,7 @@ TMDB 有这一集但没有译名 / 简介(只有占位名和播出日期)不算
|
|||||||
| 项 | MP | 我们 | 理由 |
|
| 项 | MP | 我们 | 理由 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| 已有文件 | 手动刮削一律覆盖(`overwrite=True`),自动刮削按每类文件的策略 | 缺省「更新」(只覆盖本工具写过且没被改过的,按 hash 判断),也可选「只补缺失 / 全部覆盖」,刮削对话框里可临时改 | 元数据纠正后已有 NFO 要能跟着更新,又不能覆盖用户手改的、其他工具写的 |
|
| 已有文件 | 手动刮削一律覆盖(`overwrite=True`),自动刮削按每类文件的策略 | 缺省「更新」(只覆盖本工具写过且没被改过的,按 hash 判断),也可选「只补缺失 / 全部覆盖」,刮削对话框里可临时改 | 元数据纠正后已有 NFO 要能跟着更新,又不能覆盖用户手改的、其他工具写的 |
|
||||||
| 季 / 集缺元数据 | 写占位(「第 N 集」) | 现取季详情补齐,仍缺跳过并记 partial | 占位 NFO 在「只补缺失」下永远不会被替换 |
|
| 季 / 集缺元数据 | 写占位(「第 N 集」) | 现取季详情补齐,仍缺跳过并记 partial;没有 TMDB id 的作品照旧写占位 | 占位 NFO 在「只补缺失」下永远不会被替换 |
|
||||||
| 识别 | 按路径识别,认不出直接失败;对话框可手填 TMDB / 豆瓣 id | 按路径识别,认不准时返回前 5 候选让用户选(也可搜索);已登记的目录直接用作品 | 选一次比手填 id 省事;已登记的不必再识别 |
|
| 识别 | 按路径识别,认不出直接失败;对话框可手填 TMDB / 豆瓣 id | 按路径识别,认不准时返回前 5 候选让用户选(也可搜索);已登记的目录直接用作品 | 选一次比手填 id 省事;已登记的不必再识别 |
|
||||||
| 执行 | 请求内同步 | 后台任务 `scrapeMedia`,请求内最多等 20 s | 大季包图片多;超时不丢 |
|
| 执行 | 请求内同步 | 后台任务 `scrapeMedia`,请求内最多等 20 s | 大季包图片多;超时不丢 |
|
||||||
| 记录 | 只有日志 | 每次刮削一条记录(目录、识别结果、逐文件结果、原因),作品详情显示最近一条 | 可回看、可排查 |
|
| 记录 | 只有日志 | 每次刮削一条记录(目录、识别结果、逐文件结果、原因),作品详情显示最近一条 | 可回看、可排查 |
|
||||||
|
|||||||
Reference in new issue
Block a user