diff --git a/app/modules/filter/__init__.py b/app/modules/filter/__init__.py index 35051cd8..fe7ed18f 100644 --- a/app/modules/filter/__init__.py +++ b/app/modules/filter/__init__.py @@ -161,6 +161,8 @@ class FilterModule(_ModuleBase): torrent_list=torrent_list, mediainfo=mediainfo ) + matched_orders, traces = self.__parse_rust_filter_result(matched_orders) + self.__log_rust_filter_traces(traces) ret_torrents = [] for index, pri_order in matched_orders: torrent = torrent_list[index] @@ -169,6 +171,27 @@ class FilterModule(_ModuleBase): return ret_torrents return torrent_list + @staticmethod + def __parse_rust_filter_result(result) -> Tuple[list, list]: + """ + 兼容新旧 Rust 过滤返回值,统一拆出匹配结果和调试日志。 + """ + if ( + isinstance(result, tuple) + and len(result) == 2 + and isinstance(result[1], list) + ): + return result + return result, [] + + @staticmethod + def __log_rust_filter_traces(traces: list) -> None: + """ + 输出 Rust 过滤路径返回的规则级调试日志。 + """ + for trace in traces: + logger.debug(trace) + def __filter_torrents_with_python(self, groups: List[dict], torrent_list: List[TorrentInfo], mediainfo: MediaInfo = None) -> List[TorrentInfo]: diff --git a/app/utils/rust_accel.py b/app/utils/rust_accel.py index 448fd7dd..8d76ad5f 100644 --- a/app/utils/rust_accel.py +++ b/app/utils/rust_accel.py @@ -1,7 +1,8 @@ -from typing import List, Optional +import logging +from typing import List, Optional, Tuple from app.core.config import settings -from app.log import logger +from app.log import logger, log_settings try: import moviepilot_rust as _moviepilot_rust @@ -71,25 +72,38 @@ def filter_torrents( rule_set: dict, mediainfo=None, metainfo_options: Optional[dict] = None, -) -> Optional[list]: +) -> Optional[Tuple[list, list]]: """ - 使用 Rust 执行完整种子过滤入口,返回原列表下标和优先级。 + 使用 Rust 执行完整种子过滤入口,返回原列表下标、优先级和可选调试日志。 """ if not is_enabled(): return None try: - return _moviepilot_rust.filter_torrents_fast( + args = ( groups, torrent_list, rule_set, mediainfo, metainfo_options or {}, ) + if is_debug_log_enabled() and hasattr(_moviepilot_rust, "filter_torrents_with_trace_fast"): + matched_orders, traces = _moviepilot_rust.filter_torrents_with_trace_fast(*args) + return matched_orders, traces + return _moviepilot_rust.filter_torrents_fast(*args), [] except BaseException as err: _raise_non_rust_panic(err) logger.debug(f"Rust 种子过滤失败,回退 Python:{err}") return None + +def is_debug_log_enabled() -> bool: + """ + 判断当前日志配置是否会实际输出 debug 日志。 + """ + if log_settings.DEBUG: + return True + return getattr(logging, log_settings.LOG_LEVEL.upper(), logging.INFO) <= logging.DEBUG + def parse_indexer_torrents( html_text: str, domain: str, diff --git a/tests/test_torrent_filter.py b/tests/test_torrent_filter.py index 969c8514..e9e6ad1c 100644 --- a/tests/test_torrent_filter.py +++ b/tests/test_torrent_filter.py @@ -1,4 +1,3 @@ -import unittest from datetime import datetime, timedelta from types import SimpleNamespace from unittest.mock import patch @@ -6,6 +5,7 @@ from unittest.mock import patch from app.core.context import MediaInfo, TorrentInfo from app.helper.torrent import TorrentHelper from app.modules.filter import FilterModule +from app.utils import rust_accel class _RuleHelper: @@ -14,15 +14,24 @@ class _RuleHelper: """ def __init__(self, groups): + """ + 保存测试规则组。 + """ self._groups = groups def get_rule_group_by_media(self, media=None, group_names=None): # noqa: ARG002 + """ + 按名称返回测试规则组。 + """ if not group_names: return self._groups return [group for group in self._groups if group.name in group_names] def _build_filter_module(rule_string: str, rule_set: dict) -> FilterModule: + """ + 构造绑定轻量规则仓库的过滤模块。 + """ module = FilterModule() module.rulehelper = _RuleHelper( [SimpleNamespace(name="test", rule_string=rule_string)] @@ -31,169 +40,277 @@ def _build_filter_module(rule_string: str, rule_set: dict) -> FilterModule: return module -class TorrentFilterTest(unittest.TestCase): +def test_filter_torrents_keeps_priority_and_boolean_rule_semantics(): + """ + 过滤规则应保持优先级和布尔表达式语义。 + """ + module = _build_filter_module( + rule_string="HDR & !BLU > DV", + rule_set={ + "HDR": {"include": "HDR"}, + "DV": {"include": "DOVI"}, + "BLU": {"include": "BluRay"}, + }, + ) + torrents = [ + TorrentInfo(title="Movie HDR WEB-DL", description=""), + TorrentInfo(title="Movie DOVI", description=""), + TorrentInfo(title="Movie HDR BluRay", description=""), + ] - def test_filter_torrents_keeps_priority_and_boolean_rule_semantics(self): - module = _build_filter_module( - rule_string="HDR & !BLU > DV", - rule_set={ - "HDR": {"include": "HDR"}, - "DV": {"include": "DOVI"}, - "BLU": {"include": "BluRay"}, - }, - ) - torrents = [ - TorrentInfo(title="Movie HDR WEB-DL", description=""), - TorrentInfo(title="Movie DOVI", description=""), - TorrentInfo(title="Movie HDR BluRay", description=""), + filtered = module.filter_torrents(rule_groups=["test"], torrent_list=torrents) + + assert torrents[:2] == filtered + assert filtered[0].pri_order == 100 + assert filtered[1].pri_order == 99 + + +def test_filter_torrents_keeps_lazy_priority_level_parsing(): + """ + 命中高优先级规则后不应解析低优先级坏规则。 + """ + module = _build_filter_module( + rule_string="KEEP > (", + rule_set={"KEEP": {"include": "Movie"}}, + ) + torrent = TorrentInfo(title="Movie", description="") + + filtered = module.filter_torrents(rule_groups=["test"], torrent_list=[torrent]) + + assert [torrent] == filtered + assert torrent.pri_order == 100 + + +def test_filter_torrents_keeps_sequential_rule_group_semantics(): + """ + 多个规则组应按顺序逐轮过滤。 + """ + module = FilterModule() + module.rulehelper = _RuleHelper( + [ + SimpleNamespace(name="first", rule_string="HDR"), + SimpleNamespace(name="second", rule_string="FREE"), ] + ) + module.rule_set = { + "HDR": {"include": "HDR"}, + "FREE": {"downloadvolumefactor": 0}, + } + keep = TorrentInfo(title="Movie HDR WEB-DL", description="", downloadvolumefactor=0) + drop = TorrentInfo(title="Movie HDR WEB-DL", description="", downloadvolumefactor=1) + filtered = module.filter_torrents(rule_groups=["first", "second"], torrent_list=[keep, drop]) + + assert [keep] == filtered + assert keep.pri_order == 100 + + +def test_filter_torrents_supports_full_rule_fields_in_rust_entry(): + """ + Rust 过滤入口应支持完整规则字段。 + """ + module = _build_filter_module( + rule_string="TMDB & LABEL & SIZE & SEED & PUB & SITE", + rule_set={ + "TMDB": {"tmdb": {"original_language": "zh,cn"}}, + "LABEL": {"include": "官方", "match": ["labels"]}, + "SIZE": {"size_range": "100-400"}, + "SEED": {"seeders": "5"}, + "PUB": {"publish_time": "0-120"}, + "SITE": {"include": "Alpha", "match": ["site_name"]}, + }, + ) + torrent = TorrentInfo( + site_name="Alpha", + title="Show S01E01-E02 1080p", + description="", + labels=["官方"], + size=600 * 1024 * 1024, + seeders=8, + pubdate=(datetime.now() - timedelta(minutes=30)).strftime("%Y-%m-%d %H:%M:%S"), + ) + mediainfo = MediaInfo() + mediainfo.original_language = "zh" + + filtered = module.filter_torrents(rule_groups=["test"], torrent_list=[torrent], mediainfo=mediainfo) + + assert [torrent] == filtered + assert torrent.pri_order == 100 + + +def test_filter_torrents_uses_rust_entry_without_python_match_fallback(): + """ + Rust 返回旧格式结果时应保持兼容,并且不进入 Python 过滤兜底。 + """ + module = _build_filter_module( + rule_string="KEEP", + rule_set={"KEEP": {"include": "Movie"}}, + ) + + def fail_python_fallback(*_args, **_kwargs): + """ + 如果入口仍调用旧 Python 私有匹配逻辑,测试应立即失败。 + """ + raise AssertionError("Python fallback should not be called") + + module._FilterModule__filter_torrents = fail_python_fallback + + with patch("app.modules.filter.rust_accel.is_enabled", return_value=True), \ + patch("app.modules.filter.rust_accel.filter_torrents", return_value=[(0, 100)]): + filtered = module.filter_torrents( + rule_groups=["test"], + torrent_list=[TorrentInfo(title="Movie", description="")], + ) + + assert len(filtered) == 1 + + +def test_filter_torrents_logs_rust_trace_details(): + """ + Rust trace 返回的规则明细应写入过滤模块 debug 日志。 + """ + module = _build_filter_module( + rule_string="KEEP", + rule_set={"KEEP": {"include": "Movie"}}, + ) + + with patch("app.modules.filter.rust_accel.filter_torrents", + return_value=([(0, 100)], ["种子 Alpha - Movie 优先级为 1"])) as rust_filter, \ + patch("app.modules.filter.logger.debug") as log_debug: + filtered = module.filter_torrents( + rule_groups=["test"], + torrent_list=[TorrentInfo(site_name="Alpha", title="Movie", description="")], + ) + + assert len(filtered) == 1 + rust_filter.assert_called_once() + log_debug.assert_called_with("种子 Alpha - Movie 优先级为 1") + + +def test_rust_accel_filter_torrents_uses_trace_entry_when_debug_enabled(): + """ + debug 日志启用时 Rust 包装层应调用 trace 入口并返回规则明细。 + """ + + class FakeRustExtension: + """ + 提供过滤入口的测试 Rust 扩展替身。 + """ + + def is_available(self): + """ + 声明扩展可用。 + """ + return True + + def filter_torrents_with_trace_fast(self, *_args): + """ + 返回带调试日志的过滤结果。 + """ + return [(0, 100)], ["trace"] + + def filter_torrents_fast(self, *_args): + """ + 普通入口不应在本用例中被调用。 + """ + raise AssertionError("trace entry should be used") + + with patch.object(rust_accel, "_moviepilot_rust", FakeRustExtension()), \ + patch.object(rust_accel, "is_debug_log_enabled", return_value=True): + result = rust_accel.filter_torrents([], [], {}) + + assert result == ([(0, 100)], ["trace"]) + + +def test_rust_accel_filter_torrents_keeps_fast_entry_without_debug(): + """ + debug 日志关闭时 Rust 包装层应继续调用原高速入口。 + """ + + class FakeRustExtension: + """ + 提供过滤入口的测试 Rust 扩展替身。 + """ + + def is_available(self): + """ + 声明扩展可用。 + """ + return True + + def filter_torrents_with_trace_fast(self, *_args): + """ + trace 入口不应在本用例中被调用。 + """ + raise AssertionError("fast entry should be used") + + def filter_torrents_fast(self, *_args): + """ + 返回普通过滤结果。 + """ + return [(0, 100)] + + with patch.object(rust_accel, "_moviepilot_rust", FakeRustExtension()), \ + patch.object(rust_accel, "is_debug_log_enabled", return_value=False): + result = rust_accel.filter_torrents([], [], {}) + + assert result == ([(0, 100)], []) + + +def test_filter_torrents_uses_python_fallback_when_rust_disabled(): + """ + Rust 加速关闭时应使用 Python 过滤路径,并保留规则优先级语义。 + """ + module = _build_filter_module( + rule_string="HDR & !BLU > DV", + rule_set={ + "HDR": {"include": "HDR"}, + "DV": {"include": "DOVI"}, + "BLU": {"include": "BluRay"}, + }, + ) + torrents = [ + TorrentInfo(title="Movie HDR WEB-DL", description=""), + TorrentInfo(title="Movie DOVI", description=""), + TorrentInfo(title="Movie HDR BluRay", description=""), + ] + + with patch("app.modules.filter.rust_accel.is_enabled", return_value=False): filtered = module.filter_torrents(rule_groups=["test"], torrent_list=torrents) - self.assertEqual(torrents[:2], filtered) - self.assertEqual(100, filtered[0].pri_order) - self.assertEqual(99, filtered[1].pri_order) + assert torrents[:2] == filtered + assert filtered[0].pri_order == 100 + assert filtered[1].pri_order == 99 - def test_filter_torrents_keeps_lazy_priority_level_parsing(self): - module = _build_filter_module( - rule_string="KEEP > (", - rule_set={"KEEP": {"include": "Movie"}}, - ) - torrent = TorrentInfo(title="Movie", description="") - filtered = module.filter_torrents(rule_groups=["test"], torrent_list=[torrent]) +def test_filter_torrent_keeps_extra_filter_semantics(): + """ + 普通过滤参数应保持包含、排除和大小规则语义。 + """ + torrent = TorrentInfo( + title="Movie 1080p HDR", + description="中字", + labels=["free"], + size=3 * 1024 * 1024 * 1024, + uploadvolumefactor=1, + downloadvolumefactor=0, + ) - self.assertEqual([torrent], filtered) - self.assertEqual(100, torrent.pri_order) - - def test_filter_torrents_keeps_sequential_rule_group_semantics(self): - module = FilterModule() - module.rulehelper = _RuleHelper( - [ - SimpleNamespace(name="first", rule_string="HDR"), - SimpleNamespace(name="second", rule_string="FREE"), - ] - ) - module.rule_set = { - "HDR": {"include": "HDR"}, - "FREE": {"downloadvolumefactor": 0}, - } - keep = TorrentInfo(title="Movie HDR WEB-DL", description="", downloadvolumefactor=0) - drop = TorrentInfo(title="Movie HDR WEB-DL", description="", downloadvolumefactor=1) - - filtered = module.filter_torrents(rule_groups=["first", "second"], torrent_list=[keep, drop]) - - self.assertEqual([keep], filtered) - self.assertEqual(100, keep.pri_order) - - def test_filter_torrents_supports_full_rule_fields_in_rust_entry(self): - module = _build_filter_module( - rule_string="TMDB & LABEL & SIZE & SEED & PUB & SITE", - rule_set={ - "TMDB": {"tmdb": {"original_language": "zh,cn"}}, - "LABEL": {"include": "官方", "match": ["labels"]}, - "SIZE": {"size_range": "100-400"}, - "SEED": {"seeders": "5"}, - "PUB": {"publish_time": "0-120"}, - "SITE": {"include": "Alpha", "match": ["site_name"]}, - }, - ) - torrent = TorrentInfo( - site_name="Alpha", - title="Show S01E01-E02 1080p", - description="", - labels=["官方"], - size=600 * 1024 * 1024, - seeders=8, - pubdate=(datetime.now() - timedelta(minutes=30)).strftime("%Y-%m-%d %H:%M:%S"), - ) - mediainfo = MediaInfo() - mediainfo.original_language = "zh" - - filtered = module.filter_torrents(rule_groups=["test"], torrent_list=[torrent], mediainfo=mediainfo) - - self.assertEqual([torrent], filtered) - self.assertEqual(100, torrent.pri_order) - - def test_filter_torrents_uses_rust_entry_without_python_match_fallback(self): - module = _build_filter_module( - rule_string="KEEP", - rule_set={"KEEP": {"include": "Movie"}}, - ) - - def fail_python_fallback(*_args, **_kwargs): - """ - 如果入口仍调用旧 Python 私有匹配逻辑,测试应立即失败。 - """ - raise AssertionError("Python fallback should not be called") - - module._FilterModule__filter_torrents = fail_python_fallback - - with patch("app.modules.filter.rust_accel.is_enabled", return_value=True), \ - patch("app.modules.filter.rust_accel.filter_torrents", return_value=[(0, 100)]): - filtered = module.filter_torrents( - rule_groups=["test"], - torrent_list=[TorrentInfo(title="Movie", description="")], - ) - - self.assertEqual(1, len(filtered)) - - def test_filter_torrents_uses_python_fallback_when_rust_disabled(self): - """ - Rust 加速关闭时应使用 Python 过滤路径,并保留规则优先级语义。 - """ - module = _build_filter_module( - rule_string="HDR & !BLU > DV", - rule_set={ - "HDR": {"include": "HDR"}, - "DV": {"include": "DOVI"}, - "BLU": {"include": "BluRay"}, - }, - ) - torrents = [ - TorrentInfo(title="Movie HDR WEB-DL", description=""), - TorrentInfo(title="Movie DOVI", description=""), - TorrentInfo(title="Movie HDR BluRay", description=""), - ] - - with patch("app.modules.filter.rust_accel.is_enabled", return_value=False): - filtered = module.filter_torrents(rule_groups=["test"], torrent_list=torrents) - - self.assertEqual(torrents[:2], filtered) - self.assertEqual(100, filtered[0].pri_order) - self.assertEqual(99, filtered[1].pri_order) - - def test_filter_torrent_keeps_extra_filter_semantics(self): - torrent = TorrentInfo( - title="Movie 1080p HDR", - description="中字", - labels=["free"], - size=3 * 1024 * 1024 * 1024, - uploadvolumefactor=1, - downloadvolumefactor=0, - ) - - self.assertTrue( - TorrentHelper.filter_torrent( - torrent_info=torrent, - filter_params={ - "include": "中字|free", - "exclude": "BluRay", - "resolution": "1080p", - "effect": "HDR", - "size": "1000-4000", - }, - ) - ) - self.assertFalse( - TorrentHelper.filter_torrent( - torrent_info=torrent, - filter_params={"exclude": "HDR"}, - ) - ) - self.assertFalse( - TorrentHelper.filter_torrent( - torrent_info=torrent, - filter_params={"size": "<1000"}, - ) - ) + assert TorrentHelper.filter_torrent( + torrent_info=torrent, + filter_params={ + "include": "中字|free", + "exclude": "BluRay", + "resolution": "1080p", + "effect": "HDR", + "size": "1000-4000", + }, + ) + assert not TorrentHelper.filter_torrent( + torrent_info=torrent, + filter_params={"exclude": "HDR"}, + ) + assert not TorrentHelper.filter_torrent( + torrent_info=torrent, + filter_params={"size": "<1000"}, + )