Skip to content

Commit bd4d493

Browse files
committed
feat: add Rust acceleration for core parsing
1 parent 7daeb17 commit bd4d493

28 files changed

Lines changed: 5012 additions & 77 deletions

.dockerignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ test_*
7373
build/
7474
dist/
7575
*.egg-info/
76+
rust/**/target/
7677

7778
# Docker
7879
Dockerfile*
@@ -81,4 +82,4 @@ docker-compose*
8182

8283
# Other
8384
app.ico
84-
frozen.spec
85+
frozen.spec

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
build/
77
cython_cache/
88
dist/
9+
rust/**/target/
910
nginx/
1011
test.py
1112
safety_report.txt

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,17 @@ MCP工具API文档:详见 [docs/mcp-api.md](docs/mcp-api.md)
5656

5757
开发环境准备与本地源码运行说明:[`docs/development-setup.md`](docs/development-setup.md)
5858

59+
本地开发启用 Rust 加速扩展,需先安装 Rust toolchain 并确保 `cargo` 可用:
60+
61+
```shell
62+
cargo --version
63+
python -m pip install "maturin>=1.9,<2"
64+
python -m maturin develop --release --manifest-path rust/moviepilot_rust/Cargo.toml
65+
python -c "from app.utils import rust_accel; print(rust_accel.is_available())"
66+
```
67+
68+
如果输出 `True`,说明当前开发环境已经加载 `moviepilot_rust`。重新修改 Rust 代码后再次执行 `python -m maturin develop --release --manifest-path rust/moviepilot_rust/Cargo.toml` 即可更新本地扩展。
69+
5970
插件开发说明:<https://wiki.movie-pilot.org/zh/plugindev>
6071

6172
## 相关项目

app/core/meta/metavideo.py

Lines changed: 110 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from app.utils.string import StringUtils
1212
from app.utils.tokens import Tokens
1313
from app.core.meta.streamingplatform import StreamingPlatforms
14+
from app.utils import rust_accel
1415

1516

1617
class MetaVideo(MetaBase):
@@ -102,59 +103,61 @@ def __init__(self, title: str, subtitle: str = None, isfile: bool = False):
102103
title = re.sub(r'[0-9.]+\s*[MGT]i?B(?![A-Z]+)', "", title, flags=re.IGNORECASE)
103104
# 把年月日去掉
104105
title = re.sub(r'\d{4}[\s._-]\d{1,2}[\s._-]\d{1,2}', "", title)
105-
# 拆分tokens
106-
tokens = Tokens(title)
107-
# 实例化StreamingPlatforms对象
108-
streaming_platforms = StreamingPlatforms()
109106
media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIOEXT
110-
# 解析名称、年份、季、集、资源类型、分辨率等
111-
token = tokens.get_next()
112-
while token:
113-
self._index += 1 # 更新当前处理的token索引
114-
# Part
115-
self.__init_part(token, tokens)
116-
# 标题
117-
if self._continue_flag:
118-
self.__init_name(token, media_exts)
119-
# 年份
120-
if self._continue_flag:
121-
self.__init_year(token)
122-
# 分辨率
123-
if self._continue_flag:
124-
self.__init_resource_pix(token)
125-
# 季
126-
if self._continue_flag:
127-
self.__init_season(token)
128-
# 集
129-
if self._continue_flag:
130-
self.__init_episode(token)
131-
# 资源类型
132-
if self._continue_flag:
133-
self.__init_resource_type(token)
134-
# 流媒体平台
135-
if self._continue_flag:
136-
self.__init_web_source(token, tokens, streaming_platforms)
137-
# 视频编码
138-
if self._continue_flag:
139-
self.__init_video_encode(token)
140-
# 视频位深
141-
if self._continue_flag:
142-
self.__init_video_bit(token)
143-
# 音频编码
144-
if self._continue_flag:
145-
self.__init_audio_encode(token)
146-
# 帧率
147-
if self._continue_flag:
148-
self.__init_fps(token)
149-
# 取下一个,直到没有为卡
107+
rust_parse = rust_accel.parse_video_title(title, isfile=isfile, media_exts=media_exts)
108+
if not self.__apply_rust_parse(rust_parse):
109+
# 拆分tokens
110+
tokens = Tokens(title)
111+
# 实例化StreamingPlatforms对象
112+
streaming_platforms = StreamingPlatforms()
113+
# 解析名称、年份、季、集、资源类型、分辨率等
150114
token = tokens.get_next()
151-
self._continue_flag = True
152-
# 合成质量
153-
if self._effect:
154-
self._effect.reverse()
155-
self.resource_effect = " ".join(self._effect)
156-
if self._source:
157-
self.resource_type = self._source.strip()
115+
while token:
116+
self._index += 1 # 更新当前处理的token索引
117+
# Part
118+
self.__init_part(token, tokens)
119+
# 标题
120+
if self._continue_flag:
121+
self.__init_name(token, media_exts)
122+
# 年份
123+
if self._continue_flag:
124+
self.__init_year(token)
125+
# 分辨率
126+
if self._continue_flag:
127+
self.__init_resource_pix(token)
128+
# 季
129+
if self._continue_flag:
130+
self.__init_season(token)
131+
# 集
132+
if self._continue_flag:
133+
self.__init_episode(token)
134+
# 资源类型
135+
if self._continue_flag:
136+
self.__init_resource_type(token)
137+
# 流媒体平台
138+
if self._continue_flag:
139+
self.__init_web_source(token, tokens, streaming_platforms)
140+
# 视频编码
141+
if self._continue_flag:
142+
self.__init_video_encode(token)
143+
# 视频位深
144+
if self._continue_flag:
145+
self.__init_video_bit(token)
146+
# 音频编码
147+
if self._continue_flag:
148+
self.__init_audio_encode(token)
149+
# 帧率
150+
if self._continue_flag:
151+
self.__init_fps(token)
152+
# 取下一个,直到没有为卡
153+
token = tokens.get_next()
154+
self._continue_flag = True
155+
# 合成质量
156+
if self._effect:
157+
self._effect.reverse()
158+
self.resource_effect = " ".join(self._effect)
159+
if self._source:
160+
self.resource_type = self._source.strip()
158161
# 提取原盘DIY
159162
if self.resource_type and "BluRay" in self.resource_type:
160163
if (self.subtitle and re.findall(r'D[Ii]Y', self.subtitle)) \
@@ -185,6 +188,62 @@ def __init__(self, title: str, subtitle: str = None, isfile: bool = False):
185188
if not self.video_bit:
186189
self.video_bit = self.extract_video_bit(self.video_encode)
187190

191+
def __apply_rust_parse(self, rust_parse: Optional[dict]) -> bool:
192+
"""
193+
应用 Rust 主识别结果;成功时跳过 Python token 主循环。
194+
"""
195+
if not rust_parse or not rust_parse.get("complete"):
196+
return False
197+
self.cn_name = rust_parse.get("cn_name")
198+
self.en_name = rust_parse.get("en_name")
199+
if rust_parse.get("year"):
200+
self.year = str(rust_parse.get("year"))
201+
self.part = rust_parse.get("part")
202+
self.__merge_rust_parse(rust_parse)
203+
media_type = rust_parse.get("type")
204+
if media_type == "tv":
205+
self.type = MediaType.TV
206+
elif media_type == "movie":
207+
self.type = MediaType.MOVIE
208+
return True
209+
210+
def __merge_rust_parse(self, rust_parse: Optional[dict]) -> None:
211+
"""
212+
合并 Rust 预解析结果,仅补齐 Python 识别未命中的资源字段。
213+
"""
214+
if not rust_parse:
215+
return
216+
if not self.year and rust_parse.get("year"):
217+
self.year = str(rust_parse.get("year"))
218+
if self.begin_season is None and rust_parse.get("begin_season") is not None:
219+
self.begin_season = int(rust_parse.get("begin_season"))
220+
self.type = MediaType.TV
221+
if self.end_season is None and rust_parse.get("end_season") is not None:
222+
self.end_season = int(rust_parse.get("end_season"))
223+
if not self.total_season and rust_parse.get("total_season"):
224+
self.total_season = int(rust_parse.get("total_season"))
225+
if self.begin_episode is None and rust_parse.get("begin_episode") is not None:
226+
self.begin_episode = int(rust_parse.get("begin_episode"))
227+
self.type = MediaType.TV
228+
if self.end_episode is None and rust_parse.get("end_episode") is not None:
229+
self.end_episode = int(rust_parse.get("end_episode"))
230+
if not self.total_episode and rust_parse.get("total_episode"):
231+
self.total_episode = int(rust_parse.get("total_episode"))
232+
if not self.resource_pix and rust_parse.get("resource_pix"):
233+
self.resource_pix = rust_parse.get("resource_pix")
234+
if not self.resource_type and rust_parse.get("resource_type"):
235+
self.resource_type = rust_parse.get("resource_type")
236+
if not self.resource_effect and rust_parse.get("resource_effect"):
237+
self.resource_effect = rust_parse.get("resource_effect")
238+
if not self.video_encode and rust_parse.get("video_encode"):
239+
self.video_encode = rust_parse.get("video_encode")
240+
if not self.video_bit and rust_parse.get("video_bit"):
241+
self.video_bit = rust_parse.get("video_bit")
242+
if not self.audio_encode and rust_parse.get("audio_encode"):
243+
self.audio_encode = rust_parse.get("audio_encode")
244+
if self.fps is None and rust_parse.get("fps") is not None:
245+
self.fps = int(rust_parse.get("fps"))
246+
188247
@staticmethod
189248
def __get_title_from_description(description: str) -> Optional[str]:
190249
"""

app/core/metainfo.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from app.core.meta.words import WordsMatcher
1313
from app.log import logger
1414
from app.schemas.types import MediaType
15+
from app.utils import rust_accel
1516

1617

1718
_ANIME_BRACKET_RE = re.compile(r'【[+0-9XVPI-]+】\s*【', re.IGNORECASE)
@@ -168,6 +169,9 @@ def is_anime(name: str) -> bool:
168169
:param name: 名称
169170
:return: 是否动漫
170171
"""
172+
rust_result = rust_accel.is_anime(name)
173+
if rust_result is not None:
174+
return rust_result
171175
if not name:
172176
return False
173177
if _ANIME_BRACKET_RE.search(name):
@@ -185,6 +189,9 @@ def find_metainfo(title: str) -> Tuple[str, dict]:
185189
"""
186190
从标题中提取媒体信息
187191
"""
192+
rust_result = rust_accel.find_metainfo(title)
193+
if rust_result is not None:
194+
return rust_result
188195
metainfo = _empty_metainfo()
189196
if not title:
190197
return title, metainfo

app/modules/filter/RuleParser.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from pyparsing import Forward, Literal, Word, alphas, infixNotation, opAssoc, alphanums, Combine, nums, ParseResults
44

5+
from app.utils import rust_accel
6+
57

68
class RuleParser:
79

@@ -48,9 +50,30 @@ def parse(self, expression: str) -> ParseResults:
4850
返回:
4951
解析结果
5052
"""
53+
rust_result = rust_accel.parse_filter_rule(expression)
54+
if rust_result is not None:
55+
return _RustParseResults(rust_result)
5156
return self.expr.parseString(expression)
5257

5358

59+
class _RustParseResults(list):
60+
"""
61+
包装 Rust 解析结果,提供本模块调用方使用的 as_list/asList 接口。
62+
"""
63+
64+
def as_list(self) -> list:
65+
"""
66+
返回兼容 pyparsing.ParseResults.as_list 的列表结构。
67+
"""
68+
return list(self)
69+
70+
def asList(self) -> list: # noqa: N802
71+
"""
72+
返回兼容 pyparsing.ParseResults.asList 的列表结构。
73+
"""
74+
return self.as_list()
75+
76+
5477
if __name__ == '__main__':
5578
# 测试代码
5679
expression_str = """

app/modules/filter/__init__.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from app.modules.filter.RuleParser import RuleParser
1212
from app.modules.filter.builtin_rules import BUILTIN_RULE_SET
1313
from app.schemas.types import ModuleType, OtherModulesType, SystemConfigKey
14+
from app.utils import rust_accel
1415
from app.utils.string import StringUtils
1516

1617

@@ -138,6 +139,9 @@ def filter_torrents(self, rule_groups: List[str],
138139
# 查询规则表详情
139140
groups = self.rulehelper.get_rule_group_by_media(media=mediainfo, group_names=rule_groups)
140141
if groups:
142+
rust_filtered = self.__filter_torrents_by_rust(groups, torrent_list, mediainfo)
143+
if rust_filtered is not None:
144+
return rust_filtered
141145
for group in groups:
142146
# 过滤种子
143147
torrent_list = self.__filter_torrents(
@@ -150,6 +154,46 @@ def filter_torrents(self, rule_groups: List[str],
150154
)
151155
return torrent_list
152156

157+
def __filter_torrents_by_rust(self, groups: list, torrent_list: List[TorrentInfo],
158+
mediainfo: MediaInfo) -> Optional[List[TorrentInfo]]:
159+
"""
160+
使用 Rust 批量过滤种子;遇到不可支持的规则时返回 None 交由 Python 逻辑处理。
161+
"""
162+
if not torrent_list:
163+
return []
164+
payloads = [self.__build_rust_torrent_payload(torrent) for torrent in torrent_list]
165+
media_payload = mediainfo.to_dict() if mediainfo and hasattr(mediainfo, "to_dict") else (
166+
vars(mediainfo).copy() if mediainfo else None
167+
)
168+
result = rust_accel.filter_torrents(
169+
rule_set=self.rule_set,
170+
rule_strings=[group.rule_string for group in groups],
171+
torrents=payloads,
172+
media_info=media_payload,
173+
)
174+
if result is None:
175+
return None
176+
filtered_torrents = []
177+
for index, pri_order in result:
178+
torrent = torrent_list[int(index)]
179+
torrent.pri_order = int(pri_order)
180+
filtered_torrents.append(torrent)
181+
return filtered_torrents
182+
183+
@staticmethod
184+
def __build_rust_torrent_payload(torrent: TorrentInfo) -> dict:
185+
"""
186+
组装 Rust 过滤器需要的纯数据载荷,避免 Rust 直接依赖 Python 业务对象。
187+
"""
188+
payload = torrent.to_dict() if hasattr(torrent, "to_dict") else vars(torrent).copy()
189+
payload["pub_minutes"] = torrent.pub_minutes()
190+
if payload.get("size"):
191+
meta = MetaInfo(title=torrent.title, subtitle=torrent.description)
192+
payload["episode_count"] = meta.total_episode or 1
193+
else:
194+
payload["episode_count"] = 1
195+
return payload
196+
153197
def __filter_torrents(self, rule_string: str, rule_name: str,
154198
torrent_list: List[TorrentInfo],
155199
mediainfo: MediaInfo,

app/modules/indexer/parser/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
1111
from app.core.config import settings
1212
from app.helper.cloudflare import under_challenge
1313
from app.log import logger
14+
from app.utils import rust_accel
1415
from app.utils.http import RequestUtils
1516
from app.utils.site import SiteUtils
17+
from app.utils.string import StringUtils
1618

1719

1820
# 站点框架
@@ -154,6 +156,16 @@ def site_schema(self) -> SiteSchema:
154156
"""
155157
return self.schema
156158

159+
@staticmethod
160+
def num_filesize(text) -> int:
161+
"""
162+
将站点页面中的文件大小文本转换为字节,优先使用 Rust 快路径。
163+
"""
164+
rust_value = rust_accel.parse_filesize(text)
165+
if rust_value is not None:
166+
return rust_value
167+
return StringUtils.num_filesize(text)
168+
157169
def parse(self):
158170
"""
159171
解析站点信息

0 commit comments

Comments
 (0)