feat: add Rust acceleration for core parsing

This commit is contained in:
jxxghp
2026-05-22 19:58:04 +08:00
parent 7daeb17d85
commit bd4d493f34
28 changed files with 5012 additions and 77 deletions
+2 -1
View File
@@ -73,6 +73,7 @@ test_*
build/ build/
dist/ dist/
*.egg-info/ *.egg-info/
rust/**/target/
# Docker # Docker
Dockerfile* Dockerfile*
@@ -81,4 +82,4 @@ docker-compose*
# Other # Other
app.ico app.ico
frozen.spec frozen.spec
+1
View File
@@ -6,6 +6,7 @@
build/ build/
cython_cache/ cython_cache/
dist/ dist/
rust/**/target/
nginx/ nginx/
test.py test.py
safety_report.txt safety_report.txt
+11
View File
@@ -56,6 +56,17 @@ MCP工具API文档:详见 [docs/mcp-api.md](docs/mcp-api.md)
开发环境准备与本地源码运行说明:[`docs/development-setup.md`](docs/development-setup.md) 开发环境准备与本地源码运行说明:[`docs/development-setup.md`](docs/development-setup.md)
本地开发启用 Rust 加速扩展,需先安装 Rust toolchain 并确保 `cargo` 可用:
```shell
cargo --version
python -m pip install "maturin>=1.9,<2"
python -m maturin develop --release --manifest-path rust/moviepilot_rust/Cargo.toml
python -c "from app.utils import rust_accel; print(rust_accel.is_available())"
```
如果输出 `True`,说明当前开发环境已经加载 `moviepilot_rust`。重新修改 Rust 代码后再次执行 `python -m maturin develop --release --manifest-path rust/moviepilot_rust/Cargo.toml` 即可更新本地扩展。
插件开发说明:<https://wiki.movie-pilot.org/zh/plugindev> 插件开发说明:<https://wiki.movie-pilot.org/zh/plugindev>
## 相关项目 ## 相关项目
+110 -51
View File
@@ -11,6 +11,7 @@ from app.schemas.types import MediaType
from app.utils.string import StringUtils from app.utils.string import StringUtils
from app.utils.tokens import Tokens from app.utils.tokens import Tokens
from app.core.meta.streamingplatform import StreamingPlatforms from app.core.meta.streamingplatform import StreamingPlatforms
from app.utils import rust_accel
class MetaVideo(MetaBase): class MetaVideo(MetaBase):
@@ -102,59 +103,61 @@ class MetaVideo(MetaBase):
title = re.sub(r'[0-9.]+\s*[MGT]i?B(?![A-Z]+)', "", title, flags=re.IGNORECASE) title = re.sub(r'[0-9.]+\s*[MGT]i?B(?![A-Z]+)', "", title, flags=re.IGNORECASE)
# 把年月日去掉 # 把年月日去掉
title = re.sub(r'\d{4}[\s._-]\d{1,2}[\s._-]\d{1,2}', "", title) title = re.sub(r'\d{4}[\s._-]\d{1,2}[\s._-]\d{1,2}', "", title)
# 拆分tokens
tokens = Tokens(title)
# 实例化StreamingPlatforms对象
streaming_platforms = StreamingPlatforms()
media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIOEXT media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIOEXT
# 解析名称、年份、季、集、资源类型、分辨率等 rust_parse = rust_accel.parse_video_title(title, isfile=isfile, media_exts=media_exts)
token = tokens.get_next() if not self.__apply_rust_parse(rust_parse):
while token: # 拆分tokens
self._index += 1 # 更新当前处理的token索引 tokens = Tokens(title)
# Part # 实例化StreamingPlatforms对象
self.__init_part(token, tokens) streaming_platforms = StreamingPlatforms()
# 标题 # 解析名称、年份、季、集、资源类型、分辨率等
if self._continue_flag:
self.__init_name(token, media_exts)
# 年份
if self._continue_flag:
self.__init_year(token)
# 分辨率
if self._continue_flag:
self.__init_resource_pix(token)
# 季
if self._continue_flag:
self.__init_season(token)
# 集
if self._continue_flag:
self.__init_episode(token)
# 资源类型
if self._continue_flag:
self.__init_resource_type(token)
# 流媒体平台
if self._continue_flag:
self.__init_web_source(token, tokens, streaming_platforms)
# 视频编码
if self._continue_flag:
self.__init_video_encode(token)
# 视频位深
if self._continue_flag:
self.__init_video_bit(token)
# 音频编码
if self._continue_flag:
self.__init_audio_encode(token)
# 帧率
if self._continue_flag:
self.__init_fps(token)
# 取下一个,直到没有为卡
token = tokens.get_next() token = tokens.get_next()
self._continue_flag = True while token:
# 合成质量 self._index += 1 # 更新当前处理的token索引
if self._effect: # Part
self._effect.reverse() self.__init_part(token, tokens)
self.resource_effect = " ".join(self._effect) # 标题
if self._source: if self._continue_flag:
self.resource_type = self._source.strip() self.__init_name(token, media_exts)
# 年份
if self._continue_flag:
self.__init_year(token)
# 分辨率
if self._continue_flag:
self.__init_resource_pix(token)
# 季
if self._continue_flag:
self.__init_season(token)
# 集
if self._continue_flag:
self.__init_episode(token)
# 资源类型
if self._continue_flag:
self.__init_resource_type(token)
# 流媒体平台
if self._continue_flag:
self.__init_web_source(token, tokens, streaming_platforms)
# 视频编码
if self._continue_flag:
self.__init_video_encode(token)
# 视频位深
if self._continue_flag:
self.__init_video_bit(token)
# 音频编码
if self._continue_flag:
self.__init_audio_encode(token)
# 帧率
if self._continue_flag:
self.__init_fps(token)
# 取下一个,直到没有为卡
token = tokens.get_next()
self._continue_flag = True
# 合成质量
if self._effect:
self._effect.reverse()
self.resource_effect = " ".join(self._effect)
if self._source:
self.resource_type = self._source.strip()
# 提取原盘DIY # 提取原盘DIY
if self.resource_type and "BluRay" in self.resource_type: if self.resource_type and "BluRay" in self.resource_type:
if (self.subtitle and re.findall(r'D[Ii]Y', self.subtitle)) \ if (self.subtitle and re.findall(r'D[Ii]Y', self.subtitle)) \
@@ -185,6 +188,62 @@ class MetaVideo(MetaBase):
if not self.video_bit: if not self.video_bit:
self.video_bit = self.extract_video_bit(self.video_encode) self.video_bit = self.extract_video_bit(self.video_encode)
def __apply_rust_parse(self, rust_parse: Optional[dict]) -> bool:
"""
应用 Rust 主识别结果;成功时跳过 Python token 主循环。
"""
if not rust_parse or not rust_parse.get("complete"):
return False
self.cn_name = rust_parse.get("cn_name")
self.en_name = rust_parse.get("en_name")
if rust_parse.get("year"):
self.year = str(rust_parse.get("year"))
self.part = rust_parse.get("part")
self.__merge_rust_parse(rust_parse)
media_type = rust_parse.get("type")
if media_type == "tv":
self.type = MediaType.TV
elif media_type == "movie":
self.type = MediaType.MOVIE
return True
def __merge_rust_parse(self, rust_parse: Optional[dict]) -> None:
"""
合并 Rust 预解析结果,仅补齐 Python 识别未命中的资源字段。
"""
if not rust_parse:
return
if not self.year and rust_parse.get("year"):
self.year = str(rust_parse.get("year"))
if self.begin_season is None and rust_parse.get("begin_season") is not None:
self.begin_season = int(rust_parse.get("begin_season"))
self.type = MediaType.TV
if self.end_season is None and rust_parse.get("end_season") is not None:
self.end_season = int(rust_parse.get("end_season"))
if not self.total_season and rust_parse.get("total_season"):
self.total_season = int(rust_parse.get("total_season"))
if self.begin_episode is None and rust_parse.get("begin_episode") is not None:
self.begin_episode = int(rust_parse.get("begin_episode"))
self.type = MediaType.TV
if self.end_episode is None and rust_parse.get("end_episode") is not None:
self.end_episode = int(rust_parse.get("end_episode"))
if not self.total_episode and rust_parse.get("total_episode"):
self.total_episode = int(rust_parse.get("total_episode"))
if not self.resource_pix and rust_parse.get("resource_pix"):
self.resource_pix = rust_parse.get("resource_pix")
if not self.resource_type and rust_parse.get("resource_type"):
self.resource_type = rust_parse.get("resource_type")
if not self.resource_effect and rust_parse.get("resource_effect"):
self.resource_effect = rust_parse.get("resource_effect")
if not self.video_encode and rust_parse.get("video_encode"):
self.video_encode = rust_parse.get("video_encode")
if not self.video_bit and rust_parse.get("video_bit"):
self.video_bit = rust_parse.get("video_bit")
if not self.audio_encode and rust_parse.get("audio_encode"):
self.audio_encode = rust_parse.get("audio_encode")
if self.fps is None and rust_parse.get("fps") is not None:
self.fps = int(rust_parse.get("fps"))
@staticmethod @staticmethod
def __get_title_from_description(description: str) -> Optional[str]: def __get_title_from_description(description: str) -> Optional[str]:
""" """
+7
View File
@@ -12,6 +12,7 @@ from app.core.meta.infopath import (
from app.core.meta.words import WordsMatcher from app.core.meta.words import WordsMatcher
from app.log import logger from app.log import logger
from app.schemas.types import MediaType from app.schemas.types import MediaType
from app.utils import rust_accel
_ANIME_BRACKET_RE = re.compile(r'【[+0-9XVPI-]+】\s*【', re.IGNORECASE) _ANIME_BRACKET_RE = re.compile(r'【[+0-9XVPI-]+】\s*【', re.IGNORECASE)
@@ -168,6 +169,9 @@ def is_anime(name: str) -> bool:
:param name: 名称 :param name: 名称
:return: 是否动漫 :return: 是否动漫
""" """
rust_result = rust_accel.is_anime(name)
if rust_result is not None:
return rust_result
if not name: if not name:
return False return False
if _ANIME_BRACKET_RE.search(name): if _ANIME_BRACKET_RE.search(name):
@@ -185,6 +189,9 @@ def find_metainfo(title: str) -> Tuple[str, dict]:
""" """
从标题中提取媒体信息 从标题中提取媒体信息
""" """
rust_result = rust_accel.find_metainfo(title)
if rust_result is not None:
return rust_result
metainfo = _empty_metainfo() metainfo = _empty_metainfo()
if not title: if not title:
return title, metainfo return title, metainfo
+23
View File
@@ -2,6 +2,8 @@ import threading
from pyparsing import Forward, Literal, Word, alphas, infixNotation, opAssoc, alphanums, Combine, nums, ParseResults from pyparsing import Forward, Literal, Word, alphas, infixNotation, opAssoc, alphanums, Combine, nums, ParseResults
from app.utils import rust_accel
class RuleParser: class RuleParser:
@@ -48,9 +50,30 @@ class RuleParser:
返回: 返回:
解析结果 解析结果
""" """
rust_result = rust_accel.parse_filter_rule(expression)
if rust_result is not None:
return _RustParseResults(rust_result)
return self.expr.parseString(expression) return self.expr.parseString(expression)
class _RustParseResults(list):
"""
包装 Rust 解析结果,提供本模块调用方使用的 as_list/asList 接口。
"""
def as_list(self) -> list:
"""
返回兼容 pyparsing.ParseResults.as_list 的列表结构。
"""
return list(self)
def asList(self) -> list: # noqa: N802
"""
返回兼容 pyparsing.ParseResults.asList 的列表结构。
"""
return self.as_list()
if __name__ == '__main__': if __name__ == '__main__':
# 测试代码 # 测试代码
expression_str = """ expression_str = """
+44
View File
@@ -11,6 +11,7 @@ from app.modules import _ModuleBase
from app.modules.filter.RuleParser import RuleParser from app.modules.filter.RuleParser import RuleParser
from app.modules.filter.builtin_rules import BUILTIN_RULE_SET from app.modules.filter.builtin_rules import BUILTIN_RULE_SET
from app.schemas.types import ModuleType, OtherModulesType, SystemConfigKey from app.schemas.types import ModuleType, OtherModulesType, SystemConfigKey
from app.utils import rust_accel
from app.utils.string import StringUtils from app.utils.string import StringUtils
@@ -138,6 +139,9 @@ class FilterModule(_ModuleBase):
# 查询规则表详情 # 查询规则表详情
groups = self.rulehelper.get_rule_group_by_media(media=mediainfo, group_names=rule_groups) groups = self.rulehelper.get_rule_group_by_media(media=mediainfo, group_names=rule_groups)
if groups: if groups:
rust_filtered = self.__filter_torrents_by_rust(groups, torrent_list, mediainfo)
if rust_filtered is not None:
return rust_filtered
for group in groups: for group in groups:
# 过滤种子 # 过滤种子
torrent_list = self.__filter_torrents( torrent_list = self.__filter_torrents(
@@ -150,6 +154,46 @@ class FilterModule(_ModuleBase):
) )
return torrent_list return torrent_list
def __filter_torrents_by_rust(self, groups: list, torrent_list: List[TorrentInfo],
mediainfo: MediaInfo) -> Optional[List[TorrentInfo]]:
"""
使用 Rust 批量过滤种子;遇到不可支持的规则时返回 None 交由 Python 逻辑处理。
"""
if not torrent_list:
return []
payloads = [self.__build_rust_torrent_payload(torrent) for torrent in torrent_list]
media_payload = mediainfo.to_dict() if mediainfo and hasattr(mediainfo, "to_dict") else (
vars(mediainfo).copy() if mediainfo else None
)
result = rust_accel.filter_torrents(
rule_set=self.rule_set,
rule_strings=[group.rule_string for group in groups],
torrents=payloads,
media_info=media_payload,
)
if result is None:
return None
filtered_torrents = []
for index, pri_order in result:
torrent = torrent_list[int(index)]
torrent.pri_order = int(pri_order)
filtered_torrents.append(torrent)
return filtered_torrents
@staticmethod
def __build_rust_torrent_payload(torrent: TorrentInfo) -> dict:
"""
组装 Rust 过滤器需要的纯数据载荷,避免 Rust 直接依赖 Python 业务对象。
"""
payload = torrent.to_dict() if hasattr(torrent, "to_dict") else vars(torrent).copy()
payload["pub_minutes"] = torrent.pub_minutes()
if payload.get("size"):
meta = MetaInfo(title=torrent.title, subtitle=torrent.description)
payload["episode_count"] = meta.total_episode or 1
else:
payload["episode_count"] = 1
return payload
def __filter_torrents(self, rule_string: str, rule_name: str, def __filter_torrents(self, rule_string: str, rule_name: str,
torrent_list: List[TorrentInfo], torrent_list: List[TorrentInfo],
mediainfo: MediaInfo, mediainfo: MediaInfo,
+12
View File
@@ -11,8 +11,10 @@ from requests import Session
from app.core.config import settings from app.core.config import settings
from app.helper.cloudflare import under_challenge from app.helper.cloudflare import under_challenge
from app.log import logger from app.log import logger
from app.utils import rust_accel
from app.utils.http import RequestUtils from app.utils.http import RequestUtils
from app.utils.site import SiteUtils from app.utils.site import SiteUtils
from app.utils.string import StringUtils
# 站点框架 # 站点框架
@@ -154,6 +156,16 @@ class SiteParserBase(metaclass=ABCMeta):
""" """
return self.schema return self.schema
@staticmethod
def num_filesize(text) -> int:
"""
将站点页面中的文件大小文本转换为字节,优先使用 Rust 快路径。
"""
rust_value = rust_accel.parse_filesize(text)
if rust_value is not None:
return rust_value
return StringUtils.num_filesize(text)
def parse(self): def parse(self):
""" """
解析站点信息 解析站点信息
+5 -5
View File
@@ -93,10 +93,10 @@ class NexusPhpSiteUserInfo(SiteParserBase):
html_text = self._prepare_html_text(html_text) html_text = self._prepare_html_text(html_text)
upload_match = re.search(r"[^总]上[传傳]量?[:_<>/a-zA-Z-=\"'\s#;]+([\d,.\s]+[KMGTPI]*B)", html_text, upload_match = re.search(r"[^总]上[传傳]量?[:_<>/a-zA-Z-=\"'\s#;]+([\d,.\s]+[KMGTPI]*B)", html_text,
re.IGNORECASE) re.IGNORECASE)
self.upload = StringUtils.num_filesize(upload_match.group(1).strip()) if upload_match else 0 self.upload = self.num_filesize(upload_match.group(1).strip()) if upload_match else 0
download_match = re.search(r"[^总子影力]下[载載]量?[:_<>/a-zA-Z-=\"'\s#;]+([\d,.\s]+[KMGTPI]*B)", html_text, download_match = re.search(r"[^总子影力]下[载載]量?[:_<>/a-zA-Z-=\"'\s#;]+([\d,.\s]+[KMGTPI]*B)", html_text,
re.IGNORECASE) re.IGNORECASE)
self.download = StringUtils.num_filesize(download_match.group(1).strip()) if download_match else 0 self.download = self.num_filesize(download_match.group(1).strip()) if download_match else 0
ratio_match = re.search(r"分享率[:_<>/a-zA-Z-=\"'\s#;]+([\d,.\s]+)", html_text) ratio_match = re.search(r"分享率[:_<>/a-zA-Z-=\"'\s#;]+([\d,.\s]+)", html_text)
# 计算分享率 # 计算分享率
calc_ratio = 0.0 if self.download <= 0.0 else round(self.upload / self.download, 3) calc_ratio = 0.0 if self.download <= 0.0 else round(self.upload / self.download, 3)
@@ -209,7 +209,7 @@ class NexusPhpSiteUserInfo(SiteParserBase):
page_seeding = len(seeding_sizes) page_seeding = len(seeding_sizes)
for i in range(0, len(seeding_sizes)): for i in range(0, len(seeding_sizes)):
size = StringUtils.num_filesize(seeding_sizes[i].xpath("string(.)").strip()) size = self.num_filesize(seeding_sizes[i].xpath("string(.)").strip())
seeders = StringUtils.str_int(seeding_seeders[i]) seeders = StringUtils.str_int(seeding_seeders[i])
page_seeding_size += size page_seeding_size += size
@@ -273,7 +273,7 @@ class NexusPhpSiteUserInfo(SiteParserBase):
tmp_seeding_size = 0 tmp_seeding_size = 0
tmp_seeding_info = [] tmp_seeding_info = []
for i in range(0, len(seeding_sizes)): for i in range(0, len(seeding_sizes)):
size = StringUtils.num_filesize(seeding_sizes[i].xpath("string(.)").strip()) size = self.num_filesize(seeding_sizes[i].xpath("string(.)").strip())
seeders = StringUtils.str_int(seeding_seeders[i]) seeders = StringUtils.str_int(seeding_seeders[i])
tmp_seeding_size += size tmp_seeding_size += size
@@ -292,7 +292,7 @@ class NexusPhpSiteUserInfo(SiteParserBase):
seeding_size_match = re.search(r"总做种体积:\s+([\d,.\s]+[KMGTPI]*B)", seeding_sizes[0], re.IGNORECASE) seeding_size_match = re.search(r"总做种体积:\s+([\d,.\s]+[KMGTPI]*B)", seeding_sizes[0], re.IGNORECASE)
tmp_seeding = StringUtils.str_int(seeding_match.group(1)) if ( tmp_seeding = StringUtils.str_int(seeding_match.group(1)) if (
seeding_match and seeding_match.group(1)) else 0 seeding_match and seeding_match.group(1)) else 0
tmp_seeding_size = StringUtils.num_filesize( tmp_seeding_size = self.num_filesize(
seeding_size_match.group(1).strip()) if seeding_size_match else 0 seeding_size_match.group(1).strip()) if seeding_size_match else 0
if not self.seeding_size: if not self.seeding_size:
self.seeding_size = tmp_seeding_size self.seeding_size = tmp_seeding_size
+4 -4
View File
@@ -75,7 +75,7 @@ class ZhixingSiteUserInfo(SiteParserBase):
s = s.strip() s = s.strip()
if re.match(r'^\d+(\.\d+)?$', s): if re.match(r'^\d+(\.\d+)?$', s):
s += ' B' s += ' B'
return StringUtils.num_filesize(s) if s else 0 return self.num_filesize(s) if s else 0
self.upload = num_filesize_safe(info_dict.get('上传流量')) if '上传流量' in info_dict else 0 self.upload = num_filesize_safe(info_dict.get('上传流量')) if '上传流量' in info_dict else 0
self.download = num_filesize_safe(info_dict.get('下载流量')) if '下载流量' in info_dict else 0 self.download = num_filesize_safe(info_dict.get('下载流量')) if '下载流量' in info_dict else 0
@@ -108,7 +108,7 @@ class ZhixingSiteUserInfo(SiteParserBase):
if size_td: if size_td:
size_text = size_td.find('a').text if size_td.find('a') else size_td.text.strip() size_text = size_td.find('a').text if size_td.find('a') else size_td.text.strip()
page_seeding += 1 page_seeding += 1
page_seeding_size += StringUtils.num_filesize(size_text) page_seeding_size += self.num_filesize(size_text)
return page_seeding, page_seeding_size return page_seeding, page_seeding_size
def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]: def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]:
@@ -164,7 +164,7 @@ class ZhixingSiteUserInfo(SiteParserBase):
s = s.strip() s = s.strip()
if re.match(r'^\d+(\.\d+)?$', s): if re.match(r'^\d+(\.\d+)?$', s):
s += ' B' s += ' B'
return StringUtils.num_filesize(s) if s else 0 return self.num_filesize(s) if s else 0
self.seeding = int(self._basic_info.get('当前保种数量', 0)) self.seeding = int(self._basic_info.get('当前保种数量', 0))
self.seeding_size = num_filesize_safe(self._basic_info.get('当前保种容量', '')) self.seeding_size = num_filesize_safe(self._basic_info.get('当前保种容量', ''))
@@ -181,4 +181,4 @@ class ZhixingSiteUserInfo(SiteParserBase):
self.message_unread = str(self.message_unread or 0) self.message_unread = str(self.message_unread or 0)
self.seeding = str(self.seeding or 0) self.seeding = str(self.seeding or 0)
self.seeding_size = str(self.seeding_size or 0) self.seeding_size = str(self.seeding_size or 0)
+27
View File
@@ -12,6 +12,7 @@ from pyquery import PyQuery
from app.core.config import settings from app.core.config import settings
from app.log import logger from app.log import logger
from app.schemas.types import MediaType from app.schemas.types import MediaType
from app.utils import rust_accel
from app.utils.http import RequestUtils, AsyncRequestUtils from app.utils.http import RequestUtils, AsyncRequestUtils
from app.utils.string import StringUtils from app.utils.string import StringUtils
from app.utils.url import UrlUtils from app.utils.url import UrlUtils
@@ -95,6 +96,19 @@ class SiteSpider:
""" """
获取搜索URL 获取搜索URL
""" """
rust_url = rust_accel.build_indexer_search_url({
"search": self.search,
"batch": self.batch,
"browse": self.browse,
"category": self.category,
"domain": self.domain,
"keyword": self.keyword,
"mtype": self.mtype.value if self.mtype else None,
"cat": self.cat,
"page": self.page,
})
if rust_url:
return rust_url
# 种子搜索相对路径 # 种子搜索相对路径
paths = self.search.get('paths', []) paths = self.search.get('paths', [])
torrentspath = "" torrentspath = ""
@@ -658,6 +672,9 @@ class SiteSpider:
""" """
if not text or not filters or not isinstance(filters, list): if not text or not filters or not isinstance(filters, list):
return text return text
rust_text = rust_accel.apply_indexer_text_filters(text, filters)
if rust_text is not None:
return rust_text
if not isinstance(text, str): if not isinstance(text, str):
text = str(text) text = str(text)
for filter_item in filters: for filter_item in filters:
@@ -739,6 +756,16 @@ class SiteSpider:
# 清空旧结果 # 清空旧结果
self.torrents_info_array = [] self.torrents_info_array = []
rust_torrents = rust_accel.parse_indexer_torrents(
html_text=html_text,
domain=self.domain,
list_config=self.list,
fields=self.fields,
category=self.category,
result_num=int(self.result_num),
)
if rust_torrents is not None:
return rust_torrents
html_doc = None html_doc = None
try: try:
# 解析站点文本对象 # 解析站点文本对象
+206
View File
@@ -0,0 +1,206 @@
from typing import Any, Dict, List, Optional, Tuple
from app.log import logger
from app.schemas.types import MediaType
try:
import moviepilot_rust as _moviepilot_rust
except Exception as err: # pragma: no cover - 取决于运行环境是否安装 Rust 扩展
_moviepilot_rust = None
_import_error = err
else:
_import_error = None
def is_available() -> bool:
"""
判断 Rust 扩展是否可用。
"""
return bool(_moviepilot_rust and _moviepilot_rust.is_available())
def import_error() -> Optional[Exception]:
"""
返回 Rust 扩展导入失败的异常,便于调试构建问题。
"""
return _import_error
def is_anime(name: str) -> Optional[bool]:
"""
使用 Rust 快路径判断标题是否为动漫格式,不可用时返回 None。
"""
if not _moviepilot_rust:
return None
try:
return bool(_moviepilot_rust.is_anime_fast(name or ""))
except BaseException as err:
_raise_non_rust_panic(err)
logger.debug(f"Rust 动漫识别失败,回退 Python:{err}")
return None
def find_metainfo(title: str) -> Optional[Tuple[str, Dict[str, Any]]]:
"""
使用 Rust 快路径提取标题中的内嵌媒体标签,不可用时返回 None。
"""
if not _moviepilot_rust:
return None
try:
result = _moviepilot_rust.find_metainfo_fast(title or "")
except BaseException as err:
_raise_non_rust_panic(err)
logger.debug(f"Rust 内嵌媒体标签识别失败,回退 Python:{err}")
return None
metainfo = {
"tmdbid": result.get("tmdbid"),
"doubanid": result.get("doubanid"),
"type": _coerce_media_type(result.get("type")),
"begin_season": result.get("begin_season"),
"end_season": result.get("end_season"),
"total_season": result.get("total_season"),
"begin_episode": result.get("begin_episode"),
"end_episode": result.get("end_episode"),
"total_episode": result.get("total_episode"),
}
return result.get("title"), metainfo
def parse_video_title(
title: str,
isfile: bool = False,
media_exts: Optional[List[str]] = None,
) -> Optional[Dict[str, Any]]:
"""
使用 Rust 执行影视标题主识别流程,不可用时返回 None。
"""
if not _moviepilot_rust:
return None
try:
return _moviepilot_rust.parse_video_title_fast(title or "", isfile, media_exts or [])
except BaseException as err:
_raise_non_rust_panic(err)
logger.debug(f"Rust 影视标题主识别失败,回退 Python:{err}")
return None
def parse_filter_rule(expression: str) -> Optional[list]:
"""
使用 Rust 解析过滤规则表达式,不可用时返回 None。
"""
if not _moviepilot_rust:
return None
try:
return _moviepilot_rust.parse_filter_rule_fast(expression)
except BaseException as err:
_raise_non_rust_panic(err)
logger.debug(f"Rust 过滤规则解析失败,回退 Python:{err}")
return None
def filter_torrents(
rule_set: Dict[str, dict],
rule_strings: List[str],
torrents: List[dict],
media_info: Optional[dict] = None,
) -> Optional[list]:
"""
使用 Rust 批量执行种子过滤,不可用或不兼容时返回 None。
"""
if not _moviepilot_rust:
return None
try:
return _moviepilot_rust.filter_torrents_fast(rule_set, rule_strings, torrents, media_info)
except BaseException as err:
_raise_non_rust_panic(err)
logger.debug(f"Rust 种子过滤失败,回退 Python:{err}")
return None
def apply_indexer_text_filters(text: Any, filters: Optional[List[dict]]) -> Optional[str]:
"""
使用 Rust 执行 indexer 文本过滤器,不可用或遇到不支持过滤器时返回 None。
"""
if not _moviepilot_rust or not filters or not isinstance(filters, list):
return None
try:
return _moviepilot_rust.apply_indexer_text_filters_fast(None if text is None else str(text), filters)
except BaseException as err:
_raise_non_rust_panic(err)
logger.debug(f"Rust 站点文本过滤失败,回退 Python:{err}")
return None
def parse_filesize(text: Any) -> Optional[int]:
"""
使用 Rust 将文件大小文本转换为字节,不可用时返回 None。
"""
if not _moviepilot_rust:
return None
try:
return int(_moviepilot_rust.parse_filesize_fast(text))
except BaseException as err:
_raise_non_rust_panic(err)
logger.debug(f"Rust 文件大小解析失败,回退 Python:{err}")
return None
def build_indexer_search_url(config: dict) -> Optional[str]:
"""
使用 Rust 根据普通 indexer 配置生成搜索 URL,不可用时返回 None。
"""
if not _moviepilot_rust:
return None
try:
return _moviepilot_rust.build_indexer_search_url_fast(config)
except BaseException as err:
_raise_non_rust_panic(err)
logger.debug(f"Rust 站点搜索 URL 生成失败,回退 Python:{err}")
return None
def parse_indexer_torrents(
html_text: str,
domain: str,
list_config: dict,
fields: dict,
category: Optional[dict],
result_num: int,
) -> Optional[List[dict]]:
"""
使用 Rust 批量解析普通 indexer 页面,不支持的配置返回 None。
"""
if not _moviepilot_rust:
return None
try:
return _moviepilot_rust.parse_indexer_torrents_fast(
html_text or "",
domain or "",
list_config or {},
fields or {},
category,
int(result_num or 0),
)
except BaseException as err:
_raise_non_rust_panic(err)
logger.debug(f"Rust 站点页面解析失败,回退 Python:{err}")
return None
def _coerce_media_type(value: Optional[str]) -> Optional[MediaType]:
"""
将 Rust 返回的媒体类型字符串转换为系统 MediaType。
"""
if value == "movies":
return MediaType.MOVIE
if value == "tv":
return MediaType.TV
return None
def _raise_non_rust_panic(err: BaseException) -> None:
"""
只吞掉 Rust 扩展 panic/异常,保留用户中断和进程退出语义。
"""
if isinstance(err, (KeyboardInterrupt, SystemExit)):
raise err
+18 -4
View File
@@ -85,10 +85,24 @@ RUN python3 -m venv ${VENV_PATH} \
&& ln -sf /usr/local/bin/uv-pip-compat ${VENV_PATH}/bin/pip3.12 \ && ln -sf /usr/local/bin/uv-pip-compat ${VENV_PATH}/bin/pip3.12 \
&& ln -sf /usr/local/bin/uv-pip-compat ${VENV_PATH}/bin/pip-compile \ && ln -sf /usr/local/bin/uv-pip-compat ${VENV_PATH}/bin/pip-compile \
&& ln -sf /usr/local/bin/uv-pip-compat ${VENV_PATH}/bin/pip-sync \ && ln -sf /usr/local/bin/uv-pip-compat ${VENV_PATH}/bin/pip-sync \
&& pip install "Cython~=3.1.2" \ && pip install "Cython~=3.1.2" "maturin>=1.9,<2" \
&& pip-compile requirements.in -o requirements.txt \ && pip-compile requirements.in -o requirements.txt \
&& pip install -r requirements.txt && pip install -r requirements.txt
# 准备 Rust 扩展
FROM prepare_venv AS prepare_rust
ENV PATH="${VENV_PATH}/bin:/root/.cargo/bin:${PATH}"
WORKDIR /app
COPY rust /app/rust
RUN curl https://sh.rustup.rs -sSf | sh -s -- -y --profile minimal \
&& cd /app/rust/moviepilot_rust \
&& maturin build --release -o /tmp/wheels \
&& pip install /tmp/wheels/*.whl \
&& rm -rf /tmp/wheels /root/.cargo /root/.rustup
# 下载准备代码 # 下载准备代码
FROM prepare_package AS prepare_code FROM prepare_package AS prepare_code
@@ -114,9 +128,9 @@ FROM prepare_package AS final
ENV LD_PRELOAD="/usr/local/lib/libjemalloc.so" ENV LD_PRELOAD="/usr/local/lib/libjemalloc.so"
# python 环境 # python 环境
COPY --from=prepare_venv --chmod=777 ${VENV_PATH} ${VENV_PATH} COPY --from=prepare_rust --chmod=777 ${VENV_PATH} ${VENV_PATH}
COPY --from=prepare_venv /usr/local/bin/uv /usr/local/bin/uv COPY --from=prepare_rust /usr/local/bin/uv /usr/local/bin/uv
COPY --from=prepare_venv /usr/local/bin/uv-pip-compat /usr/local/bin/uv-pip-compat COPY --from=prepare_rust /usr/local/bin/uv-pip-compat /usr/local/bin/uv-pip-compat
# 浏览器运行依赖 # 浏览器运行依赖
RUN playwright install-deps chromium \ RUN playwright install-deps chromium \
+5 -2
View File
@@ -159,6 +159,9 @@ moviepilot install deps --config-dir /path/to/moviepilot-config
说明: 说明:
- 默认会自动选择本地已安装的 `Python 3.11+` 解释器 - 默认会自动选择本地已安装的 `Python 3.11+` 解释器
- 会在安装 Python 依赖后构建并安装 `moviepilot_rust` 加速扩展,因此本机需要可用的 Rust `cargo`
- 一键安装脚本会自动准备 Rust toolchain 和系统构建工具;手动执行 CLI 安装时,如果未安装 Rust 或本机编译器,请先安装后再执行 `moviepilot install deps`
- 如需临时跳过加速扩展构建,可设置 `MOVIEPILOT_SKIP_RUST_ACCEL=1`,但相关核心处理会回退到 Python 实现,性能收益不会生效
安装前端 release 安装前端 release
@@ -220,7 +223,7 @@ moviepilot setup --config-dir /path/to/moviepilot-config
`moviepilot setup` 会串行执行: `moviepilot setup` 会串行执行:
1. 安装后端依赖 1. 安装后端依赖并构建 Rust 加速扩展
2. 下载并安装前端 release 2. 下载并安装前端 release
3. 下载并同步资源文件 3. 下载并同步资源文件
4. 初始化本地配置 4. 初始化本地配置
@@ -323,7 +326,7 @@ moviepilot update all --skip-resources
说明: 说明:
- `update backend` 会更新 Git 仓库并重新安装后端依赖 - `update backend` 会更新 Git 仓库并重新安装后端依赖,同时重新构建 Rust 加速扩展
- `update frontend` 会按当前仓库 `version.py` 中的 `FRONTEND_VERSION` 下载并替换前端 release - `update frontend` 会按当前仓库 `version.py` 中的 `FRONTEND_VERSION` 下载并替换前端 release
- `update all` 会先更新后端,再按更新后代码中的 `FRONTEND_VERSION` 更新前端,默认也会同步资源文件 - `update all` 会先更新后端,再按更新后代码中的 `FRONTEND_VERSION` 更新前端,默认也会同步资源文件
- 更新前请先执行 `moviepilot stop` - 更新前请先执行 `moviepilot stop`
+10
View File
@@ -105,6 +105,8 @@ Options:
--venv PATH 虚拟环境目录,默认 ./venv --venv PATH 虚拟环境目录,默认 ./venv
--recreate 删除并重建虚拟环境 --recreate 删除并重建虚拟环境
--config-dir PATH 指定配置目录 --config-dir PATH 指定配置目录
说明 会构建并安装 Rust 加速扩展,需本机可用 cargo 和编译器;
可临时设置 MOVIEPILOT_SKIP_RUST_ACCEL=1 跳过构建
frontend: frontend:
--version TAG 前端版本,默认使用 version.py 中的 FRONTEND_VERSION --version TAG 前端版本,默认使用 version.py 中的 FRONTEND_VERSION
@@ -152,6 +154,10 @@ Options:
--superuser-password PWD 预设超级管理员密码 --superuser-password PWD 预设超级管理员密码
--config-dir PATH 指定配置目录 --config-dir PATH 指定配置目录
-h, --help 显示帮助 -h, --help 显示帮助
说明:
- 安装后端依赖时会构建并安装 Rust 加速扩展,需本机可用 cargo 和编译器
- 可临时设置 MOVIEPILOT_SKIP_RUST_ACCEL=1 跳过加速扩展构建
EOF EOF
} }
@@ -188,6 +194,10 @@ Options:
--skip-resources 更新 all 时跳过资源同步 --skip-resources 更新 all 时跳过资源同步
--config-dir PATH 指定配置目录 --config-dir PATH 指定配置目录
-h, --help 显示帮助 -h, --help 显示帮助
说明:
- 更新后端依赖时会重新构建并安装 Rust 加速扩展,需本机可用 cargo 和编译器
- 可临时设置 MOVIEPILOT_SKIP_RUST_ACCEL=1 跳过加速扩展构建
EOF EOF
} }
+1
View File
@@ -1,4 +1,5 @@
Cython~=3.1.2 Cython~=3.1.2
maturin>=1.9,<2
pydantic>=2.0.0,<3.0.0 pydantic>=2.0.0,<3.0.0
pydantic-settings>=2.0.0,<3.0.0 pydantic-settings>=2.0.0,<3.0.0
SQLAlchemy~=2.0.41 SQLAlchemy~=2.0.41
+948
View File
@@ -0,0 +1,948 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "bitflags"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cssparser"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa"
dependencies = [
"cssparser-macros",
"dtoa-short",
"itoa",
"phf",
"smallvec",
]
[[package]]
name = "cssparser-macros"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
dependencies = [
"quote",
"syn",
]
[[package]]
name = "derive_more"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"syn",
]
[[package]]
name = "displaydoc"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "dtoa"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
[[package]]
name = "dtoa-short"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
dependencies = [
"dtoa",
]
[[package]]
name = "ego-tree"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2972feb8dffe7bc8c5463b1dacda1b0dfbed3710e50f977d965429692d74cd8"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]]
name = "futf"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
dependencies = [
"mac",
"new_debug_unreachable",
]
[[package]]
name = "fxhash"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
dependencies = [
"byteorder",
]
[[package]]
name = "getopts"
version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
dependencies = [
"unicode-width",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "html5ever"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4"
dependencies = [
"log",
"markup5ever",
"match_token",
]
[[package]]
name = "icu_collections"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
dependencies = [
"displaydoc",
"potential_utf",
"utf8_iter",
"yoke",
"zerofrom",
"zerovec",
]
[[package]]
name = "icu_locale_core"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
dependencies = [
"displaydoc",
"litemap",
"tinystr",
"writeable",
"zerovec",
]
[[package]]
name = "icu_normalizer"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
dependencies = [
"icu_collections",
"icu_normalizer_data",
"icu_properties",
"icu_provider",
"smallvec",
"zerovec",
]
[[package]]
name = "icu_normalizer_data"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
[[package]]
name = "icu_properties"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
dependencies = [
"icu_collections",
"icu_locale_core",
"icu_properties_data",
"icu_provider",
"zerotrie",
"zerovec",
]
[[package]]
name = "icu_properties_data"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
[[package]]
name = "icu_provider"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
dependencies = [
"displaydoc",
"icu_locale_core",
"writeable",
"yoke",
"zerofrom",
"zerotrie",
"zerovec",
]
[[package]]
name = "idna"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
dependencies = [
"idna_adapter",
"smallvec",
"utf8_iter",
]
[[package]]
name = "idna_adapter"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
dependencies = [
"icu_normalizer",
"icu_properties",
]
[[package]]
name = "indoc"
version = "2.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
dependencies = [
"rustversion",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "litemap"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "mac"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "markup5ever"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3"
dependencies = [
"log",
"tendril",
"web_atoms",
]
[[package]]
name = "match_token"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "memoffset"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
dependencies = [
"autocfg",
]
[[package]]
name = "moviepilot-rust"
version = "0.1.0"
dependencies = [
"once_cell",
"percent-encoding",
"pyo3",
"regex",
"scraper",
"url",
]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "phf"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_macros",
"phf_shared",
]
[[package]]
name = "phf_codegen"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
dependencies = [
"phf_generator",
"phf_shared",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared",
"rand",
]
[[package]]
name = "phf_macros"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
dependencies = [
"phf_generator",
"phf_shared",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
]
[[package]]
name = "portable-atomic"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "potential_utf"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
dependencies = [
"zerovec",
]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "pyo3"
version = "0.23.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7778bffd85cf38175ac1f545509665d0b9b92a198ca7941f131f85f7a4f9a872"
dependencies = [
"cfg-if",
"indoc",
"libc",
"memoffset",
"once_cell",
"portable-atomic",
"pyo3-build-config",
"pyo3-ffi",
"pyo3-macros",
"unindent",
]
[[package]]
name = "pyo3-build-config"
version = "0.23.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94f6cbe86ef3bf18998d9df6e0f3fc1050a8c5efa409bf712e661a4366e010fb"
dependencies = [
"once_cell",
"target-lexicon",
]
[[package]]
name = "pyo3-ffi"
version = "0.23.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9f1b4c431c0bb1c8fb0a338709859eed0d030ff6daa34368d3b152a63dfdd8d"
dependencies = [
"libc",
"pyo3-build-config",
]
[[package]]
name = "pyo3-macros"
version = "0.23.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbc2201328f63c4710f68abdf653c89d8dbc2858b88c5d88b0ff38a75288a9da"
dependencies = [
"proc-macro2",
"pyo3-macros-backend",
"quote",
"syn",
]
[[package]]
name = "pyo3-macros-backend"
version = "0.23.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fca6726ad0f3da9c9de093d6f116a93c1a38e417ed73bf138472cf4064f72028"
dependencies = [
"heck",
"proc-macro2",
"pyo3-build-config",
"quote",
"syn",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
]
[[package]]
name = "regex"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "scraper"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5f3a24d916e78954af99281a455168d4a9515d65eca99a18da1b813689c4ad9"
dependencies = [
"cssparser",
"ego-tree",
"getopts",
"html5ever",
"precomputed-hash",
"selectors",
"tendril",
]
[[package]]
name = "selectors"
version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5685b6ae43bfcf7d2e7dfcfb5d8e8f61b46442c902531e41a32a9a8bf0ee0fb6"
dependencies = [
"bitflags",
"cssparser",
"derive_more",
"fxhash",
"log",
"new_debug_unreachable",
"phf",
"phf_codegen",
"precomputed-hash",
"servo_arc",
"smallvec",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "servo_arc"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930"
dependencies = [
"stable_deref_trait",
]
[[package]]
name = "siphasher"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "string_cache"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared",
"precomputed-hash",
"serde",
]
[[package]]
name = "string_cache_codegen"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
dependencies = [
"phf_generator",
"phf_shared",
"proc-macro2",
"quote",
]
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "synstructure"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "target-lexicon"
version = "0.12.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "tendril"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
dependencies = [
"futf",
"mac",
"utf-8",
]
[[package]]
name = "tinystr"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
dependencies = [
"displaydoc",
"zerovec",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "unindent"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
[[package]]
name = "url"
version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
"serde",
]
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "web_atoms"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414"
dependencies = [
"phf",
"phf_codegen",
"string_cache",
"string_cache_codegen",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "writeable"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "yoke"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
dependencies = [
"stable_deref_trait",
"yoke-derive",
"zerofrom",
]
[[package]]
name = "yoke-derive"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [
"proc-macro2",
"quote",
"syn",
"synstructure",
]
[[package]]
name = "zerofrom"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
dependencies = [
"zerofrom-derive",
]
[[package]]
name = "zerofrom-derive"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [
"proc-macro2",
"quote",
"syn",
"synstructure",
]
[[package]]
name = "zerotrie"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
dependencies = [
"displaydoc",
"yoke",
"zerofrom",
]
[[package]]
name = "zerovec"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
dependencies = [
"yoke",
"zerofrom",
"zerovec-derive",
]
[[package]]
name = "zerovec-derive"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "moviepilot-rust"
version = "0.1.0"
edition = "2021"
[lib]
name = "moviepilot_rust"
crate-type = ["cdylib"]
[dependencies]
once_cell = "1.20"
percent-encoding = "2.3"
pyo3 = { version = "0.23", features = ["abi3-py311", "extension-module"] }
regex = "1.11"
scraper = "0.24"
url = "2.5"
+13
View File
@@ -0,0 +1,13 @@
[build-system]
requires = ["maturin>=1.9,<2"]
build-backend = "maturin"
[project]
name = "moviepilot-rust"
version = "0.1.0"
requires-python = ">=3.11"
description = "Rust acceleration helpers for MoviePilot"
[tool.maturin]
bindings = "pyo3"
strip = true
+640
View File
@@ -0,0 +1,640 @@
use crate::utils::{get_optional_f64, get_optional_i64, get_optional_string};
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use pyo3::types::{PyDict, PyList, PyString};
use regex::{Regex, RegexBuilder};
use std::collections::HashMap;
#[derive(Clone, Debug)]
enum RuleExpr {
Name(String),
Not(Box<RuleExpr>),
And(Box<RuleExpr>, Box<RuleExpr>),
Or(Box<RuleExpr>, Box<RuleExpr>),
}
#[derive(Clone, Debug, PartialEq)]
enum Token {
Name(String),
Not,
And,
Or,
LParen,
RParen,
}
#[derive(Clone, Debug)]
struct TorrentPayload {
index: usize,
title: String,
description: String,
labels: Vec<String>,
size: f64,
seeders: i64,
downloadvolumefactor: Option<f64>,
pub_minutes: f64,
episode_count: f64,
fields: HashMap<String, FieldValue>,
}
#[derive(Clone, Debug)]
enum FieldValue {
Scalar(String),
List(Vec<String>),
}
#[pyfunction]
pub(crate) fn parse_filter_rule_fast(py: Python<'_>, expression: &str) -> PyResult<PyObject> {
let tokens = tokenize_rule(expression)?;
let mut parser = RuleParserState::new(tokens);
let expr = parser.parse_expression()?;
if parser.has_remaining() {
return Err(PyValueError::new_err("规则表达式包含无法解析的剩余内容"));
}
let outer = PyList::empty(py);
outer.append(expr_to_py(py, &expr)?)?;
Ok(outer.into())
}
/// 批量执行种子过滤规则,返回保留项的原始下标和优先级。
#[pyfunction]
#[pyo3(signature = (rule_set, rule_strings, torrents, media_info=None))]
pub(crate) fn filter_torrents_fast(
py: Python<'_>,
rule_set: &Bound<'_, PyDict>,
rule_strings: Vec<String>,
torrents: &Bound<'_, PyList>,
media_info: Option<&Bound<'_, PyDict>>,
) -> PyResult<PyObject> {
py.allow_threads(|| {});
let mut payloads = Vec::with_capacity(torrents.len());
for index in 0..torrents.len() {
let item = torrents.get_item(index)?;
let dict = item.downcast::<PyDict>()?;
payloads.push(TorrentPayload::from_py_dict(index, dict)?);
}
let mut expr_cache: HashMap<String, RuleExpr> = HashMap::new();
let mut regex_cache: HashMap<String, Regex> = HashMap::new();
let mut current_indices: Vec<usize> = (0..payloads.len()).collect();
let mut priorities: HashMap<usize, i64> = HashMap::new();
for rule_string in rule_strings {
if current_indices.is_empty() {
break;
}
let levels: Vec<String> = rule_string
.split('>')
.map(|level| level.trim().to_string())
.collect();
let mut retained = Vec::new();
for payload_index in &current_indices {
let payload = &payloads[*payload_index];
let mut res_order = 100_i64;
let mut matched = false;
for level in &levels {
let expr = if let Some(cached) = expr_cache.get(level) {
cached.clone()
} else {
let parsed = parse_rule_expression(level)?;
expr_cache.insert(level.clone(), parsed.clone());
parsed
};
if match_expr(&expr, payload, rule_set, media_info, &mut regex_cache)? {
matched = true;
priorities.insert(payload.index, res_order);
break;
}
res_order -= 1;
}
if matched {
retained.push(*payload_index);
}
}
current_indices = retained;
}
let result = PyList::empty(py);
for payload_index in current_indices {
let payload = &payloads[payload_index];
result.append((
payload.index,
priorities.get(&payload.index).copied().unwrap_or(0),
))?;
}
Ok(result.into())
}
fn parse_rule_expression(expression: &str) -> PyResult<RuleExpr> {
let tokens = tokenize_rule(expression)?;
let mut parser = RuleParserState::new(tokens);
let expr = parser.parse_expression()?;
if parser.has_remaining() {
return Err(PyValueError::new_err("规则表达式包含无法解析的剩余内容"));
}
Ok(expr)
}
/// 将规则字符串切分为名称、逻辑符和括号。
fn tokenize_rule(expression: &str) -> PyResult<Vec<Token>> {
let chars: Vec<char> = expression.chars().collect();
let mut tokens = Vec::new();
let mut index = 0;
while index < chars.len() {
let ch = chars[index];
if ch.is_whitespace() {
index += 1;
continue;
}
match ch {
'!' => {
tokens.push(Token::Not);
index += 1;
}
'&' => {
tokens.push(Token::And);
index += 1;
}
'|' => {
tokens.push(Token::Or);
index += 1;
}
'(' => {
tokens.push(Token::LParen);
index += 1;
}
')' => {
tokens.push(Token::RParen);
index += 1;
}
_ => {
let start = index;
while index < chars.len() && chars[index].is_ascii_alphanumeric() {
index += 1;
}
if start == index {
return Err(PyValueError::new_err(format!("非法规则字符: {ch}")));
}
let name: String = chars[start..index].iter().collect();
if !is_valid_rule_name(&name) {
return Err(PyValueError::new_err(format!("非法规则名称: {name}")));
}
tokens.push(Token::Name(name));
}
}
}
if tokens.is_empty() {
return Err(PyValueError::new_err("规则表达式不能为空"));
}
Ok(tokens)
}
/// 判断规则名称是否符合原 pyparsing 语法。
fn is_valid_rule_name(name: &str) -> bool {
if name.is_empty() {
return false;
}
let mut chars = name.chars();
let Some(first) = chars.next() else {
return false;
};
if first.is_ascii_alphabetic() {
return chars.all(|ch| ch.is_ascii_alphanumeric());
}
if first.is_ascii_digit() {
let mut seen_alpha = false;
for ch in name.chars().skip_while(|ch| ch.is_ascii_digit()) {
if !ch.is_ascii_alphanumeric() {
return false;
}
if ch.is_ascii_alphabetic() {
seen_alpha = true;
}
}
return seen_alpha;
}
false
}
struct RuleParserState {
tokens: Vec<Token>,
index: usize,
}
impl RuleParserState {
/// 创建规则解析器状态。
fn new(tokens: Vec<Token>) -> Self {
Self { tokens, index: 0 }
}
/// 解析完整表达式。
fn parse_expression(&mut self) -> PyResult<RuleExpr> {
self.parse_or()
}
/// 返回是否还有未消费 token。
fn has_remaining(&self) -> bool {
self.index < self.tokens.len()
}
/// 解析 or 表达式。
fn parse_or(&mut self) -> PyResult<RuleExpr> {
let mut expr = self.parse_and()?;
while self.consume(&Token::Or) {
let right = self.parse_and()?;
expr = RuleExpr::Or(Box::new(expr), Box::new(right));
}
Ok(expr)
}
/// 解析 and 表达式。
fn parse_and(&mut self) -> PyResult<RuleExpr> {
let mut expr = self.parse_not()?;
while self.consume(&Token::And) {
let right = self.parse_not()?;
expr = RuleExpr::And(Box::new(expr), Box::new(right));
}
Ok(expr)
}
/// 解析 not 表达式。
fn parse_not(&mut self) -> PyResult<RuleExpr> {
if self.consume(&Token::Not) {
return Ok(RuleExpr::Not(Box::new(self.parse_not()?)));
}
self.parse_primary()
}
/// 解析原子或括号表达式。
fn parse_primary(&mut self) -> PyResult<RuleExpr> {
let Some(token) = self.tokens.get(self.index).cloned() else {
return Err(PyValueError::new_err("规则表达式意外结束"));
};
match token {
Token::Name(name) => {
self.index += 1;
Ok(RuleExpr::Name(name))
}
Token::LParen => {
self.index += 1;
let expr = self.parse_expression()?;
if !self.consume(&Token::RParen) {
return Err(PyValueError::new_err("规则表达式缺少右括号"));
}
Ok(expr)
}
_ => Err(PyValueError::new_err("规则表达式缺少规则名称")),
}
}
/// 如果下一个 token 匹配则消费它。
fn consume(&mut self, token: &Token) -> bool {
if self.tokens.get(self.index) == Some(token) {
self.index += 1;
return true;
}
false
}
}
/// 将规则 AST 转换为 Python 兼容嵌套列表。
fn expr_to_py(py: Python<'_>, expr: &RuleExpr) -> PyResult<PyObject> {
match expr {
RuleExpr::Name(name) => Ok(PyString::new(py, name).into_any().unbind()),
RuleExpr::Not(inner) => {
let list = PyList::empty(py);
list.append("not")?;
list.append(expr_to_py(py, inner)?)?;
Ok(list.into())
}
RuleExpr::And(left, right) => expr_binary_to_py(py, "and", left, right),
RuleExpr::Or(left, right) => expr_binary_to_py(py, "or", left, right),
}
}
/// 将二元规则 AST 转换为 Python 兼容嵌套列表。
fn expr_binary_to_py(
py: Python<'_>,
operator: &str,
left: &RuleExpr,
right: &RuleExpr,
) -> PyResult<PyObject> {
let list = PyList::empty(py);
list.append(expr_to_py(py, left)?)?;
list.append(operator)?;
list.append(expr_to_py(py, right)?)?;
Ok(list.into())
}
impl TorrentPayload {
/// 从 Python 字典构造 Rust 过滤载荷。
fn from_py_dict(index: usize, dict: &Bound<'_, PyDict>) -> PyResult<Self> {
let title = get_optional_string(dict, "title")?.unwrap_or_default();
let description = get_optional_string(dict, "description")?.unwrap_or_default();
let labels = get_string_list(dict, "labels")?;
let size = get_optional_f64(dict, "size")?.unwrap_or(0.0);
let seeders = get_optional_i64(dict, "seeders")?.unwrap_or(0);
let downloadvolumefactor = get_optional_f64(dict, "downloadvolumefactor")?;
let pub_minutes = get_optional_f64(dict, "pub_minutes")?.unwrap_or(0.0);
let episode_count = get_optional_f64(dict, "episode_count")?
.unwrap_or(1.0)
.max(1.0);
let mut fields = HashMap::new();
for (key, value) in dict.iter() {
let key = key.extract::<String>()?;
if value.is_none() {
continue;
}
if let Ok(values) = value.extract::<Vec<String>>() {
fields.insert(key, FieldValue::List(values));
} else {
fields.insert(key, FieldValue::Scalar(value.str()?.to_str()?.to_string()));
}
}
Ok(Self {
index,
title,
description,
labels,
size,
seeders,
downloadvolumefactor,
pub_minutes,
episode_count,
fields,
})
}
/// 返回指定字段的匹配文本。
fn content_for_matches(&self, match_fields: &[String]) -> String {
if match_fields.is_empty() {
return format!(
"{} {} {}",
self.title,
self.description,
self.labels.join(" ")
);
}
let mut parts = Vec::new();
for field in match_fields {
if let Some(value) = self.fields.get(field) {
match value {
FieldValue::Scalar(text) => {
if !text.is_empty() {
parts.push(text.clone());
}
}
FieldValue::List(values) => {
parts.extend(values.iter().filter(|v| !v.is_empty()).cloned())
}
}
}
}
parts.join(" ")
}
}
/// 从 Python 字典读取字符串列表。
fn get_string_list(dict: &Bound<'_, PyDict>, key: &str) -> PyResult<Vec<String>> {
let Some(value) = dict.get_item(key)? else {
return Ok(Vec::new());
};
if value.is_none() {
return Ok(Vec::new());
}
if let Ok(values) = value.extract::<Vec<String>>() {
return Ok(values);
}
Ok(vec![value.str()?.to_str()?.to_string()])
}
/// 执行规则 AST 匹配。
fn match_expr(
expr: &RuleExpr,
torrent: &TorrentPayload,
rule_set: &Bound<'_, PyDict>,
media_info: Option<&Bound<'_, PyDict>>,
regex_cache: &mut HashMap<String, Regex>,
) -> PyResult<bool> {
match expr {
RuleExpr::Name(name) => match_rule(name, torrent, rule_set, media_info, regex_cache),
RuleExpr::Not(inner) => Ok(!match_expr(
inner,
torrent,
rule_set,
media_info,
regex_cache,
)?),
RuleExpr::And(left, right) => {
Ok(
match_expr(left, torrent, rule_set, media_info, regex_cache)?
&& match_expr(right, torrent, rule_set, media_info, regex_cache)?,
)
}
RuleExpr::Or(left, right) => {
Ok(
match_expr(left, torrent, rule_set, media_info, regex_cache)?
|| match_expr(right, torrent, rule_set, media_info, regex_cache)?,
)
}
}
}
/// 执行单条规则匹配。
fn match_rule(
rule_name: &str,
torrent: &TorrentPayload,
rule_set: &Bound<'_, PyDict>,
media_info: Option<&Bound<'_, PyDict>>,
regex_cache: &mut HashMap<String, Regex>,
) -> PyResult<bool> {
let Some(rule_obj) = rule_set.get_item(rule_name)? else {
return Ok(false);
};
let rule = rule_obj.downcast::<PyDict>()?;
if let Some(tmdb_obj) = rule.get_item("tmdb")? {
if !tmdb_obj.is_none() {
if let Ok(tmdb) = tmdb_obj.downcast::<PyDict>() {
if match_tmdb(tmdb, media_info)? {
return Ok(true);
}
}
}
}
let match_fields = get_string_list(rule, "match")?;
let content = torrent.content_for_matches(&match_fields);
let includes = get_string_list(rule, "include")?;
let excludes = get_string_list(rule, "exclude")?;
if !includes.is_empty() {
let mut included = false;
for pattern in &includes {
if regex_search(pattern, &content, regex_cache)? {
included = true;
break;
}
}
if !included {
return Ok(false);
}
}
for exclude in excludes {
if regex_search(&exclude, &content, regex_cache)? {
return Ok(false);
}
}
if let Some(size_range) = get_optional_string(rule, "size_range")? {
if !match_size(torrent, &size_range)? {
return Ok(false);
}
}
if let Some(seeders) = get_optional_i64(rule, "seeders")? {
if torrent.seeders < seeders {
return Ok(false);
}
}
if let Some(downloadvolumefactor) = get_optional_f64(rule, "downloadvolumefactor")? {
if torrent.downloadvolumefactor != Some(downloadvolumefactor) {
return Ok(false);
}
}
if let Some(pubdate) = get_optional_string(rule, "publish_time")? {
if !match_publish_time(torrent.pub_minutes, &pubdate) {
return Ok(false);
}
}
Ok(true)
}
/// 使用带缓存的忽略大小写正则搜索。
fn regex_search(
pattern: &str,
content: &str,
cache: &mut HashMap<String, Regex>,
) -> PyResult<bool> {
if !cache.contains_key(pattern) {
let regex = RegexBuilder::new(pattern)
.case_insensitive(true)
.build()
.map_err(|err| PyValueError::new_err(err.to_string()))?;
cache.insert(pattern.to_string(), regex);
}
Ok(cache
.get(pattern)
.is_some_and(|regex| regex.is_match(content)))
}
/// 匹配 TMDB 媒体属性规则。
fn match_tmdb(tmdb: &Bound<'_, PyDict>, media_info: Option<&Bound<'_, PyDict>>) -> PyResult<bool> {
let Some(media) = media_info else {
return Ok(false);
};
for (attr, value) in tmdb.iter() {
if value.is_none() {
continue;
}
let attr_name = attr.extract::<String>()?;
let expected = value.str()?.to_str()?.to_string();
if expected.is_empty() {
continue;
}
let info_values = media_values(media, &attr_name)?;
if info_values.is_empty() {
return Ok(false);
}
let expected_values: Vec<String> = expected
.split(',')
.filter(|item| !item.is_empty())
.map(|item| item.to_uppercase())
.collect();
if !expected_values.iter().any(|expected_item| {
info_values
.iter()
.any(|info_item| info_item == expected_item)
}) {
return Ok(false);
}
}
Ok(true)
}
/// 获取媒体属性的可比较字符串集合。
fn media_values(media: &Bound<'_, PyDict>, attr_name: &str) -> PyResult<Vec<String>> {
let Some(value) = media.get_item(attr_name)? else {
return Ok(Vec::new());
};
if value.is_none() {
return Ok(Vec::new());
}
if attr_name == "production_countries" {
let Ok(items) = value.downcast::<PyList>() else {
return Ok(Vec::new());
};
let mut values = Vec::new();
for item in items.iter() {
if let Ok(dict) = item.downcast::<PyDict>() {
if let Some(country) = dict.get_item("iso_3166_1")? {
values.push(country.str()?.to_str()?.to_uppercase());
}
}
}
return Ok(values);
}
if let Ok(items) = value.extract::<Vec<String>>() {
return Ok(items.into_iter().map(|item| item.to_uppercase()).collect());
}
Ok(vec![value.str()?.to_str()?.to_uppercase()])
}
/// 按每集大小匹配大小范围规则。
fn match_size(torrent: &TorrentPayload, size_range: &str) -> PyResult<bool> {
let torrent_size = torrent.size / torrent.episode_count;
let size_range = size_range.trim();
let unit = 1024.0 * 1024.0;
if let Some((min, max)) = size_range.split_once('-') {
let min = min
.trim()
.parse::<f64>()
.map_err(|err| PyValueError::new_err(err.to_string()))?
* unit;
let max = max
.trim()
.parse::<f64>()
.map_err(|err| PyValueError::new_err(err.to_string()))?
* unit;
return Ok(min <= torrent_size && torrent_size <= max);
}
if let Some(min) = size_range.strip_prefix('>') {
let min = min
.trim()
.parse::<f64>()
.map_err(|err| PyValueError::new_err(err.to_string()))?
* unit;
return Ok(torrent_size >= min);
}
if let Some(max) = size_range.strip_prefix('<') {
let max = max
.trim()
.parse::<f64>()
.map_err(|err| PyValueError::new_err(err.to_string()))?
* unit;
return Ok(torrent_size <= max);
}
Ok(false)
}
/// 匹配发布时间分钟数规则。
fn match_publish_time(pub_minutes: f64, publish_time: &str) -> bool {
let values: Vec<f64> = publish_time
.split('-')
.filter_map(|item| item.parse::<f64>().ok())
.collect();
if values.len() == 1 {
return pub_minutes >= values[0];
}
if values.len() >= 2 {
return values[0] <= pub_minutes && pub_minutes <= values[1];
}
true
}
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
mod filter;
mod indexer;
mod meta;
mod utils;
use pyo3::prelude::*;
/// 返回扩展是否已成功加载,用于 Python 侧健康检查。
#[pyfunction]
fn is_available() -> bool {
true
}
/// 注册 MoviePilot Rust 扩展模块。
#[pymodule]
fn moviepilot_rust(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(is_available, m)?)?;
m.add_function(wrap_pyfunction!(meta::is_anime_fast, m)?)?;
m.add_function(wrap_pyfunction!(meta::find_metainfo_fast, m)?)?;
m.add_function(wrap_pyfunction!(meta::parse_video_title_fast, m)?)?;
m.add_function(wrap_pyfunction!(filter::parse_filter_rule_fast, m)?)?;
m.add_function(wrap_pyfunction!(filter::filter_torrents_fast, m)?)?;
m.add_function(wrap_pyfunction!(
indexer::apply_indexer_text_filters_fast,
m
)?)?;
m.add_function(wrap_pyfunction!(indexer::parse_filesize_fast, m)?)?;
m.add_function(wrap_pyfunction!(indexer::build_indexer_search_url_fast, m)?)?;
m.add_function(wrap_pyfunction!(indexer::parse_indexer_torrents_fast, m)?)?;
Ok(())
}
File diff suppressed because it is too large Load Diff
+103
View File
@@ -0,0 +1,103 @@
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use pyo3::types::{PyAny, PyDict};
use regex::Regex;
/// 捕获正则第一组并转换为整数。
pub(crate) fn capture_i64(regex: &Regex, text: &str) -> Option<i64> {
regex
.captures(text)
.and_then(|caps| caps.get(1))
.and_then(|value| value.as_str().parse::<i64>().ok())
}
/// 捕获正则所有分组中的整数,用于 S01E02 和范围类 token 的多值识别。
pub(crate) fn capture_all_i64(regex: &Regex, text: &str) -> Vec<i64> {
let mut values = Vec::new();
for caps in regex.captures_iter(text) {
for item in caps.iter().skip(1).flatten() {
if let Ok(value) = item.as_str().parse::<i64>() {
values.push(value);
break;
}
}
}
values
}
/// 计算范围的开始、结束和总数,保持 Python 侧的倒序交换语义。
pub(crate) fn apply_range_total(
mut begin: Option<i64>,
mut end: Option<i64>,
) -> (Option<i64>, Option<i64>, Option<i64>) {
let total = match (begin, end) {
(Some(begin_value), Some(end_value)) => {
if begin_value > end_value {
begin = Some(end_value);
end = Some(begin_value);
Some(begin_value - end_value + 1)
} else {
Some(end_value - begin_value + 1)
}
}
(Some(_), None) => Some(1),
_ => None,
};
(begin, end, total)
}
/// 将 Python 对象转换为 usize,用于过滤器下标。
pub(crate) fn py_i64_to_usize(value: &Bound<'_, PyAny>) -> PyResult<usize> {
let index = value.extract::<i64>()?;
if index < 0 {
return Err(PyValueError::new_err("下标不能为负数"));
}
Ok(index as usize)
}
/// 从 Python 字典读取可选字符串。
pub(crate) fn get_optional_string(dict: &Bound<'_, PyDict>, key: &str) -> PyResult<Option<String>> {
let Some(value) = dict.get_item(key)? else {
return Ok(None);
};
if value.is_none() {
return Ok(None);
}
Ok(Some(value.str()?.to_str()?.to_string()))
}
/// 从 Python 字典读取可选整数。
pub(crate) fn get_optional_i64(dict: &Bound<'_, PyDict>, key: &str) -> PyResult<Option<i64>> {
let Some(value) = dict.get_item(key)? else {
return Ok(None);
};
if value.is_none() {
return Ok(None);
}
if let Ok(parsed) = value.extract::<i64>() {
return Ok(Some(parsed));
}
let text = value.str()?.to_str()?.trim().to_string();
if text.is_empty() {
return Ok(None);
}
Ok(text.parse::<i64>().ok())
}
/// 从 Python 字典读取可选浮点数。
pub(crate) fn get_optional_f64(dict: &Bound<'_, PyDict>, key: &str) -> PyResult<Option<f64>> {
let Some(value) = dict.get_item(key)? else {
return Ok(None);
};
if value.is_none() {
return Ok(None);
}
if let Ok(parsed) = value.extract::<f64>() {
return Ok(Some(parsed));
}
let text = value.str()?.to_str()?.trim().to_string();
if text.is_empty() {
return Ok(None);
}
Ok(text.parse::<f64>().ok())
}
+125 -8
View File
@@ -17,6 +17,7 @@ SUPERUSER_PASSWORD=""
OS_NAME="Unknown" OS_NAME="Unknown"
PYTHON_BIN="" PYTHON_BIN=""
BREW_BIN="" BREW_BIN=""
RUSTUP_BIN=""
PACKAGE_MANAGER="" PACKAGE_MANAGER=""
PACKAGE_INDEX_UPDATED="false" PACKAGE_INDEX_UPDATED="false"
PROMPT_INPUT="/dev/stdin" PROMPT_INPUT="/dev/stdin"
@@ -227,20 +228,20 @@ find_uv_python() {
python_install_hint() { python_install_hint() {
case "$OS_NAME" in case "$OS_NAME" in
macOS) macOS)
echo "脚本已尝试自动安装 Git、curlPython 3.11+。" >&2 echo "脚本已尝试自动安装 Git、curlPython 3.11+、Rust toolchain 和构建工具。" >&2
echo "如果自动安装失败,请先安装 Homebrew,或手动执行:brew install git curl python@3.11" >&2 echo "如果自动安装失败,请先安装 Homebrew 和 Xcode Command Line Tools,或手动执行:brew install git curl python@3.11 rustup-init" >&2
;; ;;
Linux*) Linux*)
echo "脚本已尝试自动安装 Git、curlPython 3.11+。" >&2 echo "脚本已尝试自动安装 Git、curlPython 3.11+、Rust toolchain 和构建工具。" >&2
echo "如果自动安装失败,请先安装 Git、curlPython 3.11+,并确保包含 venv 模块。" >&2 echo "如果自动安装失败,请先安装 Git、curlPython 3.11+、cargo,并确保包含 venv 模块。" >&2
echo "例如 Debian/Ubuntu: sudo apt install git curl python3.11 python3.11-venv" >&2 echo "例如 Debian/Ubuntu: sudo apt install git curl python3.11 python3.11-venv build-essential" >&2
echo "例如 Fedora/RHEL: sudo dnf install git curl python3.11" >&2 echo "例如 Fedora/RHEL: sudo dnf install git curl python3.11 gcc gcc-c++ make" >&2
;; ;;
Windows) Windows)
echo "推荐在 WSL、Linux 或 macOS 终端中运行此脚本。" >&2 echo "推荐在 WSL、Linux 或 macOS 终端中运行此脚本。" >&2
;; ;;
*) *)
echo "请先安装 Git、curlPython 3.11 或更高版本。" >&2 echo "请先安装 Git、curlPython 3.11 或更高版本、cargo 和 C 编译器。" >&2
;; ;;
esac esac
} }
@@ -270,6 +271,74 @@ ensure_brew() {
fi fi
} }
# 检查 cargo 是否已可用,兼容 rustup 安装后 PATH 尚未刷新。
find_cargo() {
local cargo_bin=""
cargo_bin="$(command -v cargo 2>/dev/null || true)"
if [[ -n "$cargo_bin" ]]; then
printf '%s\n' "$cargo_bin"
return 0
fi
if [[ -x "$HOME/.cargo/bin/cargo" ]]; then
printf '%s\n' "$HOME/.cargo/bin/cargo"
fi
}
# 判断是否显式跳过 Rust 加速扩展,避免一键安装继续准备构建工具链。
rust_accel_should_skip() {
case "${MOVIEPILOT_SKIP_RUST_ACCEL:-}" in
1|true|TRUE|yes|YES|on|ON)
return 0
;;
*)
return 1
;;
esac
}
# 为 CLI 一键安装准备 Rust toolchain,后续 setup 会用它构建加速扩展。
ensure_rust_toolchain() {
if rust_accel_should_skip; then
return 0
fi
if [[ -n "$(find_cargo)" ]]; then
export PATH="$HOME/.cargo/bin:$PATH"
return 0
fi
echo "==> 自动安装 Rust toolchain,用于构建 MoviePilot 加速扩展"
case "$PACKAGE_MANAGER" in
brew)
ensure_brew
"$BREW_BIN" install rustup-init
RUSTUP_BIN="$(command -v rustup-init 2>/dev/null || true)"
;;
*)
RUSTUP_BIN="$(command -v rustup 2>/dev/null || true)"
if [[ -z "$RUSTUP_BIN" ]]; then
curl https://sh.rustup.rs -sSf | sh -s -- -y --profile minimal
else
"$RUSTUP_BIN" toolchain install stable --profile minimal
fi
;;
esac
export PATH="$HOME/.cargo/bin:$PATH"
if [[ "$PACKAGE_MANAGER" == "brew" ]]; then
RUSTUP_BIN="$(command -v rustup-init 2>/dev/null || true)"
if [[ -n "$RUSTUP_BIN" ]]; then
"$RUSTUP_BIN" -y --profile minimal
fi
fi
hash -r
if [[ -z "$(find_cargo)" ]]; then
echo "Rust toolchain 安装失败,请手动安装 cargo 后重试。" >&2
return 1
fi
}
run_privileged() { run_privileged() {
if [[ "$(id -u)" -eq 0 ]]; then if [[ "$(id -u)" -eq 0 ]]; then
"$@" "$@"
@@ -383,6 +452,54 @@ ensure_base_tools() {
fi fi
} }
# 安装 Rust 扩展构建需要的本机编译器和链接器。
ensure_build_tools() {
if rust_accel_should_skip; then
return 0
fi
if [[ "$OS_NAME" == "macOS" ]]; then
if xcode-select -p >/dev/null 2>&1; then
return 0
fi
echo "当前 macOS 缺少 Command Line Tools,请先执行:xcode-select --install" >&2
return 1
fi
if command -v cc >/dev/null 2>&1 || command -v gcc >/dev/null 2>&1 || command -v clang >/dev/null 2>&1; then
return 0
fi
echo "==> 自动安装系统构建工具,用于编译 Rust 加速扩展"
case "$PACKAGE_MANAGER" in
apt-get)
install_system_packages build-essential
;;
dnf|yum)
install_system_packages gcc gcc-c++ make
;;
zypper)
install_system_packages gcc gcc-c++ make
;;
pacman)
install_system_packages base-devel
;;
apk)
install_system_packages build-base
;;
*)
echo "当前系统暂不支持自动安装构建工具,请手动安装 C 编译器后重试。" >&2
return 1
;;
esac
hash -r
if ! command -v cc >/dev/null 2>&1 && ! command -v gcc >/dev/null 2>&1 && ! command -v clang >/dev/null 2>&1; then
echo "系统构建工具安装失败,请确认 C 编译器可用后重试。" >&2
return 1
fi
}
ensure_uv() { ensure_uv() {
if command -v uv >/dev/null 2>&1; then if command -v uv >/dev/null 2>&1; then
return 0 return 0
@@ -427,7 +544,7 @@ ensure_prereqs() {
exit 1 exit 1
fi fi
if ! ensure_base_tools || ! ensure_python || ! ensure_uv; then if ! ensure_base_tools || ! ensure_build_tools || ! ensure_python || ! ensure_uv || ! ensure_rust_toolchain; then
python_install_hint python_install_hint
exit 1 exit 1
fi fi
+107 -2
View File
@@ -45,6 +45,9 @@ COOKIE_DIR = CONFIG_DIR / "cookies"
ENV_FILE = CONFIG_DIR / "app.env" ENV_FILE = CONFIG_DIR / "app.env"
DEFAULT_NODE_VERSION = "20.12.1" DEFAULT_NODE_VERSION = "20.12.1"
RUST_ACCEL_DIR = ROOT / "rust" / "moviepilot_rust"
RUST_ACCEL_MANIFEST = RUST_ACCEL_DIR / "Cargo.toml"
RUST_ACCEL_SKIP_ENV = "MOVIEPILOT_SKIP_RUST_ACCEL"
FRONTEND_LATEST_API = ( FRONTEND_LATEST_API = (
"https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases/latest" "https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases/latest"
) )
@@ -475,10 +478,17 @@ def print_step(message: str) -> None:
print(f"==> {message}") print(f"==> {message}")
def run(command: list[str], cwd: Optional[Path] = None) -> None: def run(
command: list[str],
cwd: Optional[Path] = None,
env: Optional[dict[str, str]] = None,
) -> None:
"""
执行安装步骤中的外部命令,并在失败时让调用方中断流程。
"""
pretty = " ".join(command) pretty = " ".join(command)
print(f"+ {pretty}") print(f"+ {pretty}")
subprocess.run(command, cwd=str(cwd or ROOT), check=True) subprocess.run(command, cwd=str(cwd or ROOT), check=True, env=env)
def capture(command: list[str], cwd: Optional[Path] = None) -> str: def capture(command: list[str], cwd: Optional[Path] = None) -> str:
@@ -579,6 +589,9 @@ def _ensure_uv_available_for_venv(venv_dir: Path, venv_python: Path) -> Optional
def configure_venv_pip_compat(venv_dir: Path, venv_python: Path) -> Path: def configure_venv_pip_compat(venv_dir: Path, venv_python: Path) -> Path:
"""
在虚拟环境中安装 uv 并保持 pip 命令兼容,供现有安装流程复用。
"""
if os.name == "nt": if os.name == "nt":
return get_venv_pip(venv_dir) return get_venv_pip(venv_dir)
@@ -606,6 +619,93 @@ def configure_venv_pip_compat(venv_dir: Path, venv_python: Path) -> Path:
return get_venv_pip(venv_dir) return get_venv_pip(venv_dir)
def _rust_accel_should_skip() -> bool:
"""
判断当前安装是否显式跳过 Rust 加速扩展构建。
"""
raw_value = os.getenv(RUST_ACCEL_SKIP_ENV, "").strip().lower()
return raw_value in {"1", "true", "yes", "on"}
def _cargo_env_path() -> str:
"""
组合 PATH,兼容 rustup 默认安装到用户目录但当前 shell 未刷新环境的场景。
"""
extra_paths = [str(Path.home() / ".cargo" / "bin")]
current_path = os.environ.get("PATH", "")
return os.pathsep.join([*extra_paths, current_path])
def _find_cargo() -> Optional[str]:
"""
查找 Rust cargo 可执行文件,供本地 CLI 安装构建 PyO3 扩展。
"""
return shutil.which("cargo", path=_cargo_env_path())
def _find_native_linker() -> Optional[str]:
"""
查找 Rust 扩展构建所需的本机链接器。
"""
if os.name == "nt":
return "windows-msvc"
for candidate in ("cc", "gcc", "clang"):
linker = shutil.which(candidate)
if linker:
return linker
return None
def ensure_rust_accel_ready() -> None:
"""
确认 Rust 加速扩展源码存在且本机具备 cargo 与链接器。
"""
if not RUST_ACCEL_MANIFEST.exists():
return
if _rust_accel_should_skip():
print_step(f"已跳过 Rust 加速扩展构建:{RUST_ACCEL_SKIP_ENV}=1")
return
if not _find_cargo():
raise RuntimeError(
"未找到 Rust cargo,无法构建 MoviePilot Rust 加速扩展。"
"请先安装 Rust toolchain 后重试,或临时设置 "
f"{RUST_ACCEL_SKIP_ENV}=1 跳过加速扩展。"
)
if not _find_native_linker():
raise RuntimeError(
"未找到本机 C 编译器/链接器,无法构建 MoviePilot Rust 加速扩展。"
"请先安装系统构建工具后重试,或临时设置 "
f"{RUST_ACCEL_SKIP_ENV}=1 跳过加速扩展。"
)
def install_rust_accel(venv_python: Path) -> None:
"""
构建并安装 MoviePilot Rust 加速扩展到当前虚拟环境。
"""
if not RUST_ACCEL_MANIFEST.exists():
return
if _rust_accel_should_skip():
return
ensure_rust_accel_ready()
print_step("构建并安装 Rust 加速扩展")
env = os.environ.copy()
env["PATH"] = _cargo_env_path()
run(
[
str(venv_python),
"-m",
"maturin",
"develop",
"--release",
"--manifest-path",
str(RUST_ACCEL_MANIFEST),
],
env=env,
)
def ensure_supported_python(python_bin: str) -> None: def ensure_supported_python(python_bin: str) -> None:
version = get_python_version(python_bin) version = get_python_version(python_bin)
if version < MIN_PYTHON_VERSION: if version < MIN_PYTHON_VERSION:
@@ -2628,7 +2728,11 @@ def init_local(
def install_deps(*, python_bin: str, venv_dir: Path, recreate: bool) -> Path: def install_deps(*, python_bin: str, venv_dir: Path, recreate: bool) -> Path:
"""
创建或复用本地虚拟环境,并安装后端依赖、Rust 扩展和浏览器运行时。
"""
ensure_supported_python(python_bin) ensure_supported_python(python_bin)
ensure_rust_accel_ready()
venv_dir = venv_dir.expanduser().resolve() venv_dir = venv_dir.expanduser().resolve()
venv_python = get_venv_python(venv_dir) venv_python = get_venv_python(venv_dir)
venv_pip = get_venv_pip(venv_dir) venv_pip = get_venv_pip(venv_dir)
@@ -2653,6 +2757,7 @@ def install_deps(*, python_bin: str, venv_dir: Path, recreate: bool) -> Path:
print_step("安装项目依赖") print_step("安装项目依赖")
run([str(venv_pip), "install", "-r", str(ROOT / "requirements.txt")]) run([str(venv_pip), "install", "-r", str(ROOT / "requirements.txt")])
install_rust_accel(venv_python)
install_browser_runtime(venv_python) install_browser_runtime(venv_python)
return venv_python return venv_python
+69
View File
@@ -68,6 +68,8 @@ class LocalSetupConfigDirTests(unittest.TestCase):
venv_pip = venv_dir / "bin" / "pip" venv_pip = venv_dir / "bin" / "pip"
with patch.object(module, "ensure_supported_python"), \ with patch.object(module, "ensure_supported_python"), \
patch.object(module, "ensure_rust_accel_ready") as rust_ready, \
patch.object(module, "install_rust_accel") as install_rust, \
patch.object( patch.object(
module, module,
"configure_venv_pip_compat", "configure_venv_pip_compat",
@@ -86,8 +88,75 @@ class LocalSetupConfigDirTests(unittest.TestCase):
run_mock.assert_any_call( run_mock.assert_any_call(
[str(venv_pip), "install", "-r", str(module.ROOT / "requirements.txt")] [str(venv_pip), "install", "-r", str(module.ROOT / "requirements.txt")]
) )
rust_ready.assert_called_once_with()
install_rust.assert_called_once_with(venv_python)
install_browser.assert_called_once_with(venv_python) install_browser.assert_called_once_with(venv_python)
def test_install_rust_accel_runs_maturin_develop(self):
"""
验证本地 CLI 安装会通过 maturin Rust 扩展安装进虚拟环境
"""
module = load_local_setup_module()
with tempfile.TemporaryDirectory() as temp_dir:
manifest = Path(temp_dir) / "Cargo.toml"
manifest.write_text("[package]\nname = \"moviepilot_rust\"\n")
venv_python = Path(temp_dir) / "venv" / "bin" / "python"
with patch.object(module, "RUST_ACCEL_MANIFEST", manifest), \
patch.object(module, "_rust_accel_should_skip", return_value=False), \
patch.object(module, "ensure_rust_accel_ready"), \
patch.object(module, "_cargo_env_path", return_value="/cargo/bin:/bin"), \
patch.object(module, "run") as run_mock:
module.install_rust_accel(venv_python)
run_mock.assert_called_once()
command = run_mock.call_args.args[0]
self.assertEqual(
command,
[
str(venv_python),
"-m",
"maturin",
"develop",
"--release",
"--manifest-path",
str(manifest),
],
)
self.assertEqual(run_mock.call_args.kwargs["env"]["PATH"], "/cargo/bin:/bin")
def test_ensure_rust_accel_ready_requires_cargo(self):
"""
验证 Rust 扩展源码存在时CLI 安装会检查 cargo 是否可用
"""
module = load_local_setup_module()
with tempfile.TemporaryDirectory() as temp_dir:
manifest = Path(temp_dir) / "Cargo.toml"
manifest.write_text("[package]\nname = \"moviepilot_rust\"\n")
with patch.object(module, "RUST_ACCEL_MANIFEST", manifest), \
patch.object(module, "_rust_accel_should_skip", return_value=False), \
patch.object(module, "_find_cargo", return_value=None):
with self.assertRaisesRegex(RuntimeError, "cargo"):
module.ensure_rust_accel_ready()
def test_ensure_rust_accel_ready_allows_skip(self):
"""
验证显式跳过 Rust 扩展时不再要求本机存在 cargo
"""
module = load_local_setup_module()
with tempfile.TemporaryDirectory() as temp_dir:
manifest = Path(temp_dir) / "Cargo.toml"
manifest.write_text("[package]\nname = \"moviepilot_rust\"\n")
with patch.object(module, "RUST_ACCEL_MANIFEST", manifest), \
patch.object(module, "_rust_accel_should_skip", return_value=True), \
patch.object(module, "_find_cargo", return_value=None):
module.ensure_rust_accel_ready()
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+174
View File
@@ -0,0 +1,174 @@
import pytest
from app.core.context import TorrentInfo
from app.modules.filter import FilterModule
from app.modules.indexer.spider import SiteSpider
from app.schemas.types import MediaType
from app.utils import rust_accel
pytestmark = pytest.mark.skipif(
not rust_accel.is_available(),
reason="moviepilot_rust 扩展未安装",
)
def test_rust_metainfo_fast_path_extracts_emby_override():
"""
Rust 内嵌媒体标签识别应保持 Emby tmdbid 标签优先级
"""
title, metainfo = rust_accel.find_metainfo("Movie {[tmdbid=111;type=movies]} [tmdbid=222]")
assert title == "Movie"
assert metainfo["tmdbid"] == "222"
assert metainfo["type"] == MediaType.MOVIE
def test_rust_video_title_fast_path_extracts_common_resource_fields():
"""
Rust 影视标题预解析应能提取常见资源字段
"""
result = rust_accel.parse_video_title(
"The 355 2022 BluRay 1080p DTS-HD MA5.1 X265.10bit 60FPS"
)
assert result["year"] == "2022"
assert result["resource_pix"] == "1080p"
assert result["resource_type"] == "BluRay"
assert result["video_encode"] == "x265 10bit"
assert result["video_bit"] == "10bit"
assert result["fps"] == 60
def test_rust_filter_fast_path_matches_priority_semantics():
"""
Rust 批量过滤应保持优先级和布尔表达式语义
"""
module = FilterModule()
module.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=""),
]
result = module._FilterModule__filter_torrents_by_rust( # noqa: SLF001
groups=[type("RuleGroup", (), {"rule_string": "HDR & !BLU > DV"})()],
torrent_list=torrents,
mediainfo=None,
)
assert result == torrents[:2]
assert result[0].pri_order == 100
assert result[1].pri_order == 99
def test_rust_indexer_search_url_keeps_existing_query_and_category():
"""
Rust URL 生成应保留路径原有查询参数并应用分类参数
"""
spider = SiteSpider(
indexer={
"id": "ttg",
"name": "TTG",
"domain": "https://totheglory.im/",
"search": {
"paths": [{"path": "browse.php?c=M"}],
"params": {"search_field": "{keyword}", "c": "M"},
"imdbid_format": "imdb{imdbid_num}",
},
"category": {
"field": "search_field",
"delimiter": " 分类:",
"movie": [{"id": "电影DVDRip", "cat": "Movies/SD"}],
},
"torrents": {"list": {}, "fields": {}},
},
keyword="tt0049406",
mtype=MediaType.MOVIE,
)
search_url = spider._SiteSpider__get_search_url() # noqa: SLF001
assert search_url.count("?") == 1
assert "c=M" in search_url
assert "search_field=imdb0049406" in search_url
def test_rust_filesize_parser_matches_site_units():
"""
Rust 文件大小解析应覆盖站点解析器常见单位
"""
assert rust_accel.parse_filesize("1.5 GB") == 1610612736
assert rust_accel.parse_filesize("2 TiB") == 2199023255552
assert rust_accel.parse_filesize("42") == 42
def test_rust_indexer_page_parser_handles_common_fields():
"""
Rust 普通 indexer 页面解析应批量提取列表行核心字段
"""
spider = SiteSpider(
indexer={
"id": "demo",
"name": "Demo",
"domain": "https://example.org/",
"search": {"paths": [{"path": "torrents.php"}]},
"category": {
"movie": [{"id": "401"}],
"tv": [{"id": "402"}],
},
"torrents": {
"list": {"selector": "tr.torrent"},
"fields": {
"title": {"selector": "a.title"},
"description": {"selector": ".desc"},
"details": {"selector": "a.title", "attribute": "href"},
"download": {"selector": "a.dl", "attribute": "href"},
"size": {"selector": ".size"},
"seeders": {"selector": ".seeders"},
"leechers": {"selector": ".leechers"},
"grabs": {"selector": ".grabs"},
"downloadvolumefactor": {"case": {".free": 0}},
"uploadvolumefactor": {"selector": ".up"},
"labels": {"selector": ".label"},
"hr": {"selector": ".hr"},
"category": {"selector": ".cat"},
},
},
},
)
html = """
<table>
<tr class="torrent">
<td><a class="title" href="/details/1">Movie 2024 1080p</a><span class="desc">BluRay</span></td>
<td><a class="dl" href="/download/1">DL</a></td>
<td class="size">1.5 GB</td><td class="seeders">1,234</td><td class="leechers">5/10</td>
<td class="grabs">42</td><td class="free">Free</td><td class="up">2x</td>
<td><span class="label">DIY</span><span class="label">HDR</span></td>
<td class="hr">H&R</td><td class="cat">401</td>
</tr>
</table>
"""
torrents = spider.parse(html)
assert torrents == [{
"title": "Movie 2024 1080p",
"description": "BluRay",
"page_url": "https://example.org/details/1",
"enclosure": "https://example.org/download/1",
"size": 1610612736,
"seeders": 1234,
"peers": 5,
"grabs": 42,
"downloadvolumefactor": 0,
"uploadvolumefactor": 2,
"labels": ["DIY", "HDR"],
"hit_and_run": True,
"category": MediaType.MOVIE.value,
}]