feat(contracts,server,web): files G6–G8 fields and identify S-7 contract

- G6: FileRoot.pendingCount/pendingPartial for download roots (same rule as the
  unidentified filter, recursive, cached 5 min, invalidated by files.changed)
- G7: FileEntry.parsedHint from parseReleasePath (file + parent dirs); the web
  subline uses it and no longer parses names itself
- G8: FileIdentifyCandidate.posterUrl (TMDB poster_path via adapter and
  catalog.lookup, local entities from metadata.posterPath); thumbnails in the
  identify panel
- S-7 contract: FilesIdentifyResult.remoteTaskId/remotePending,
  MediaServerInventory.partial, EventType identify.completed with
  IdentifyCompletedPayload; identify panel (useIdentifyLive) and organize wizard
  merge remote results by root + path, buffering events that beat the response

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 20:09:29 +08:00
1 parent fd2f7bc33d
commit abc69886ea
26 files changed
+510 -134

No files matched your search

+3 -3
View File
@@ -280,9 +280,9 @@
| G3 | 目标已存在策略 | `FilesOrganizeRequest` 加 `onConflict: "skip"\|"decision"\|"replace_if_better"`;替换走现有 `replace_with_verify`,旧文件进回收站 | 是 |
| G4 | 命名方式 + 来源 | 请求加 `naming: "template"\|"keep"`;响应每组加 `templateSource: "entity"\|"root"\|"global"` 与目标路径 `segments:[{text, role, bound}]` | 是 |
| G5 | 入库后刷新元数据 | 请求加 `refreshMetadata: boolean`,执行成功后对每个作品排一次 refresh run | 否(前端可在完成后调 `/files/scrape`) |
| G6 | 下载目录待识别计数 | `FileRoot` 加 `pendingCount`(缓存 5 分钟) | 否 |
| G7 | 列表轻量解析 | 待识别视频的 entry 加 `parsedHint`(标题·季集·分辨率,纯字符串解析,不查库) | 否 |
| G8 | 候选海报 | `FileIdentifyCandidate` 加 `posterUrl` | 否 |
| G6 | 下载目录待识别计数 | `FileRoot` 加 `pendingCount`(缓存 5 分钟) | 否(C1-C 已做,另有 `pendingPartial`) |
| G7 | 列表轻量解析 | 待识别视频的 entry 加 `parsedHint`(标题·季集·分辨率,纯字符串解析,不查库) | 否(C1-C 已做) |
| G8 | 候选海报 | `FileIdentifyCandidate` 加 `posterUrl` | 否(C1-C 已做) |
| G9 | 推荐名 | `POST /files/suggest-name {root,path}` → 命名引擎渲染(依赖 `naming-template.md` §8 的引擎);不可信时返回 `null` + 原因 | 是(依赖命名引擎) |
## 16. 实施拆分与估算
+4
View File
@@ -46,6 +46,10 @@
| 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` | 整理记录按 `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`;带幂等键 |
| 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` 懒加载 |
| S-7 | identify 响应 `remoteTaskId` / `remotePending`;SSE `identify.completed` | 缺省请求只做本地召回,需要 TMDB 时入队 `identifyRemote` 并返回 `remoteTaskId`(引擎 runId);任务结束发 `identify.completed {taskId, status, items, truncated, excludedBySize, error}`(契约 `IdentifyCompletedPayload`,失败也发)。前端识别节(`useIdentifyLive`)与整理向导按 taskId 对账,按 root + path 替换条目(向导保留已选作品 / 季集);事件先于响应到达时暂存再对账。`?wait=true` 同步查远端(整体 ≤15 s) |
| G8 | `GET /files/tree?root=&path=` | 目录摘要:`parent`(根为 null)、子目录 `hasChildren`(隐藏点目录与临时文件)、本目录递归视频数(上限 `dirSummaryMax`,超出 `partial`)与已登记位置数 |
| G9 | `POST /files/suggest-name {root, path, mediaEntityId?}` | 与整理同一命名引擎与变量来源(`effectiveNaming` 作品 > 库根 > 全局 + `loadNamingEntity`),按解析结果渲染整条模板:文件给文件名段,目录给作品目录段(只有季号时给季目录段),同时回 `relativePath`、`segments[{text, role}]`、`templateSource`。作品取 请求指定 > 已登记资产所属 > 解析标题在本地别名里唯一精确命中(同年);都没有 → `name=null, reason=identity_uncertain`;模板渲染失败 → `reason=naming_failed: …` |