fix(filter): 修复分类名称与目录不一致时跳过规则组 (#7165)

* fix(filter): match rule groups to effective media categories

* docs(architecture): sync dependency count for category matching
This commit is contained in:
jxxghp
2026-10-23 23:15:28 +08:00
committed by GitHub
parent b51f93f60f
commit d8cb95ab9e
5 changed files with 201 additions and 6 deletions

View File

@@ -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:
"""
兼容协议对象和轻量无参函数两种策略提供器。

View File

@@ -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))
)
]

View File

@@ -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) |

View File

@@ -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",

View File

@@ -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 规则组写入入口应统一接受中英文音乐类型。