mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-07 16:36:53 +08:00
refactor: use external moviepilot rust package
This commit is contained in:
@@ -56,16 +56,14 @@ 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` 可用;未安装时项目会自动使用 Python 实现:
|
本地开发默认通过 PyPI 依赖安装 Rust 加速扩展;扩展未安装或 `RUST_ACCEL=false` 时会自动使用 Python 实现:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
cargo --version
|
python -m pip install moviepilot-rust
|
||||||
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())"
|
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` 即可更新本地扩展。
|
如果输出 `True`,说明当前开发环境已经加载 `moviepilot_rust`。Rust 源码和打包发布流程在 [MoviePilot-Rust](https://github.com/jxxghp/MoviePilot-Rust) 仓库维护。
|
||||||
|
|
||||||
需要本地评估 Rust 加速效果时,可运行:
|
需要本地评估 Rust 加速效果时,可运行:
|
||||||
|
|
||||||
|
|||||||
+4
-18
@@ -85,24 +85,10 @@ 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" "maturin>=1.9,<2" \
|
&& pip install "Cython~=3.1.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
|
||||||
|
|
||||||
@@ -128,9 +114,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_rust --chmod=777 ${VENV_PATH} ${VENV_PATH}
|
COPY --from=prepare_venv --chmod=777 ${VENV_PATH} ${VENV_PATH}
|
||||||
COPY --from=prepare_rust /usr/local/bin/uv /usr/local/bin/uv
|
COPY --from=prepare_venv /usr/local/bin/uv /usr/local/bin/uv
|
||||||
COPY --from=prepare_rust /usr/local/bin/uv-pip-compat /usr/local/bin/uv-pip-compat
|
COPY --from=prepare_venv /usr/local/bin/uv-pip-compat /usr/local/bin/uv-pip-compat
|
||||||
|
|
||||||
# 浏览器运行依赖
|
# 浏览器运行依赖
|
||||||
RUN playwright install-deps chromium \
|
RUN playwright install-deps chromium \
|
||||||
|
|||||||
@@ -24,63 +24,6 @@ function WARN() {
|
|||||||
VENV_PATH="${VENV_PATH:-/opt/venv}"
|
VENV_PATH="${VENV_PATH:-/opt/venv}"
|
||||||
export PATH="${VENV_PATH}/bin:$PATH"
|
export PATH="${VENV_PATH}/bin:$PATH"
|
||||||
|
|
||||||
# 配置 Rust 工具链目录,避免 Docker 中 HOME 与 root 真实主目录不一致时 rustup 无法推断路径。
|
|
||||||
function configure_rust_build_env() {
|
|
||||||
local home_dir="${HOME:-/root}"
|
|
||||||
export CARGO_HOME="${CARGO_HOME:-${home_dir}/.cargo}"
|
|
||||||
export RUSTUP_HOME="${RUSTUP_HOME:-${home_dir}/.rustup}"
|
|
||||||
if [[ ":${PATH}:" != *":${CARGO_HOME}/bin:"* ]]; then
|
|
||||||
export PATH="${CARGO_HOME}/bin:$PATH"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# 按需准备 Rust 构建环境,避免把工具链常驻打进 Docker runtime 镜像。
|
|
||||||
function ensure_rust_build_env() {
|
|
||||||
configure_rust_build_env
|
|
||||||
if command -v cargo > /dev/null 2>&1; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
INFO "→ 当前镜像未包含 cargo,正在按需准备 Rust 构建环境..."
|
|
||||||
if command -v apt-get > /dev/null 2>&1; then
|
|
||||||
if ! apt-get update; then
|
|
||||||
ERROR "更新 apt 索引失败,无法安装 Rust 构建依赖"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if ! apt-get install -y --no-install-recommends build-essential curl ca-certificates; then
|
|
||||||
ERROR "安装 Rust 构建依赖失败"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
apt-get clean
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
fi
|
|
||||||
if ! curl ${CURL_OPTIONS} https://sh.rustup.rs -sSf | sh -s -- -y --profile minimal; then
|
|
||||||
ERROR "安装 Rust 工具链失败"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
configure_rust_build_env
|
|
||||||
command -v cargo > /dev/null 2>&1
|
|
||||||
}
|
|
||||||
|
|
||||||
# 更新 Rust 加速扩展,确保 Docker dev/release 更新源码后不会继续加载旧 wheel。
|
|
||||||
function install_rust_accel() {
|
|
||||||
local manifest="/app/rust/moviepilot_rust/Cargo.toml"
|
|
||||||
if [ ! -f "${manifest}" ]; then
|
|
||||||
WARN "未找到 Rust 扩展源码,跳过 Rust 加速扩展更新"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
if ! ensure_rust_build_env; then
|
|
||||||
ERROR "Rust 构建环境不可用,无法更新 Rust 加速扩展"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
INFO "→ 正在更新 Rust 加速扩展..."
|
|
||||||
# maturin develop 需要显式的虚拟环境标记,容器内直接调用 venv Python 时不会自动识别。
|
|
||||||
if ! VIRTUAL_ENV="${VENV_PATH}" "${VENV_PATH}/bin/python" -m maturin develop --release --manifest-path "${manifest}"; then
|
|
||||||
ERROR "Rust 加速扩展更新失败"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
INFO "Rust 加速扩展更新成功"
|
|
||||||
}
|
|
||||||
|
|
||||||
# 下载及解压
|
# 下载及解压
|
||||||
function download_and_unzip() {
|
function download_and_unzip() {
|
||||||
local retries=0
|
local retries=0
|
||||||
@@ -223,9 +166,6 @@ function install_backend_and_download_resources() {
|
|||||||
WARN "${sites_file} 下载失败,继续使用旧的资源来启动..."
|
WARN "${sites_file} 下载失败,继续使用旧的资源来启动..."
|
||||||
fi
|
fi
|
||||||
INFO "站点资源更新成功"
|
INFO "站点资源更新成功"
|
||||||
if ! install_rust_accel; then
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
# 清理临时目录
|
# 清理临时目录
|
||||||
rm -rf "${TMP_PATH}"
|
rm -rf "${TMP_PATH}"
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
+3
-5
@@ -159,9 +159,7 @@ moviepilot install deps --config-dir /path/to/moviepilot-config
|
|||||||
说明:
|
说明:
|
||||||
|
|
||||||
- 默认会自动选择本地已安装的 `Python 3.11+` 解释器
|
- 默认会自动选择本地已安装的 `Python 3.11+` 解释器
|
||||||
- 会在安装 Python 依赖后构建并安装 `moviepilot_rust` 加速扩展,因此本机需要可用的 Rust `cargo`
|
- `moviepilot_rust` 加速扩展通过 `moviepilot-rust` PyPI 依赖安装,主项目本地安装不需要 Rust toolchain
|
||||||
- 一键安装脚本会自动准备 Rust toolchain 和系统构建工具;手动执行 CLI 安装时,如果未安装 Rust 或本机编译器,请先安装后再执行 `moviepilot install deps`
|
|
||||||
- 如需临时跳过加速扩展构建,可设置 `MOVIEPILOT_SKIP_RUST_ACCEL=1`,但相关核心处理会回退到 Python 实现,性能收益不会生效
|
|
||||||
- 安装完成后可在前端“高级设置 - 实验室”中关闭或重新开启 Rust 加速;如果后端未加载扩展,该开关会保持关闭且不可操作
|
- 安装完成后可在前端“高级设置 - 实验室”中关闭或重新开启 Rust 加速;如果后端未加载扩展,该开关会保持关闭且不可操作
|
||||||
|
|
||||||
安装前端 release:
|
安装前端 release:
|
||||||
@@ -224,7 +222,7 @@ moviepilot setup --config-dir /path/to/moviepilot-config
|
|||||||
|
|
||||||
`moviepilot setup` 会串行执行:
|
`moviepilot setup` 会串行执行:
|
||||||
|
|
||||||
1. 安装后端依赖并构建 Rust 加速扩展
|
1. 安装后端依赖,包括 `moviepilot-rust` 加速扩展
|
||||||
2. 下载并安装前端 release
|
2. 下载并安装前端 release
|
||||||
3. 下载并同步资源文件
|
3. 下载并同步资源文件
|
||||||
4. 初始化本地配置
|
4. 初始化本地配置
|
||||||
@@ -327,7 +325,7 @@ moviepilot update all --skip-resources
|
|||||||
|
|
||||||
说明:
|
说明:
|
||||||
|
|
||||||
- `update backend` 会更新 Git 仓库并重新安装后端依赖,同时重新构建 Rust 加速扩展
|
- `update backend` 会更新 Git 仓库并重新安装后端依赖,包括 `moviepilot-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,6 +10,8 @@
|
|||||||
- **pip** (Python 包管理器)
|
- **pip** (Python 包管理器)
|
||||||
- **Git** (用于版本控制)
|
- **Git** (用于版本控制)
|
||||||
|
|
||||||
|
Rust 加速扩展通过 `moviepilot-rust` PyPI 包安装,主项目本地开发不再需要 Rust toolchain。需要修改或发布 Rust 扩展时,请在 `MoviePilot-Rust` 仓库中构建。
|
||||||
|
|
||||||
### 1. 创建虚拟环境
|
### 1. 创建虚拟环境
|
||||||
|
|
||||||
在项目根目录下创建并激活虚拟环境:
|
在项目根目录下创建并激活虚拟环境:
|
||||||
|
|||||||
@@ -116,8 +116,8 @@
|
|||||||
| Item | Detail |
|
| Item | Detail |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Rust extension | `moviepilot_rust` — optional compiled accelerator for core processing paths |
|
| Rust extension | `moviepilot_rust` — optional compiled accelerator for core processing paths |
|
||||||
| Build | Requires Rust `cargo`; built automatically by `moviepilot install deps` |
|
| Install | Installed from the `moviepilot-rust` PyPI package with normal Python dependencies |
|
||||||
| Skip flag | `MOVIEPILOT_SKIP_RUST_ACCEL=1` disables build (falls back to Python implementation) |
|
| Source | Maintained in the separate `MoviePilot-Rust` repository |
|
||||||
| Toggle | Can be disabled/re-enabled at runtime via frontend Advanced Settings → Lab |
|
| Toggle | Can be disabled/re-enabled at runtime via frontend Advanced Settings → Lab |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+5
-6
@@ -105,8 +105,7 @@ Options:
|
|||||||
--venv PATH 虚拟环境目录,默认 ./venv
|
--venv PATH 虚拟环境目录,默认 ./venv
|
||||||
--recreate 删除并重建虚拟环境
|
--recreate 删除并重建虚拟环境
|
||||||
--config-dir PATH 指定配置目录
|
--config-dir PATH 指定配置目录
|
||||||
说明 会构建并安装 Rust 加速扩展,需本机可用 cargo 和编译器;
|
说明 Rust 加速扩展通过 moviepilot-rust PyPI 依赖安装
|
||||||
可临时设置 MOVIEPILOT_SKIP_RUST_ACCEL=1 跳过构建
|
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
--version TAG 前端版本,默认使用 version.py 中的 FRONTEND_VERSION
|
--version TAG 前端版本,默认使用 version.py 中的 FRONTEND_VERSION
|
||||||
@@ -156,8 +155,8 @@ Options:
|
|||||||
-h, --help 显示帮助
|
-h, --help 显示帮助
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
- 安装后端依赖时会构建并安装 Rust 加速扩展,需本机可用 cargo 和编译器
|
- Rust 加速扩展通过 moviepilot-rust PyPI 依赖安装
|
||||||
- 可临时设置 MOVIEPILOT_SKIP_RUST_ACCEL=1 跳过加速扩展构建
|
- 可在前端“高级设置 - 实验室”中关闭或重新开启 Rust 加速
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,8 +195,8 @@ Options:
|
|||||||
-h, --help 显示帮助
|
-h, --help 显示帮助
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
- 更新后端依赖时会重新构建并安装 Rust 加速扩展,需本机可用 cargo 和编译器
|
- 更新后端依赖时会同步更新 moviepilot-rust PyPI 依赖
|
||||||
- 可临时设置 MOVIEPILOT_SKIP_RUST_ACCEL=1 跳过加速扩展构建
|
- 可在前端“高级设置 - 实验室”中关闭或重新开启 Rust 加速
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
Cython~=3.1.2
|
Cython~=3.1.2
|
||||||
maturin>=1.9,<2
|
moviepilot-rust~=0.1.0
|
||||||
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
|
||||||
|
|||||||
Generated
-1261
File diff suppressed because it is too large
Load Diff
@@ -1,21 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "moviepilot-rust"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
name = "moviepilot_rust"
|
|
||||||
crate-type = ["cdylib"]
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
anitomy-pure = "0.1"
|
|
||||||
fancy-regex = "0.14"
|
|
||||||
inputx-pinyin = "1.0.2"
|
|
||||||
minijinja = "2.20"
|
|
||||||
chrono = "0.4"
|
|
||||||
once_cell = "1.20"
|
|
||||||
pyo3 = { version = "0.23", features = ["abi3-py311", "extension-module"] }
|
|
||||||
quick-xml = "0.38"
|
|
||||||
regex = "1.11"
|
|
||||||
scraper = "0.24"
|
|
||||||
url = "2.5"
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
[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
|
|
||||||
@@ -1,925 +0,0 @@
|
|||||||
use crate::metainfo::parse_total_episode_for_filter;
|
|
||||||
use crate::utils::{
|
|
||||||
get_optional_f64, get_optional_i64, get_optional_nonempty_string, get_string_list,
|
|
||||||
object_optional_f64, object_optional_i64, object_optional_string, object_string_list,
|
|
||||||
py_any_to_string_list,
|
|
||||||
};
|
|
||||||
use chrono::{Local, NaiveDateTime};
|
|
||||||
use fancy_regex::Regex as FancyRegex;
|
|
||||||
use once_cell::sync::Lazy;
|
|
||||||
use pyo3::exceptions::PyValueError;
|
|
||||||
use pyo3::prelude::*;
|
|
||||||
use pyo3::types::{PyAny, PyDict, PyList, PyString};
|
|
||||||
use std::collections::{HashMap, HashSet};
|
|
||||||
use std::sync::Mutex;
|
|
||||||
|
|
||||||
static REGEX_CACHE: Lazy<Mutex<HashMap<String, FancyRegex>>> =
|
|
||||||
Lazy::new(|| Mutex::new(HashMap::new()));
|
|
||||||
const SIZE_UNIT: f64 = 1024.0 * 1024.0;
|
|
||||||
|
|
||||||
#[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)]
|
|
||||||
struct FilterGroup {
|
|
||||||
levels: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct RuleMatcher {
|
|
||||||
rules: HashMap<String, PyObject>,
|
|
||||||
match_fields: HashSet<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TorrentSnapshot {
|
|
||||||
title: String,
|
|
||||||
description: String,
|
|
||||||
labels: Vec<String>,
|
|
||||||
fields: HashMap<String, Vec<String>>,
|
|
||||||
size: f64,
|
|
||||||
seeders: i64,
|
|
||||||
downloadvolumefactor: Option<f64>,
|
|
||||||
pub_minutes: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct MediaSnapshot {
|
|
||||||
available: bool,
|
|
||||||
values: HashMap<String, Vec<String>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[pyfunction]
|
|
||||||
#[pyo3(signature = (groups, torrent_list, rule_set, mediainfo=None, metainfo_options=None))]
|
|
||||||
pub(crate) fn filter_torrents_fast(
|
|
||||||
py: Python<'_>,
|
|
||||||
groups: &Bound<'_, PyList>,
|
|
||||||
torrent_list: &Bound<'_, PyList>,
|
|
||||||
rule_set: &Bound<'_, PyDict>,
|
|
||||||
mediainfo: Option<&Bound<'_, PyAny>>,
|
|
||||||
metainfo_options: Option<&Bound<'_, PyDict>>,
|
|
||||||
) -> PyResult<PyObject> {
|
|
||||||
let groups = parse_filter_groups(groups)?;
|
|
||||||
if groups.is_empty() {
|
|
||||||
return Ok(PyList::empty(py).into());
|
|
||||||
}
|
|
||||||
let matcher = RuleMatcher::from_py(rule_set)?;
|
|
||||||
let media = MediaSnapshot::from_py(mediainfo)?;
|
|
||||||
let results = PyList::empty(py);
|
|
||||||
let mut parsed_rule_cache: HashMap<String, RuleExpr> = HashMap::new();
|
|
||||||
let mut episode_count_cache: HashMap<String, i64> = HashMap::new();
|
|
||||||
for (index, torrent_obj) in torrent_list.iter().enumerate() {
|
|
||||||
let torrent = TorrentSnapshot::from_py(&torrent_obj, &matcher.match_fields)?;
|
|
||||||
if let Some(priority) = match_torrent(
|
|
||||||
py,
|
|
||||||
&torrent,
|
|
||||||
&groups,
|
|
||||||
&matcher,
|
|
||||||
&media,
|
|
||||||
metainfo_options,
|
|
||||||
&mut parsed_rule_cache,
|
|
||||||
&mut episode_count_cache,
|
|
||||||
)? {
|
|
||||||
results.append((index, priority))?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(results.into())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 将规则字符串切分为名称、逻辑符和括号。
|
|
||||||
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())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 解析 Python 侧已经按媒体筛选后的规则组。
|
|
||||||
fn parse_filter_groups(groups: &Bound<'_, PyList>) -> PyResult<Vec<FilterGroup>> {
|
|
||||||
let mut result = Vec::new();
|
|
||||||
for item in groups.iter() {
|
|
||||||
let dict = item.downcast::<PyDict>()?;
|
|
||||||
let rule_string = get_optional_nonempty_string(dict, "rule_string")?.unwrap_or_default();
|
|
||||||
if rule_string.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let levels = rule_string
|
|
||||||
.split('>')
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.map(str::to_string)
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
if !levels.is_empty() {
|
|
||||||
result.push(FilterGroup { levels });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RuleMatcher {
|
|
||||||
/// 构建规则查找表,保留 Python 规则对象引用以按需读取字段。
|
|
||||||
fn from_py(rule_set: &Bound<'_, PyDict>) -> PyResult<Self> {
|
|
||||||
let mut rules = HashMap::new();
|
|
||||||
let mut match_fields = HashSet::new();
|
|
||||||
for (key, value) in rule_set.iter() {
|
|
||||||
if let Ok(rule) = value.downcast::<PyDict>() {
|
|
||||||
for field in get_string_list(rule, "match")? {
|
|
||||||
match_fields.insert(field);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rules.insert(key.extract::<String>()?, value.into());
|
|
||||||
}
|
|
||||||
Ok(Self {
|
|
||||||
rules,
|
|
||||||
match_fields,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 根据规则名获取规则字典。
|
|
||||||
fn get<'py>(&self, py: Python<'py>, name: &str) -> Option<Bound<'py, PyDict>> {
|
|
||||||
self.rules
|
|
||||||
.get(name)?
|
|
||||||
.bind(py)
|
|
||||||
.downcast::<PyDict>()
|
|
||||||
.ok()
|
|
||||||
.cloned()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TorrentSnapshot {
|
|
||||||
/// 从 Python TorrentInfo 对象抽取过滤所需字段。
|
|
||||||
fn from_py(torrent: &Bound<'_, PyAny>, match_fields: &HashSet<String>) -> PyResult<Self> {
|
|
||||||
let title = object_optional_string(torrent, "title")?.unwrap_or_default();
|
|
||||||
let description = object_optional_string(torrent, "description")?.unwrap_or_default();
|
|
||||||
let labels = object_string_list(torrent, "labels")?;
|
|
||||||
let fields = selected_object_fields(torrent, match_fields, &title, &description, &labels)?;
|
|
||||||
Ok(Self {
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
labels,
|
|
||||||
fields,
|
|
||||||
size: object_optional_f64(torrent, "size")?.unwrap_or(0.0),
|
|
||||||
seeders: object_optional_i64(torrent, "seeders")?.unwrap_or(0),
|
|
||||||
downloadvolumefactor: object_optional_f64(torrent, "downloadvolumefactor")?,
|
|
||||||
pub_minutes: pub_minutes_from_py(torrent)?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 拼接默认匹配内容:标题、副标题和标签。
|
|
||||||
fn default_content(&self) -> String {
|
|
||||||
format!(
|
|
||||||
"{} {} {}",
|
|
||||||
if self.title.is_empty() {
|
|
||||||
"None"
|
|
||||||
} else {
|
|
||||||
&self.title
|
|
||||||
},
|
|
||||||
if self.description.is_empty() {
|
|
||||||
"None"
|
|
||||||
} else {
|
|
||||||
&self.description
|
|
||||||
},
|
|
||||||
self.labels.join(" ")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 读取任意 TorrentInfo 字段的匹配文本列表。
|
|
||||||
fn field_values(&self, field: &str) -> Option<&Vec<String>> {
|
|
||||||
self.fields.get(field)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MediaSnapshot {
|
|
||||||
/// 从 Python MediaInfo 对象抽取 TMDB 规则可能访问的属性。
|
|
||||||
fn from_py(mediainfo: Option<&Bound<'_, PyAny>>) -> PyResult<Self> {
|
|
||||||
let mut values = HashMap::new();
|
|
||||||
let Some(media) = mediainfo else {
|
|
||||||
return Ok(Self {
|
|
||||||
available: false,
|
|
||||||
values,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
if media.is_none() {
|
|
||||||
return Ok(Self {
|
|
||||||
available: false,
|
|
||||||
values,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
for attr in [
|
|
||||||
"type",
|
|
||||||
"category",
|
|
||||||
"original_language",
|
|
||||||
"tmdb_id",
|
|
||||||
"imdb_id",
|
|
||||||
"tvdb_id",
|
|
||||||
"douban_id",
|
|
||||||
"bangumi_id",
|
|
||||||
"collection_id",
|
|
||||||
"origin_country",
|
|
||||||
"genre_ids",
|
|
||||||
"production_countries",
|
|
||||||
"spoken_languages",
|
|
||||||
"languages",
|
|
||||||
] {
|
|
||||||
let attr_values = media_attr_values(media, attr)?;
|
|
||||||
if !attr_values.is_empty() {
|
|
||||||
values.insert(attr.to_string(), attr_values);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Ok(dict) = media.getattr("__dict__") {
|
|
||||||
if let Ok(dict) = dict.downcast::<PyDict>() {
|
|
||||||
for (key, value) in dict.iter() {
|
|
||||||
let key = key.extract::<String>()?;
|
|
||||||
if values.contains_key(&key) || value.is_none() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let attr_values = if key == "production_countries" {
|
|
||||||
production_country_values(&value)?
|
|
||||||
} else {
|
|
||||||
py_any_to_string_list(&value)?
|
|
||||||
.into_iter()
|
|
||||||
.map(|item| item.to_uppercase())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
};
|
|
||||||
if !attr_values.is_empty() {
|
|
||||||
values.insert(key, attr_values);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(Self {
|
|
||||||
available: true,
|
|
||||||
values,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 判断 TMDB 字段是否包含任一目标值。
|
|
||||||
fn matches(&self, attr: &str, value: &str) -> bool {
|
|
||||||
let Some(info_values) = self.values.get(attr) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
let values = value
|
|
||||||
.split(',')
|
|
||||||
.filter(|item| !item.is_empty())
|
|
||||||
.map(|item| item.to_uppercase())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
values
|
|
||||||
.iter()
|
|
||||||
.any(|value| info_values.iter().any(|info_value| info_value == value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 执行完整种子过滤并返回匹配优先级。
|
|
||||||
fn match_torrent(
|
|
||||||
py: Python<'_>,
|
|
||||||
torrent: &TorrentSnapshot,
|
|
||||||
groups: &[FilterGroup],
|
|
||||||
matcher: &RuleMatcher,
|
|
||||||
media: &MediaSnapshot,
|
|
||||||
metainfo_options: Option<&Bound<'_, PyDict>>,
|
|
||||||
parsed_rule_cache: &mut HashMap<String, RuleExpr>,
|
|
||||||
episode_count_cache: &mut HashMap<String, i64>,
|
|
||||||
) -> PyResult<Option<i64>> {
|
|
||||||
let mut last_priority = None;
|
|
||||||
for group in groups {
|
|
||||||
let mut priority = 100i64;
|
|
||||||
let mut matched_priority = None;
|
|
||||||
for level in &group.levels {
|
|
||||||
let expr = parse_cached_expr(level, parsed_rule_cache)?;
|
|
||||||
if match_group(
|
|
||||||
py,
|
|
||||||
torrent,
|
|
||||||
&expr,
|
|
||||||
matcher,
|
|
||||||
media,
|
|
||||||
metainfo_options,
|
|
||||||
episode_count_cache,
|
|
||||||
)? {
|
|
||||||
matched_priority = Some(priority);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
priority -= 1;
|
|
||||||
}
|
|
||||||
match matched_priority {
|
|
||||||
Some(priority) => last_priority = Some(priority),
|
|
||||||
None => return Ok(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(last_priority)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 延迟解析并缓存优先级层级表达式,保持命中高优先级后不解析低层级的语义。
|
|
||||||
fn parse_cached_expr<'a>(
|
|
||||||
level: &str,
|
|
||||||
parsed_rule_cache: &'a mut HashMap<String, RuleExpr>,
|
|
||||||
) -> PyResult<&'a RuleExpr> {
|
|
||||||
if !parsed_rule_cache.contains_key(level) {
|
|
||||||
let tokens = tokenize_rule(level)?;
|
|
||||||
let mut parser = RuleParserState::new(tokens);
|
|
||||||
let expr = parser.parse_expression()?;
|
|
||||||
if parser.has_remaining() {
|
|
||||||
return Err(PyValueError::new_err("规则表达式包含无法解析的剩余内容"));
|
|
||||||
}
|
|
||||||
parsed_rule_cache.insert(level.to_string(), expr);
|
|
||||||
}
|
|
||||||
Ok(parsed_rule_cache.get(level).expect("cached rule exists"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 递归求值规则布尔表达式。
|
|
||||||
fn match_group(
|
|
||||||
py: Python<'_>,
|
|
||||||
torrent: &TorrentSnapshot,
|
|
||||||
expr: &RuleExpr,
|
|
||||||
matcher: &RuleMatcher,
|
|
||||||
media: &MediaSnapshot,
|
|
||||||
metainfo_options: Option<&Bound<'_, PyDict>>,
|
|
||||||
episode_count_cache: &mut HashMap<String, i64>,
|
|
||||||
) -> PyResult<bool> {
|
|
||||||
match expr {
|
|
||||||
RuleExpr::Name(name) => match_rule(
|
|
||||||
py,
|
|
||||||
torrent,
|
|
||||||
name,
|
|
||||||
matcher,
|
|
||||||
media,
|
|
||||||
metainfo_options,
|
|
||||||
episode_count_cache,
|
|
||||||
),
|
|
||||||
RuleExpr::Not(inner) => Ok(!match_group(
|
|
||||||
py,
|
|
||||||
torrent,
|
|
||||||
inner,
|
|
||||||
matcher,
|
|
||||||
media,
|
|
||||||
metainfo_options,
|
|
||||||
episode_count_cache,
|
|
||||||
)?),
|
|
||||||
RuleExpr::And(left, right) => {
|
|
||||||
if !match_group(
|
|
||||||
py,
|
|
||||||
torrent,
|
|
||||||
left,
|
|
||||||
matcher,
|
|
||||||
media,
|
|
||||||
metainfo_options,
|
|
||||||
episode_count_cache,
|
|
||||||
)? {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
match_group(
|
|
||||||
py,
|
|
||||||
torrent,
|
|
||||||
right,
|
|
||||||
matcher,
|
|
||||||
media,
|
|
||||||
metainfo_options,
|
|
||||||
episode_count_cache,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
RuleExpr::Or(left, right) => {
|
|
||||||
if match_group(
|
|
||||||
py,
|
|
||||||
torrent,
|
|
||||||
left,
|
|
||||||
matcher,
|
|
||||||
media,
|
|
||||||
metainfo_options,
|
|
||||||
episode_count_cache,
|
|
||||||
)? {
|
|
||||||
return Ok(true);
|
|
||||||
}
|
|
||||||
match_group(
|
|
||||||
py,
|
|
||||||
torrent,
|
|
||||||
right,
|
|
||||||
matcher,
|
|
||||||
media,
|
|
||||||
metainfo_options,
|
|
||||||
episode_count_cache,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 执行单条规则匹配。
|
|
||||||
fn match_rule(
|
|
||||||
py: Python<'_>,
|
|
||||||
torrent: &TorrentSnapshot,
|
|
||||||
rule_name: &str,
|
|
||||||
matcher: &RuleMatcher,
|
|
||||||
media: &MediaSnapshot,
|
|
||||||
metainfo_options: Option<&Bound<'_, PyDict>>,
|
|
||||||
episode_count_cache: &mut HashMap<String, i64>,
|
|
||||||
) -> PyResult<bool> {
|
|
||||||
let Some(rule) = matcher.get(py, rule_name) else {
|
|
||||||
return Ok(false);
|
|
||||||
};
|
|
||||||
if match_tmdb_rule(&rule, media)? {
|
|
||||||
return Ok(true);
|
|
||||||
}
|
|
||||||
let content = rule_match_content(&rule, torrent)?;
|
|
||||||
let includes = get_string_list(&rule, "include")?;
|
|
||||||
if !includes.is_empty() {
|
|
||||||
let mut included = false;
|
|
||||||
for pattern in includes {
|
|
||||||
if regex_search(&pattern, &content)? {
|
|
||||||
included = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !included {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let excludes = get_string_list(&rule, "exclude")?;
|
|
||||||
for pattern in excludes {
|
|
||||||
if regex_search(&pattern, &content)? {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(size_range) = get_optional_nonempty_string(&rule, "size_range")? {
|
|
||||||
if !match_size(torrent, &size_range, metainfo_options, episode_count_cache)? {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(seeders) = get_optional_i64(&rule, "seeders")? {
|
|
||||||
if torrent.seeders < seeders {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(download_factor) = get_optional_f64(&rule, "downloadvolumefactor")? {
|
|
||||||
if torrent.downloadvolumefactor != Some(download_factor) {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(publish_time) = get_optional_nonempty_string(&rule, "publish_time")? {
|
|
||||||
if !match_publish_time(torrent.pub_minutes, &publish_time)? {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 判断规则中的 TMDB 条件是否匹配媒体信息。
|
|
||||||
fn match_tmdb_rule(rule: &Bound<'_, PyDict>, media: &MediaSnapshot) -> PyResult<bool> {
|
|
||||||
let Some(tmdb_obj) = rule.get_item("tmdb")? else {
|
|
||||||
return Ok(false);
|
|
||||||
};
|
|
||||||
if tmdb_obj.is_none() {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
if !media.available {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
let tmdb = tmdb_obj.downcast::<PyDict>()?;
|
|
||||||
for (key, value) in tmdb.iter() {
|
|
||||||
if value.is_none() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let value = value.str()?.to_str()?.to_string();
|
|
||||||
if value.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if !media.matches(&key.extract::<String>()?, &value) {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 计算规则实际用于正则匹配的内容。
|
|
||||||
fn rule_match_content(rule: &Bound<'_, PyDict>, torrent: &TorrentSnapshot) -> PyResult<String> {
|
|
||||||
let matches = get_string_list(rule, "match")?;
|
|
||||||
if matches.is_empty() {
|
|
||||||
return Ok(torrent.default_content());
|
|
||||||
}
|
|
||||||
let mut content = Vec::new();
|
|
||||||
for field in matches {
|
|
||||||
if let Some(values) = torrent.field_values(&field) {
|
|
||||||
content.extend(values.iter().filter(|item| !item.is_empty()).cloned());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if content.is_empty() {
|
|
||||||
Ok(torrent.default_content())
|
|
||||||
} else {
|
|
||||||
Ok(content.join(" "))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 匹配大小范围,剧集按总集数折算单集大小。
|
|
||||||
fn match_size(
|
|
||||||
torrent: &TorrentSnapshot,
|
|
||||||
size_range: &str,
|
|
||||||
metainfo_options: Option<&Bound<'_, PyDict>>,
|
|
||||||
episode_count_cache: &mut HashMap<String, i64>,
|
|
||||||
) -> PyResult<bool> {
|
|
||||||
let cache_key = format!("{}\n{}", torrent.title, torrent.description);
|
|
||||||
let episode_count = match episode_count_cache.get(&cache_key) {
|
|
||||||
Some(value) => *value,
|
|
||||||
None => {
|
|
||||||
let value = parse_total_episode_for_filter(
|
|
||||||
torrent.title.as_str(),
|
|
||||||
Some(torrent.description.as_str()),
|
|
||||||
metainfo_options,
|
|
||||||
)?;
|
|
||||||
episode_count_cache.insert(cache_key, value);
|
|
||||||
value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.max(1) as f64;
|
|
||||||
let torrent_size = torrent.size / episode_count;
|
|
||||||
match parse_size_range(size_range)? {
|
|
||||||
SizeRange::Between(min, max) => Ok(min <= torrent_size && torrent_size <= max),
|
|
||||||
SizeRange::Gte(min) => Ok(torrent_size >= min),
|
|
||||||
SizeRange::Lte(max) => Ok(torrent_size <= max),
|
|
||||||
SizeRange::Unknown => Ok(false),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum SizeRange {
|
|
||||||
Between(f64, f64),
|
|
||||||
Gte(f64),
|
|
||||||
Lte(f64),
|
|
||||||
Unknown,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 解析大小规则,单位与 Python 旧实现保持为 MB。
|
|
||||||
fn parse_size_range(size_range: &str) -> PyResult<SizeRange> {
|
|
||||||
let size_range = size_range.trim();
|
|
||||||
if let Some((left, right)) = size_range.split_once('-') {
|
|
||||||
return Ok(SizeRange::Between(
|
|
||||||
parse_f64(left.trim(), "大小范围")? * SIZE_UNIT,
|
|
||||||
parse_f64(right.trim(), "大小范围")? * SIZE_UNIT,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if let Some(value) = size_range.strip_prefix('>') {
|
|
||||||
return Ok(SizeRange::Gte(
|
|
||||||
parse_f64(value.trim(), "大小范围")? * SIZE_UNIT,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if let Some(value) = size_range.strip_prefix('<') {
|
|
||||||
return Ok(SizeRange::Lte(
|
|
||||||
parse_f64(value.trim(), "大小范围")? * SIZE_UNIT,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(SizeRange::Unknown)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 匹配发布时间分钟数范围。
|
|
||||||
fn match_publish_time(pub_minutes: f64, publish_time: &str) -> PyResult<bool> {
|
|
||||||
let values = publish_time
|
|
||||||
.split('-')
|
|
||||||
.map(|item| parse_f64(item, "发布时间规则"))
|
|
||||||
.collect::<PyResult<Vec<_>>>()?;
|
|
||||||
if values.len() == 1 {
|
|
||||||
Ok(pub_minutes >= values[0])
|
|
||||||
} else if values.len() >= 2 {
|
|
||||||
Ok(values[0] <= pub_minutes && pub_minutes <= values[1])
|
|
||||||
} else {
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 执行忽略大小写的正则搜索,按规则文本缓存编译结果。
|
|
||||||
fn regex_search(pattern: &str, content: &str) -> PyResult<bool> {
|
|
||||||
let cache_key = format!("(?i){pattern}");
|
|
||||||
if let Ok(guard) = REGEX_CACHE.lock() {
|
|
||||||
if let Some(regex) = guard.get(&cache_key) {
|
|
||||||
return regex
|
|
||||||
.is_match(content)
|
|
||||||
.map_err(|err| PyValueError::new_err(err.to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let regex =
|
|
||||||
FancyRegex::new(&cache_key).map_err(|err| PyValueError::new_err(err.to_string()))?;
|
|
||||||
let result = regex
|
|
||||||
.is_match(content)
|
|
||||||
.map_err(|err| PyValueError::new_err(err.to_string()))?;
|
|
||||||
if let Ok(mut guard) = REGEX_CACHE.lock() {
|
|
||||||
guard.insert(cache_key, regex);
|
|
||||||
}
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 抽取媒体字段值并统一转为大写字符串列表。
|
|
||||||
fn media_attr_values(media: &Bound<'_, PyAny>, attr: &str) -> PyResult<Vec<String>> {
|
|
||||||
let Ok(value) = media.getattr(attr) else {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
};
|
|
||||||
if value.is_none() {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
if attr == "production_countries" {
|
|
||||||
return production_country_values(&value);
|
|
||||||
}
|
|
||||||
let mut result = py_any_to_string_list(&value)?
|
|
||||||
.into_iter()
|
|
||||||
.map(|item| item.to_uppercase())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
if result.is_empty() {
|
|
||||||
let text = value.str()?.to_str()?.to_uppercase();
|
|
||||||
if !text.is_empty() {
|
|
||||||
result.push(text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从 TMDB production_countries 字段提取 iso_3166_1。
|
|
||||||
fn production_country_values(value: &Bound<'_, PyAny>) -> PyResult<Vec<String>> {
|
|
||||||
let Ok(list) = value.downcast::<PyList>() else {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
};
|
|
||||||
let mut result = Vec::new();
|
|
||||||
for item in list.iter() {
|
|
||||||
if let Ok(dict) = item.downcast::<PyDict>() {
|
|
||||||
if let Some(code) = get_optional_nonempty_string(dict, "iso_3166_1")? {
|
|
||||||
result.push(code.to_uppercase());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 按规则 match 字段读取 TorrentInfo 属性,避免热路径遍历整个 __dict__。
|
|
||||||
fn selected_object_fields(
|
|
||||||
torrent: &Bound<'_, PyAny>,
|
|
||||||
match_fields: &HashSet<String>,
|
|
||||||
title: &str,
|
|
||||||
description: &str,
|
|
||||||
labels: &[String],
|
|
||||||
) -> PyResult<HashMap<String, Vec<String>>> {
|
|
||||||
let mut result = HashMap::new();
|
|
||||||
for field in match_fields {
|
|
||||||
match field.as_str() {
|
|
||||||
"title" => {
|
|
||||||
if !title.is_empty() {
|
|
||||||
result.insert(field.clone(), vec![title.to_string()]);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
"description" => {
|
|
||||||
if !description.is_empty() {
|
|
||||||
result.insert(field.clone(), vec![description.to_string()]);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
"labels" => {
|
|
||||||
if !labels.is_empty() {
|
|
||||||
result.insert(field.clone(), labels.to_vec());
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
let Ok(value) = torrent.getattr(field) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if value.is_none() || !value.is_truthy()? {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let values = if let Ok(list) = value.downcast::<PyList>() {
|
|
||||||
let mut items = Vec::new();
|
|
||||||
for item in list.iter() {
|
|
||||||
if !item.is_none() && item.is_truthy()? {
|
|
||||||
items.push(item.str()?.to_str()?.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
items
|
|
||||||
} else {
|
|
||||||
vec![value.str()?.to_str()?.to_string()]
|
|
||||||
};
|
|
||||||
if !values.is_empty() {
|
|
||||||
result.insert(field.clone(), values);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 用 Rust 复刻 TorrentInfo.pub_minutes,避免过滤热路径回调 Python 方法。
|
|
||||||
fn pub_minutes_from_py(torrent: &Bound<'_, PyAny>) -> PyResult<f64> {
|
|
||||||
let Some(pubdate) = object_optional_string(torrent, "pubdate")? else {
|
|
||||||
return Ok(0.0);
|
|
||||||
};
|
|
||||||
let Ok(pubdate) = NaiveDateTime::parse_from_str(&pubdate, "%Y-%m-%d %H:%M:%S") else {
|
|
||||||
return Ok(0.0);
|
|
||||||
};
|
|
||||||
let now = Local::now().naive_local();
|
|
||||||
Ok((now - pubdate).num_seconds().div_euclid(60) as f64)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 解析浮点数字符串,保持 Python float 转换失败时抛异常的语义。
|
|
||||||
fn parse_f64(value: &str, context: &str) -> PyResult<f64> {
|
|
||||||
value
|
|
||||||
.trim()
|
|
||||||
.parse::<f64>()
|
|
||||||
.map_err(|err| PyValueError::new_err(format!("{context}解析失败: {err}")))
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,27 +0,0 @@
|
|||||||
mod filter;
|
|
||||||
mod indexer;
|
|
||||||
mod metainfo;
|
|
||||||
mod rss;
|
|
||||||
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!(filter::parse_filter_rule_fast, m)?)?;
|
|
||||||
m.add_function(wrap_pyfunction!(filter::filter_torrents_fast, m)?)?;
|
|
||||||
m.add_function(wrap_pyfunction!(indexer::parse_indexer_torrents_fast, m)?)?;
|
|
||||||
m.add_function(wrap_pyfunction!(metainfo::parse_metainfo_fast, m)?)?;
|
|
||||||
m.add_function(wrap_pyfunction!(metainfo::parse_metainfo_path_fast, m)?)?;
|
|
||||||
m.add_function(wrap_pyfunction!(metainfo::find_metainfo_fast, m)?)?;
|
|
||||||
m.add_function(wrap_pyfunction!(rss::parse_rss_items_fast, m)?)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,456 +0,0 @@
|
|||||||
use chrono::{
|
|
||||||
DateTime, Datelike, Local, NaiveDate, NaiveDateTime, NaiveTime, Offset, TimeZone, Timelike, Utc,
|
|
||||||
};
|
|
||||||
use pyo3::prelude::*;
|
|
||||||
use pyo3::types::{PyAny, PyDict, PyList};
|
|
||||||
use quick_xml::events::{BytesRef, BytesStart, Event};
|
|
||||||
use quick_xml::Reader;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct RssItem {
|
|
||||||
title: String,
|
|
||||||
description: String,
|
|
||||||
link: String,
|
|
||||||
enclosure: String,
|
|
||||||
size: i64,
|
|
||||||
pubdate: String,
|
|
||||||
nickname: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
enum TextField {
|
|
||||||
Title,
|
|
||||||
Description,
|
|
||||||
Link,
|
|
||||||
Pubdate,
|
|
||||||
Nickname,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 解析 RSS/Atom 文本并返回 MoviePilot 现有调用方兼容的条目字典。
|
|
||||||
#[pyfunction]
|
|
||||||
#[pyo3(signature = (xml_text, max_items=1000))]
|
|
||||||
pub(crate) fn parse_rss_items_fast(
|
|
||||||
py: Python<'_>,
|
|
||||||
xml_text: &str,
|
|
||||||
max_items: usize,
|
|
||||||
) -> PyResult<Option<PyObject>> {
|
|
||||||
let parsed = parse_rss_items(xml_text, max_items)?;
|
|
||||||
let result = PyList::empty(py);
|
|
||||||
let datetime_mod = py.import("datetime")?;
|
|
||||||
let datetime_cls = datetime_mod.getattr("datetime")?;
|
|
||||||
let timezone_cls = datetime_mod.getattr("timezone")?;
|
|
||||||
let timedelta_cls = datetime_mod.getattr("timedelta")?;
|
|
||||||
let mut timezone_cache = HashMap::new();
|
|
||||||
for item in parsed {
|
|
||||||
result.append(item_to_py(
|
|
||||||
py,
|
|
||||||
&item,
|
|
||||||
&datetime_cls,
|
|
||||||
&timezone_cls,
|
|
||||||
&timedelta_cls,
|
|
||||||
&mut timezone_cache,
|
|
||||||
)?)?;
|
|
||||||
}
|
|
||||||
Ok(Some(result.into()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 使用 quick-xml 流式读取 RSS/Atom,避免 lxml XPath 对每个 item 的重复遍历。
|
|
||||||
fn parse_rss_items(xml_text: &str, max_items: usize) -> PyResult<Vec<RssItem>> {
|
|
||||||
let mut reader = Reader::from_str(xml_text);
|
|
||||||
|
|
||||||
let mut results = Vec::with_capacity(max_items.min(1024));
|
|
||||||
let mut current_item: Option<RssItem> = None;
|
|
||||||
let mut item_depth = 0usize;
|
|
||||||
let mut current_field: Option<(TextField, usize)> = None;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
match reader.read_event() {
|
|
||||||
Ok(Event::Start(event)) => {
|
|
||||||
let name = event.name();
|
|
||||||
let local = local_name(name.as_ref());
|
|
||||||
if current_item.is_none() && is_item_node(local) {
|
|
||||||
current_item = Some(RssItem::default());
|
|
||||||
item_depth = 1;
|
|
||||||
current_field = None;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(item) = current_item.as_mut() {
|
|
||||||
item_depth += 1;
|
|
||||||
handle_start_field(&event, local, item, item_depth, &mut current_field)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(Event::Empty(event)) => {
|
|
||||||
let name = event.name();
|
|
||||||
let local = local_name(name.as_ref());
|
|
||||||
if let Some(item) = current_item.as_mut() {
|
|
||||||
handle_empty_field(&event, local, item)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(Event::Text(event)) => {
|
|
||||||
if let (Some(item), Some((field, _))) = (current_item.as_mut(), current_field) {
|
|
||||||
let text = event.decode().map_err(to_py_value_error)?;
|
|
||||||
append_text_field(item, field, text.as_ref());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(Event::CData(event)) => {
|
|
||||||
if let (Some(item), Some((field, _))) = (current_item.as_mut(), current_field) {
|
|
||||||
let text = event.decode().map_err(to_py_value_error)?;
|
|
||||||
append_text_field(item, field, text.as_ref());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(Event::GeneralRef(event)) => {
|
|
||||||
if let (Some(item), Some((field, _))) = (current_item.as_mut(), current_field) {
|
|
||||||
let text = resolve_general_ref(&event)?;
|
|
||||||
append_text_field(item, field, &text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(Event::End(event)) => {
|
|
||||||
let name = event.name();
|
|
||||||
let local = local_name(name.as_ref());
|
|
||||||
if current_item.is_some() && item_depth == 1 && is_item_node(local) {
|
|
||||||
if let Some(item) = current_item.take() {
|
|
||||||
if let Some(item) = finalize_item(item) {
|
|
||||||
results.push(item);
|
|
||||||
if results.len() >= max_items {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item_depth = 0;
|
|
||||||
current_field = None;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if current_item.is_some() && item_depth > 0 {
|
|
||||||
if current_field
|
|
||||||
.map(|(_, depth)| depth == item_depth)
|
|
||||||
.unwrap_or(false)
|
|
||||||
{
|
|
||||||
current_field = None;
|
|
||||||
}
|
|
||||||
item_depth = item_depth.saturating_sub(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(Event::Eof) => break,
|
|
||||||
Err(err) => {
|
|
||||||
return Err(to_py_value_error(err));
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(results)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 处理开始标签,记录当前需要采集文本的字段和链接属性。
|
|
||||||
fn handle_start_field(
|
|
||||||
event: &BytesStart<'_>,
|
|
||||||
local: &[u8],
|
|
||||||
item: &mut RssItem,
|
|
||||||
depth: usize,
|
|
||||||
current_field: &mut Option<(TextField, usize)>,
|
|
||||||
) -> PyResult<()> {
|
|
||||||
if local.eq_ignore_ascii_case(b"enclosure") {
|
|
||||||
fill_enclosure(event, item)?;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
if local.eq_ignore_ascii_case(b"link") {
|
|
||||||
fill_link_from_href(event, item)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
if current_field.is_none() {
|
|
||||||
if let Some(field) = pick_text_field(local, item) {
|
|
||||||
*current_field = Some((field, depth));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 处理空标签,覆盖 Atom 的 link href 和 RSS 的 enclosure。
|
|
||||||
fn handle_empty_field(event: &BytesStart<'_>, local: &[u8], item: &mut RssItem) -> PyResult<()> {
|
|
||||||
if local.eq_ignore_ascii_case(b"enclosure") {
|
|
||||||
fill_enclosure(event, item)?;
|
|
||||||
} else if local.eq_ignore_ascii_case(b"link") {
|
|
||||||
fill_link_from_href(event, item)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 根据标签名和已采集状态选择当前文本字段。
|
|
||||||
fn pick_text_field(local: &[u8], item: &RssItem) -> Option<TextField> {
|
|
||||||
if local.eq_ignore_ascii_case(b"title") && item.title.is_empty() {
|
|
||||||
return Some(TextField::Title);
|
|
||||||
}
|
|
||||||
if (local.eq_ignore_ascii_case(b"description") || local.eq_ignore_ascii_case(b"summary"))
|
|
||||||
&& item.description.is_empty()
|
|
||||||
{
|
|
||||||
return Some(TextField::Description);
|
|
||||||
}
|
|
||||||
if local.eq_ignore_ascii_case(b"link") && item.link.is_empty() {
|
|
||||||
return Some(TextField::Link);
|
|
||||||
}
|
|
||||||
if (local.eq_ignore_ascii_case(b"pubDate")
|
|
||||||
|| local.eq_ignore_ascii_case(b"published")
|
|
||||||
|| local.eq_ignore_ascii_case(b"updated"))
|
|
||||||
&& item.pubdate.is_empty()
|
|
||||||
{
|
|
||||||
return Some(TextField::Pubdate);
|
|
||||||
}
|
|
||||||
if local.eq_ignore_ascii_case(b"creator") && item.nickname.is_empty() {
|
|
||||||
return Some(TextField::Nickname);
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 追加文本字段内容,兼容 CDATA 和带内联标签的描述。
|
|
||||||
fn append_text_field(item: &mut RssItem, field: TextField, text: &str) {
|
|
||||||
if text.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
match field {
|
|
||||||
TextField::Title => item.title.push_str(text),
|
|
||||||
TextField::Description => item.description.push_str(text),
|
|
||||||
TextField::Link => item.link.push_str(text),
|
|
||||||
TextField::Pubdate => item.pubdate.push_str(text),
|
|
||||||
TextField::Nickname => item.nickname.push_str(text),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 解析 XML 通用实体,保留未识别实体的原始文本以便 Python 兜底时可复查。
|
|
||||||
fn resolve_general_ref(event: &BytesRef<'_>) -> PyResult<String> {
|
|
||||||
if let Some(value) = event.resolve_char_ref().map_err(to_py_value_error)? {
|
|
||||||
return Ok(value.to_string());
|
|
||||||
}
|
|
||||||
let name = event.decode().map_err(to_py_value_error)?;
|
|
||||||
let resolved = match name.as_ref() {
|
|
||||||
"amp" => "&".to_string(),
|
|
||||||
"lt" => "<".to_string(),
|
|
||||||
"gt" => ">".to_string(),
|
|
||||||
"apos" => "'".to_string(),
|
|
||||||
"quot" => "\"".to_string(),
|
|
||||||
other => format!("&{other};"),
|
|
||||||
};
|
|
||||||
Ok(resolved)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从 enclosure 标签读取下载链接和大小。
|
|
||||||
fn fill_enclosure(event: &BytesStart<'_>, item: &mut RssItem) -> PyResult<()> {
|
|
||||||
if !item.enclosure.is_empty() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
if let Some(url) = attr_value(event, b"url")? {
|
|
||||||
item.enclosure = url;
|
|
||||||
}
|
|
||||||
if let Some(length) = attr_value(event, b"length")? {
|
|
||||||
item.size = length.trim().parse::<i64>().unwrap_or(0);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从 Atom link 的 href 属性读取页面地址。
|
|
||||||
fn fill_link_from_href(event: &BytesStart<'_>, item: &mut RssItem) -> PyResult<()> {
|
|
||||||
if !item.link.is_empty() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
if let Some(href) = attr_value(event, b"href")? {
|
|
||||||
item.link = href;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 读取并反转义指定属性值。
|
|
||||||
fn attr_value(event: &BytesStart<'_>, name: &[u8]) -> PyResult<Option<String>> {
|
|
||||||
for attr in event.attributes().with_checks(false) {
|
|
||||||
let attr = attr.map_err(to_py_value_error)?;
|
|
||||||
if attr.key.as_ref().eq_ignore_ascii_case(name) {
|
|
||||||
let value = attr
|
|
||||||
.decode_and_unescape_value(event.decoder())
|
|
||||||
.map_err(to_py_value_error)?;
|
|
||||||
return Ok(Some(value.trim().to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 完成单条 RSS item 的兼容性整理,保留原 Python 逻辑的跳过条件。
|
|
||||||
fn finalize_item(mut item: RssItem) -> Option<RssItem> {
|
|
||||||
item.title = item.title.trim().to_string();
|
|
||||||
item.description = item.description.trim().to_string();
|
|
||||||
item.link = item.link.trim().to_string();
|
|
||||||
item.enclosure = item.enclosure.trim().to_string();
|
|
||||||
item.pubdate = item.pubdate.trim().to_string();
|
|
||||||
item.nickname = item.nickname.trim().to_string();
|
|
||||||
|
|
||||||
if item.title.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
if item.enclosure.is_empty() {
|
|
||||||
if item.link.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
item.enclosure = item.link.clone();
|
|
||||||
}
|
|
||||||
Some(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 将 Rust 条目转换为 Python dict,字段名保持与 RssHelper.parse 原返回一致。
|
|
||||||
fn item_to_py(
|
|
||||||
py: Python<'_>,
|
|
||||||
item: &RssItem,
|
|
||||||
datetime_cls: &Bound<'_, PyAny>,
|
|
||||||
timezone_cls: &Bound<'_, PyAny>,
|
|
||||||
timedelta_cls: &Bound<'_, PyAny>,
|
|
||||||
timezone_cache: &mut HashMap<i32, PyObject>,
|
|
||||||
) -> PyResult<PyObject> {
|
|
||||||
let dict = PyDict::new(py);
|
|
||||||
dict.set_item("title", &item.title)?;
|
|
||||||
dict.set_item("enclosure", &item.enclosure)?;
|
|
||||||
dict.set_item("size", item.size)?;
|
|
||||||
dict.set_item("description", &item.description)?;
|
|
||||||
dict.set_item("link", &item.link)?;
|
|
||||||
if let Some(timestamp) = parse_pubdate_timestamp(&item.pubdate) {
|
|
||||||
dict.set_item(
|
|
||||||
"pubdate",
|
|
||||||
py_datetime_from_timestamp(
|
|
||||||
py,
|
|
||||||
timestamp,
|
|
||||||
datetime_cls,
|
|
||||||
timezone_cls,
|
|
||||||
timedelta_cls,
|
|
||||||
timezone_cache,
|
|
||||||
)?,
|
|
||||||
)?;
|
|
||||||
} else {
|
|
||||||
dict.set_item("pubdate", "")?;
|
|
||||||
}
|
|
||||||
if !item.nickname.is_empty() {
|
|
||||||
dict.set_item("nickname", &item.nickname)?;
|
|
||||||
}
|
|
||||||
Ok(dict.into())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 将 Unix 时间戳转换为本地时区 Python datetime,匹配原 astimezone(tz=None) 语义。
|
|
||||||
fn py_datetime_from_timestamp<'py>(
|
|
||||||
py: Python<'py>,
|
|
||||||
timestamp: i64,
|
|
||||||
datetime_cls: &Bound<'py, PyAny>,
|
|
||||||
timezone_cls: &Bound<'py, PyAny>,
|
|
||||||
timedelta_cls: &Bound<'py, PyAny>,
|
|
||||||
timezone_cache: &mut HashMap<i32, PyObject>,
|
|
||||||
) -> PyResult<Bound<'py, PyAny>> {
|
|
||||||
let Some(local_dt) = Local
|
|
||||||
.timestamp_opt(timestamp, 0)
|
|
||||||
.single()
|
|
||||||
.or_else(|| Local.timestamp_opt(timestamp, 0).earliest())
|
|
||||||
else {
|
|
||||||
return datetime_cls.call_method1("fromtimestamp", (timestamp,));
|
|
||||||
};
|
|
||||||
let offset_seconds = local_dt.offset().fix().local_minus_utc();
|
|
||||||
let tzinfo = match timezone_cache.get(&offset_seconds) {
|
|
||||||
Some(cached) => cached.clone_ref(py),
|
|
||||||
None => {
|
|
||||||
let delta = timedelta_cls.call1((0, offset_seconds))?;
|
|
||||||
let timezone = timezone_cls.call1((delta,))?.unbind();
|
|
||||||
timezone_cache.insert(offset_seconds, timezone.clone_ref(py));
|
|
||||||
timezone
|
|
||||||
}
|
|
||||||
};
|
|
||||||
datetime_cls.call1((
|
|
||||||
local_dt.year(),
|
|
||||||
local_dt.month(),
|
|
||||||
local_dt.day(),
|
|
||||||
local_dt.hour(),
|
|
||||||
local_dt.minute(),
|
|
||||||
local_dt.second(),
|
|
||||||
0,
|
|
||||||
tzinfo.bind(py),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 解析 RSS/Atom 常见日期格式并返回时间戳。
|
|
||||||
fn parse_pubdate_timestamp(value: &str) -> Option<i64> {
|
|
||||||
let trimmed = value.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
if let Ok(datetime) = DateTime::parse_from_rfc2822(trimmed) {
|
|
||||||
return Some(datetime.timestamp());
|
|
||||||
}
|
|
||||||
if let Ok(datetime) = DateTime::parse_from_rfc3339(trimmed) {
|
|
||||||
return Some(datetime.timestamp());
|
|
||||||
}
|
|
||||||
if let Some(timestamp) = parse_utc_suffix_datetime(trimmed) {
|
|
||||||
return Some(timestamp);
|
|
||||||
}
|
|
||||||
parse_local_naive_datetime(trimmed)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 兼容部分站点输出的 UTC/GMT 文本后缀。
|
|
||||||
fn parse_utc_suffix_datetime(value: &str) -> Option<i64> {
|
|
||||||
for suffix in [" UTC", " GMT"] {
|
|
||||||
let Some(stripped) = value.strip_suffix(suffix) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
for format in [
|
|
||||||
"%a, %d %b %Y %H:%M:%S",
|
|
||||||
"%d %b %Y %H:%M:%S",
|
|
||||||
"%Y-%m-%d %H:%M:%S",
|
|
||||||
"%Y-%m-%dT%H:%M:%S",
|
|
||||||
] {
|
|
||||||
if let Ok(naive) = NaiveDateTime::parse_from_str(stripped.trim(), format) {
|
|
||||||
return Some(Utc.from_utc_datetime(&naive).timestamp());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 解析不带时区的日期格式,并按系统本地时区解释。
|
|
||||||
fn parse_local_naive_datetime(value: &str) -> Option<i64> {
|
|
||||||
for format in [
|
|
||||||
"%Y-%m-%d %H:%M:%S",
|
|
||||||
"%Y-%m-%dT%H:%M:%S",
|
|
||||||
"%Y-%m-%d %H:%M",
|
|
||||||
"%Y/%m/%d %H:%M:%S",
|
|
||||||
"%Y/%m/%d %H:%M",
|
|
||||||
"%d %b %Y %H:%M:%S",
|
|
||||||
"%a, %d %b %Y %H:%M:%S",
|
|
||||||
] {
|
|
||||||
if let Ok(naive) = NaiveDateTime::parse_from_str(value, format) {
|
|
||||||
return local_timestamp(naive);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for format in ["%Y-%m-%d", "%Y/%m/%d", "%d %b %Y"] {
|
|
||||||
if let Ok(date) = NaiveDate::parse_from_str(value, format) {
|
|
||||||
return local_timestamp(NaiveDateTime::new(date, NaiveTime::MIN));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 将本地无时区时间转换为时间戳,处理夏令时歧义时取较早值。
|
|
||||||
fn local_timestamp(naive: NaiveDateTime) -> Option<i64> {
|
|
||||||
Local
|
|
||||||
.from_local_datetime(&naive)
|
|
||||||
.single()
|
|
||||||
.or_else(|| Local.from_local_datetime(&naive).earliest())
|
|
||||||
.map(|datetime| datetime.timestamp())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 判断当前标签是否为 RSS item 或 Atom entry。
|
|
||||||
fn is_item_node(local: &[u8]) -> bool {
|
|
||||||
local.eq_ignore_ascii_case(b"item") || local.eq_ignore_ascii_case(b"entry")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 提取 XML 名称的本地部分,用于兼容 dc:creator 这类命名空间字段。
|
|
||||||
fn local_name(raw: &[u8]) -> &[u8] {
|
|
||||||
raw.rsplit(|byte| *byte == b':').next().unwrap_or(raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 将 quick-xml 错误转换为 Python ValueError 交给 Python 包装层判断是否兜底。
|
|
||||||
fn to_py_value_error<E: std::fmt::Display>(err: E) -> PyErr {
|
|
||||||
pyo3::exceptions::PyValueError::new_err(err.to_string())
|
|
||||||
}
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
use pyo3::prelude::*;
|
|
||||||
use pyo3::types::{PyAny, PyDict, PyList};
|
|
||||||
use regex::Regex;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::Mutex;
|
|
||||||
|
|
||||||
/// 从 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_nonempty_string(
|
|
||||||
dict: &Bound<'_, PyDict>,
|
|
||||||
key: &str,
|
|
||||||
) -> PyResult<Option<String>> {
|
|
||||||
let Some(value) = get_optional_string(dict, key)? else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let value = value.trim().to_string();
|
|
||||||
if value.is_empty() {
|
|
||||||
Ok(None)
|
|
||||||
} else {
|
|
||||||
Ok(Some(value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从 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);
|
|
||||||
};
|
|
||||||
py_any_to_f64(&value)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从 Python 字典读取字符串列表,兼容单值字符串。
|
|
||||||
pub(crate) fn get_string_list(dict: &Bound<'_, PyDict>, key: &str) -> PyResult<Vec<String>> {
|
|
||||||
let Some(value) = dict.get_item(key)? else {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
};
|
|
||||||
py_any_to_string_list(&value)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从 Python 字典读取配置字符串列表,兼容列表和以换行、竖线、分号分隔的字符串。
|
|
||||||
pub(crate) fn get_config_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(list) = value.downcast::<PyList>() {
|
|
||||||
let mut result = Vec::new();
|
|
||||||
for item in list.iter() {
|
|
||||||
let text = item.extract::<String>()?;
|
|
||||||
if !text.is_empty() {
|
|
||||||
result.push(text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
if let Ok(text) = value.extract::<String>() {
|
|
||||||
return Ok(text
|
|
||||||
.replace('\n', ";")
|
|
||||||
.replace('|', ";")
|
|
||||||
.split(';')
|
|
||||||
.filter_map(|item| {
|
|
||||||
let item = item.trim();
|
|
||||||
(!item.is_empty()).then(|| item.to_string())
|
|
||||||
})
|
|
||||||
.collect());
|
|
||||||
}
|
|
||||||
Ok(Vec::new())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 将 Python 对象转换为 i64,用于兼容配置里字符串或数字形式的下标。
|
|
||||||
pub(crate) fn extract_i64(value: &Bound<'_, PyAny>) -> PyResult<Option<i64>> {
|
|
||||||
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 值转换为可选 i64。
|
|
||||||
pub(crate) fn py_any_to_i64(value: &Bound<'_, PyAny>) -> PyResult<Option<i64>> {
|
|
||||||
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 值转换为可选 f64。
|
|
||||||
pub(crate) fn py_any_to_f64(value: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
|
|
||||||
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())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 将 Python 值转换为字符串列表。
|
|
||||||
pub(crate) fn py_any_to_string_list(value: &Bound<'_, PyAny>) -> PyResult<Vec<String>> {
|
|
||||||
if value.is_none() {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
if let Ok(list) = value.downcast::<PyList>() {
|
|
||||||
let mut result = Vec::new();
|
|
||||||
for item in list.iter() {
|
|
||||||
let text = item.str()?.to_str()?.to_string();
|
|
||||||
if !text.is_empty() {
|
|
||||||
result.push(text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
let text = value.str()?.to_str()?.to_string();
|
|
||||||
if text.is_empty() {
|
|
||||||
Ok(Vec::new())
|
|
||||||
} else {
|
|
||||||
Ok(vec![text])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从对象读取可选字符串属性。
|
|
||||||
pub(crate) fn object_optional_string(
|
|
||||||
obj: &Bound<'_, PyAny>,
|
|
||||||
attr: &str,
|
|
||||||
) -> PyResult<Option<String>> {
|
|
||||||
let value = obj.getattr(attr)?;
|
|
||||||
if value.is_none() {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
let text = value.str()?.to_str()?.to_string();
|
|
||||||
if text.is_empty() {
|
|
||||||
Ok(None)
|
|
||||||
} else {
|
|
||||||
Ok(Some(text))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从对象读取可选字符串列表属性。
|
|
||||||
pub(crate) fn object_string_list(obj: &Bound<'_, PyAny>, attr: &str) -> PyResult<Vec<String>> {
|
|
||||||
let value = obj.getattr(attr)?;
|
|
||||||
py_any_to_string_list(&value)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从对象读取可选整数属性。
|
|
||||||
pub(crate) fn object_optional_i64(obj: &Bound<'_, PyAny>, attr: &str) -> PyResult<Option<i64>> {
|
|
||||||
let value = obj.getattr(attr)?;
|
|
||||||
py_any_to_i64(&value)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从对象读取可选浮点属性。
|
|
||||||
pub(crate) fn object_optional_f64(obj: &Bound<'_, PyAny>, attr: &str) -> PyResult<Option<f64>> {
|
|
||||||
let value = obj.getattr(attr)?;
|
|
||||||
py_any_to_f64(&value)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 按正则文本缓存动态正则,避免热路径重复编译。
|
|
||||||
pub(crate) fn cached_regex(cache: &Mutex<HashMap<String, Regex>>, pattern: &str) -> Option<Regex> {
|
|
||||||
if let Ok(guard) = cache.lock() {
|
|
||||||
if let Some(regex) = guard.get(pattern) {
|
|
||||||
return Some(regex.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let regex = Regex::new(pattern).ok()?;
|
|
||||||
if let Ok(mut guard) = cache.lock() {
|
|
||||||
guard.insert(pattern.to_string(), regex.clone());
|
|
||||||
}
|
|
||||||
Some(regex)
|
|
||||||
}
|
|
||||||
+7
-131
@@ -17,7 +17,6 @@ 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"
|
||||||
@@ -228,20 +227,20 @@ find_uv_python() {
|
|||||||
python_install_hint() {
|
python_install_hint() {
|
||||||
case "$OS_NAME" in
|
case "$OS_NAME" in
|
||||||
macOS)
|
macOS)
|
||||||
echo "脚本已尝试自动安装 Git、curl、Python 3.11+、Rust toolchain 和构建工具。" >&2
|
echo "脚本已尝试自动安装 Git、curl 和 Python 3.11+。" >&2
|
||||||
echo "如果自动安装失败,请先安装 Homebrew 和 Xcode Command Line Tools,或手动执行:brew install git curl python@3.11 rustup-init" >&2
|
echo "如果自动安装失败,请先安装 Homebrew,或手动执行:brew install git curl python@3.11" >&2
|
||||||
;;
|
;;
|
||||||
Linux*)
|
Linux*)
|
||||||
echo "脚本已尝试自动安装 Git、curl、Python 3.11+、Rust toolchain 和构建工具。" >&2
|
echo "脚本已尝试自动安装 Git、curl 和 Python 3.11+。" >&2
|
||||||
echo "如果自动安装失败,请先安装 Git、curl、Python 3.11+、cargo,并确保包含 venv 模块。" >&2
|
echo "如果自动安装失败,请先安装 Git、curl、Python 3.11+,并确保包含 venv 模块。" >&2
|
||||||
echo "例如 Debian/Ubuntu: sudo apt install git curl python3.11 python3.11-venv build-essential" >&2
|
echo "例如 Debian/Ubuntu: sudo apt install git curl python3.11 python3.11-venv" >&2
|
||||||
echo "例如 Fedora/RHEL: sudo dnf install git curl python3.11 gcc gcc-c++ make" >&2
|
echo "例如 Fedora/RHEL: sudo dnf install git curl python3.11" >&2
|
||||||
;;
|
;;
|
||||||
Windows)
|
Windows)
|
||||||
echo "推荐在 WSL、Linux 或 macOS 终端中运行此脚本。" >&2
|
echo "推荐在 WSL、Linux 或 macOS 终端中运行此脚本。" >&2
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "请先安装 Git、curl、Python 3.11 或更高版本、cargo 和 C 编译器。" >&2
|
echo "请先安装 Git、curl、Python 3.11 或更高版本。" >&2
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
@@ -271,74 +270,6 @@ 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
|
||||||
"$@"
|
"$@"
|
||||||
@@ -452,54 +383,6 @@ 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
|
||||||
@@ -548,13 +431,6 @@ ensure_prereqs() {
|
|||||||
python_install_hint
|
python_install_hint
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! rust_accel_should_skip; then
|
|
||||||
if ! ensure_build_tools || ! ensure_rust_toolchain; then
|
|
||||||
export MOVIEPILOT_SKIP_RUST_ACCEL=1
|
|
||||||
echo "==> Rust 加速扩展准备失败,已跳过;应用将继续使用 Python 实现"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
prompt_text() {
|
prompt_text() {
|
||||||
|
|||||||
+1
-97
@@ -45,9 +45,6 @@ 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"
|
||||||
)
|
)
|
||||||
@@ -637,98 +634,6 @@ 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() -> bool:
|
|
||||||
"""
|
|
||||||
确认 Rust 加速扩展源码存在且本机具备 cargo 与链接器。
|
|
||||||
"""
|
|
||||||
if not RUST_ACCEL_MANIFEST.exists():
|
|
||||||
return False
|
|
||||||
if _rust_accel_should_skip():
|
|
||||||
print_step(f"已跳过 Rust 加速扩展构建:{RUST_ACCEL_SKIP_ENV}=1")
|
|
||||||
return False
|
|
||||||
if not _find_cargo():
|
|
||||||
print_step(
|
|
||||||
"未找到 Rust cargo,已跳过 Rust 加速扩展构建;"
|
|
||||||
"应用将继续使用 Python 实现。"
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
if not _find_native_linker():
|
|
||||||
print_step(
|
|
||||||
"未找到本机 C 编译器/链接器,已跳过 Rust 加速扩展构建;"
|
|
||||||
"应用将继续使用 Python 实现。"
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def install_rust_accel(venv_python: Path) -> None:
|
|
||||||
"""
|
|
||||||
构建并安装 MoviePilot Rust 加速扩展到当前虚拟环境。
|
|
||||||
"""
|
|
||||||
if not RUST_ACCEL_MANIFEST.exists():
|
|
||||||
return
|
|
||||||
if _rust_accel_should_skip():
|
|
||||||
return
|
|
||||||
|
|
||||||
if not ensure_rust_accel_ready():
|
|
||||||
return
|
|
||||||
print_step("构建并安装 Rust 加速扩展")
|
|
||||||
env = os.environ.copy()
|
|
||||||
env["PATH"] = _cargo_env_path()
|
|
||||||
try:
|
|
||||||
run(
|
|
||||||
[
|
|
||||||
str(venv_python),
|
|
||||||
"-m",
|
|
||||||
"maturin",
|
|
||||||
"develop",
|
|
||||||
"--release",
|
|
||||||
"--manifest-path",
|
|
||||||
str(RUST_ACCEL_MANIFEST),
|
|
||||||
],
|
|
||||||
env=env,
|
|
||||||
)
|
|
||||||
except subprocess.CalledProcessError as exc:
|
|
||||||
print_step(f"Rust 加速扩展构建失败,已跳过;应用将继续使用 Python 实现:{exc}")
|
|
||||||
|
|
||||||
|
|
||||||
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:
|
||||||
@@ -2752,7 +2657,7 @@ 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)
|
||||||
venv_dir = venv_dir.expanduser().resolve()
|
venv_dir = venv_dir.expanduser().resolve()
|
||||||
@@ -2779,7 +2684,6 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -68,8 +68,6 @@ 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",
|
||||||
@@ -88,75 +86,8 @@ 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()
|
||||||
|
|||||||
Reference in New Issue
Block a user