docs(scrape): 按现状改写 MP 对照 §10,运行手册写覆盖方式、自动重刮与缺元数据处理

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-03 21:04:57 +08:00
1 parent 4306bff6d3
commit 132f150901
3 files changed
+77 -15

No files matched your search

+1 -1
View File
@@ -25,7 +25,7 @@
| AcquisitionPlan(下载计划,DL-3 起界面不单独命名) | 下载;还没提交到下载器的按在等什么写状态:「等你批准」「等磁盘空间」「等开启自动化」「准备提交」;没提交就结束的写「没提交成功 / 已取消 / 已过期」+ 原因;来由写「为什么下这个」;智能体动作计数写"准备下载 N 个资源" | 下载计划、草稿、已预留(界面) |
| DownloadTask | 下载任务 | 下载单 |
| Organization record | 整理记录;动作叫"整理",结果状态叫"已入库" | 转移、入库记录 |
| 在目录里就地写 NFO 与图片(scrapeMedia,SCR-1 / SCR-2) | 刮削;文件管理菜单「刮削…」,作品详情「重新刮削」「覆盖重写」;覆盖方式叫「只补缺失 / 全部覆盖」;认不准作品时"选一个再刮削";只更新本工具字段的叫「刷新元数据」 | 投影、NFO 投影、写元数据(界面) |
| 在目录里就地写 NFO 与图片(scrapeMedia,SCR-1 / SCR-2) | 刮削;文件管理菜单「刮削…」,作品详情「重新刮削」「覆盖重写」;覆盖方式叫「更新 / 只补缺失 / 全部覆盖」(更新 = 只替换本工具写过且没被改过的文件);认不准作品时"选一个再刮削";只更新本工具字段的叫「刷新元数据」 | 投影、NFO 投影、写元数据(界面) |
| 整理方式 | 复制 / 移动 / 硬链接 | Copy / Move |
| Library root | 媒体库目录 | 库根、媒体库根、根目录 |
| 文件管理可浏览的目录(库根 + 下载器路径映射) | 登记目录 | 登记根、根 |
@@ -426,13 +426,17 @@ export interface TmdbPort { searchMovie(q: string, year?: number): Promise<TmdbS
## 10. 刮削(NFO、图片)
| 上游 | 行为 | 本项目 |
按 2026-10 的实现改写;细节与排查见 `docs/runbook/scrape.md`。
| 上游 | 行为 | 本项目(现状) |
| --- | --- | --- |
| `app/chain/scraping.py:148 ScrapingOption`、`:243 get_default_config` | 按 `(movie|tv|season|episode)_(nfo|poster|backdrop|logo|disc|banner|thumb|clearart|landscape)` 逐项配置策略 `skip/missingonly/overwrite/upgrade`,默认 `missingonly` | 保留"逐项策略"的结构,放在 `policy_revision` 下的目录策略中(REQ §14.1.3:"NFO/海报写入受目录策略控制") |
| `:320 _should_scrape` | skip 时不写;文件不存在时写;文件存在时,只有 overwrite 或全局 overwrite 才写 | 保留;另外增加一条:**用户或其他工具已修改的 NFO(含 `<lockdata>true` 或 hash 与上次写入不一致)不覆盖** |
| `:356 _save_file` | 先写临时文件,chmod `0666 & ~umask`,再经存储链上传 | 保留"临时文件加原子替换" |
| `app/modules/themoviedb/scraper.py:35 get_metadata_nfo` | 电影、剧、季、集四种 NFO;季和集走 `get_tv_season_detail`,有剧集组时走 `get_tv_group_detail`;标签包括 `uniqueid(tmdb default, imdb, tvdb)`、`plot`、`genre`、`actor`、`director`、`studio`、`country`、`mpaa`、`rating`、`runtime` | `packages/adapters/src/nfo/writer.ts` 输出 Kodi/Emby 兼容 NFO;**另写一个 `uniqueid type="uhd"`,内容为本地 UUID** |
| 失败语义 | 刮削在整理后执行,失败只记日志,不回滚转移 | 保留"不重做搬运";改为**独立任务 `refreshMetadata`,有自己的状态和重试**,不能伪报全部步骤成功(REQ §14.1.3) |
| `app/chain/scraping.py:148 ScrapingOption`、`:243 get_default_config` | 按 `(movie\|tv\|season\|episode)_(nfo\|poster\|backdrop\|…)` 逐项配置策略 `skip/missingonly/overwrite/upgrade`,默认 `missingonly` | 没有逐项策略,只有全局覆盖方式 `organize.scrapeMode`:`update` 更新(缺省)/ `missing` 只补缺失 / `overwrite` 全部覆盖;图片与剧照各有一个开关 |
| `: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`),目标必须在登记目录内 |
| `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 集」占位** |
| 元数据变化后 | 不会自动重刮 | 刷新元数据后 NFO 相关字段有变化、且作品刮削过 → 延后 2 分钟排一次 `update` 模式重刮(同目录排队记录合批防抖);改 TMDB 映射时先清旧作品的 tmdb 来源字段与没锁的 IMDb 映射,再 full 刷新,刷新后按同一规则重刮 |
| 失败语义 | 刮削在整理后执行,失败只记日志,不回滚转移 | 不重做搬运;刮削是独立任务 `scrapeMedia`(不重试,失败的重新刮削),每次一条 `scrape_record`(逐文件结果与原因);非写文件的意外错误记 `scrape_internal_error` 并保留已写文件结果 |
---
+66 -8
View File
@@ -18,6 +18,8 @@ SCR-2(用户 2026-09-30:"已经有的也可以重新刮削……如果是直接
| `apps/server/src/modules/file-manager/scrape.ts` | 文件管理「刮削」:按路径识别(复用 `identifyCore` + `resolveIdentity`)、候选、排任务、请求内等结果 |
| `handlers.ts` 任务 `scrapeMedia`(v2,输入 `{recordId}`) | 文件道;不重试(失败的重新刮削) |
| 迁移 `0058_scrape_record` | 每次刮削一条记录;替代 0057 的 `media_scrape`(一部作品一行) |
| 迁移 `0070_scrape_update_mode` | `scrape_record.mode` 增加 `update`;逐文件 hash 记在 `files` jsonb 里,不加列 |
| `sources-identity/service/metadata.ts` `refreshMetadata` / `clearTmdbMetadata` | 刷新后 NFO 字段有变化 → `scheduleEntityRescrape`;改 TMDB 映射时清旧字段(`identity-admin.ts` `setEntityIdentity` 调用) |
| 端口 `AoPorts.scrapeImage` / `scrapeTmdbDetail` / `scrapeTmdbSeason` | 图片字节(图片缓存 `DATA_DIR/cache/images` + 出口代理);未登记目录直接取 TMDB 富详情 / 季详情(元数据语言链,经 TMDB 缓存) |
## 触发
@@ -27,9 +29,58 @@ SCR-2(用户 2026-09-30:"已经有的也可以重新刮削……如果是直接
| 文件管理「刮削…」(右键 / ⋯ 菜单、多选批量栏、详情按钮) | `POST /files/scrape {items[{root,path,mediaEntityId?,external?}], mode?, addToLibrary?}`;任意视频文件或目录,每项一条记录,请求内最多等 `FILES_TRANSFER_WAIT_SEC`(缺省 20 s),没刮完的 `pending=true`,前端按 `GET /files/scrape-records?ids=` 轮询 |
| 作品详情「重新刮削」「覆盖重写」 | `POST /library/{id}/scrape {mode?}`(202):对作品目录刮削,每个目录一条记录;没有已入库文件 412(到文件管理里对目录刮削) |
| 整理入库成功(`finalizeRecord` 结果为已入库 / 部分入库) | 设置 `organize.scrapeAfterImport` 打开时对作品目录排刮削,幂等键 `scrapeMedia:record:<整理记录 id>:<序号>` |
| 元数据刷新后 NFO 字段有变化 | 只对刮削过的作品(有成功 / 部分成功的记录):对作品目录排「更新」模式刮削(`trigger=auto`),任务延后 2 分钟开始;见下「刷新后自动重刮」 |
| 改作品的 TMDB 映射 | 清掉旧作品的 tmdb 来源字段 → 排 full 刷新(幂等键 `refreshMetadata:identity:<新身份 id>`)→ 刷新写回新字段后按上一行自动重刮 |
同一目录已有排队中(未开始,30 分钟内)、同覆盖方式、同作品的刮削时直接复用(`deduplicated`)。
### 覆盖方式
| 方式 | 目标不存在 | 目标存在 |
| --- | --- | --- |
| `update` 更新(缺省) | 写 | 本工具上次写入、之后没被改过 → 按新内容覆盖(内容没变就不重写,记 `scrape_unchanged`);被改过 → 跳过 `scrape_user_modified`;不是本工具写的 → 跳过 `scrape_exists` |
| `missing` 只补缺失 | 写 | 跳过 `scrape_exists` |
| `overwrite` 全部覆盖 | 写 | 覆盖(包括其他工具写的、人改过的) |
- 每次写入(任何方式)在该记录的逐文件结果里记内容的 sha256(`files[].hash`);「更新」时取同一登记目录里以前的记录,
每个路径按从新到旧第一条带 hash 的结果,与磁盘上现在的内容比对。跳过的结果不带 hash,人改过的文件以后每次都还是跳过,
要替换就用「全部覆盖」。
- 旧版本写的文件没有 hash:修改时间不晚于那次刮削结束(留 2 秒余量)就当没被改过。
- 已存在的目标是目录 / 符号链接时三种方式都跳过。
- 更新不删除文件:改身份后新作品没有背景图时,旧的 `fanart.jpg` 留在目录里(元数据里旧的背景图路径已清掉,不会再写旧图)。
### 刷新后自动重刮
- `refreshMetadata` 结束后,作品写进 NFO 的字段(标题、原名、年份、简介、标语、首映、类型、制片国、平台、时长、演职员、外部 ID、海报、背景图)
或季 / 集的名称、简介、日期、时长、剧照、季海报有变化时,调 `scheduleEntityRescrape`。评分、票数、播出状态的变化不触发(避免每天刷新都改写 NFO,
下次因为别的原因重刮时会带上新评分)。
- 没刮削过的作品不排(要先手动或入库后刮一次)。
- 防抖:任务带 `availableAt = 现在 + 2 分钟`;这段时间里再来的刷新命中同目录、同作品、`update` 方式的排队记录直接复用,不另排。
- 排上的记录在作品详情「刮削」卡片里显示为「入库后自动」那一类(`trigger=auto`)、方式 `update`。
### 改 TMDB 映射(纠错)后
`setEntityIdentity`(作品页改映射、`POST /media-entities/{id}/identities`)在同一事务里:
1. 旧映射退役、新映射接受(原有逻辑);
2. 旧 TMDB 带出来的 IMDb 映射(没人工锁的)一并退役——NFO 里 IMDb 是 default uniqueid,留着媒体服务器会认成旧作品;
3. 清掉该作品及其季 / 集单元的 tmdb 来源字段(`metadata_field` 与投影出的最终值;单元的 `title` / `air_date` 列跟着清),
人工覆盖、锁定字段和 web / agent 补全字段保留;海报 / 背景图一并清掉,不再按「TMDB 没有时保留旧值」留旧作品的图。
提交后排 full 刷新;刷新填回新作品字段后自动重刮(上一节),本工具写过且没被改过的 NFO 和图片换成新作品的。
刷新完成前若刚好有刮削在跑,季 / 集缺元数据的部分会按下一节跳过,不会写进旧作品的内容。
### 季 / 集缺元数据
本地作品有元数据时用最终字段;季单元或要刮的集单元**缺元数据**(没有单元,或从没被元数据来源填过、也没有标题 / 播出日期,
典型是整理懒建的集、改身份后被清空的单元)时,先按作品的 TMDB id 现取该季详情补齐(映射同刷新元数据,只补缺的单元,本地已有的字段不动,
不落库)。仍然缺(没有 TMDB id、季详情失败、TMDB 没有这一集)时:
- 该季 `season.nfo` / 该集 NFO 跳过,原因 `scrape_metadata_missing`,**不写「季 N」「第 N 集」占位**;
- 记录状态是 `partial`(原因 `scrape_metadata_missing`),不再报成功;刷新元数据后会自动重刮(上一节),或手动重新刮削。
TMDB 有这一集但没有译名 / 简介(只有占位名和播出日期)不算缺,照常写(和 MP 一样)。未登记目录直接取 TMDB 时同一规则:季详情失败的季跳过。
### 识别(文件管理)
顺序:请求指定 > 目录里已登记文件所属的唯一作品(`source=library`)> 按路径识别(`source=auto`)。
@@ -53,6 +104,8 @@ SCR-2(用户 2026-09-30:"已经有的也可以重新刮削……如果是直接
- 集号:已登记视频按覆盖的集单元;其余按路径解析(季号缺失时取季目录名,再缺按第 1 季;同 MP 对每集重新 `MetaInfoPath`)。
认不出集号的视频记 `scrape_no_episode` 跳过。多集文件按首集。
- 字段:本地作品有元数据时用最终字段(字段锁、人工覆盖优先,见 `metadata.md`);否则按 TMDB id 直接取 TMDB(与刷新元数据同一映射)。
TMDB id 只认与刮削类型一致的映射(同一作品同时有 movie / tv 两个 accepted 映射时不串)。
- 作品类型:剧集 / 综艺按剧集刮,电影 / 合集 / 其他按电影刮(`<视频名>.nfo`,根元素 `<movie>`),范围判定与 NFO 生成同一个函数 `scrapeKindOf`。
- 登记目录:库根只写 `managed`(只登记模式跳过 `scrape_root_read_only`),库根离线 / 标记文件不对 `scrape_root_offline`;
下载根也能刮削(同 MP)。目标路径必须在登记目录内;已存在的目标是目录 / 符号链接时跳过。
@@ -97,7 +150,8 @@ SCR-2(用户 2026-09-30:"已经有的也可以重新刮削……如果是直接
| 项 | MP | 我们 | 理由 |
| --- | --- | --- | --- |
| 已有文件 | 手动刮削一律覆盖(`overwrite=True`),自动刮削按每类文件的策略 | 缺省按设置「只补缺失 / 全部覆盖」,刮削对话框里可临时改 | 用户要求覆盖方式沿用设置、菜单里可临时选;缺省不动其他工具写的文件 |
| 已有文件 | 手动刮削一律覆盖(`overwrite=True`),自动刮削按每类文件的策略 | 缺省「更新」(只覆盖本工具写过且没被改过的,按 hash 判断),也可选「只补缺失 / 全部覆盖」,刮削对话框里可临时改 | 元数据纠正后已有 NFO 要能跟着更新,又不能覆盖用户手改的、其他工具写的 |
| 季 / 集缺元数据 | 写占位(「第 N 集」) | 现取季详情补齐,仍缺跳过并记 partial | 占位 NFO 在「只补缺失」下永远不会被替换 |
| 识别 | 按路径识别,认不出直接失败;对话框可手填 TMDB / 豆瓣 id | 按路径识别,认不准时返回前 5 候选让用户选(也可搜索);已登记的目录直接用作品 | 选一次比手填 id 省事;已登记的不必再识别 |
| 执行 | 请求内同步 | 后台任务 `scrapeMedia`,请求内最多等 20 s | 大季包图片多;超时不丢 |
| 记录 | 只有日志 | 每次刮削一条记录(目录、识别结果、逐文件结果、原因),作品详情显示最近一条 | 可回看、可排查 |
@@ -110,7 +164,7 @@ SCR-2(用户 2026-09-30:"已经有的也可以重新刮削……如果是直接
| 字段 | 缺省 | 说明 |
| --- | --- | --- |
| `organize.scrapeAfterImport` | 关 | 入库后自动刮削(MP 目录「刮削」缺省同样关) |
| `organize.scrapeMode` | `missing` | `missing` 只补缺失(MP「仅缺失」)/ `overwrite` 全部覆盖;手动刮削时对话框可临时改 |
| `organize.scrapeMode` | `update` | `update` 更新 / `missing` 只补缺失(MP「仅缺失」)/ `overwrite` 全部覆盖,见上「覆盖方式」;手动刮削时对话框可临时改。以前保存过的 `missing` / `overwrite` 照旧生效,要用更新到设置里改 |
| `organize.scrapeImages` | 开 | 海报、背景图、季海报 |
| `organize.scrapeEpisodeThumbs` | 开 | 每集剧照;`scrapeImages` 关时不生效 |
@@ -121,8 +175,9 @@ SCR-2(用户 2026-09-30:"已经有的也可以重新刮削……如果是直接
- 公共(电影 / 剧集):`tmdbid` + `uniqueid type=tmdb`;有 TVDB / IMDb 时追加(有 IMDb 时 IMDb 为 `default`);`plot` / `outline`(CDATA);
`director`(带 `tmdbid`);`actor{name,type,role,tmdbid,thumb,profile}`;`genre`;`rating`(一位小数);`studio`;`country`;`runtime`;
`title`、`originaltitle`、`premiered`、`year`;剧集另有 `season=-1`、`episode=-1`。
- 季:`plot`、`outline`、`title`(没有时「季 N」)、`premiered`、`releasedate`、`year`、`seasonnumber`。
- 集:`title`(没有或 TMDB 占位名时「第 N 集」)、`plot`、`outline`、`aired`、`year`、`season`、`episode`、`rating`。
- 季:`plot`、`outline`、`title`(单元有简介 / 日期但没名称时「季 N」)、`premiered`、`releasedate`、`year`、`seasonnumber`。
- 集:`title`(TMDB 占位名时「第 N 集」)、`plot`、`outline`、`aired`、`year`、`season`、`episode`、`rating`。
季 / 集单元整个缺元数据时不写(见「季 / 集缺元数据」)。
- 取值:标题 / 简介 / 标语按元数据语言链(见 `metadata.md`);字段锁与人工覆盖优先(用的是投影后的最终值)。
与 MP 的差异(差分登记 `apps/server/test/scrape/nfo-deviations.json`):
@@ -153,17 +208,20 @@ cd /opt/uhd_media_tool && deno run -A npm:@biomejs/biome check --write apps/serv
- 作品详情「刮削」卡片:该作品最近一条刮削记录;排队或进行中时每 3 秒刷新。
- `GET /files/scrape-records?ids=`、`GET /library/{id}` 的 `scrape`:`id`、`root`、`path`、`status`、`trigger`、`mode`、
`match{mediaEntityId, provider, externalId, kind, title, year, source}`、`written` / `skipped` / `failed`、`reason`、
`files[{path, kind, role, action, reason}]`(path 为登记目录内相对路径)、`organizationRecordId`、`taskId`。
`files[{path, kind, role, action, reason, hash}]`(path 为登记目录内相对路径;hash 为写入时内容的 sha256)、`organizationRecordId`、`taskId`。
- 审计 `organize.scrape`(目标 `scrape_record`;载荷含目录、识别结果、写入与失败的文件,最多 200 条);写入后发 `files.changed`,文件管理自动刷新。
- 原因码(`packages/contracts/src/reasons.ts`):`scrape_no_metadata`、`scrape_root_read_only`、`scrape_root_offline`、`scrape_not_video`、
`scrape_no_videos`、`scrape_path_missing`、`scrape_tmdb_unavailable`、`scrape_no_episode`、`scrape_exists`、`scrape_write_failed`、
`scrape_image_failed`、`path_escape`、`task_enqueue_failed`。
`scrape_no_videos`、`scrape_path_missing`、`scrape_tmdb_unavailable`、`scrape_no_episode`、`scrape_exists`、`scrape_unchanged`、
`scrape_user_modified`、`scrape_metadata_missing`、`scrape_write_failed`、`scrape_image_failed`、`scrape_internal_error`(查库等非写文件错误,
已写的文件结果保留)、`path_escape`、`task_enqueue_failed`。
| 现象 | 看哪里 |
| --- | --- |
| 入库后没有 NFO | 设置里「入库后自动刮削」是否打开;作品详情「刮削」是否有记录;引擎运行 `scrapeMedia` |
| 刮削对话框一直让选作品 | 文件名解析不出标题,或 TMDB 有多个同名候选;选一次即可,打开「同时加入媒体库」后下次按已入库作品识别 |
| 全部「跳过」 | 覆盖方式是只补缺失;要替换选「全部覆盖」或作品详情「覆盖重写」 |
| 全部「跳过」 | 覆盖方式是只补缺失,或更新时文件不是本工具写的 / 被人改过(`scrape_exists` / `scrape_user_modified`);要替换选「全部覆盖」或作品详情「覆盖重写」 |
| 改了映射 / 刷新了元数据,NFO 没变 | 作品以前没刮削过(不自动重刮);重刮任务要等 2 分钟;NFO 被人改过(`scrape_user_modified`) |
| 记录「部分成功」、原因缺元数据 | 季 / 集还没取到元数据且 TMDB 季详情取不到;刷新元数据(作品详情「刷新」带 full)后会自动重刮 |
| 图片失败、NFO 正常 | TMDB 图片访问(出口代理);稍后重新刮削,只补缺失的图片 |
| 「没有可用的元数据」 | 作品没有 TMDB 字段也没有 TMDB id;在文件管理里刮削并指定 TMDB 条目 |
| 「TMDB 不可用」 | 设置里 TMDB API Key;未登记的目录要直接取 TMDB |