diff --git a/app/api/endpoints/system.py b/app/api/endpoints/system.py index 3901895c..77d7af3d 100644 --- a/app/api/endpoints/system.py +++ b/app/api/endpoints/system.py @@ -42,6 +42,7 @@ from app.schemas import ConfigChangeEventData from app.schemas.types import SystemConfigKey, EventType from app.utils.crypto import HashUtils from app.utils.http import RequestUtils, AsyncRequestUtils +from app.utils import rust_accel from app.utils.security import SecurityUtils from app.utils.url import UrlUtils from version import APP_VERSION @@ -514,6 +515,7 @@ async def get_env_setting(_: User = Depends(get_current_active_user_async)): "AUTH_VERSION": SitesHelper().auth_version, "INDEXER_VERSION": SitesHelper().indexer_version, "FRONTEND_VERSION": SystemChain().get_frontend_version(), + "RUST_ACCEL_ENABLED": rust_accel.is_enabled(), } ) return schemas.Response(success=True, data=info) diff --git a/app/core/config.py b/app/core/config.py index e5f52433..b31a0544 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -480,6 +480,8 @@ class ConfigModel(BaseModel): # ==================== 性能配置 ==================== # 大内存模式 BIG_MEMORY_MODE: bool = False + # Rust 加速总开关,关闭时所有 Rust 快路径回退到 Python 实现 + RUST_ACCEL: bool = True # 是否启用编码探测的性能模式 ENCODING_DETECTION_PERFORMANCE_MODE: bool = True # 编码探测的最低置信度阈值 diff --git a/app/modules/filter/__init__.py b/app/modules/filter/__init__.py index 74ffb5db..2103f06b 100644 --- a/app/modules/filter/__init__.py +++ b/app/modules/filter/__init__.py @@ -4,7 +4,7 @@ from functools import lru_cache from typing import List, Tuple, Union, Dict, Optional from app.core.context import TorrentInfo, MediaInfo -from app.core.metainfo import MetaInfo, clear_rust_parse_options_cache, _rust_parse_options +from app.core.metainfo import lear_rust_parse_options_cache, _rust_parse_options from app.helper.rule import RuleHelper from app.log import logger from app.modules import _ModuleBase @@ -12,7 +12,6 @@ from app.modules.filter.RuleParser import RuleParser from app.modules.filter.builtin_rules import BUILTIN_RULE_SET from app.schemas.types import ModuleType, OtherModulesType, SystemConfigKey from app.utils import rust_accel -from app.utils.string import StringUtils _SIZE_UNIT = 1024 * 1024 @@ -155,6 +154,12 @@ class FilterModule(_ModuleBase): mediainfo=mediainfo, metainfo_options=_rust_parse_options() if self.__needs_metainfo_options(group_defs) else None, ) + if matched_orders is None: + return self.__filter_torrents_with_python( + groups=group_defs, + torrent_list=torrent_list, + mediainfo=mediainfo + ) ret_torrents = [] for index, pri_order in matched_orders: torrent = torrent_list[index] @@ -163,6 +168,31 @@ class FilterModule(_ModuleBase): return ret_torrents return torrent_list + def __filter_torrents_with_python(self, groups: List[dict], + torrent_list: List[TorrentInfo], + mediainfo: MediaInfo = None) -> List[TorrentInfo]: + """ + 使用 Python 旧路径过滤种子,供 Rust 加速关闭或不可用时兜底。 + """ + ret_torrents = torrent_list + parser = RuleParser() + parsed_rule_cache = {} + for group in groups: + rule_string = group.get("rule_string") + if not rule_string: + continue + ret_torrents = self.__filter_torrents( + rule_string=rule_string, + rule_name=group.get("name") or rule_string, + torrent_list=ret_torrents, + mediainfo=mediainfo, + parser=parser, + parsed_rule_cache=parsed_rule_cache, + ) + if not ret_torrents: + break + return ret_torrents + def __needs_metainfo_options(self, groups: List[dict]) -> bool: """ 判断当前规则链是否会触发 size_range,避免无大小规则时读取 MetaInfo 运行配置。 diff --git a/app/utils/rust_accel.py b/app/utils/rust_accel.py index 864fb888..5726fc1c 100644 --- a/app/utils/rust_accel.py +++ b/app/utils/rust_accel.py @@ -1,5 +1,6 @@ from typing import List, Optional +from app.core.config import settings from app.log import logger try: @@ -18,6 +19,31 @@ def is_available() -> bool: return bool(_moviepilot_rust and _moviepilot_rust.is_available()) +def is_config_enabled() -> bool: + """ + 判断系统配置是否允许使用 Rust 加速。 + """ + return bool(settings.RUST_ACCEL, True) + + +def is_enabled() -> bool: + """ + 判断当前运行时是否实际启用 Rust 加速。 + """ + return is_config_enabled() and is_available() + + +def status() -> dict: + """ + 返回 Rust 加速能力与开关状态,供系统配置接口展示。 + """ + return { + "available": is_available(), + "enabled": is_enabled(), + "import_error": str(_import_error) if _import_error else "", + } + + def import_error() -> Optional[Exception]: """ 返回 Rust 扩展导入失败的异常,便于调试构建问题。 @@ -29,7 +55,7 @@ def parse_filter_rule(expression: str) -> Optional[list]: """ 使用 Rust 解析过滤规则表达式,不可用时返回 None。 """ - if not _moviepilot_rust: + if not is_enabled(): return None try: return _moviepilot_rust.parse_filter_rule_fast(expression) @@ -45,12 +71,12 @@ def filter_torrents( rule_set: dict, mediainfo=None, metainfo_options: Optional[dict] = None, -) -> list: +) -> Optional[list]: """ 使用 Rust 执行完整种子过滤入口,返回原列表下标和优先级。 """ - if not _moviepilot_rust: - raise RuntimeError(f"Rust 扩展不可用,无法执行种子过滤: {_import_error}") + if not is_enabled(): + return None try: return _moviepilot_rust.filter_torrents_fast( groups, @@ -61,8 +87,8 @@ def filter_torrents( ) except BaseException as err: _raise_non_rust_panic(err) - raise - + logger.debug(f"Rust 种子过滤失败,回退 Python:{err}") + return None def parse_indexer_torrents( html_text: str, @@ -75,7 +101,7 @@ def parse_indexer_torrents( """ 使用 Rust 批量解析普通配置站点种子列表,不可用时返回 None。 """ - if not _moviepilot_rust: + if not is_enabled(): return None try: return _moviepilot_rust.parse_indexer_torrents_fast( @@ -96,7 +122,7 @@ def parse_rss_items(xml_text: str, max_items: int = 1000) -> Optional[List[dict] """ 使用 Rust 解析 RSS/Atom 条目,不可用或异常时返回 None。 """ - if not _moviepilot_rust: + if not is_enabled(): return None try: return _moviepilot_rust.parse_rss_items_fast(xml_text, max_items) @@ -110,7 +136,7 @@ def parse_metainfo(title: str, subtitle: Optional[str] = None, options: Optional """ 使用 Rust 从标题入口解析 MetaInfo,不可用或异常时返回 None。 """ - if not _moviepilot_rust: + if not is_enabled(): return None try: return _moviepilot_rust.parse_metainfo_fast(title, subtitle, options or {}) @@ -124,7 +150,7 @@ def parse_metainfo_path(path: str, options: Optional[dict] = None) -> Optional[d """ 使用 Rust 从路径入口解析 MetaInfoPath,不可用或异常时返回 None。 """ - if not _moviepilot_rust: + if not is_enabled(): return None try: return _moviepilot_rust.parse_metainfo_path_fast(path, options or {}) @@ -138,7 +164,7 @@ def find_metainfo(title: str) -> Optional[dict]: """ 使用 Rust 提取标题中的显式媒体标签,不可用或异常时返回 None。 """ - if not _moviepilot_rust: + if not is_enabled(): return None try: return _moviepilot_rust.find_metainfo_fast(title) diff --git a/docs/cli.md b/docs/cli.md index 50ca9aff..4b2449e3 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -162,6 +162,7 @@ moviepilot install deps --config-dir /path/to/moviepilot-config - 会在安装 Python 依赖后构建并安装 `moviepilot_rust` 加速扩展,因此本机需要可用的 Rust `cargo` - 一键安装脚本会自动准备 Rust toolchain 和系统构建工具;手动执行 CLI 安装时,如果未安装 Rust 或本机编译器,请先安装后再执行 `moviepilot install deps` - 如需临时跳过加速扩展构建,可设置 `MOVIEPILOT_SKIP_RUST_ACCEL=1`,但相关核心处理会回退到 Python 实现,性能收益不会生效 +- 安装完成后可在前端“高级设置 - 实验室”中关闭或重新开启 Rust 加速;如果后端未加载扩展,该开关会保持关闭且不可操作 安装前端 release: diff --git a/tests/test_rust_accel_toggle.py b/tests/test_rust_accel_toggle.py new file mode 100644 index 00000000..f3b88c3a --- /dev/null +++ b/tests/test_rust_accel_toggle.py @@ -0,0 +1,45 @@ +from app.core.config import settings +from app.utils import rust_accel + + +class _DummyRustExtension: + """ + 测试用 Rust 扩展替身,用来验证总开关会阻止扩展调用。 + """ + + @staticmethod + def is_available() -> bool: + """ + 模拟 Rust 扩展基础能力可用。 + """ + return True + + @staticmethod + def parse_filter_rule_fast(_expression: str) -> list: + """ + 如果总开关关闭后仍调用扩展,测试应立即失败。 + """ + raise AssertionError("Rust extension should not be called") + + +def test_rust_accel_runtime_switch_disables_fast_paths(monkeypatch): + """ + RUST_ACCEL 关闭时,即便扩展可用也应回退到 Python 路径。 + """ + monkeypatch.setattr(settings, "RUST_ACCEL", False) + monkeypatch.setattr(rust_accel, "_moviepilot_rust", _DummyRustExtension()) + + assert rust_accel.is_available() + assert not rust_accel.is_enabled() + assert rust_accel.parse_filter_rule("HDR") is None + + +def test_rust_accel_status_reports_enabled_state(monkeypatch): + """ + 状态接口应同时体现扩展可用性和配置开关后的实际启用状态。 + """ + monkeypatch.setattr(settings, "RUST_ACCEL", True) + monkeypatch.setattr(rust_accel, "_moviepilot_rust", _DummyRustExtension()) + + assert rust_accel.status()["available"] is True + assert rust_accel.status()["enabled"] is True diff --git a/tests/test_torrent_filter.py b/tests/test_torrent_filter.py index 91c401ae..1ff7c985 100644 --- a/tests/test_torrent_filter.py +++ b/tests/test_torrent_filter.py @@ -1,6 +1,7 @@ import unittest from datetime import datetime, timedelta from types import SimpleNamespace +from unittest.mock import patch from app.core.context import MediaInfo, TorrentInfo from app.helper.torrent import TorrentHelper @@ -128,13 +129,40 @@ class TorrentFilterTest(unittest.TestCase): module._FilterModule__filter_torrents = fail_python_fallback - filtered = module.filter_torrents( - rule_groups=["test"], - torrent_list=[TorrentInfo(title="Movie", description="")], - ) + 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",