fix(series): 彻底修复剧集被按单集显示且无法修复

根因(用真实数据确诊, 两个互相独立):
- Web 端拆集: episode NFO 的 <uniqueid type=tmdb> 是【单集 episode id】、
  <title> 是【单集名】, mergeEpisodeMetadata 却无条件覆盖了整剧 tm_db_id /
  original_name → 同剧每集 id/原名各不相同, 被 getSeriesKey 按 tmdb_id 拆成
  N 张单集卡(实测「遮天」90 集 = 89 个不同 tmdb_id)。
- Emby 散装单集: 电影库混入按 Season/SxxE 整理的剧集结构内容, 被判成 Episode
  却因 movie 库不聚 Series 而漏成散装单集。
- 无法修复: EnrichLibrary 只处理 pending/no_match, 跳过 matched 脏行。

修复:
- local_metadata.mergeEpisodeMetadata: 单集 NFO 的外部 id 不再写入整剧字段,
  单集名不再污染 original_name; 整剧 id/原名只认 tvshow.nfo。
- groupSeries.ts: 剧集分组改「库+路径剧名」优先(整剧目录对全剧一致), 外部 id
  仅作回退, 对存量脏数据也能合并。
- emby_compat: 电影库常规浏览新增 movieLibraryItems, 把剧集结构内容聚成 Series
  卡片与真电影并列(方案 B), 不再漏散装单集。
- cloud_path_repair: 「修复+重刮」先把脏的 matched 剧集行重置为 pending 再重刮,
  使其能被重新刮削; 结果新增 reset 计数。
- 新增 NormalizePollutedEpisodeMetadata 启动迁移(幂等): 按整剧目录聚合, 清洗
  tmdb/原名散乱的剧组并重置 pending。

测试: local_metadata/emby_compat/cloud_path_repair/episode_metadata_cleanup
新增或更新断言; 修正 scanner_cloud_test 中依赖旧错误行为的用例。
This commit is contained in:
ShukeBta
2026-06-20 01:04:19 +08:00
parent b40e2c7280
commit dff19801b6
12 changed files with 658 additions and 37 deletions
+27 -8
View File
@@ -9,28 +9,47 @@ import type { Media } from '../types'
*
* 折叠键优先级(命中第一个就分组):
*
* 1. series_id (后端某些场景下会预先聚合)
* 2. 有季/集信息时用 library_id + 节目名(综艺每集常有不同 TMDB episode id)
* 3. tmdb_id / bangumi_id(电影或无季集条目)
* 4. library_id + title (fallback:同库同名视为同一剧)
* 剧集(有季集号 / 路径像剧集):
* 1. library_id + 路径剧名 ← 最稳定:同一部剧的各集都在同一剧目录下
* 2. tmdb_id / bangumi_id / douban / thetvdb (无路径剧名时兜底)
* 3. series_id (后端预聚合,目前仅 Emby 虚拟分组用,DB 一般为空)
* 4. library_id + title (最终兜底)
* 电影:
* 1. tmdb_id / bangumi_id
* 2. library_id + 路径剧名
* 3. library_id + title
*
* 为什么剧集要把「路径剧名」放在 tmdb_id 之前:
* 本地/网盘按 MoviePilot 整理的剧集每集旁带 episode NFO, 其 <uniqueid type="tmdb">
* 是【单集 episode id】(每集都不同)。若按 tmdb_id 分组, 同一部剧 N 集会被拆成 N
* 张卡(实测「遮天」90 集 = 89 个不同 tmdb_id)。而整剧目录名对全剧一致, 是最可靠的
* 分组依据, 且对「部分集已刮削、部分未刮削」也能稳定合并。
*
* key 必须是「单条 media 的纯函数」且与子集无关——它会被写进卡片链接
* (seriesCardLink → ?series=KEY), 之后 LibraryPage 用 getSeriesKey(m)===KEY 反查
* 该剧的所有集。路径剧名优先正好满足:同剧各集的 key 恒等, 跨页面/子集稳定。
*
* 同一组内取最早 created_at 的那条作为代表卡片,并带 count 表示集数。
*/
export type SeriesCard = { key: string; rep: Media; linkMedia: Media; count: number }
export function getSeriesKey(media: Media): string {
if (media.series_id) return `series:${media.series_id}`
const fromPath = seriesTitleFromPath(media.path)
if (isEpisodeLike(media)) {
if (isEpisodeLike(media) || pathLooksEpisodic(media)) {
// 路径剧名优先:对全剧一致, 不受单集 tmdb 污染影响。
if (fromPath) return `lib:${targetLibraryID(media)}|show:${fromPath}`
// 无路径剧名(扁平目录)时才退而用外部 id;此时各集若共享整剧 id 仍能合并。
if (media.tmdb_id && media.tmdb_id > 0) return `tmdb:${media.tmdb_id}`
if (media.bangumi_id && media.bangumi_id > 0) return `bgm:${media.bangumi_id}`
if (media.douban_id) return `douban:${media.douban_id}`
if (media.thetvdb_id) return `thetvdb:${media.thetvdb_id}`
return `lib:${targetLibraryID(media)}|show:${normalizeTitle(fromPath || seriesTitle(media))}`
if (media.series_id) return `series:${media.series_id}`
return `lib:${targetLibraryID(media)}|show:${normalizeTitle(seriesTitle(media))}`
}
if (media.series_id) return `series:${media.series_id}`
if (media.tmdb_id && media.tmdb_id > 0) return `tmdb:${media.tmdb_id}`
if (media.bangumi_id && media.bangumi_id > 0) return `bgm:${media.bangumi_id}`
if (fromPath) return `lib:${media.library_id}|show:${normalizeTitle(fromPath)}`
if (fromPath) return `lib:${media.library_id}|show:${fromPath}`
return `lib:${media.library_id}|${normalizeTitle(media.title)}`
}