13 KiB
刮削运行手册(SCR-1 写 NFO 与图片;SCR-2 照 MP 简化为就地刮削)
「刮削」= 在视频所在目录就地写 NFO,并下载海报、背景图、季海报、剧照,Emby / Jellyfin / Kodi 直接读取。 「刷新元数据」只更新本工具库里的字段,不写目录里的文件;两者分开。
SCR-2(用户 2026-09-30:"已经有的也可以重新刮削……如果是直接在对应目录刮削,那就直接在对应目录生成")起, 刮削对象从"作品"改成"目录 / 视频文件",照 MP 文件管理的刮削:不要求先登记,按路径识别作品后在该目录里写文件。 已登记作品的「重新刮削」、入库后自动刮削走同一实现(本质是对作品目录刮削)。
实现位置
| 位置 | 内容 |
|---|---|
packages/domain/src/scrape/nfo.ts |
NFO 生成(电影 / 剧集 / 季 / 集),纯函数 |
packages/domain/src/scrape/layout.ts |
目标文件清单与命名 planScrapeTargets(parts 控制作品级 / 季级)、inferWorkDir、isSeasonDirName / seasonOfDirName |
apps/server/src/modules/acquisition-organize/services/scrape-input.ts |
最终字段 → NFO 输入;TMDB 富详情 / 季详情 → NFO 输入(tmdbEntity / tmdbSeasonUnits,映射同刷新元数据) |
apps/server/src/modules/acquisition-organize/services/scrape.ts |
刮削记录 queueScrape、执行 runScrapeRecord(目录布局 → 字段 → 写文件)、作品目录 entityScrapeLocations / scrapeEntity、入库后钩子 scrapeAfterImport |
apps/server/src/modules/file-manager/scrape.ts |
文件管理「刮削」:按路径识别(复用 identifyCore + resolveIdentity)、候选、排任务、请求内等结果 |
handlers.ts 任务 scrapeMedia(v2,输入 {recordId}) |
文件道;不重试(失败的重新刮削) |
迁移 0058_scrape_record |
每次刮削一条记录;替代 0057 的 media_scrape(一部作品一行) |
端口 AoPorts.scrapeImage / scrapeTmdbDetail / scrapeTmdbSeason |
图片字节(图片缓存 DATA_DIR/cache/images + 出口代理);未登记目录直接取 TMDB 富详情 / 季详情(元数据语言链,经 TMDB 缓存) |
触发
| 触发 | 做法 |
|---|---|
| 文件管理「刮削…」(右键 / ⋯ 菜单、多选批量栏、详情按钮) | 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>:<序号> |
同一目录已有排队中(未开始,30 分钟内)、同覆盖方式、同作品的刮削时直接复用(deduplicated)。
识别(文件管理)
顺序:请求指定 > 目录里已登记文件所属的唯一作品(source=library)> 按路径识别(source=auto)。
- 按路径识别复用
/files/identify的核心(文件名 + 父 / 祖父目录解析 → 本地别名召回 + TMDB 远端候选,≤15 s),每个视频按resolveIdentity判断能否自动确认;目录里所有视频都能自动确认且是同一作品时直接用,否则该项进needsChoice(合并各视频候选,前 5)。 - 选定后带
mediaEntityId(本地作品)或external {provider:"tmdb", externalId, kind}再提交一次(source=chosen)。 - 候选是 TMDB 条目:有已接受该 TMDB id 的本地作品就用它;没有也能刮削(直接取 TMDB 字段);
addToLibrary=true时顺手建作品 (同「整理」的按候选建作品,幂等)。登记不是刮削的前提。
写在哪里(对照 MP ScrapingChain.scrape_metadata)
| 选中的 | 写的文件 |
|---|---|
| 剧集目录 | tvshow.nfo、poster.jpg、fanart.jpg、seasonNN-poster.jpg;每个季目录(Season 1 / S01 / Specials / SPs / 第 1 季)season.nfo + poster.jpg;每集 <视频名>.nfo + <视频名>-thumb.jpg。只进季目录一层,其他子目录不管 |
| 季目录 | season.nfo + 季目录 poster.jpg + 上级 seasonNN-poster.jpg;每集 NFO 与剧照。不写 tvshow.nfo |
| 单集文件 | 该集 <视频名>.nfo + 剧照 |
| 电影目录 | 目录里每个视频 <视频名>.nfo(不进子目录)+ 目录 poster.jpg / fanart.jpg;蓝光原盘目录(含 BDMV / VIDEO_TS)写 <目录名>.nfo |
| 电影文件 | <视频名>.nfo + 同目录 poster.jpg / fanart.jpg;文件直接在登记目录根时图片改为 <视频名>-poster.jpg / -fanart.jpg |
- 集号:已登记视频按覆盖的集单元;其余按路径解析(季号缺失时取季目录名,再缺按第 1 季;同 MP 对每集重新
MetaInfoPath)。 认不出集号的视频记scrape_no_episode跳过。多集文件按首集。 - 字段:本地作品有元数据时用最终字段(字段锁、人工覆盖优先,见
metadata.md);否则按 TMDB id 直接取 TMDB(与刷新元数据同一映射)。 - 登记目录:库根只写
managed(只登记模式跳过scrape_root_read_only),库根离线 / 标记文件不对scrape_root_offline; 下载根也能刮削(同 MP)。目标路径必须在登记目录内;已存在的目标是目录 / 符号链接时跳过。
人生切割术 (2022)/
├── tvshow.nfo
├── poster.jpg
├── fanart.jpg
├── season01-poster.jpg # 特别篇:season-specials-poster.jpg
└── Season 1/
├── season.nfo
├── poster.jpg # 季海报(与作品目录那张同一张)
├── 人生切割术 - S01E01 - 有关地狱的好消息.mkv
├── 人生切割术 - S01E01 - 有关地狱的好消息.nfo
├── 人生切割术 - S01E01 - 有关地狱的好消息-thumb.jpg
├── 人生切割术 - S01E02 - 半循环.mkv
├── 人生切割术 - S01E02 - 半循环.nfo
└── 人生切割术 - S01E02 - 半循环-thumb.jpg
星际穿越 (2014)/
├── 星际穿越 (2014) - 1080p.mkv
├── 星际穿越 (2014) - 1080p.nfo
├── poster.jpg
└── fanart.jpg
(上面是 apps/server/test/file-manager/scrape.test.ts 第一个用例对未登记目录刮削后的实际文件树。)
与 MP 的差分
文件布局用 MP 上游 ScrapingChain.scrape_metadata 的真实决策代码跑同一组目录(apps/server/test/scrape/gen-scrape-layout-mp.py →
scrape-layout-mp.json),scrape-layout-mp.test.ts 对照 7 个场景(剧集目录、季目录、单集文件、电影目录、电影文件、根下电影文件、蓝光原盘目录),
只允许下表登记的偏离:
| 同一目录结构 | MP 写 | 我们写 | 理由 |
|---|---|---|---|
| 作品 / 电影目录背景图 | backdrop.jpg + fanart.jpg |
fanart.jpg |
三家都认 fanart.jpg,副本没用 |
| 每集剧照 | <视频名>.jpg |
<视频名>-thumb.jpg |
Kodi 约定,Jellyfin / Emby 同样识别;不和同名封面混 |
| 视频直接在登记目录根 | 根里写 poster.jpg / backdrop.jpg / fanart.jpg |
<视频名>-poster.jpg / <视频名>-fanart.jpg |
根里放一张 poster.jpg 会被当成整个库的封面 |
其余(tvshow / season / 集 NFO、季海报两处、电影 NFO、蓝光 <目录名>.nfo、季目录只写季级、单集只写该集) |
— | 同 MP | — |
行为上的差异:
| 项 | MP | 我们 | 理由 |
|---|---|---|---|
| 已有文件 | 手动刮削一律覆盖(overwrite=True),自动刮削按每类文件的策略 |
缺省按设置「只补缺失 / 全部覆盖」,刮削对话框里可临时改 | 用户要求覆盖方式沿用设置、菜单里可临时选;缺省不动其他工具写的文件 |
| 识别 | 按路径识别,认不出直接失败;对话框可手填 TMDB / 豆瓣 id | 按路径识别,认不准时返回前 5 候选让用户选(也可搜索);已登记的目录直接用作品 | 选一次比手填 id 省事;已登记的不必再识别 |
| 执行 | 请求内同步 | 后台任务 scrapeMedia,请求内最多等 20 s |
大季包图片多;超时不丢 |
| 记录 | 只有日志 | 每次刮削一条记录(目录、识别结果、逐文件结果、原因),作品详情显示最近一条 | 可回看、可排查 |
| 图片种类 | poster / backdrop / logo / banner / disc / clearart / thumb(按图片源) | poster / fanart / 季海报 / 剧照 | 本地只有 TMDB 海报、背景图、剧照;其余要 fanart.tv 源 |
| 批量 | 多选共用一个识别条件 | 多选逐项各自识别,各自选候选 | 多选里常是不同作品 |
| 音乐 | 支持 | 不做 | 范围外 |
设置(设置 → 媒体库与目录 →「刮削」)
| 字段 | 缺省 | 说明 |
|---|---|---|
organize.scrapeAfterImport |
关 | 入库后自动刮削(MP 目录「刮削」缺省同样关) |
organize.scrapeMode |
missing |
missing 只补缺失(MP「仅缺失」)/ overwrite 全部覆盖;手动刮削时对话框可临时改 |
organize.scrapeImages |
开 | 海报、背景图、季海报 |
organize.scrapeEpisodeThumbs |
开 | 每集剧照;scrapeImages 关时不生效 |
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 直接抛错 |
重新生成 MP 输出(改了样例或跟进上游后):
cd /opt/moviepilot-plus/upstream/MoviePilot && PYTHONPATH=. \
.venv/bin/python /opt/uhd_media_tool/apps/server/test/scrape/gen-scrape-mp.py
cd /opt/moviepilot-plus/upstream/MoviePilot && PYTHONPATH=. \
.venv/bin/python /opt/uhd_media_tool/apps/server/test/scrape/gen-scrape-layout-mp.py
cd /opt/uhd_media_tool && deno run -A npm:@biomejs/biome check --write apps/server/test/scrape/scrape-layout-mp.json
结果与排查
- 文件管理刮削对话框:每项的状态、识别结果(作品、按路径识别 / 手动指定 / 已入库的作品、是否在媒体库)、写入 / 跳过 / 失败计数、逐文件结果; 认不准的项列候选,选定后「继续刮削」,可打开「同时加入媒体库」。
- 作品详情「刮削」卡片:该作品最近一条刮削记录;排队或进行中时每 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。- 审计
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。
| 现象 | 看哪里 |
|---|---|
| 入库后没有 NFO | 设置里「入库后自动刮削」是否打开;作品详情「刮削」是否有记录;引擎运行 scrapeMedia |
| 刮削对话框一直让选作品 | 文件名解析不出标题,或 TMDB 有多个同名候选;选一次即可,打开「同时加入媒体库」后下次按已入库作品识别 |
| 全部「跳过」 | 覆盖方式是只补缺失;要替换选「全部覆盖」或作品详情「覆盖重写」 |
| 图片失败、NFO 正常 | TMDB 图片访问(出口代理);稍后重新刮削,只补缺失的图片 |
| 「没有可用的元数据」 | 作品没有 TMDB 字段也没有 TMDB id;在文件管理里刮削并指定 TMDB 条目 |
| 「TMDB 不可用」 | 设置里 TMDB API Key;未登记的目录要直接取 TMDB |