From c29e6a12d300f7c882ad9f7382dc72f8e8138ceb Mon Sep 17 00:00:00 2001 From: root Date: Thu, 1 Oct 2026 00:08:17 +0800 Subject: [PATCH] =?UTF-8?q?docs(scrape):=20SCR-2=20=E5=B0=B1=E5=9C=B0?= =?UTF-8?q?=E5=88=AE=E5=89=8A=E8=BF=90=E8=A1=8C=E6=89=8B=E5=86=8C=E4=B8=8E?= =?UTF-8?q?=20MP=20=E5=B7=AE=E5=88=86=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 +++ docs/design/glossary.md | 2 +- docs/runbook/files.md | 8 +-- docs/runbook/scrape.md | 141 ++++++++++++++++++++++++++++------------ 4 files changed, 112 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29b5f7e4..2db07c23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ ## [Unreleased] +### 变更(SCR-2 刮削照 MP 简化:在目录里就地刮削) +- 文件管理里任意视频文件或目录都能「刮削…」(右键 / ⋯ 菜单、多选批量栏、详情),不要求先整理入库:服务端按路径识别作品(目录里已入库的直接用;没入库的按文件名与目录名查本地作品和 TMDB),在该目录里就地写 NFO 和图片。剧集目录递归到季目录与每集,季目录只写季与各集,单集文件只写该集,电影目录写每个视频的 NFO 与目录海报,蓝光原盘目录写 `<目录名>.nfo`。 +- 认不准作品时对话框列出候选(可搜索),选一个后「继续刮削」;可打开「同时加入媒体库」,不打开也能刮削(直接用 TMDB 字段)。覆盖方式缺省按设置,对话框里可临时选「只补缺失 / 全部覆盖」。多选时逐项识别。 +- 作品详情「重新刮削」「覆盖重写」与入库后自动刮削改为对作品目录刮削,和文件管理同一实现。 +- 刮削记录简化为每次一条(目录、识别结果、写了哪些文件、跳过原因),作品详情显示该作品最近一条。去掉"只有已入库作品才能刮削"的限制;「刷新元数据」不再要求有已入库文件。 +- 与 MP 的文件布局差分:用 MP `ScrapingChain.scrape_metadata` 的真实决策代码跑同一组目录对照 7 个场景,只有 3 处登记偏离(不写 backdrop.jpg 副本、剧照 `-thumb.jpg`、根下电影图片带视频名)。见 `docs/runbook/scrape.md`「与 MP 的差分」。 +- 接口:`POST /files/scrape` 改为 `{items[{root,path,mediaEntityId?,external?}], mode?, addToLibrary?}` → `{records, needsChoice, skipped, pending, entities}`;新增 `GET /files/scrape-records?ids=`;`POST /files/scrape-batch` 去掉 `write`(只刷新元数据);`POST /library/{id}/scrape` 返回 `recordIds`;`GET /library/{id}` 的 `scrape` 改为刮削记录(带 `root` / `path` / `match`)。任务 `scrapeMedia` 升到 v2(输入 `{recordId}`)。迁移 `0058_scrape_record`(替代 `media_scrape`)。 + ## [0.3.6] - 2026-09-30 ### 新增(SEARCH-4 资源筛选对齐 MP) diff --git a/docs/design/glossary.md b/docs/design/glossary.md index a5fab6af..51a1d6c4 100644 --- a/docs/design/glossary.md +++ b/docs/design/glossary.md @@ -25,7 +25,7 @@ | AcquisitionPlan(下载计划,DL-3 起界面不单独命名) | 下载;还没提交到下载器的按在等什么写状态:「等你批准」「等磁盘空间」「等开启自动化」「准备提交」;没提交就结束的写「没提交成功 / 已取消 / 已过期」+ 原因;来由写「为什么下这个」;智能体动作计数写"准备下载 N 个资源" | 下载计划、草稿、已预留(界面) | | DownloadTask | 下载任务 | 下载单 | | Organization record | 整理记录;动作叫"整理",结果状态叫"已入库" | 转移、入库记录 | -| 往媒体库目录写 NFO 与图片(scrapeMedia,SCR-1) | 刮削;按钮「刮削」「重新刮削」「覆盖重写」;覆盖方式叫「只补缺失 / 全部覆盖」;只更新本工具字段的叫「刷新元数据」 | 投影、NFO 投影、写元数据(界面) | +| 在目录里就地写 NFO 与图片(scrapeMedia,SCR-1 / SCR-2) | 刮削;文件管理菜单「刮削…」,作品详情「重新刮削」「覆盖重写」;覆盖方式叫「只补缺失 / 全部覆盖」;认不准作品时"选一个再刮削";只更新本工具字段的叫「刷新元数据」 | 投影、NFO 投影、写元数据(界面) | | 整理方式 | 复制 / 移动 / 硬链接 | Copy / Move | | Library root | 媒体库目录 | 库根、媒体库根、根目录 | | 文件管理可浏览的目录(库根 + 下载器路径映射) | 登记目录 | 登记根、根 | diff --git a/docs/runbook/files.md b/docs/runbook/files.md index 020fe80b..9f1115a6 100644 --- a/docs/runbook/files.md +++ b/docs/runbook/files.md @@ -34,7 +34,7 @@ | 回收站 | `GET /files/trash`;`restore`(原位被占 → 该项 `exists`,可带 `target` 还原到别处;还原不恢复资产状态,需要时重新整理登记;有成功项时写 `organization_record(action=restore)`,返回 `recordId`);`purge` 仅管理员会话,缺省只清过期项。"还原到…"的目标必须是可写的 managed 库根(否则 403 `not_writable`);原位还原时缺失的父目录在根内逐级重建(已存在的必须是真目录,符号链接拒绝 `path_escape`);跨卷还原受 `FILES_TRASH_CROSS_MAX_BYTES` 上限(超出该项 `too_large`),复制经 fsproxy。持久任务 `purgeTrash` 每日入队(`FILES_TRASH_RETENTION_DAYS`,默认 7);清除前复核行仍存在,且路径必须在某根的 `.umt-trash` 或 `DATA_DIR/trash` 下 | | identify | 只读:`parseReleasePath`(文件 + 父/祖父目录)→ 本地召回(别名 pg_trgm + 外部身份)→ `catalog.lookup` 能力取远端候选(经进程共享的 TMDB 信号量与 15 s 超时;单次请求去重后最多 20 个远端查询,超出的项只本地召回并给 warning;失败只给 warning)→ `rankCandidates` 前 5;目录展开视频上限 200;不建作品、不进待识别池 | | probe | 需要 `organize.execute`;≤20 项;已登记资产写回 `media_asset.media_info.probe`;未登记缓存 24 h(`DATA_DIR/cache/probe`) | -| scrape | 仅有 active 资产的作品;入队 `refreshMetadata`(字段锁优先,不动路径),返回 `lockedFields`;`writeNfo` / `images` 另排 `scrapeMedia` 写 NFO 与图片(返回 `queued` + `scrapeTaskId`,见 `scrape.md`)。界面「刷新元数据」只刷字段,「刮削」走 `scrape-batch` `write=true` | +| scrape | SCR-2 照 MP:任意视频文件或目录就地刮削(不要求已登记):已登记目录用其作品,否则按路径识别(本地 + TMDB),认不准返回 `needsChoice` 候选,选定后带 `mediaEntityId` / `external` 再提交;`addToLibrary` 顺手建作品;每项一条刮削记录,排 `scrapeMedia`,请求内最多等 `FILES_TRANSFER_WAIT_SEC`;`GET /files/scrape-records?ids=` 回查。只登记库根跳过。见 `scrape.md`。界面「刷新元数据」只刷字段(`scrape-batch`),「刮削…」走 `/files/scrape` | | download | `GET /files/download?root=&path=&inline=`:仅管理员会话(API Key 含 admin scope 也 403);只允许 NFO/字幕/图片(视频 403),≤ `FILES_DOWNLOAD_MAX_BYTES`(默认 20 MiB,超出 413 `too_large:`);路径守卫同其它接口,打开前再 realpath 复核、按文件句柄 stat;流式返回(非 JSON 信封),`inline=true` 仅对图片生效(SVG 一律附件),带 `nosniff` + `CSP sandbox`;写审计 `files.download` | ## 文件管理 v2 后端缺口(W9-H,`docs/design/file-manager-v2.md` §15) @@ -45,7 +45,7 @@ | G2 | organize `minSizeBytes`;identify `minSizeBytes` | 缺省取 `settings.organize.minSizeBytes`(缺省 50 MiB,W10-pre2 起进契约);设置读不到时 organize 退回端口 `minVideoBytes`,identify 不过滤。请求 / 向导给的值优先,给 0 表示不过滤。自动入库的 `planImport` 同样按"请求 > 设置 > 端口"取值。设置页还没有这个字段(前端后续在「媒体库与目录 → 整理方式」加),目前只能 `PATCH /settings` `{"organize":{"minSizeBytes":<字节>}}` 修改。organize:低于阈值的视频进组 `excluded[]`,`reason=below_min_size`(sample/no_episode 等照原因名);identify:该项 `skipped="below_min_size"`、不查候选,结果 `excludedBySize` 计数;每项带 `size` | | G3 | organize `onExisting: skip\|conflict\|replace_with_verify`(缺省 conflict) | 透传 `planImport.onExisting`;选 replace 即视为人工授权(`grantReplace`),执行走现有 replace 分支(新文件核验通过后旧文件进回收站)。预览逐文件 `action`(transfer/replace/skip/hold)+ `existing`(same 同一文件已在库 / skip / replace / conflict / null)+ `skipReason`;conflict 时该组 `error.code=plan_blocked`。策略进请求指纹,换策略须重新预览 | | G4 | organize `naming: template\|keep`(缺省 template) | keep = 保留源文件名(目录仍按模板/绑定/targetDir;改名后撞名 → hold + duplicate_target)。`planImport` 新参数 `namingMode` / `namingSource`,冻结计划 `organization_record.plan.naming = {mode, templateSource: entity\|root\|global\|override, templateVersion}`(模板来源按 作品 > 库根 > 全局 回落;keep = override)。组响应同名 `naming` + `targetSegments[{text, role: root\|season\|dir, bound}]`(bound = 沿用已有路径绑定) | -| G5 | identify 项 `records{count,lastRecordId,lastStatus}`;`GET /files/record-sources?recordId=`;organize `refreshMetadata`;`POST /files/scrape-batch`(SCR-1 起 `write=true` 改排 `scrapeMedia`) | 整理记录按 `organization_file_result.source_path` 回查;record-sources 把记录主视频源(多个且同在源目录下时给目录)按登记目录 hostPath 换算成 FileRef,不在登记目录/已不存在的进 `unresolved`。`refreshMetadata=true`:执行成功的组按作品去重各排一次 `refreshMetadata`(组里 `refreshTaskId`)。scrape-batch:`items`(文件/目录 → 其下已登记资产的作品)∪ `mediaEntityIds` 去重,≤200 个作品;无 active 资产 `not_registered`、不存在 `not_found`、文件不属于任何作品 `no_entity`;带幂等键 | +| G5 | identify 项 `records{count,lastRecordId,lastStatus}`;`GET /files/record-sources?recordId=`;organize `refreshMetadata`;`POST /files/scrape-batch`(只刷新元数据;刮削见 `/files/scrape`) | 整理记录按 `organization_file_result.source_path` 回查;record-sources 把记录主视频源(多个且同在源目录下时给目录)按登记目录 hostPath 换算成 FileRef,不在登记目录/已不存在的进 `unresolved`。`refreshMetadata=true`:执行成功的组按作品去重各排一次 `refreshMetadata`(组里 `refreshTaskId`)。scrape-batch(刷新元数据):`items`(文件/目录 → 其下已登记资产的作品)∪ `mediaEntityIds` 去重,≤200 个作品;不存在 `not_found`、文件不属于任何作品 `no_entity`;带幂等键 | | G6 | `GET /files/roots` 每项 `pendingCount` / `pendingPartial` | 仅下载目录:递归数待识别视频(与列表 `unidentified` 同口径:视频、非临时名、不在纳管下载任务内容内),上限 `FILES_LIST_MAX_ENTRIES` 项,超出 `pendingPartial=true`(计数为下限)。按根缓存 5 分钟,该根的 `files.changed` 事件使缓存失效;库根与离线根为 `null` | | G7 | 列表项 `parsedHint` | 待识别视频行:`parseReleasePath`(文件名 + 父/祖父目录)的轻量结果 `标题 (年) · S01E02 · 1080p`(剧集不带年);不查库,解析不出标题或非待识别行为 `null`。前端副行直接显示,不再自己解析 | | G8(海报) | identify 候选 `posterUrl` | TMDB `poster_path` 拼 `https://image.tmdb.org/t/p/w154…`(TMDB 适配器候选带 `posterPath`,`catalog.lookup` 远端项透传);本地作品取 `metadata.posterPath`,没有时用同一请求里其已关联 TMDB 候选的海报;都没有为 `null`。前端按 `no-referrer` 懒加载 | @@ -57,7 +57,7 @@ | 操作 | 管理员会话 / DEV_ADMIN_KEY | API Key | |---|---|---| -| roots / list / stat / identify / trash 列表 / records / tree / record-sources / suggest-name | ✓ | `organize.read` | +| roots / list / stat / identify / trash 列表 / records / tree / record-sources / suggest-name / scrape-records | ✓ | `organize.read` | | mkdir / rename / copy / organize(copy) / restore / scrape / scrape-batch / probe / entity-from-candidate | ✓ | `organize.execute` | | move / organize(move) | ✓ | `organize.execute` + **每个源**所属作品(声明作品 ∪ 源下已登记资产的作品)都被持续授权覆盖,或整请求一次性审批;任一不覆盖 → 整体 403 `approval_required` 并列出未授权项;无法归属作品的源只认审批 | | delete(回收站) | ✓ | `organize.execute` + 每次一次性审批(dryRun 的 planId) | @@ -76,7 +76,7 @@ ## 已知缺口 -- 硬链接、蓝光原盘整目录操作仍未做(刮削也跳过蓝光原盘目录)。NFO / 图片投影已由 SCR-1 实现,见 `scrape.md`。 +- 硬链接、蓝光原盘整目录操作仍未做。刮削(NFO / 图片,含蓝光原盘目录的 `<目录名>.nfo`)见 `scrape.md`。 - 搬运 move 在终态提交前删源侧空目录;若进程恰在两者之间退出,重跑 finalize 幂等,但极端情况下可能留下空目录(无数据风险)。 - 路径守卫未下沉到 `packages/adapters/src/fs/guard.ts`(本模块自带按根解析的守卫,整理仍用 `sourceGuard`)。 diff --git a/docs/runbook/scrape.md b/docs/runbook/scrape.md index 4a4c3c25..09ec7125 100644 --- a/docs/runbook/scrape.md +++ b/docs/runbook/scrape.md @@ -1,44 +1,60 @@ -# 刮削运行手册(SCR-1:NFO 与图片写进媒体库目录) +# 刮削运行手册(SCR-1 写 NFO 与图片;SCR-2 照 MP 简化为就地刮削) -「刮削」= 把作品的最终字段写成 NFO,并下载海报、背景图、季海报、剧照,放进媒体库目录,Emby / Jellyfin / Kodi 直接读取。 -「刷新元数据」只更新本工具库里的字段,不写媒体库目录;两者分开。 +「刮削」= 在视频所在目录就地写 NFO,并下载海报、背景图、季海报、剧照,Emby / Jellyfin / Kodi 直接读取。 +「刷新元数据」只更新本工具库里的字段,不写目录里的文件;两者分开。 + +SCR-2(用户 2026-09-30:"已经有的也可以重新刮削……如果是直接在对应目录刮削,那就直接在对应目录生成")起, +刮削对象从"作品"改成"目录 / 视频文件",照 MP 文件管理的刮削:**不要求先登记**,按路径识别作品后在该目录里写文件。 +已登记作品的「重新刮削」、入库后自动刮削走同一实现(本质是对作品目录刮削)。 ## 实现位置 | 位置 | 内容 | | --- | --- | | `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`) | +| `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 缓存) | ## 触发 | 触发 | 做法 | | --- | --- | -| 整理入库成功(`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` | +| 文件管理「刮削…」(右键 / ⋯ 菜单、多选批量栏、详情按钮) | `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: true`)。 +同一目录已有排队中(未开始,30 分钟内)、同覆盖方式、同作品的刮削时直接复用(`deduplicated`)。 -## 设置(设置 → 媒体库与目录 →「刮削」) +### 识别(文件管理) -| 字段 | 缺省 | 说明 | -| --- | --- | --- | -| `organize.scrapeAfterImport` | 关 | 入库后自动刮削(MP 目录「刮削」缺省同样关) | -| `organize.scrapeMode` | `missing` | `missing` 只补缺失(MP「仅缺失」)/ `overwrite` 全部覆盖(替换别的工具写的 NFO 和图片) | -| `organize.scrapeImages` | 开 | 海报、背景图、季海报 | -| `organize.scrapeEpisodeThumbs` | 开 | 每集剧照;`scrapeImages` 关时不生效 | +顺序:请求指定 > 目录里已登记文件所属的唯一作品(`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` 时顺手建作品 + (同「整理」的按候选建作品,幂等)。**登记不是刮削的前提**。 -文件名跟随已入库的视频(视频名本身由命名模板生成);作品目录优先取作品的路径绑定,没有时从视频路径推(剧集父目录像 -`Season 1` / `S01` / `Specials` 时取上一级)。 +### 写在哪里(对照 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)/ @@ -49,9 +65,12 @@ └── Season 1/ ├── season.nfo ├── poster.jpg # 季海报(与作品目录那张同一张) - ├── 人生切割术 - S01E01 - 第 1 集.mkv - ├── 人生切割术 - S01E01 - 第 1 集.nfo - └── 人生切割术 - S01E01 - 第 1 集-thumb.jpg + ├── 人生切割术 - S01E01 - 有关地狱的好消息.mkv + ├── 人生切割术 - S01E01 - 有关地狱的好消息.nfo + ├── 人生切割术 - S01E01 - 有关地狱的好消息-thumb.jpg + ├── 人生切割术 - S01E02 - 半循环.mkv + ├── 人生切割术 - S01E02 - 半循环.nfo + └── 人生切割术 - S01E02 - 半循环-thumb.jpg 星际穿越 (2014)/ ├── 星际穿越 (2014) - 1080p.mkv ├── 星际穿越 (2014) - 1080p.nfo @@ -59,11 +78,41 @@ └── 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`)。 -- 目标路径必须仍在库根内;已存在的目标是目录 / 符号链接时跳过。 +(上面是 `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 内容 @@ -86,27 +135,35 @@ | 电影不写 `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 +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 /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`。 +- 文件管理刮削对话框:每项的状态、识别结果(作品、按路径识别 / 手动指定 / 已入库的作品、是否在媒体库)、写入 / 跳过 / 失败计数、逐文件结果; + 认不准的项列候选,选定后「继续刮削」,可打开「同时加入媒体库」。 +- 作品详情「刮削」卡片:该作品最近一条刮削记录;排队或进行中时每 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` | -| 全部「已存在」 | 覆盖方式是只补缺失;要替换用「覆盖重写」 | -| 图片失败、NFO 正常 | TMDB 图片访问(出口代理);稍后「重新刮削」只补缺失的图片 | -| 「作品还没有元数据」 | 作品没有 TMDB 字段,先刷新元数据或指定 TMDB 条目 | +| 刮削对话框一直让选作品 | 文件名解析不出标题,或 TMDB 有多个同名候选;选一次即可,打开「同时加入媒体库」后下次按已入库作品识别 | +| 全部「跳过」 | 覆盖方式是只补缺失;要替换选「全部覆盖」或作品详情「覆盖重写」 | +| 图片失败、NFO 正常 | TMDB 图片访问(出口代理);稍后重新刮削,只补缺失的图片 | +| 「没有可用的元数据」 | 作品没有 TMDB 字段也没有 TMDB id;在文件管理里刮削并指定 TMDB 条目 | +| 「TMDB 不可用」 | 设置里 TMDB API Key;未登记的目录要直接取 TMDB |