Files
UHD_Media_Tool/docs/runbook/scrape.md
T
2026-09-30 22:36:14 +08:00

113 lines
7.3 KiB
Markdown

# 刮削运行手册(SCR-1:NFO 与图片写进媒体库目录)
「刮削」= 把作品的最终字段写成 NFO,并下载海报、背景图、季海报、剧照,放进媒体库目录,Emby / Jellyfin / Kodi 直接读取。
「刷新元数据」只更新本工具库里的字段,不写媒体库目录;两者分开。
## 实现位置
| 位置 | 内容 |
| --- | --- |
| `packages/domain/src/scrape/nfo.ts` | NFO 生成(电影 / 剧集 / 季 / 集),纯函数 |
| `packages/domain/src/scrape/layout.ts` | 目标文件清单与命名 `planScrapeTargets`、作品目录推断 `inferWorkDir` |
| `apps/server/src/modules/acquisition-organize/services/scrape-input.ts` | 最终字段(`media_entity.metadata` / `content_unit.metadata`)→ NFO 输入 |
| `apps/server/src/modules/acquisition-organize/services/scrape.ts` | 排任务 `enqueueScrape`、执行 `runScrape`、状态 `loadScrapeState`、入库后钩子 `scrapeAfterImport` |
| `handlers.ts` 任务 `scrapeMedia` | 文件道;不重试(失败的文件在作品详情里重新刮削) |
| 迁移 `0057_media_scrape` | 每部作品最近一次刮削:状态、覆盖方式、逐文件结果 |
| 端口 `AoPorts.scrapeImage` | TMDB 图片路径 → 字节:`ImageCache.bytes("original", path)`,缓存目录 `DATA_DIR/cache/images`,出口按环境变量代理(`externalFetch`) |
## 触发
| 触发 | 做法 |
| --- | --- |
| 整理入库成功(`finalizeRecord` 结果为已入库 / 部分入库) | 设置 `organize.scrapeAfterImport` 打开时排 `scrapeMedia`,幂等键 `scrapeMedia:record:<整理记录 id>`(同一记录只排一次;季包一次整理只刮一次) |
| 作品详情「重新刮削」「覆盖重写」 | `POST /library/{id}/scrape {mode?}`(`organize.execute`,202);没有已入库文件 412 |
| 文件管理「刮削」(单项菜单、多选、详情按钮) | `POST /files/scrape-batch {write: true}`,按作品去重各排一次 |
| `POST /files/scrape {writeNfo / images}` | 在原有 `refreshMetadata` 之外再排一次 `scrapeMedia`,返回 `queued` 与 `scrapeTaskId` |
同一作品已有排队中(未开始,30 分钟内)的刮削时直接复用(`deduplicated: true`)。
## 设置(设置 → 媒体库与目录 →「刮削」)
| 字段 | 缺省 | 说明 |
| --- | --- | --- |
| `organize.scrapeAfterImport` | 关 | 入库后自动刮削(MP 目录「刮削」缺省同样关) |
| `organize.scrapeMode` | `missing` | `missing` 只补缺失(MP「仅缺失」)/ `overwrite` 全部覆盖(替换别的工具写的 NFO 和图片) |
| `organize.scrapeImages` | 开 | 海报、背景图、季海报 |
| `organize.scrapeEpisodeThumbs` | 开 | 每集剧照;`scrapeImages` 关时不生效 |
## 写哪些文件
文件名跟随已入库的视频(视频名本身由命名模板生成);作品目录优先取作品的路径绑定,没有时从视频路径推(剧集父目录像
`Season 1` / `S01` / `Specials` 时取上一级)。
```
人生切割术 (2022)/
├── tvshow.nfo
├── poster.jpg
├── fanart.jpg
├── season01-poster.jpg # 特别篇:season-specials-poster.jpg
└── Season 1/
├── season.nfo
├── poster.jpg # 季海报(与作品目录那张同一张)
├── 人生切割术 - S01E01 - 第 1 集.mkv
├── 人生切割术 - S01E01 - 第 1 集.nfo
└── 人生切割术 - S01E01 - 第 1 集-thumb.jpg
星际穿越 (2014)/
├── 星际穿越 (2014) - 1080p.mkv
├── 星际穿越 (2014) - 1080p.nfo
├── poster.jpg
└── fanart.jpg
```
- 电影视频直接放在库根(没有作品目录)时,图片改为 `<视频名>-poster.jpg` / `<视频名>-fanart.jpg`,不往库根放 poster.jpg;
剧集视频直接在库根时只写每集文件,不写 tvshow / 季文件(结果里记 `scrape_no_work_dir`)。
- 多集文件(S01E01-E02)按首集写 NFO 与剧照(同 MP `begin_episode`)。蓝光原盘目录不刮削。
- 只写 managed 库根;只登记(`register_only`)库根整体跳过(`scrape_root_read_only`)。库根离线整体失败(`library_root_offline`)。
- 目标路径必须仍在库根内;已存在的目标是目录 / 符号链接时跳过。
## NFO 内容
对照 MP `app/modules/themoviedb/scraper.py`,元素名、顺序、属性一致,格式同 minidom `toprettyxml(indent=" ", encoding="utf-8")`:
- 公共(电影 / 剧集):`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`。
- 取值:标题 / 简介 / 标语按元数据语言链(见 `metadata.md`);字段锁与人工覆盖优先(用的是投影后的最终值)。
与 MP 的差异(差分登记 `apps/server/test/scrape/nfo-deviations.json`):
| 差异 | 理由 |
| --- | --- |
| 缺值不写元素(MP 写 `<premiered/>`、评分 0、空 CDATA) | 空元素不带信息;评分 0 会被当成真实评分 |
| 额外写豆瓣 id(`doubanid` + `uniqueid type=douban`)、`tagline`、集 `runtime` | 本地有这些字段 |
| 制片国取 TMDB `origin_country`(代码转中文名);剧集 `studio` 写播出平台 | 本地不存 `production_countries` / `production_companies` |
| 电影不写 `studio` | 同上 |
| 集 NFO 不写 `uniqueid` / `tmdbid` | 本地不存 TMDB 集 id;媒体服务器按季号集号匹配 |
| 简介含 `]]>` 时拆成两段 CDATA | MP 的 minidom 直接抛错 |
| 不写 `backdrop.jpg` 副本,只写 `fanart.jpg` | 三家都认 fanart.jpg |
| 剧照 `<视频名>-thumb.jpg`(MP `<视频名>.jpg`) | Kodi 约定,Jellyfin / Emby 同样识别 |
重新生成 MP 输出(改了样例或跟进上游后):
```sh
cd /opt/moviepilot-plus/upstream/MoviePilot && PYTHONPATH=. \
.venv/bin/python /opt/uhd_media_tool/apps/server/test/scrape/gen-scrape-mp.py
```
## 结果与排查
- 作品详情「刮削」卡片:状态(排队中 / 刮削中 / 已完成 / 部分完成 / 失败)、写入 / 跳过 / 失败计数、逐文件结果;排队或进行中时每 3 秒刷新。
- `GET /library/{id}` 的 `scrape`:`status`、`trigger`(auto / manual)、`mode`、`written` / `skipped` / `failed`、`reason`、`files[{path, kind, role, action, reason}]`、`recordId`、`taskId`。
- 审计 `organize.scrape`(目标作品;载荷含写入与失败的文件,最多 200 条);写入后发 `files.changed`,文件管理自动刷新。
- 原因码(`packages/contracts/src/reasons.ts`):`scrape_no_files`、`scrape_no_metadata`、`scrape_root_read_only`、`scrape_exists`、
`scrape_write_failed`、`scrape_image_failed`、`scrape_no_work_dir`、`library_root_offline`、`entity_not_found`。
| 现象 | 看哪里 |
| --- | --- |
| 入库后没有 NFO | 设置里「入库后自动刮削」是否打开;作品详情「刮削」是否有记录;引擎运行 `scrapeMedia` |
| 全部「已存在」 | 覆盖方式是只补缺失;要替换用「覆盖重写」 |
| 图片失败、NFO 正常 | TMDB 图片访问(出口代理);稍后「重新刮削」只补缺失的图片 |
| 「作品还没有元数据」 | 作品没有 TMDB 字段,先刷新元数据或指定 TMDB 条目 |