diff --git a/app/application/download/organization.py b/app/application/download/organization.py index 0132c92b7..0c10cc52a 100644 --- a/app/application/download/organization.py +++ b/app/application/download/organization.py @@ -56,7 +56,13 @@ def _local_path(value: Any, *, label: str, validate: bool = False) -> PurePath: def _normalize_music_category(media: Any) -> tuple[str, list[str]]: - """音乐目录只使用主类型,副类型只作识别信息展示。""" + """优先使用已生效分类路径,缺失时兼容退回音乐主类型。""" + classified_path = DirectoryHelper().resolve_media_category(media).path + if classified_path: + path = validate_classification_category_path(classified_path) + category = "/".join(path) + else: + category = "" primary = str(getattr(media, "album_type", None) or "").strip() if not primary: primary = str(getattr(media, "category", None) or "").split("/")[0].strip() @@ -68,7 +74,7 @@ def _normalize_music_category(media: Any) -> tuple[str, list[str]]: for item in (getattr(media, "secondary_types", None) or []) if str(item).strip() and str(item).strip() != primary ] - return _safe_relative_name(primary, label="音乐主类型"), secondary + return category or _safe_relative_name(primary, label="音乐主类型"), secondary def _resolve_media(request: Any, history: Any, torrent: Any, media_chain: Any) -> tuple[MetaBase, Any]: diff --git a/docs/architecture/media-classification-design.md b/docs/architecture/media-classification-design.md index c682335ea..363393f79 100644 --- a/docs/architecture/media-classification-design.md +++ b/docs/architecture/media-classification-design.md @@ -935,6 +935,7 @@ API 常规读取只返回 `active`,历史接口按需读取 `history`。选择 9. 新策略为电影、电视剧和音乐保留按媒体类型的通用兜底;数据源只能作为规则的筛选条件,不能单独决定默认分类。 尚未配置音乐分类时,追加 `Album`、`Album / Compilation`、`EP`、`Single` 四个常用分类和显式规则。 `Album / Compilation` 只是展示名称,目录路径保存为 `["Album", "Compilation"]` 两个无空白片段,且精选集规则先于普通专辑规则。 + 下载历史的资源目录识别归类也消费识别结果中的生效分类路径,因此多级路径会原样映射为下载器保存目录;分类结果不可用时才兼容退回音乐主类型。 10. 保存新策略 revision 1,并保留原 YAML 文件只读备份,不再继续写入。已经存在的旧版 revision 1 仅兜底默认策略,只在没有历史且内容与旧默认值完全一致时通过 CAS 升级为 revision 2;任何用户编辑过的策略均保持原样。 11. 同一字段的正向枚举合并为 `contains_any`,排除枚举合并为 `contains_none`;国家、语言等值数量 diff --git a/docs/mcp-api.md b/docs/mcp-api.md index fd90dfe02..b2850e1ae 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -373,13 +373,14 @@ SSE 的 `candidate_items` 是站点原始返回数量,`match_counts` 记录身 | GET | `/api/v1/download/start/{hashString}` | 恢复下载任务,参数:`name` | | GET | `/api/v1/download/stop/{hashString}` | 暂停下载任务,参数:`name` | | PATCH | `/api/v1/download/{hashString}` | 高级更新下载任务,可修改限速、标签、Tracker、保存目录和下载器分类 | -| POST | `/api/v1/download/{hashString}/classify-source` | 按下载历史中的媒体分类和当前资源目录规则重新计算保存位置;`execute=false` 只预览,`execute=true` 由下载器移动任务数据;旧历史没有分类时可传当前策略中已启用的 `media_category` 路径 | +| POST | `/api/v1/download/{hashString}/classify-source` | `recognize` 模式重新识别媒体并按当前生效分类计算保存位置,`manual` 模式使用明确目标目录;`execute=false` 只预览,`execute=true` 由下载器移动任务数据;可传当前策略中已启用的 `media_category` 路径覆盖自动分类 | | GET | `/api/v1/download/clients` | 查询可用下载器 | | GET | `/api/v1/download/paths` | 查询可用于下载接口 `save_path` 参数的下载路径 | | DELETE | `/api/v1/download/{hashString}` | 删除下载任务,参数:`name` | -资源目录重新分类只接受仍存在于下载器且具有可恢复媒体类型的下载历史任务;默认使用历史分类快照,旧历史缺少分类时必须显式传入当前策略中已启用且媒体类型匹配的 `media_category`。 +资源目录重新分类只接受仍存在于下载器且具有可恢复媒体类型的下载历史任务;识别模式可复用历史中的媒体来源和同来源媒体 ID,也可在请求中指定来源、媒体 ID 或当前策略中已启用且媒体类型匹配的 `media_category`。 目标路径必须落在已配置的资源根目录内,并且目录需开启“资源目录按类别分类”或绑定固定分类。 +识别模式会优先使用媒体识别链产生的当前生效分类路径;例如 MusicBrainz 返回 `Album` 主类型和 `Compilation` 副类型并命中默认精选集规则时,目标分类为 `Album/Compilation`。识别结果尚无可用分类路径时,音乐兼容退回主类型目录。 执行时 MoviePilot 调用下载器的位置更新能力,不直接移动或改写 PT 数据文件。 #### 历史 diff --git a/tests/test_download_source_organization.py b/tests/test_download_source_organization.py index aea62c1a5..e4b8b7ae6 100644 --- a/tests/test_download_source_organization.py +++ b/tests/test_download_source_organization.py @@ -105,6 +105,7 @@ class SourceOrganizationTests(unittest.TestCase): type=MediaType.MUSIC, album_type="Album", secondary_types=["Compilation"], + classification_path=("Album", "Compilation"), album="含情脉脉", title="含情脉脉", album_artist="莫文蔚", @@ -139,9 +140,13 @@ class SourceOrganizationTests(unittest.TestCase): ] directory_module.DirectoryHelper.return_value.classification_category_paths.return_value = ( ("Album",), + ("Album", "Compilation"), ("EP",), ("Action",), ) + directory_module.DirectoryHelper.return_value.resolve_media_category.side_effect = ( + lambda media: NS(path=getattr(media, "classification_path", ())) + ) def preview(self): return organization.organize_existing_source( @@ -151,12 +156,20 @@ class SourceOrganizationTests(unittest.TestCase): self.media_chain, ) - def test_music_secondary_type_never_becomes_a_path_segment(self): + def test_music_effective_classification_path_drives_source_directory(self): + result = self.preview() + self.assertEqual(result["category"], "Album/Compilation") + self.assertEqual(result["secondary_categories"], ["Compilation"]) + self.assertEqual( + result["target_save_path"], + "/volume1/UT/Musics/Album/Compilation", + ) + + def test_music_category_falls_back_to_primary_type_without_classification(self): + self.media.classification_path = () result = self.preview() self.assertEqual(result["category"], "Album") - self.assertEqual(result["secondary_categories"], ["Compilation"]) self.assertEqual(result["target_save_path"], "/volume1/UT/Musics/Album") - self.assertNotIn("Compilation", result["target_save_path"]) def test_preview_is_read_only_and_includes_qb_root_rename(self): result = self.preview() @@ -189,7 +202,7 @@ class SourceOrganizationTests(unittest.TestCase): self.chain.update_torrent.assert_called_once_with( hash_string=self.hash_value, downloader="qb", - save_path="/volume1/UT/Musics/Album", + save_path="/volume1/UT/Musics/Album/Compilation", ) def test_manual_directory_without_rename_skips_recognition(self): @@ -269,7 +282,7 @@ class SourceOrganizationTests(unittest.TestCase): ) result = self.preview() self.assertEqual(result["current_save_path"], "D:/Downloads") - self.assertEqual(result["target_save_path"], "D:/Downloads/Album") + self.assertEqual(result["target_save_path"], "D:/Downloads/Album/Compilation") self.assertEqual(result["current_root_name"], folder)