diff --git a/app/application/classification/reference.py b/app/application/classification/reference.py index 0b496b880..284aabe68 100644 --- a/app/application/classification/reference.py +++ b/app/application/classification/reference.py @@ -488,6 +488,35 @@ class ClassificationCategoryResolver: paths.append(path) return tuple(paths) + def matches_media_category(self, media: object, category: str) -> bool: + """ + 按分类名称匹配媒体生效分类,并兼容旧规则保存的目录路径。 + + 分类接口返回显示名称,媒体的 category 则保存目录路径,两者可能不同。 + 活动策略中存在同名分类时优先比较稳定 ID;旧媒体没有 ID 时按路径关联, + 不使用自动推荐或描述性分类覆盖已经生效的人工选择。 + + :param media: 已识别媒体,读取其生效分类快照及主媒体类型 + :param category: 过滤规则保存的分类显示名称或兼容目录路径 + :return: 分类名称或旧路径对应媒体生效分类时返回 True;同名分类已停用时不匹配 + """ + snapshot = effective_classification_snapshot(media) + policy = self._active_policy() + media_type = classification_media_type(getattr(media, "type", None)) + named_categories = [ + item for item in policy.categories + if item.media_type == media_type and item.name == category + ] if policy is not None else [] + if named_categories: + return any( + item.enabled and ( + item.id == snapshot.category_id if snapshot.category_id + else tuple(item.path) == snapshot.category_path + ) + for item in named_categories + ) + return category == snapshot.path + def _active_policy(self) -> ClassificationPolicy | None: """ 兼容协议对象和轻量无参函数两种策略提供器。 diff --git a/app/application/rules.py b/app/application/rules.py index eb9fe3971..4f3666926 100644 --- a/app/application/rules.py +++ b/app/application/rules.py @@ -22,6 +22,7 @@ from pyparsing import ( opAssoc, ) +from app.application.classification.reference import classification_category_resolver_snapshot from app.application.configuration import ( SystemConfigStagingPort, get_configured_system_config, @@ -524,13 +525,20 @@ class RuleHelper: group_names: Optional[list[str]] = None, ) -> List[FilterRuleGroup]: """ - 按媒体类型、分类和候选名称筛选适用规则组。 + 按媒体类型、生效分类和候选名称筛选适用规则组。 + + 规则组的分类名称通过活动分类策略关联媒体,兼容已有目录路径条件。 + + :param media: 已识别并应用最终分类的媒体;未提供时仅匹配无类型限制的规则组 + :param group_names: 限定规则组名称;为空时检查全部配置规则组 + :return: 保持配置顺序的适用规则组,分类不跨主媒体类型匹配 """ rule_groups = self.get_rule_groups() if group_names: rule_groups = [ group for group in rule_groups if group.name in group_names ] + category_resolver = classification_category_resolver_snapshot() return [ group for group in rule_groups @@ -539,7 +547,7 @@ class RuleHelper: media and media.type and group.media_type == media.type.value - and (not group.category or group.category == media.category) + and (not group.category or category_resolver.matches_media_category(media, group.category)) ) ] diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index d4c989fba..f4a88ff80 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -817,7 +817,7 @@ flowchart LR | 指标 | 当前值 | |---|---:| | Python 模块 | 1129 | -| 内部导入边 | 9,713 | +| 内部导入边 | 9,715 | | 非平凡 SCC | 1(29 个模块的 TMDB vendor 精确 containment) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 51(精确 policy 准入,Application / Chain 均为 0) | diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index a8b53632d..4df09284e 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -959,8 +959,8 @@ "runtime_only": true } }, - "edge_count": 9713, - "edge_sha256": "80a64865791510e96da79d2c47098559cae77c5401e1e6a3c0950c18044c38b5", + "edge_count": 9715, + "edge_sha256": "f0613215dea0fa239b7444ab4dc0955fdf32e0bad1ebf5f1ae0b01221f5920b0", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -4053,6 +4053,8 @@ "app.application.rss -> app.runtime", "app.application.rss -> app.runtime.log", "app.application.rules -> app.application", + "app.application.rules -> app.application.classification", + "app.application.rules -> app.application.classification.reference", "app.application.rules -> app.application.configuration", "app.application.rules -> app.application.outbox", "app.application.rules -> app.application.subscription", diff --git a/tests/test_rule_group_media.py b/tests/test_rule_group_media.py index defb0b455..cb188bfe9 100644 --- a/tests/test_rule_group_media.py +++ b/tests/test_rule_group_media.py @@ -2,11 +2,21 @@ 验证过滤规则组与媒体类型的匹配范围。 """ +from collections.abc import Iterator from contextlib import contextmanager from unittest.mock import MagicMock +import pytest + +from app.application.classification.execution import ClassificationExecutionService +from app.application.classification.reference import ( + ClassificationCategoryResolver, + configure_classification_category_resolver, + reset_classification_category_resolver, +) +from app.application.classification.runtime import ClassificationRuntime from app.application.filtering import normalize_media_type -from app.application.rules import RuleHelper +from app.application.rules import BUILTIN_RULE_SET, RuleHelper # 由惰性稳定入口导出,Pylint 无法静态解析。 # pylint: disable=no-name-in-module # SubscribeChain @@ -14,11 +24,157 @@ from app.chain.subscribe import SubscribeChain from app.domain.context import MediaInfo, MusicInfo, TorrentInfo from app.modules.filter import FilterModule from app.runtime.events import Event +from app.schemas.category import ClassificationCategory, ClassificationPolicy, ClassificationSelection from app.schemas.event import ConfigChangeEventData from app.schemas.rule import FilterRuleGroup from app.schemas.types import EventType, MediaType, SystemConfigKey +@pytest.fixture +def named_classification() -> Iterator[ClassificationRuntime]: + """ + 装配名称与目录不同的分类策略,并恢复进程级分类解析器。 + """ + policy = ClassificationPolicy( + revision=1, + categories=[ + ClassificationCategory(id="movie.default", media_type="电影", name="电影", path=["Movie"]), + ClassificationCategory(id="tv.default", media_type="电视剧", name="电视剧", path=["TV"]), + ClassificationCategory(id="tv.favorite", media_type="电视剧", name="收藏剧集", path=["TV", "Favorites"]), + ClassificationCategory(id="tv.disabled", media_type="电视剧", name="停用剧集", path=["Disabled"], enabled=False), + ClassificationCategory(id="music.default", media_type="音乐", name="专辑", path=["Music"]), + ], + fallbacks={"电影": "movie.default", "电视剧": "tv.default", "音乐": "music.default"}, + ) + service = MagicMock() + service.active.return_value = policy + runtime = ClassificationRuntime(service) + previous = configure_classification_category_resolver(ClassificationCategoryResolver(runtime)) + try: + yield runtime + finally: + reset_classification_category_resolver(previous) + + +@pytest.mark.parametrize("media_type", [MediaType.MOVIE, MediaType.TV, MediaType.MUSIC]) +def test_rule_group_matches_category_name_from_api(named_classification, monkeypatch, media_type) -> None: + """ + 分类接口提供的显示名称应匹配真实分类结果,不能直接与目录路径比较。 + """ + source = ( + MusicInfo(title="Album", media_source="musicbrainz", media_id="release-1") + if media_type == MediaType.MUSIC + else MediaInfo(type=media_type, title="Show", media_source="themoviedb", media_id="300146") + ) + media = ClassificationExecutionService(named_classification).finalize(source, allow_enrichment=False) + category = named_classification.media_categories().root[media_type.value][0] + assert category != media.category + helper = RuleHelper() + group = FilterRuleGroup(name="named", rule_string="RULE1", media_type=media_type.value, category=category) + monkeypatch.setattr(helper, "get_rule_groups", lambda: [group]) + + assert helper.get_rule_group_by_media(media=media, group_names=["named"]) == [group] + + +@pytest.mark.parametrize("use_rust", [False, True], ids=["python", "rust"]) +def test_named_tv_category_enforces_episode_size(named_classification, monkeypatch, use_rust) -> None: + """ + Issue 7139 的分类名称必须使单集和多集体积限制实际进入两种过滤引擎。 + """ + media = ClassificationExecutionService(named_classification).finalize( + MediaInfo(type=MediaType.TV, title="嫁金钗", media_source="themoviedb", media_id="300146", number_of_episodes=30), + allow_enrichment=False, + ) + assert media.category == "TV" + helper = RuleHelper() + group = FilterRuleGroup( + name="电视剧规则", rule_string="1080P & !DOLBY & !ATMOS & !HDR & RULE1", + media_type="电视剧", category="电视剧", + ) + monkeypatch.setattr(helper, "get_rule_groups", lambda: [group]) + module = FilterModule() + module.rulehelper = helper + module.rule_set = {**BUILTIN_RULE_SET, "RULE1": {"size_range": "0-1024"}} + monkeypatch.setattr("app.modules.filter.rust_accel.is_enabled", lambda: use_rust) + engine = MagicMock(wraps=module._FilterModule__filter_torrents_with_python) + monkeypatch.setattr(module, "_FilterModule__filter_torrents_with_python", engine) + oversized = [ + TorrentInfo( + title=f"Where the Mask Ends S01 {episodes} 2026 1080p NF WEB-DL x264 AAC2.0-HHWEB", + description=f"嫁金钗 | 第{description}集 |1080p | 类型: 剧情/爱情/古装", + size=int(gib * 1024**3), + ) + for episodes, description, gib in [ + ("E16", "16", 2.14), ("E07-E08", "07-08", 3.74), + ("E09-E10", "09-10", 3.60), ("E11-E12", "11-12", 3.57), + ("E01-E04", "01-04", 7.32), ("", "", 1.88), + ] + ] + accepted = [ + TorrentInfo(title="Where the Mask Ends S01E13 1080p WEB-DL H.264-MWeb", size=508 * 1024**2), + TorrentInfo(title="Where the Mask Ends S01E01-E02 1080p WEB-DL H.264", size=2 * 1024**3), + ] + + result = module.filter_torrents(["电视剧规则"], oversized + accepted, media) + + assert result == accepted + assert all(torrent.pri_order == 100 for torrent in result) + assert engine.call_count == (0 if use_rust else 1) + + +@pytest.mark.parametrize("category", ["收藏剧集", "TV/Favorites"]) +def test_rule_group_uses_effective_category_override(named_classification, monkeypatch, category) -> None: + """ + 人工生效分类优先于自动推荐,旧目录路径规则继续兼容。 + """ + media = ClassificationExecutionService(named_classification).finalize( + MediaInfo(type=MediaType.TV, title="Show", media_source="themoviedb", media_id="300146"), + effective_override=ClassificationSelection( + category_id="tv.favorite", category_path=["TV", "Favorites"], source="subscription", + ), + allow_enrichment=False, + ) + helper = RuleHelper() + groups = [ + FilterRuleGroup(name="effective", media_type="电视剧", category=category), + FilterRuleGroup(name="recommended", media_type="电视剧", category="电视剧"), + FilterRuleGroup(name="other-type", media_type="电影", category=category), + ] + monkeypatch.setattr(helper, "get_rule_groups", lambda: groups) + + assert helper.get_rule_group_by_media(media=media) == groups[:1] + + +@pytest.mark.parametrize("category", ["停用剧集", "其他剧集"]) +def test_named_category_does_not_match_disabled_or_unrelated_category(named_classification, monkeypatch, category) -> None: + """ + 名称解析不能放宽到已停用或与媒体生效分类无关的规则组。 + """ + media = ClassificationExecutionService(named_classification).finalize( + MediaInfo(type=MediaType.TV, title="Show", media_source="themoviedb", media_id="300146"), allow_enrichment=False, + ) + helper = RuleHelper() + group = FilterRuleGroup(name="excluded", media_type="电视剧", category=category) + monkeypatch.setattr(helper, "get_rule_groups", lambda: [group]) + + assert helper.get_rule_group_by_media(media=media) == [] + + +@pytest.mark.parametrize("category, expected", [("电视剧", True), ("TV", True), ("收藏剧集", False)]) +def test_named_category_matches_legacy_media_path(named_classification, monkeypatch, category, expected) -> None: + """ + 旧媒体缺少稳定分类 ID 时,仍能按其目录路径关联当前分类名称。 + """ + assert named_classification.active_policy() is not None + helper = RuleHelper() + group = FilterRuleGroup(name="legacy", media_type="电视剧", category=category) + monkeypatch.setattr(helper, "get_rule_groups", lambda: [group]) + + matched = helper.get_rule_group_by_media(media=MediaInfo(type=MediaType.TV, category="TV")) + + assert matched == ([group] if expected else []) + + def test_agent_rule_group_media_type_accepts_music_aliases(): """ Agent 规则组写入入口应统一接受中英文音乐类型。