mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-06 15:22:05 +08:00
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:
@@ -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:
|
||||
"""
|
||||
兼容协议对象和轻量无参函数两种策略提供器。
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 规则组写入入口应统一接受中英文音乐类型。
|
||||
|
||||
Reference in New Issue
Block a user