Add Rust filter trace logging support

This commit is contained in:
jxxghp
2026-06-21 21:25:50 +08:00
parent 8938ae7baa
commit 3c74f1bf58
3 changed files with 318 additions and 164 deletions

View File

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

View File

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

View File

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