Compare commits

..
72 Commits
Author SHA1 Message Date
DDSRem a0ee99aacc chore: bump moviepilot-rust to 0.2.3 (#6128) 2026-07-16 06:31:31 +08:00
InfinityPacer 92918ce380 ci(pr-agent): use shared review runner (#6127) 2026-07-16 06:24:52 +08:00
InfinityPacer a4335fe753 fix(lifecycle): harden application shutdown (#6125) 2026-07-16 06:24:31 +08:00
jxxghp 107ba37834 更新 version.py 2026-07-15 20:22:27 +08:00
jxxghp c27678ce06 fix(ugreen): send client id during login 2026-07-15 17:38:31 +08:00
InfinityPacer 7725342a80 fix(scheduler): refresh plugin jobs after reload (#6124) 2026-07-15 17:29:31 +08:00
InfinityPacer 893269f8c1 fix(modules): serialize configuration reload lifecycle (#6122) 2026-07-15 17:28:49 +08:00
InfinityPacer 00d46f3aab docs: clarify docstring punctuation style (#6121) 2026-07-15 16:01:47 +08:00
jxxghp 077241b6ed Merge remote-tracking branch 'origin/v2' into v2 2026-07-15 10:46:28 +08:00
jxxghp b24a07e388 fix: enhance response data structure in filtering rules with media info 2026-07-15 10:46:21 +08:00
InfinityPacer f814c271cc refactor(runtime): tighten resource cleanup and test isolation (#6116) 2026-07-14 16:03:29 +08:00
InfinityPacer e015c67689 chore(db): add driver error diagnostics (#6115) 2026-07-14 12:31:46 +08:00
qqcomeup 98b16bda8d 优化 Docker 启动完成日志 (#6112) 2026-07-14 12:31:07 +08:00
jxxghp b8233e1789 v2.14.3 2026-07-13 18:46:35 +08:00
InfinityPacer 83107bf447 ci(pr-agent): publish native code reviews (#6110) 2026-07-13 18:41:18 +08:00
jxxghp 3a2f90c567 fix(metainfo): improve regex for episode range recognition with end markers 2026-07-13 18:02:44 +08:00
DDSRem 4826e3301c chore: bump moviepilot-rust to 0.2.2 (#6109) 2026-07-13 18:00:50 +08:00
freeman 1855ba81ec fix(meta): 副标题识别 01-26Fin 等数字范围完结标记集数 (#6105) 2026-07-13 16:50:00 +08:00
jxxghp 96ef431efc feat: 支持豆瓣识别缓存管理 2026-07-13 12:33:56 +08:00
freeman 4f2935c85e fix(transfer): 种子未下载完成时不回写已整理标签 (#6106) 2026-07-13 12:05:01 +08:00
freeman 2a49495e27 fix(jellyfin): 媒体统计改为按用户视图逐库累计 (#5915) (#6104) 2026-07-13 11:51:16 +08:00
jxxghp b628bc7209 fix: 补齐识别缓存多语言响应 2026-07-13 09:58:11 +08:00
jxxghp 29068a5846 feat: 支持 TheMovieDb 识别缓存管理 2026-07-13 09:48:08 +08:00
jxxghp 51a7120c79 完善 qBittorrent 临时标签清理 (#6093) 2026-07-12 16:51:46 +08:00
jxxghp 476dfef7d9 修复 qBittorrent 重复任务临时标签残留 (#6093) 2026-07-12 16:47:12 +08:00
jxxghp bd5ddd6158 fix: remove standalone site collector download section from README 2026-07-12 16:38:53 +08:00
jxxghp a30a48b8f4 fix: allow publishing collector artifacts manually 2026-07-12 16:37:05 +08:00
jxxghp 8e60e5571b fix: support Windows collector console encoding 2026-07-12 16:30:30 +08:00
jxxghp 18c1ec4b82 feat: add standalone site adapter collector 2026-07-12 13:51:07 +08:00
InfinityPacer 30b932e07e fix(subscribe): preserve confirmed episode floor (#6102) 2026-07-12 07:18:29 +08:00
InfinityPacer 54be1143fc feat(plugin): sync federated assets during local development (#6100) 2026-07-11 21:59:53 +08:00
秋澪Akimio 13f27854fd fix: clear Rust parse options cache after updating custom identifiers (#6097) 2026-07-11 18:15:26 +08:00
InfinityPacer 770201c48c fix(plugin): exclude build dependencies from runtime copies (#6096) 2026-07-11 18:15:00 +08:00
Xuanjie Xia 685f044312 fix: 模拟登录时页面跳转导致 page.content() 竞态失败(未知错误) (#6091) 2026-07-10 12:44:36 +08:00
qqcomeup 8c0afac5d1 feat: support prompt-bound plugin input replies (#6087) 2026-07-09 12:52:22 +08:00
qqcomeup 099ef7d5bf fix: avoid blocking plugin release history refresh (#6084) 2026-07-08 12:51:24 +08:00
InfinityPacer f3ac69669c ci(pr-agent): simplify review workflow (#6082) 2026-07-08 12:49:19 +08:00
jxxghp eb4ecd990a fix: restore full test suite 2026-07-08 08:54:53 +08:00
jxxghp b51971ee7d feat: add agent MCP support 2026-07-08 08:44:33 +08:00
InfinityPacer 6f6ed998bb ci(pr-agent): align inline review workflow (#6079) 2026-07-08 07:04:39 +08:00
drdon1234 844407dc41 修复 qBittorrent 已完成但未做种任务识别 (#6076) 2026-07-08 07:01:03 +08:00
jxxghp c54605f8ce fix: support ugreen token_id login response 2026-07-07 20:15:19 +08:00
qqcomeup 0fbf05d72f fix: handle Telegram urllib3 header formatter compatibility (#6074) 2026-07-07 19:58:49 +08:00
jxxghp 09bb32f681 fix: cool down failed subscription resources 2026-07-07 17:07:13 +08:00
qqcomeup a37f118576 perf(docker): skip image path chown by default (#6071) 2026-07-07 16:25:43 +08:00
jxxghp e635bc8e04 chore: bump version to v2.14.2 2026-07-07 12:48:23 +08:00
jxxghp 8245124e82 support scheduler progress i18n (#6069) 2026-07-07 06:04:45 +08:00
qqcomeup 827ed8330c feat: add plugin-scoped text input sessions (#6068) 2026-07-07 05:15:18 +08:00
jxxghp 136c1baed3 fix: keep new subscribe state during guard 2026-07-06 23:10:25 +08:00
jxxghp 992031ef95 Revert "fix: trigger initial subscribe search"
This reverts commit 76803ae7a3.
2026-07-06 19:15:16 +08:00
jxxghp b16c50b03a feat: add backend i18n response support 2026-07-06 19:13:50 +08:00
jxxghp 76803ae7a3 fix: trigger initial subscribe search 2026-07-06 19:02:01 +08:00
jxxghp 56bda11947 fix python metainfo xxx title parsing 2026-07-06 12:36:06 +08:00
jxxghp 1b12d7664e fix: avoid cnsub matching file size unit 2026-07-06 11:58:03 +08:00
jxxghp db9960d9b9 refactor: enhance customization and streaming platform handling 2026-07-06 09:33:49 +08:00
InfinityPacer 2f0c1252da feat(subscribe): expose cached site candidates (#6062) 2026-07-06 06:54:19 +08:00
jxxghp 36d4434596 fix: allow manage users to access management APIs 2026-07-05 20:23:04 +08:00
jxxghp 93e907d032 fix: remove openlist directory via fs remove 2026-07-05 17:24:44 +08:00
jxxghp 132f27c1c6 fix: retry qbittorrent files after add 2026-07-05 17:15:07 +08:00
jxxghp b231ad415f fix title search filter rules 2026-07-05 17:02:19 +08:00
jxxghp 0f183ae08e test: fix download save path fixtures 2026-07-05 14:56:19 +08:00
jxxghp a71d3ea03f feat: support llm temperature override 2026-07-05 13:04:51 +08:00
InfinityPacer 7f82a9ea4d fix(security): scope subscriptions to owner (#6056) 2026-07-05 09:45:27 +08:00
InfinityPacer d977e4c48a fix(security): require signed subtitle downloads (#6055) 2026-07-05 09:43:58 +08:00
InfinityPacer 95b6adbeee fix(security): restrict download save paths (#6054) 2026-07-05 09:31:01 +08:00
InfinityPacer 964fee1106 fix(security): protect cookiecloud update uploads (#6053) 2026-07-05 09:28:07 +08:00
InfinityPacer 656473f3aa fix(security): require manage access for workflows (#6052) 2026-07-05 09:27:24 +08:00
InfinityPacer ab5995a609 fix(security): contain plugin zip extraction (#6051) 2026-07-05 09:26:09 +08:00
InfinityPacer 064e6535d5 fix(security): restrict agent log file access (#6050) 2026-07-05 09:25:25 +08:00
InfinityPacer cab2ac400a fix(security): rebind resource cookie on user changes (#6049) 2026-07-05 09:24:27 +08:00
InfinityPacer d14d401c86 docs(security): clarify api token admin contract (#6048) 2026-07-05 09:22:59 +08:00
Album 6c3c5e042d 修复手动整理按集数定位模板过滤 (#6043) 2026-07-03 07:55:59 +08:00
184 changed files with 20018 additions and 1049 deletions
+15 -1
View File
@@ -7,11 +7,13 @@ body:
attributes:
value: |
请说明你希望添加的功能。
站点适配请求请先按 [站点适配采集说明](https://github.com/jxxghp/MoviePilot/blob/v2/docs/site-adapter-capture.md) 生成脱敏 ZIP,并在下方附加。Issue 及附件是公开内容,提交前必须解压预览四个文件。不要上传 Cookie、Authorization、通行密钥、会话字段或任何原始数据。
- type: input
id: version
attributes:
label: 当前程序版本
description: 目前使用的程序版本
description: 目前使用的程序版本;仅提供站点采集文件且未安装 MoviePilot 时填写“不适用”
validations:
required: true
- type: dropdown
@@ -22,6 +24,9 @@ body:
options:
- Docker
- Windows
- macOS
- Linux
- 仅提供站点采集文件
validations:
required: true
- type: dropdown
@@ -32,6 +37,7 @@ body:
options:
- 主程序
- 插件
- 站点适配
- 其他
validations:
required: true
@@ -43,6 +49,14 @@ body:
placeholder: "功能改进"
validations:
required: true
- type: textarea
id: site-adapter-capture
attributes:
label: 站点适配采集文件
description: 站点适配请求必须把采集器生成并人工预览确认过的脱敏 ZIP 拖到这里;Issue 附件公开,严禁附加 Cookie、原始 HTML、HAR 或浏览器网络归档。其他类型请填写“不适用”。
placeholder: "将 moviepilot-site-capture-*.zip 拖到这里;非站点适配填写:不适用"
validations:
required: true
- type: textarea
id: references
attributes:
+15 -86
View File
@@ -1,9 +1,8 @@
name: PR Agent
name: PR-Agent
on:
pull_request_target:
# PR-Agent 通过 base repo 上下文读取 PR diff 并发布 Review,不 checkout 或执行 PR 分支代码。
# pull_request_target 允许 fork PR 使用仓库 secrets,因此 workflow 只运行固定 digest 的 PR-Agent 容器。
# Fork 审查需要目标仓库凭据;该 job 仅通过 GitHub API 读取 PR 内容,不 checkout 或执行 PR 分支代码。
types:
- opened
- reopened
@@ -11,24 +10,17 @@ on:
- review_requested
- synchronize
issue_comment:
# 手动命令如 "/review"、"/describe"、"/improve" 和 "/ask ..." 只在 PR 评论中有意义。
# issue_comment 同时覆盖普通 issue,因此 job 里还会再判断是否属于 PR。
types:
- created
- edited
permissions:
# 读取仓库内容和 PR diff。
contents: read
# 更新 PR 描述、发布 PR Review 或修改 PR 相关元数据。
pull-requests: write
# PR 评论在 GitHub API 中属于 issue comments,手动命令和总结评论需要该权限。
issues: write
jobs:
pr-agent:
name: PR-Agent review and describe
# PR 事件自动处理;评论命令仅允许指定身份在 PR 下触发,避免任意评论消耗模型配额。
if: >-
github.event.sender.type != 'Bot' &&
(
@@ -36,88 +28,25 @@ jobs:
(
github.event_name == 'issue_comment' &&
github.event.issue.pull_request != null &&
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR"]'), github.event.comment.author_association) &&
(
github.event.comment.body == '/review' ||
startsWith(github.event.comment.body, '/review ') ||
github.event.comment.body == '/describe' ||
startsWith(github.event.comment.body, '/describe ') ||
github.event.comment.body == '/improve' ||
startsWith(github.event.comment.body, '/improve ') ||
github.event.comment.body == '/ask' ||
startsWith(github.event.comment.body, '/ask ')
)
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR"]'), github.event.comment.author_association)
)
)
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number }}
cancel-in-progress: ${{ github.event_name == 'pull_request_target' }}
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Run PR-Agent
id: pragent
# 使用版本号加 digest 固定容器构建,避免 tag 被重推后改变运行内容。
uses: docker://pragent/pr-agent:0.37.0-github_action@sha256:4ec7bac814050a1bc8c96ab2fab6b7b0f65df0049a5ec43f3fee1a0b551c28ca
- name: Run PR Review
uses: docker://ghcr.io/infinitypacer/pr-review-runner:latest
env:
# PR-Agent 使用该 token 读取 PR 元数据并发布评论。
GITHUB_TOKEN: ${{ github.token }}
# 仓库设置中添加的 SecretSettings -> Secrets and variables -> Actions。
# 该 key 只传给 PR-Agent 运行时,不写入仓库。
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
# 仓库设置中添加的 Secret。OpenAI 兼容服务通常需要填写以 "/v1" 结尾的 API 根地址。
OPENAI.API_BASE: ${{ secrets.OPENAI_API_BASE }}
# 模型、输出语言和大 diff 处理策略。
config.model: "gpt-5.5"
config.fallback_models: '["gpt-5.4"]'
config.reasoning_effort: "xhigh"
config.ai_timeout: "900"
config.response_language: "zh-CN"
config.large_patch_policy: "clip"
config.ignore_pr_title: '["^\\[Auto\\]", "^Auto"]'
config.ignore_pr_labels: '["skip pr-agent"]'
# pull_request_target 事件默认自动执行 /review 和 /describe/improve 保持手动触发。
github_action_config.auto_review: "true"
github_action_config.auto_describe: "true"
github_action_config.auto_improve: "false"
# 允许触发自动工具的 PR 动作。包含 synchronize,便于新 commit 推送后刷新结果。
github_action_config.pr_actions: '["opened", "reopened", "ready_for_review", "review_requested", "synchronize"]'
# 保留 action outputs,便于后续 workflow 编排或排查。
github_action_config.enable_output: "true"
# /describe 行为控制;与自动触发配置放在同一层,避免使用默认图表和标签策略。
pr_description.generate_ai_title: "false"
pr_description.publish_labels: "false"
pr_description.enable_pr_diagram: "false"
pr_description.collapsible_file_list: "adaptive"
pr_description.add_original_user_description: "true"
# /review 输出策略,聚焦维护者需要处理的风险和缺口。
pr_reviewer.extra_instructions: |
请用中文输出。
优先指出 P0/P1 风险,避免纠结纯格式问题。
重点检查安全、权限、状态一致性、异步/缓存、副作用和测试缺口。
pr_reviewer.num_max_findings: "5"
pr_reviewer.persistent_comment: "true"
pr_reviewer.publish_output_no_suggestions: "true"
pr_reviewer.require_tests_review: "true"
pr_reviewer.require_security_review: "true"
pr_reviewer.require_estimate_effort_to_review: "true"
pr_reviewer.require_can_be_split_review: "true"
pr_reviewer.require_todo_scan: "false"
pr_reviewer.enable_review_labels_effort: "false"
pr_reviewer.enable_review_labels_security: "true"
# /improve 和 /ask 的手动命令策略。
pr_code_suggestions.focus_only_on_problems: "true"
pr_code_suggestions.suggestions_score_threshold: "7"
pr_code_suggestions.commitable_code_suggestions: "false"
pr_questions.use_conversation_history: "true"
# 可选成本和噪音控制:
# github_action_config.auto_improve: "true"
# config.verbosity_level: "1"
# pr_reviewer.num_max_findings: "3"
PRR_AUTO_REVIEW_SCOPE: all
PRR_ALLOWED_ASSOCIATIONS: '["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR"]'
PRR_DISABLED_COMMANDS: '["/improve"]'
PRR_SKIP_LABEL: skip pr-agent
PRR_SKIP_TITLE_PATTERN: '^(?:\[Auto\]|Auto)'
config.response_language: zh-CN
@@ -0,0 +1,134 @@
name: Site Adapter Collector
on:
workflow_dispatch:
inputs:
release_tag:
description: Existing release tag to receive collector assets; leave empty for artifacts only
required: false
type: string
release:
types:
- published
permissions:
contents: read
jobs:
build:
name: Build ${{ matrix.platform_name }} collector
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- platform_name: Windows
platform_id: windows
runner: windows-latest
source_name: moviepilot-site-collector.exe
asset_name: moviepilot-site-collector-windows.exe
artifact_name: site-adapter-collector-windows
- platform_name: macOS
platform_id: macos
runner: macos-latest
source_name: moviepilot-site-collector
asset_name: MoviePilot-Site-Collector-macOS.zip
artifact_name: site-adapter-collector-macos
- platform_name: Linux
platform_id: linux
runner: ubuntu-latest
source_name: moviepilot-site-collector
asset_name: moviepilot-site-collector-linux
artifact_name: site-adapter-collector-linux
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.12'
cache: pip
cache-dependency-path: scripts/site_adapter_collector_requirements.txt
- name: Install build dependencies
run: |
python -m pip install --upgrade pip setuptools wheel
pip install -r scripts/site_adapter_collector_requirements.txt
- name: Build single-file collector
run: |
pyinstaller --clean --noconfirm scripts/site_adapter_collector.spec
- name: Smoke-test collector
env:
SOURCE_NAME: ${{ matrix.source_name }}
run: |
python -c "import os, subprocess; from pathlib import Path; subprocess.run([str((Path('dist') / os.environ['SOURCE_NAME']).resolve()), '--help'], check=True)"
- name: Package macOS double-click archive
if: matrix.platform_id == 'macos'
shell: bash
env:
ASSET_NAME: ${{ matrix.asset_name }}
SOURCE_NAME: ${{ matrix.source_name }}
run: |
package_dir="dist/MoviePilot-Collector"
mkdir -p "$package_dir"
cp "dist/$SOURCE_NAME" "$package_dir/moviepilot-site-collector-macos"
cp scripts/start-site-adapter-collector.command "$package_dir/start-site-adapter-collector.command"
chmod +x "$package_dir/moviepilot-site-collector-macos"
chmod +x "$package_dir/start-site-adapter-collector.command"
cd dist
COPYFILE_DISABLE=1 zip -q -r -X "$ASSET_NAME" MoviePilot-Collector
- name: Rename Windows and Linux collector
if: matrix.platform_id != 'macos'
env:
ASSET_NAME: ${{ matrix.asset_name }}
SOURCE_NAME: ${{ matrix.source_name }}
run: |
python -c "import os; from pathlib import Path; (Path('dist') / os.environ['SOURCE_NAME']).replace(Path('dist') / os.environ['ASSET_NAME'])"
- name: Generate SHA-256 checksum
env:
ASSET_NAME: ${{ matrix.asset_name }}
run: |
python -c "import hashlib, os; from pathlib import Path; path = Path('dist') / os.environ['ASSET_NAME']; path.with_name(path.name + '.sha256').write_text(f'{hashlib.sha256(path.read_bytes()).hexdigest()} {path.name}\n', encoding='utf-8')"
- name: Upload collector artifact
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.artifact_name }}
path: |
dist/${{ matrix.asset_name }}
dist/${{ matrix.asset_name }}.sha256
if-no-files-found: error
retention-days: 3
publish:
name: Upload collectors to release
if: github.event_name == 'release' || inputs.release_tag != ''
needs:
- build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download collector artifacts
uses: actions/download-artifact@v8
with:
pattern: site-adapter-collector-*
path: release-assets
merge-multiple: true
- name: Upload assets to published release
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }}
run: |
gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null
gh release upload "$RELEASE_TAG" release-assets/* --clobber --repo "$GITHUB_REPOSITORY"
+1
View File
@@ -37,6 +37,7 @@ coverage.json
htmlcov/
.vscode
venv
moviepilot-site-capture-*.zip
# Pylint
pylint-report.json
+1
View File
@@ -59,6 +59,7 @@ curl -fsSL https://raw.githubusercontent.com/jxxghp/MoviePilot/v2/scripts/bootst
- 文档规则入口:[docs/rules/README.md](docs/rules/README.md)
- 开发环境与本地源码运行:[docs/development-setup.md](docs/development-setup.md)
- 测试说明:[docs/testing.md](docs/testing.md)
- 新站点适配采集与 Feature Request 提交:[docs/site-adapter-capture.md](docs/site-adapter-capture.md)
- REST API 文档:https://api.movie-pilot.org
- 插件开发说明:https://wiki.movie-pilot.org/zh/plugindev
+1
View File
@@ -58,6 +58,7 @@ Before contributing, read the repository rules and local environment guide, keep
- Rule index: [docs/rules/README.md](docs/rules/README.md)
- Development setup and local source run: [docs/development-setup.md](docs/development-setup.md)
- Testing guide: [docs/testing.md](docs/testing.md)
- New site adapter capture and Feature Request submission: [docs/site-adapter-capture.md](docs/site-adapter-capture.md)
- REST API documentation: https://api.movie-pilot.org
- Plugin development guide: https://wiki.movie-pilot.org/zh/plugindev
+40 -1
View File
@@ -51,7 +51,9 @@ from app.agent.middleware.tool_selection import ToolSelectorMiddleware
from app.agent.middleware.usage import UsageMiddleware
from app.agent.prompt import prompt_manager
from app.agent.runtime import agent_runtime_manager
from app.agent.mcp import agent_mcp_manager
from app.agent.tools.factory import MoviePilotToolFactory
from app.agent.tools.impl.mcp import create_external_mcp_tools
from app.chain import ChainBase
from app.core.config import settings
from app.core.event import eventmanager
@@ -1041,6 +1043,7 @@ class MoviePilotAgent:
settings.LLM_MAX_ITERATIONS,
self._public_runtime_config_signature(runtime_config),
agent_runtime_manager.current_signature(),
agent_mcp_manager.config_signature(),
)
def _get_cached_agent(
@@ -1097,6 +1100,39 @@ class MoviePilotAgent:
allow_message_tools=False,
)
async def _initialize_mcp_tools(self) -> List:
"""
初始化外部 MCP 工具列表。
"""
return await create_external_mcp_tools(
session_id=self.session_id,
user_id=self.user_id,
channel=self.channel,
source=self.source,
username=self.username,
stream_handler=self.stream_handler,
agent_context=self._tool_context,
)
async def _initialize_subagent_mcp_tools(self) -> List:
"""
初始化子代理可用的外部 MCP 工具列表。
"""
return await create_external_mcp_tools(
session_id=self.session_id,
user_id=self.user_id,
channel=self.channel,
source=self.source,
username=self.username,
stream_handler=None,
agent_context={
"user_reply_sent": False,
"reply_mode": None,
"should_dispatch_reply": False,
"is_admin": bool(self._tool_context.get("is_admin")),
},
)
async def _create_agent(self, streaming: bool = False):
"""
创建 LangGraph Agent(使用 create_agent + SummarizationMiddleware
@@ -1126,6 +1162,7 @@ class MoviePilotAgent:
# 工具列表
tools = self._initialize_tools()
tools.extend(await self._initialize_mcp_tools())
skills_middleware = SkillsMiddleware(
sources=[str(agent_runtime_manager.skills_dir)],
bundled_skills_dir=str(settings.ROOT_PATH / "skills"),
@@ -1142,9 +1179,11 @@ class MoviePilotAgent:
activity_log_tools = list(
getattr(activity_log_middleware, "tools", []) or []
)
subagent_tools = self._initialize_subagent_tools()
subagent_tools.extend(await self._initialize_subagent_mcp_tools())
subagent_middlewares, subagent_task_tools = create_subagent_middlewares(
model=non_streaming_model,
tools=self._initialize_subagent_tools(),
tools=subagent_tools,
stream_handler=self.stream_handler,
)
max_tools = settings.LLM_MAX_TOOLS
+26 -16
View File
@@ -952,6 +952,7 @@ class LLMHelper:
base_url: str | None = None,
base_url_preset: str | None = None,
user_agent: str | None = None,
temperature: Optional[float] = None,
use_proxy: bool | None = None,
):
"""
@@ -967,6 +968,7 @@ class LLMHelper:
:param base_url: API Base URL。未显式传入时使用当前配置项 LLM_BASE_URL。
:param base_url_preset: Base URL 预设。未显式传入时使用当前配置项 LLM_BASE_URL_PRESET。
:param user_agent: OpenAI兼容接口请求 User-Agent。未显式传入时使用配置项 LLM_USER_AGENT。
:param temperature: LLM 温度参数。未显式传入时使用配置项 LLM_TEMPERATURE。
:param use_proxy: 是否为本次 LLM 调用使用系统代理。未显式传入时使用配置项 LLM_USE_PROXY。
:return: LLM实例
"""
@@ -978,6 +980,7 @@ class LLMHelper:
base_url_preset if base_url_preset is not None else settings.LLM_BASE_URL_PRESET
)
user_agent_value = user_agent if user_agent is not None else settings.LLM_USER_AGENT
temperature_value = temperature if temperature is not None else settings.LLM_TEMPERATURE
normalized_thinking_level = cls._resolve_thinking_level(
thinking_level=thinking_level,
)
@@ -1034,7 +1037,7 @@ class LLMHelper:
model=model_name,
api_key=runtime["api_key"],
retries=3,
temperature=settings.LLM_TEMPERATURE,
temperature=temperature_value,
streaming=streaming,
client_args=_build_google_client_args(llm_proxy),
**thinking_kwargs,
@@ -1048,7 +1051,7 @@ class LLMHelper:
api_key=runtime["api_key"],
api_base=runtime["base_url"],
max_retries=3,
temperature=settings.LLM_TEMPERATURE,
temperature=temperature_value,
streaming=streaming,
stream_usage=True,
http_client=_build_httpx_client(llm_proxy),
@@ -1063,7 +1066,7 @@ class LLMHelper:
api_key=runtime["api_key"],
base_url=runtime["base_url"],
max_retries=3,
temperature=settings.LLM_TEMPERATURE,
temperature=temperature_value,
streaming=streaming,
stream_usage=True,
anthropic_proxy=llm_proxy,
@@ -1084,7 +1087,7 @@ class LLMHelper:
api_key=runtime["api_key"],
max_retries=3,
base_url=runtime.get("base_url"),
temperature=settings.LLM_TEMPERATURE,
temperature=temperature_value,
streaming=streaming,
stream_usage=True,
openai_proxy=llm_proxy,
@@ -1178,25 +1181,32 @@ class LLMHelper:
base_url: str | None = None,
base_url_preset: str | None = None,
user_agent: str | None = None,
temperature: Optional[float] = None,
use_proxy: bool | None = None,
) -> dict:
"""
使用当前已保存配置执行一次最小 LLM 调用。
使用当前配置或显式传入的临时配置执行一次最小 LLM 调用。
:param temperature: LLM 温度参数。未显式传入时沿用已保存配置。
"""
provider_name = provider if provider is not None else settings.LLM_PROVIDER
model_name = model if model is not None else settings.LLM_MODEL
start = time.perf_counter()
llm = await LLMHelper.get_llm(
streaming=False,
provider=provider_name,
model=model_name,
thinking_level=thinking_level,
api_key=api_key,
base_url=base_url,
base_url_preset=base_url_preset,
user_agent=user_agent,
use_proxy=use_proxy,
)
llm_kwargs = {
"streaming": False,
"provider": provider_name,
"model": model_name,
"thinking_level": thinking_level,
"api_key": api_key,
"base_url": base_url,
"base_url_preset": base_url_preset,
"user_agent": user_agent,
"use_proxy": use_proxy,
}
if temperature is not None:
llm_kwargs["temperature"] = temperature
llm = await LLMHelper.get_llm(**llm_kwargs)
try:
response = await asyncio.wait_for(llm.ainvoke(prompt), timeout=timeout)
except TimeoutError as err:
+600
View File
@@ -0,0 +1,600 @@
"""Agent 外部 MCP 客户端与配置管理。"""
from __future__ import annotations
import asyncio
import hashlib
import json
import os
import re
import uuid
from dataclasses import dataclass
from typing import Any, Optional
from urllib.parse import urljoin
from app.db.systemconfig_oper import SystemConfigOper
from app.log import logger
from app.schemas.agent import (
AgentMcpServerConfig,
AgentMcpServerTestResult,
AgentMcpServerToolInfo,
)
from app.schemas.types import SystemConfigKey
from app.utils.http import AsyncRequestUtils
MCP_PROTOCOL_VERSION = "2025-11-25"
MCP_CLIENT_NAME = "MoviePilot Agent"
DEFAULT_MCP_TIMEOUT = 30
MCP_TOOL_NAME_PATTERN = re.compile(r"[^a-zA-Z0-9_]+")
@dataclass(frozen=True)
class AgentMcpToolSpec:
"""已发现的外部 MCP 工具定义。"""
server: AgentMcpServerConfig
name: str
agent_tool_name: str
description: str
input_schema: dict[str, Any]
def _normalize_identifier(value: str, fallback: str = "mcp") -> str:
"""把服务器或工具名称转换为 Agent 工具可用的标识片段。"""
normalized = MCP_TOOL_NAME_PATTERN.sub("_", str(value or "").strip())
normalized = re.sub(r"_+", "_", normalized).strip("_").lower()
if not normalized:
normalized = fallback
if normalized[0].isdigit():
normalized = f"{fallback}_{normalized}"
return normalized[:64]
def _normalize_timeout(value: Any) -> int:
"""规范化 MCP 连接和调用超时时间。"""
try:
timeout = int(value or DEFAULT_MCP_TIMEOUT)
except (TypeError, ValueError):
timeout = DEFAULT_MCP_TIMEOUT
return min(max(timeout, 1), 600)
def _normalize_string_dict(value: Any) -> dict[str, str]:
"""规范化请求头和环境变量字典,移除空键。"""
if not isinstance(value, dict):
return {}
normalized: dict[str, str] = {}
for key, item in value.items():
normalized_key = str(key or "").strip()
if not normalized_key:
continue
normalized[normalized_key] = str(item or "")
return normalized
def _normalize_input_schema(value: Any) -> dict[str, Any]:
"""规范化 MCP 工具参数 Schema,保证至少是 object schema。"""
if not isinstance(value, dict):
return {"type": "object", "properties": {}, "required": []}
schema = dict(value)
schema.setdefault("type", "object")
schema.setdefault("properties", {})
schema.setdefault("required", [])
return schema
def _build_agent_tool_name(server: AgentMcpServerConfig, tool_name: str) -> str:
"""构造注入 Agent 的外部 MCP 工具名。"""
prefix = server.tool_prefix or f"mcp_{server.name or server.id}"
normalized_prefix = _normalize_identifier(prefix, fallback="mcp")
normalized_tool_name = _normalize_identifier(tool_name, fallback="tool")
if normalized_tool_name.startswith(f"{normalized_prefix}_"):
return normalized_tool_name
return f"{normalized_prefix}_{normalized_tool_name}"[:128]
def _jsonrpc_message(method: str, params: Optional[dict[str, Any]] = None, *, request_id: Optional[str] = None) -> dict:
"""构造 JSON-RPC 2.0 消息。"""
payload = {"jsonrpc": "2.0", "method": method}
if request_id is not None:
payload["id"] = request_id
if params is not None:
payload["params"] = params
return payload
def _raise_for_jsonrpc_error(payload: Any) -> None:
"""检查 JSON-RPC 响应错误并转换为运行时异常。"""
if isinstance(payload, dict) and payload.get("error"):
error = payload["error"]
if isinstance(error, dict):
message = error.get("message") or error
else:
message = error
raise RuntimeError(f"MCP JSON-RPC 错误: {message}")
def _extract_jsonrpc_result(payload: Any, request_id: str) -> Any:
"""从 JSON-RPC 响应中提取 result 字段。"""
if not isinstance(payload, dict):
raise RuntimeError("MCP 响应不是有效 JSON 对象")
if payload.get("id") != request_id:
raise RuntimeError("MCP 响应 ID 与请求不匹配")
_raise_for_jsonrpc_error(payload)
return payload.get("result")
async def _iter_sse_events(response) -> Any:
"""按 SSE 事件格式迭代响应流。"""
event_name = "message"
data_lines: list[str] = []
async for raw_line in response.aiter_lines():
line = raw_line.rstrip("\r")
if not line:
if data_lines:
yield {"event": event_name, "data": "\n".join(data_lines)}
event_name = "message"
data_lines = []
continue
if line.startswith(":"):
continue
field, _, value = line.partition(":")
if value.startswith(" "):
value = value[1:]
if field == "event":
event_name = value or "message"
elif field == "data":
data_lines.append(value)
if data_lines:
yield {"event": event_name, "data": "\n".join(data_lines)}
def _parse_sse_text_response(text: str, request_id: str) -> Any:
"""从非流式 SSE 文本响应中提取匹配请求的 JSON-RPC 结果。"""
event_name = "message"
data_lines: list[str] = []
for raw_line in str(text or "").splitlines():
line = raw_line.rstrip("\r")
if not line:
if data_lines:
payload = _load_sse_json_payload(event_name, "\n".join(data_lines))
if isinstance(payload, dict) and payload.get("id") == request_id:
return _extract_jsonrpc_result(payload, request_id)
event_name = "message"
data_lines = []
continue
if line.startswith(":"):
continue
field, _, value = line.partition(":")
if value.startswith(" "):
value = value[1:]
if field == "event":
event_name = value or "message"
elif field == "data":
data_lines.append(value)
if data_lines:
payload = _load_sse_json_payload(event_name, "\n".join(data_lines))
if isinstance(payload, dict) and payload.get("id") == request_id:
return _extract_jsonrpc_result(payload, request_id)
raise RuntimeError("MCP SSE 响应中未找到匹配请求")
def _load_sse_json_payload(event_name: str, data: str) -> Optional[dict]:
"""解析 SSE data 中的 JSON-RPC 消息。"""
if event_name not in {"message", "messages"}:
return None
try:
payload = json.loads(data)
except (TypeError, ValueError):
return None
return payload if isinstance(payload, dict) else None
class _StdioMcpSession:
"""stdio MCP 会话,按一次操作生命周期启动外部进程。"""
def __init__(self, server: AgentMcpServerConfig) -> None:
self.server = server
self.process: Optional[asyncio.subprocess.Process] = None
self.stderr_task: Optional[asyncio.Task] = None
async def __aenter__(self) -> "_StdioMcpSession":
"""启动 stdio MCP 子进程。"""
if not self.server.command:
raise RuntimeError("stdio MCP 服务器缺少启动命令")
env = os.environ.copy()
env.update(self.server.env or {})
self.process = await asyncio.create_subprocess_exec(
self.server.command,
*(self.server.args or []),
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
)
self.stderr_task = asyncio.create_task(self._drain_stderr())
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
"""结束 stdio MCP 子进程。"""
if self.stderr_task:
self.stderr_task.cancel()
if not self.process:
return
if self.process.returncode is None:
self.process.terminate()
try:
await asyncio.wait_for(self.process.wait(), timeout=2)
except asyncio.TimeoutError:
self.process.kill()
await self.process.wait()
async def _drain_stderr(self) -> None:
"""持续读取子进程 stderr,避免缓冲区阻塞。"""
if not self.process or not self.process.stderr:
return
try:
while True:
line = await self.process.stderr.readline()
if not line:
break
logger.debug(f"MCP stdio[{self.server.name}] stderr: {line.decode(errors='replace').strip()}")
except asyncio.CancelledError:
return
async def notify(self, method: str, params: Optional[dict[str, Any]] = None) -> None:
"""发送不需要响应的 JSON-RPC 通知。"""
await self._write_json(_jsonrpc_message(method, params))
async def request(self, method: str, params: Optional[dict[str, Any]] = None) -> Any:
"""发送 JSON-RPC 请求并等待响应。"""
request_id = uuid.uuid4().hex
await self._write_json(_jsonrpc_message(method, params, request_id=request_id))
while True:
payload = await self._read_json()
if payload.get("id") == request_id:
return _extract_jsonrpc_result(payload, request_id)
async def _write_json(self, payload: dict) -> None:
"""写入一行 JSON-RPC 消息。"""
if not self.process or not self.process.stdin:
raise RuntimeError("stdio MCP 进程未启动")
data = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
self.process.stdin.write(data.encode("utf-8"))
await self.process.stdin.drain()
async def _read_json(self) -> dict:
"""从 stdout 读取一行 JSON-RPC 消息。"""
if not self.process or not self.process.stdout:
raise RuntimeError("stdio MCP 进程未启动")
timeout = _normalize_timeout(self.server.timeout)
while True:
line = await asyncio.wait_for(self.process.stdout.readline(), timeout=timeout)
if not line:
raise RuntimeError("stdio MCP 进程已退出")
try:
payload = json.loads(line.decode("utf-8"))
except ValueError:
logger.debug(f"忽略非 JSON MCP stdout 行: {line!r}")
continue
if isinstance(payload, dict):
return payload
class _HttpMcpSession:
"""Streamable HTTP MCP 会话。"""
def __init__(self, server: AgentMcpServerConfig) -> None:
self.server = server
self.session_id: Optional[str] = None
async def __aenter__(self) -> "_HttpMcpSession":
"""进入 HTTP MCP 会话。"""
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
"""退出 HTTP MCP 会话。"""
return None
async def notify(self, method: str, params: Optional[dict[str, Any]] = None) -> None:
"""发送不需要响应的 JSON-RPC 通知。"""
await self._post(_jsonrpc_message(method, params), expect_response=False)
async def request(self, method: str, params: Optional[dict[str, Any]] = None) -> Any:
"""发送 JSON-RPC 请求并等待响应。"""
request_id = uuid.uuid4().hex
return await self._post(
_jsonrpc_message(method, params, request_id=request_id),
expect_response=True,
request_id=request_id,
)
async def _post(
self,
payload: dict,
*,
expect_response: bool,
request_id: Optional[str] = None,
) -> Any:
"""向 Streamable HTTP MCP 服务发送一条 JSON-RPC 消息。"""
if not self.server.url:
raise RuntimeError("HTTP MCP 服务器缺少 URL")
headers = {
"Accept": "application/json, text/event-stream",
"Content-Type": "application/json",
**(self.server.headers or {}),
}
if self.session_id:
headers["Mcp-Session-Id"] = self.session_id
response = await AsyncRequestUtils(
headers=headers,
timeout=_normalize_timeout(self.server.timeout),
content_type="application/json",
accept_type="application/json, text/event-stream",
http2=False,
).post_res(self.server.url, json=payload, raise_exception=True)
try:
if not response:
raise RuntimeError("HTTP MCP 请求无响应")
response.raise_for_status()
session_id = response.headers.get("Mcp-Session-Id")
if session_id:
self.session_id = session_id
if not expect_response:
return None
content_type = response.headers.get("content-type", "").lower()
if "text/event-stream" in content_type:
return _parse_sse_text_response(response.text, request_id or "")
data = response.json()
return _extract_jsonrpc_result(data, request_id or "")
finally:
if response is not None:
await response.aclose()
class _SseMcpSession:
"""旧版 HTTP+SSE MCP 会话。"""
def __init__(self, server: AgentMcpServerConfig) -> None:
self.server = server
self.response = None
self.endpoint: Optional[str] = None
self._stream_manager = None
self._event_iterator = None
async def __aenter__(self) -> "_SseMcpSession":
"""打开 SSE 流并读取服务端回传的 POST endpoint。"""
if not self.server.url:
raise RuntimeError("SSE MCP 服务器缺少 URL")
self._stream_manager = AsyncRequestUtils(
headers={"Accept": "text/event-stream", **(self.server.headers or {})},
timeout=_normalize_timeout(self.server.timeout),
accept_type="text/event-stream",
http2=False,
).get_stream(self.server.url, raise_exception=True)
self.response = await self._stream_manager.__aenter__()
if not self.response:
raise RuntimeError("SSE MCP 连接无响应")
self.response.raise_for_status()
self._event_iterator = _iter_sse_events(self.response).__aiter__()
self.endpoint = await self._read_endpoint()
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
"""关闭 SSE 流。"""
if self._stream_manager:
await self._stream_manager.__aexit__(exc_type, exc, tb)
async def notify(self, method: str, params: Optional[dict[str, Any]] = None) -> None:
"""发送不需要响应的 JSON-RPC 通知。"""
await self._post(_jsonrpc_message(method, params))
async def request(self, method: str, params: Optional[dict[str, Any]] = None) -> Any:
"""发送 JSON-RPC 请求并等待 SSE 流上的响应。"""
request_id = uuid.uuid4().hex
await self._post(_jsonrpc_message(method, params, request_id=request_id))
timeout = _normalize_timeout(self.server.timeout)
while True:
event = await asyncio.wait_for(self._event_iterator.__anext__(), timeout=timeout)
payload = _load_sse_json_payload(event.get("event", ""), event.get("data", ""))
if isinstance(payload, dict) and payload.get("id") == request_id:
return _extract_jsonrpc_result(payload, request_id)
async def _read_endpoint(self) -> str:
"""读取 SSE endpoint 事件中的 POST 地址。"""
timeout = _normalize_timeout(self.server.timeout)
while True:
event = await asyncio.wait_for(self._event_iterator.__anext__(), timeout=timeout)
if event.get("event") != "endpoint":
continue
endpoint = str(event.get("data") or "").strip()
if not endpoint:
continue
return urljoin(self.server.url, endpoint)
async def _post(self, payload: dict) -> None:
"""向 SSE 握手返回的 endpoint 发送 JSON-RPC 消息。"""
if not self.endpoint:
raise RuntimeError("SSE MCP endpoint 未初始化")
response = await AsyncRequestUtils(
headers={
"Accept": "application/json",
"Content-Type": "application/json",
**(self.server.headers or {}),
},
timeout=_normalize_timeout(self.server.timeout),
content_type="application/json",
accept_type="application/json",
http2=False,
).post_res(self.endpoint, json=payload, raise_exception=True)
try:
if not response:
raise RuntimeError("SSE MCP POST 请求无响应")
response.raise_for_status()
finally:
if response is not None:
await response.aclose()
async def _open_mcp_session(server: AgentMcpServerConfig):
"""根据配置创建对应的 MCP 传输会话。"""
transport = "http" if server.transport == "streamable_http" else server.transport
if transport == "stdio":
return _StdioMcpSession(server)
if transport == "sse":
return _SseMcpSession(server)
if transport == "http":
return _HttpMcpSession(server)
raise RuntimeError(f"不支持的 MCP 传输协议: {server.transport}")
class AgentMcpManager:
"""管理 Agent 外部 MCP 服务器配置、工具发现和工具调用。"""
def get_servers(self) -> list[AgentMcpServerConfig]:
"""读取已保存的外部 MCP 服务器配置。"""
raw_servers = SystemConfigOper().get(SystemConfigKey.AIAgentMcpServers) or []
if not isinstance(raw_servers, list):
return []
servers: list[AgentMcpServerConfig] = []
for raw_server in raw_servers:
try:
servers.append(self.normalize_server(raw_server))
except Exception as err:
logger.warning(f"忽略无效的 Agent MCP 配置: {err}")
return servers
async def save_servers(self, servers: list[AgentMcpServerConfig]) -> bool:
"""保存外部 MCP 服务器配置。"""
normalized_servers = [self.normalize_server(server).model_dump() for server in servers]
return await SystemConfigOper().async_set(
SystemConfigKey.AIAgentMcpServers,
normalized_servers or None,
)
def normalize_server(self, value: Any) -> AgentMcpServerConfig:
"""规范化单个 MCP 服务器配置。"""
if isinstance(value, AgentMcpServerConfig):
raw_server = value.model_dump()
elif isinstance(value, dict):
raw_server = dict(value)
else:
raise ValueError("MCP 服务器配置必须是对象")
raw_server["id"] = str(raw_server.get("id") or uuid.uuid4().hex[:12]).strip()
raw_server["name"] = str(raw_server.get("name") or raw_server["id"]).strip()
raw_server["transport"] = str(raw_server.get("transport") or "stdio").strip()
raw_server["description"] = str(raw_server.get("description") or "").strip() or None
raw_server["command"] = str(raw_server.get("command") or "").strip() or None
raw_server["args"] = [str(item) for item in raw_server.get("args") or []]
raw_server["env"] = _normalize_string_dict(raw_server.get("env"))
raw_server["url"] = str(raw_server.get("url") or "").strip() or None
raw_server["headers"] = _normalize_string_dict(raw_server.get("headers"))
raw_server["timeout"] = _normalize_timeout(raw_server.get("timeout"))
raw_server["tool_prefix"] = str(raw_server.get("tool_prefix") or "").strip() or None
raw_server["require_admin"] = bool(raw_server.get("require_admin", True))
return AgentMcpServerConfig.model_validate(raw_server)
def config_signature(self) -> str:
"""生成外部 MCP 配置签名,用于 Agent 图缓存失效。"""
payload = [server.model_dump() for server in self.get_servers()]
raw_text = json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str)
return hashlib.sha256(raw_text.encode("utf-8")).hexdigest()
async def initialize_session(self, session) -> None:
"""完成 MCP initialize 和 initialized 通知流程。"""
await session.request(
"initialize",
{
"protocolVersion": MCP_PROTOCOL_VERSION,
"capabilities": {},
"clientInfo": {
"name": MCP_CLIENT_NAME,
"version": "1.0.0",
},
},
)
await session.notify("notifications/initialized")
async def list_server_tools(self, server: AgentMcpServerConfig) -> list[AgentMcpToolSpec]:
"""连接单个 MCP 服务器并读取工具列表。"""
normalized_server = self.normalize_server(server)
session_manager = await _open_mcp_session(normalized_server)
async with session_manager as session:
await self.initialize_session(session)
result = await session.request("tools/list")
tools_payload = result.get("tools", []) if isinstance(result, dict) else []
tool_specs: list[AgentMcpToolSpec] = []
for item in tools_payload:
if not isinstance(item, dict) or not item.get("name"):
continue
tool_name = str(item["name"])
tool_specs.append(
AgentMcpToolSpec(
server=normalized_server,
name=tool_name,
agent_tool_name=_build_agent_tool_name(normalized_server, tool_name),
description=str(item.get("description") or ""),
input_schema=_normalize_input_schema(item.get("inputSchema")),
)
)
return tool_specs
async def list_enabled_tool_specs(self) -> list[AgentMcpToolSpec]:
"""读取所有启用 MCP 服务器暴露的工具定义。"""
tool_specs: list[AgentMcpToolSpec] = []
seen_names: set[str] = set()
for server in self.get_servers():
if not server.enabled:
continue
try:
for spec in await self.list_server_tools(server):
if spec.agent_tool_name in seen_names:
logger.warning(f"跳过重复的 MCP Agent 工具名: {spec.agent_tool_name}")
continue
tool_specs.append(spec)
seen_names.add(spec.agent_tool_name)
except Exception as err:
logger.warning(f"读取 MCP 服务器 {server.name} 工具失败: {err}")
return tool_specs
async def call_server_tool(
self,
server: AgentMcpServerConfig,
tool_name: str,
arguments: Optional[dict[str, Any]] = None,
) -> Any:
"""调用单个 MCP 服务器上的指定工具。"""
normalized_server = self.normalize_server(server)
session_manager = await _open_mcp_session(normalized_server)
async with session_manager as session:
await self.initialize_session(session)
return await session.request(
"tools/call",
{
"name": tool_name,
"arguments": arguments or {},
},
)
async def test_server(self, server: AgentMcpServerConfig) -> AgentMcpServerTestResult:
"""测试 MCP 服务器连接并返回工具列表。"""
tool_specs = await self.list_server_tools(server)
tools = [
AgentMcpServerToolInfo(
name=spec.name,
agent_tool_name=spec.agent_tool_name,
description=spec.description,
input_schema=spec.input_schema,
)
for spec in tool_specs
]
return AgentMcpServerTestResult(
success=True,
message=f"连接成功,发现 {len(tools)} 个工具",
tools=tools,
tool_count=len(tools),
)
agent_mcp_manager = AgentMcpManager()
-2
View File
@@ -315,8 +315,6 @@ class PromptManager:
"项目根目录": settings.ROOT_PATH,
"配置目录": settings.CONFIG_PATH,
"临时目录": settings.TEMP_PATH,
"日志目录": settings.LOG_PATH,
"主日志文件": settings.LOG_PATH / "moviepilot.log",
}
return [f" - {label}: `{path}`" for label, path in paths.items()]
+2 -3
View File
@@ -425,7 +425,6 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
"""
roots = [
settings.CONFIG_PATH / "agent",
settings.LOG_PATH,
]
resolved_roots = []
for root in roots:
@@ -461,7 +460,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
allowed_text = "".join(str(root) for root in allowed_roots)
return (
resolved_path,
f"抱歉,普通用户只能{operation}Agent配置目录和日志目录内的文件或目录:{allowed_text}",
f"抱歉,普通用户只能{operation}Agent配置目录内的文件或目录:{allowed_text}",
)
async def _check_local_storage_access(
@@ -483,7 +482,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
return None, None
return (
None,
f"抱歉,普通用户只能{operation}本地配置目录、Agent记忆目录和日志目录,不能访问远程存储。",
f"抱歉,普通用户只能{operation}本地Agent配置目录,不能访问远程存储。",
)
return await self._check_local_file_access(path=path, operation=operation)
@@ -59,6 +59,10 @@ SYSTEMCONFIG_SETTING_METADATA = {
"group": "ai_agent",
"label": "AI 智能体配置",
},
SystemConfigKey.AIAgentMcpServers.value: {
"group": "ai_agent",
"label": "AI 智能体外部 MCP 服务器",
},
SystemConfigKey.CustomIdentifiers.value: {
"group": "custom_identifiers",
"label": "自定义识别词",
+11 -3
View File
@@ -15,7 +15,7 @@ from app.core.config import settings
from app.core.context import Context
from app.core.metainfo import MetaInfo
from app.db.site_oper import SiteOper
from app.helper.directory import DirectoryHelper
from app.helper.directory import DirectoryHelper, validate_download_save_path
from app.log import logger
from app.schemas import FileURI, TorrentInfo
from app.utils.crypto import HashUtils
@@ -183,8 +183,8 @@ class AddDownloadTasksTool(MoviePilotTool):
@staticmethod
def _resolve_direct_download_dir(save_path: Optional[str]) -> Optional[Path]:
"""解析直接下载使用的目录,优先使用 save_path,其次使用默认下载目录"""
if save_path:
return Path(save_path)
if save_path is not None:
return Path(validate_download_save_path(save_path))
download_dirs = DirectoryHelper().get_download_dirs()
if not download_dirs:
@@ -225,6 +225,8 @@ class AddDownloadTasksTool(MoviePilotTool):
merged_labels: Optional[str],
) -> tuple[Optional[str], Optional[str]]:
"""同步提交带上下文的下载任务,避免站点下载与下载器调用阻塞事件循环。"""
if save_path is not None:
save_path = validate_download_save_path(save_path)
return DownloadChain().download_single(
context=context,
downloader=downloader,
@@ -245,6 +247,12 @@ class AddDownloadTasksTool(MoviePilotTool):
if not torrent_inputs:
return "错误:torrent_url 不能为空。"
if save_path is not None:
try:
save_path = validate_download_save_path(save_path)
except ValueError as err:
return f"参数错误:save_path {str(err)}"
merged_labels = self._merge_labels_with_system_tag(labels)
success_count = 0
failed_messages = []
+1 -1
View File
@@ -28,7 +28,7 @@ class EditFileTool(MoviePilotTool):
description: str = (
"Edit a local text file by replacing specific old text with new text. "
"Non-admin users can only edit files inside the MoviePilot Agent config "
"and log directories."
"directory."
)
args_schema: Type[BaseModel] = EditFileInput
+98
View File
@@ -0,0 +1,98 @@
"""外部 MCP 工具适配器。"""
import json
from typing import Any, Optional
from pydantic import PrivateAttr
from app.agent.mcp import AgentMcpToolSpec, agent_mcp_manager
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
class McpExternalTool(MoviePilotTool):
"""将外部 MCP 工具包装为 MoviePilot Agent 工具。"""
name: str = "mcp_external_tool"
tags: list[str] = [
ToolTag.Read,
ToolTag.Admin,
]
description: str = "Call an external MCP tool configured for MoviePilot Agent."
args_schema: dict[str, Any] = {"type": "object", "properties": {}, "required": []}
require_admin: bool = True
_spec: AgentMcpToolSpec = PrivateAttr()
def __init__(self, spec: AgentMcpToolSpec, session_id: str, user_id: str) -> None:
super().__init__(
session_id=session_id,
user_id=user_id,
name=spec.agent_tool_name,
description=spec.description
or f"Call external MCP tool {spec.name} on {spec.server.name}.",
args_schema=spec.input_schema,
require_admin=spec.server.require_admin,
)
self._spec = spec
def get_tool_message(self, **kwargs) -> Optional[str]:
"""根据 MCP 工具信息生成友好的提示消息。"""
return f"调用 MCP 工具: {self._spec.server.name}/{self._spec.name}"
async def run(self, **kwargs) -> str:
"""
调用外部 MCP 工具。
:param kwargs: 传递给外部 MCP 工具的参数
:return: MCP 工具返回内容
"""
result = await agent_mcp_manager.call_server_tool(
server=self._spec.server,
tool_name=self._spec.name,
arguments=kwargs,
)
return self._format_mcp_result(result)
@staticmethod
def _format_mcp_result(result: Any) -> str:
"""将 MCP tools/call 返回结构转换为 Agent 可读文本。"""
if isinstance(result, dict):
content = result.get("content")
if isinstance(content, list):
parts = []
for item in content:
if not isinstance(item, dict):
continue
if item.get("type") == "text" and item.get("text") is not None:
parts.append(str(item["text"]))
elif item:
parts.append(json.dumps(item, ensure_ascii=False, default=str))
if parts:
return "\n".join(parts)
if result.get("isError"):
return json.dumps(result, ensure_ascii=False, indent=2, default=str)
if isinstance(result, str):
return result
return json.dumps(result, ensure_ascii=False, indent=2, default=str)
async def create_external_mcp_tools(
*,
session_id: str,
user_id: str,
channel: Optional[str] = None,
source: Optional[str] = None,
username: Optional[str] = None,
stream_handler=None,
agent_context: Optional[dict] = None,
) -> list[McpExternalTool]:
"""创建当前已启用的外部 MCP Agent 工具列表。"""
tools = []
for spec in await agent_mcp_manager.list_enabled_tool_specs():
tool = McpExternalTool(spec=spec, session_id=session_id, user_id=user_id)
tool.set_message_attr(channel=channel, source=source, username=username)
tool.set_stream_handler(stream_handler=stream_handler)
tool.set_agent_context(agent_context=agent_context)
tools.append(tool)
return tools
@@ -7,6 +7,7 @@ from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.core.metainfo import clear_rust_parse_options_cache
from app.db.systemconfig_oper import SystemConfigOper
from app.log import logger
from app.schemas.types import SystemConfigKey
@@ -85,6 +86,7 @@ class UpdateCustomIdentifiersTool(MoviePilotTool):
SystemConfigKey.CustomIdentifiers, value
)
if success:
clear_rust_parse_options_cache()
return json.dumps(
{
"success": True,
@@ -8,6 +8,7 @@ from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.download import DownloadChain
from app.helper.directory import validate_download_save_path
from app.log import logger
@@ -150,6 +151,18 @@ class UpdateDownloadTasksTool(MoviePilotTool):
],
}
if save_path is not None:
try:
save_path = validate_download_save_path(save_path)
except ValueError:
return {
"hash": hash_value,
"downloader": resolved_downloader,
"results": [
cls._build_result("save_path", False, "保存目录不在允许的下载目录范围内")
],
}
results = []
if tags:
tag_result = download_chain.set_torrents_tag(
+1 -1
View File
@@ -26,7 +26,7 @@ class WriteFileTool(MoviePilotTool):
]
description: str = (
"Write full content to a local text file. Non-admin users can only write "
"inside the MoviePilot Agent config and log directories."
"inside the MoviePilot Agent config directory."
)
args_schema: Type[BaseModel] = WriteFileInput
+108 -5
View File
@@ -20,6 +20,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app import schemas
from app.agent import MoviePilotAgent, ReplyMode, StreamingHandler, agent_manager
from app.agent.llm.capability import AgentCapabilityManager
from app.agent.mcp import agent_mcp_manager
from app.chain.message import MessageChain
from app.chain.site import site_interaction_manager
from app.chain.skills import skills_interaction_manager
@@ -34,6 +35,7 @@ from app.db.models.agentchat import AgentChat
from app.db.user_oper import UserOper, get_current_active_user
from app.helper.agent import attach_web_agent_edit_queue, detach_web_agent_edit_queue
from app.helper.interaction import agent_interaction_manager, media_interaction_manager
from app.helper.locale import LocaleHelper
from app.log import logger
from app.schemas.types import EventType, MessageChannel
@@ -55,6 +57,78 @@ _WEB_AGENT_NOTICE_LISTENER_REGISTERED = False
_WEB_AGENT_BACKGROUND_TASKS: set[asyncio.Task] = set()
def _ensure_superuser(user: User) -> None:
"""校验当前用户是否为超级管理员。"""
if not getattr(user, "is_superuser", False):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
@router.get("/mcp/servers", summary="查询 Agent MCP 服务器配置", response_model=schemas.Response)
async def list_agent_mcp_servers(
current_user: User = Depends(get_current_active_user),
) -> schemas.Response:
"""
查询 Agent 外部 MCP 服务器配置。
"""
_ensure_superuser(current_user)
servers = agent_mcp_manager.get_servers()
enabled_count = len([server for server in servers if server.enabled])
return schemas.Response(
success=True,
data={
"servers": [server.model_dump() for server in servers],
"enabled_count": enabled_count,
"total_count": len(servers),
},
)
@router.post("/mcp/servers", summary="保存 Agent MCP 服务器配置", response_model=schemas.Response)
async def save_agent_mcp_servers(
request: schemas.AgentMcpServersSaveRequest,
current_user: User = Depends(get_current_active_user),
) -> schemas.Response:
"""
保存 Agent 外部 MCP 服务器配置。
"""
_ensure_superuser(current_user)
success = await agent_mcp_manager.save_servers(request.servers)
return schemas.Response(
success=success,
message="保存MCP配置成功" if success else "保存MCP配置失败",
)
@router.post("/mcp/servers/test", summary="测试 Agent MCP 服务器", response_model=schemas.Response)
async def test_agent_mcp_server(
request: schemas.AgentMcpServerTestRequest,
current_user: User = Depends(get_current_active_user),
) -> schemas.Response:
"""
测试 Agent 外部 MCP 服务器连接并读取工具列表。
"""
_ensure_superuser(current_user)
try:
result = await agent_mcp_manager.test_server(request.server)
return schemas.Response(
success=result.success,
message=result.message,
data=result.model_dump(),
)
except Exception as err:
logger.warning(f"测试 Agent MCP 服务器失败: {err}")
return schemas.Response(
success=False,
message=f"测试MCP服务器失败: {str(err)}",
data={
"success": False,
"message": str(err),
"tools": [],
"tool_count": 0,
},
)
class _WebAgentStreamingHandler(StreamingHandler):
"""
Web 前端专用流式处理器,将工具提示和文本统一回调给 SSE。
@@ -326,15 +400,25 @@ def _save_web_agent_display_snapshot(
logger.debug(f"保存WebAgent展示历史失败: {e}")
def _build_web_agent_sse(event_type: str, data: Optional[dict] = None) -> str:
def _build_web_agent_sse(
event_type: str,
data: Optional[dict] = None,
locale: Optional[str] = None,
) -> str:
"""
构建 Web Agent SSE 消息。
:param event_type: 前端事件类型
:param data: 事件数据
:param locale: 当前请求语言
:return: 符合 SSE 格式的字符串
"""
payload = {"type": event_type, **(data or {})}
message = payload.get("message")
if event_type == "error" and isinstance(message, str):
payload["message_i18n"] = LocaleHelper.translate_text(
message, locale=locale
)
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
@@ -1597,6 +1681,7 @@ async def web_agent_stream(
:return: SSE 流式响应
"""
prompt = payload.text.strip()
locale = LocaleHelper.get_locale_from_request(request)
display_prompt = (payload.display_text or payload.text).strip()
is_traditional_message = (
_is_web_agent_traditional_message(prompt)
@@ -1610,6 +1695,7 @@ async def web_agent_stream(
_build_web_agent_sse(
"error",
{"message": denied_message},
locale=locale,
)
]),
media_type="text/event-stream",
@@ -1621,6 +1707,7 @@ async def web_agent_stream(
_build_web_agent_sse(
"error",
{"message": unknown_command_message},
locale=locale,
)
]),
media_type="text/event-stream",
@@ -1649,7 +1736,11 @@ async def web_agent_stream(
"""
生成传统消息链路的 WebAgent SSE 事件。
"""
yield _build_web_agent_sse("start", {"session_id": session_id})
yield _build_web_agent_sse(
"start",
{"session_id": session_id},
locale=locale,
)
events = await _collect_web_agent_traditional_events(
text=prompt,
current_user=current_user,
@@ -1660,7 +1751,11 @@ async def web_agent_stream(
display_messages.append(assistant_message)
for event in events:
event_payload = copy.deepcopy(event)
yield _build_web_agent_sse(event_payload.pop("type"), event_payload)
yield _build_web_agent_sse(
event_payload.pop("type"),
event_payload,
locale=locale,
)
if await request.is_disconnected():
break
await run_in_threadpool(
@@ -1670,7 +1765,7 @@ async def web_agent_stream(
messages=display_messages,
client_session_id=payload.session_id or session_id,
)
yield _build_web_agent_sse("done", {})
yield _build_web_agent_sse("done", {}, locale=locale)
return StreamingResponse(
traditional_event_generator(),
@@ -1688,6 +1783,7 @@ async def web_agent_stream(
_build_web_agent_sse(
"error",
{"message": "智能助手未启用,请先在系统设置中开启。"},
locale=locale,
)
]),
media_type="text/event-stream",
@@ -1703,6 +1799,7 @@ async def web_agent_stream(
_build_web_agent_sse(
"error",
{"message": "语音识别失败,请稍后重试。"},
locale=locale,
)
]),
media_type="text/event-stream",
@@ -1713,6 +1810,7 @@ async def web_agent_stream(
_build_web_agent_sse(
"error",
{"message": "请输入要发送给智能助手的内容或选择附件。"},
locale=locale,
)
]),
media_type="text/event-stream",
@@ -1825,6 +1923,7 @@ async def web_agent_stream(
yield _build_web_agent_sse(
"start",
{"session_id": session_id},
locale=locale,
)
disconnected = False
while not global_vars.is_system_stopped:
@@ -1832,7 +1931,11 @@ async def web_agent_stream(
disconnected = True
break
event = await event_queue.get()
yield _build_web_agent_sse(event.pop("type"), event)
yield _build_web_agent_sse(
event.pop("type"),
event,
locale=locale,
)
if task.done() and event_queue.empty():
break
except asyncio.CancelledError:
+4
View File
@@ -39,6 +39,9 @@ def _anthropic_error_response(
def _check_auth(api_key: Optional[str]) -> Optional[JSONResponse]:
"""
Anthropic 兼容接口以 API_TOKEN 认证受信客户端,认证通过即按管理员级 Agent 集成处理。
"""
if not api_key or api_key != settings.API_TOKEN:
return _anthropic_error_response(
"invalid x-api-key",
@@ -122,6 +125,7 @@ async def messages(
session_seed = anthropic_version or "anthropic"
session_id = build_session_id(f"{session_seed}:{uuid.uuid4().hex}", SESSION_PREFIX)
# 兼容接口的 API_TOKEN 客户端按管理员级 MoviePilot Agent 集成处理。
agent = _CollectingMoviePilotAgent(
session_id=session_id,
user_id=session_id,
+50
View File
@@ -6,11 +6,61 @@ from app import schemas
from app.chain.douban import DoubanChain
from app.core.context import MediaInfo
from app.core.security import verify_token
from app.db.models.user import User
from app.db.user_oper import get_current_active_superuser_async
from app.modules.douban.douban_cache import DoubanCache
from app.schemas import MediaType
router = APIRouter()
@router.get(
"/cache", summary="查询豆瓣识别缓存", response_model=schemas.Response
)
async def douban_recognition_cache(
_: User = Depends(get_current_active_superuser_async),
) -> schemas.Response:
"""查询可管理的豆瓣识别缓存。"""
cache_items = DoubanCache().list_items()
recognized_count = sum(1 for item in cache_items if item["douban_id"])
return schemas.Response(
success=True,
data={
"count": len(cache_items),
"recognized": recognized_count,
"unrecognized": len(cache_items) - recognized_count,
"data": cache_items,
},
)
@router.delete(
"/cache/{cache_key:path}",
summary="删除指定豆瓣识别缓存",
response_model=schemas.Response,
)
async def delete_douban_recognition_cache(
cache_key: str,
_: User = Depends(get_current_active_superuser_async),
) -> schemas.Response:
"""按缓存键删除单条豆瓣识别缓存。"""
deleted_item = DoubanCache().delete(cache_key)
if not deleted_item:
return schemas.Response(success=False, message="豆瓣识别缓存不存在")
return schemas.Response(success=True, message="豆瓣识别缓存删除成功")
@router.delete(
"/cache", summary="清空豆瓣识别缓存", response_model=schemas.Response
)
async def clear_douban_recognition_cache(
_: User = Depends(get_current_active_superuser_async),
) -> schemas.Response:
"""清空全部豆瓣识别缓存。"""
DoubanCache().clear()
return schemas.Response(success=True, message="豆瓣识别缓存清理完成")
@router.get(
"/person/{person_id}", summary="人物详情", response_model=schemas.MediaPerson
)
+31
View File
@@ -9,14 +9,41 @@ from app.core.context import MediaInfo, Context, SubtitleInfo, TorrentInfo
from app.core.metainfo import MetaInfo
from app.core.security import verify_token
from app.db.models.user import User
from app.db.site_oper import SiteOper
from app.db.systemconfig_oper import SystemConfigOper
from app.db.user_oper import get_current_active_user
from app.helper.directory import DirectoryHelper
from app.schemas.types import SystemConfigKey
from app.utils.security import SecurityUtils
router = APIRouter()
def _prepare_subtitle_download(subtitle: SubtitleInfo) -> tuple[bool, str]:
"""
校验字幕下载签名,并用服务端站点配置覆盖请求凭据。
"""
if subtitle.site is None:
return False, "字幕站点信息为空"
clean_url = SecurityUtils.verify_signed_url(
subtitle.enclosure,
purpose=SecurityUtils.subtitle_download_purpose(subtitle.site),
)
if not clean_url:
return False, "字幕下载链接签名无效"
site = SiteOper().get(subtitle.site)
if not site:
return False, "字幕站点信息不存在"
subtitle.enclosure = clean_url
subtitle.site_cookie = site.cookie
subtitle.site_ua = site.ua
subtitle.site_proxy = bool(site.proxy)
return True, ""
@router.get("/", summary="正在下载", response_model=List[schemas.DownloaderTorrent])
def current(
name: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)
@@ -127,6 +154,10 @@ def download_subtitle(
"""
subtitle_info = SubtitleInfo()
subtitle_info.from_dict(subtitle_in.model_dump())
valid, message = _prepare_subtitle_download(subtitle_info)
if not valid:
return schemas.Response(success=False, message=message)
success, message, saved_files = DownloadChain().download_subtitle(
subtitle=subtitle_info,
tmdbid=tmdbid,
+5 -4
View File
@@ -22,8 +22,9 @@ from app.db.models import User
from app.db.models.downloadhistory import DownloadHistory, DownloadFiles
from app.db.models.transferhistory import TransferHistory
from app.db.user_oper import (
get_current_active_superuser_async,
get_current_active_manage_user,
get_current_active_superuser,
get_current_active_superuser_async,
)
from app.helper.progress import ProgressHelper
from app.schemas.types import EventType
@@ -223,7 +224,7 @@ def delete_transfer_history(
deletesrc: Optional[bool] = False,
deletedest: Optional[bool] = False,
db: Session = Depends(get_db),
_: User = Depends(get_current_active_superuser),
_: User = Depends(get_current_active_manage_user),
) -> Any:
"""
删除整理记录
@@ -264,7 +265,7 @@ def delete_transfer_history(
def ai_redo_transfer_history(
history_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_active_superuser),
_: User = Depends(get_current_active_manage_user),
) -> Any:
"""
手动触发单条历史记录的 AI 重新整理,并返回进度键。
@@ -293,7 +294,7 @@ def ai_redo_transfer_history(
def batch_ai_redo_transfer_history(
payload: schemas.BatchTransferHistoryRedoRequest,
db: Session = Depends(get_db),
_: User = Depends(get_current_active_superuser),
_: User = Depends(get_current_active_manage_user),
) -> Any:
"""
手动触发多条历史记录的 AI 批量重新整理,并返回进度键。
+15 -10
View File
@@ -36,6 +36,7 @@ class LlmTestRequest(BaseModel):
base_url: Optional[str] = None
base_url_preset: Optional[str] = None
user_agent: Optional[str] = None
temperature: Optional[float] = None
use_proxy: Optional[bool] = None
@@ -292,16 +293,20 @@ async def llm_test(
)
try:
result = await LLMHelper.test_current_settings(
provider=payload.provider,
model=payload.model,
thinking_level=payload.thinking_level,
api_key=payload.api_key,
base_url=payload.base_url,
base_url_preset=payload.base_url_preset,
user_agent=payload.user_agent,
use_proxy=payload.use_proxy,
)
test_kwargs = {
"provider": payload.provider,
"model": payload.model,
"thinking_level": payload.thinking_level,
"api_key": payload.api_key,
"base_url": payload.base_url,
"base_url_preset": payload.base_url_preset,
"user_agent": payload.user_agent,
"use_proxy": payload.use_proxy,
}
if payload.temperature is not None:
test_kwargs["temperature"] = payload.temperature
result = await LLMHelper.test_current_settings(**test_kwargs)
if not result.get("reply_preview"):
return schemas.Response(
success=False,
+1
View File
@@ -19,6 +19,7 @@ router = APIRouter()
# MCP 协议版本
MCP_PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18", "2024-11-05"]
MCP_PROTOCOL_VERSION = MCP_PROTOCOL_VERSIONS[0] # 默认使用最新版本
# MCP 经 API_TOKEN / X-API-KEY 认证后是管理员级集成入口;隐藏工具只收敛暴露面,不构成权限边界。
MCP_HIDDEN_TOOLS = {
"execute_command",
"search_web",
+5
View File
@@ -231,6 +231,9 @@ def _error_response(
def _check_auth(
credentials: Optional[HTTPAuthorizationCredentials],
) -> Optional[JSONResponse]:
"""
OpenAI 兼容接口以 API_TOKEN 认证受信客户端认证通过即按管理员级 Agent 集成处理
"""
if not credentials or credentials.scheme.lower() != "bearer":
return _error_response(
"Invalid bearer token.",
@@ -317,6 +320,7 @@ async def chat_completions(
session_id = build_session_id(session_key, SESSION_PREFIX)
username = str(payload.user or "openai-client")
# 兼容接口的 API_TOKEN 客户端按管理员级 MoviePilot Agent 集成处理。
agent = _CollectingMoviePilotAgent(
session_id=session_id,
user_id=session_key,
@@ -409,6 +413,7 @@ async def responses(
session_key = str(payload.user or uuid.uuid4())
session_id = build_session_id(session_key, SESSION_PREFIX)
# 兼容接口的 API_TOKEN 客户端按管理员级 MoviePilot Agent 集成处理。
agent = _CollectingMoviePilotAgent(
session_id=session_id,
user_id=session_key,
+82 -25
View File
@@ -1,3 +1,4 @@
import asyncio
import mimetypes
import shutil
from typing import Annotated, Any, List, Optional
@@ -39,6 +40,67 @@ PROTECTED_ROUTES = {"/api/v1/openapi.json", "/docs", "/docs/oauth2-redirect", "/
PLUGIN_PREFIX = f"{settings.API_V1_STR}/plugin"
router = APIRouter()
_plugin_release_refresh_tasks: set[asyncio.Task] = set()
async def _get_market_plugin_from_repo(
plugin_manager: PluginManager,
plugin_id: str,
repo_url: str,
force: bool,
) -> Optional[schemas.Plugin]:
"""
只读取指定插件仓库的市场元数据避免单插件详情触发全部市场刷新
"""
market_plugins = await plugin_manager.async_get_plugins_from_market(
repo_url, settings.VERSION_FLAG, force
)
market_plugin = next(
(
plugin
for plugin in market_plugins or []
if plugin.id == plugin_id
),
None,
)
if market_plugin or not settings.VERSION_FLAG:
return market_plugin
compatible_plugins = await plugin_manager.async_get_plugins_from_market(
repo_url, None, force
)
return next(
(
plugin
for plugin in compatible_plugins or []
if plugin.id == plugin_id
),
None,
)
async def _refresh_plugin_release_versions(plugin_id: str, repo_url: str) -> None:
"""
后台强制刷新 Release 缓存接口响应路径优先返回已有缓存
"""
try:
async with async_fresh(True):
await PluginHelper().async_get_plugin_release_versions(plugin_id, repo_url)
except Exception as e:
logger.warning(f"后台刷新插件 {plugin_id} Release 列表失败:{e}")
def _schedule_plugin_release_refresh(plugin_id: str, repo_url: str) -> None:
"""
保留后台任务引用避免任务被回收同时让 helper 负责同仓库强刷合并
"""
task = asyncio.create_task(_refresh_plugin_release_versions(plugin_id, repo_url))
_plugin_release_refresh_tasks.add(task)
def _discard_task(completed_task: asyncio.Task) -> None:
_plugin_release_refresh_tasks.discard(completed_task)
task.add_done_callback(_discard_task)
def register_plugin_api(plugin_id: Optional[str] = None):
@@ -239,6 +301,15 @@ async def _get_plugin_history_detail(
if local_repo_plugin:
return _merge_plugin_market_metadata(installed_plugin, local_repo_plugin)
if installed_plugin.repo_url:
market_plugin = await _get_market_plugin_from_repo(
plugin_manager, plugin_id, installed_plugin.repo_url, force
)
if not market_plugin:
logger.debug(f"插件 {plugin_id} 未从来源仓库获取到更新说明,返回本地插件信息")
return installed_plugin
return _merge_plugin_market_metadata(installed_plugin, market_plugin)
market_plugin = next(
(
plugin
@@ -359,30 +430,9 @@ async def plugin_releases(
}
plugin_manager = PluginManager()
market_plugins = await plugin_manager.async_get_plugins_from_market(
repo_url, settings.VERSION_FLAG, force
market_plugin = await _get_market_plugin_from_repo(
plugin_manager, plugin_id, repo_url, force
)
market_plugin = next(
(
plugin
for plugin in market_plugins or []
if plugin.id == plugin_id
),
None,
)
if not market_plugin and settings.VERSION_FLAG:
compatible_plugins = await plugin_manager.async_get_plugins_from_market(
repo_url, None, force
)
market_plugin = next(
(
plugin
for plugin in compatible_plugins or []
if plugin.id == plugin_id
),
None,
)
latest_version = market_plugin.plugin_version if market_plugin else None
current_version = plugin_manager.get_local_plugin_version(plugin_id)
if not getattr(market_plugin, "release", False):
@@ -393,8 +443,15 @@ async def plugin_releases(
"items": [],
}
async with async_fresh(force):
release_items = await PluginHelper().async_get_plugin_release_versions(plugin_id, repo_url)
plugin_helper = PluginHelper()
has_release_cache = (
await plugin_helper.async_has_plugin_release_cache(repo_url)
if force
else False
)
release_items = await plugin_helper.async_get_plugin_release_versions(plugin_id, repo_url)
if force and has_release_cache:
_schedule_plugin_release_refresh(plugin_id, repo_url)
items = []
for item in release_items:
version = item.get("version")
+79 -9
View File
@@ -12,9 +12,11 @@ from app.core.config import settings
from app.core.event import eventmanager
from app.core.metainfo import MetaInfo
from app.core.security import verify_resource_token, verify_token
from app.helper.locale import LocaleHelper
from app.log import logger
from app.schemas import MediaRecognizeConvertEventData
from app.schemas.types import MediaType, ChainEventType
from app.utils.security import SecurityUtils
router = APIRouter()
@@ -38,11 +40,65 @@ def _parse_media_type(mtype: Optional[str]) -> Optional[MediaType]:
return MediaType.from_agent(mtype) or MediaType(mtype)
def _sse_event(data: dict) -> str:
def _sse_event(data: dict, locale: Optional[str] = None) -> str:
"""
转换为SSE事件
"""
return f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
payload = data
message = payload.get("message")
text = payload.get("text")
if isinstance(message, str) or isinstance(text, str):
payload = data.copy()
if isinstance(message, str):
payload["message_i18n"] = LocaleHelper.translate_text(
message, locale=locale
)
if isinstance(text, str):
payload["text_i18n"] = LocaleHelper.translate_text(text, locale=locale)
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
def _serialize_signed_subtitle_result(subtitle: Any) -> dict:
"""
序列化字幕结果并签名下载链接签名用途绑定站点 ID
"""
data = subtitle.to_dict() if hasattr(subtitle, "to_dict") else dict(subtitle)
enclosure = data.get("enclosure")
if enclosure:
data["enclosure"] = SecurityUtils.sign_url(
enclosure,
purpose=SecurityUtils.subtitle_download_purpose(data.get("site")),
)
return data
def _serialize_signed_subtitle_results(subtitles: List[Any]) -> List[dict]:
"""
批量序列化字幕结果确保返回给客户端的下载链接均已签名
"""
return [_serialize_signed_subtitle_result(subtitle) for subtitle in subtitles]
def _sign_subtitle_search_event(event: dict) -> dict:
"""
签名字幕搜索流事件中的下载链接
"""
signed_event = dict(event)
if "items" in signed_event:
signed_event["items"] = _serialize_signed_subtitle_results(
signed_event.get("items") or []
)
return signed_event
async def _iter_signed_subtitle_search_events(
event_source: AsyncIterator[dict],
) -> AsyncIterator[dict]:
"""
输出仅包含签名字幕下载链接的搜索流事件
"""
async for event in event_source:
yield _sign_subtitle_search_event(event)
def _merge_append_event(pending_event: Optional[dict], event: dict) -> dict:
@@ -123,6 +179,7 @@ async def _stream_search_events(request: Request, event_source: AsyncIterator[di
"""
输出搜索SSE事件
"""
locale = LocaleHelper.get_locale_from_request(request)
try:
has_sent_final_replace = False
async for event in _iter_batched_search_events(event_source):
@@ -138,10 +195,13 @@ async def _stream_search_events(request: Request, event_source: AsyncIterator[di
and event.get("items")
):
event = {key: value for key, value in event.items() if key != "items"}
yield _sse_event(event)
yield _sse_event(event, locale=locale)
except Exception as err:
logger.error(f"渐进式搜索出错:{err}", exc_info=True)
yield _sse_event({"type": "error", "success": False, "message": str(err)})
yield _sse_event(
{"type": "error", "success": False, "message": str(err)},
locale=locale,
)
@router.get("/last", summary="查询搜索结果", response_model=List[schemas.Context])
@@ -168,7 +228,9 @@ async def search_latest_context(_: schemas.TokenPayload = Depends(verify_token))
success=True,
data={
"params": params,
"results": [result.to_dict() for result in results],
"results": _serialize_signed_subtitle_results(results)
if params.get("result_type") == "subtitle"
else [result.to_dict() for result in results],
},
)
@@ -625,7 +687,11 @@ async def search_subtitle_by_title_stream(
title=keyword, page=page, sites=_parse_site_list(sites), cache_local=True
)
return StreamingResponse(
_stream_search_events(request, event_source), media_type="text/event-stream"
_stream_search_events(
request,
_iter_signed_subtitle_search_events(event_source),
),
media_type="text/event-stream",
)
@@ -645,7 +711,7 @@ async def search_subtitle_by_title(
if not subtitles:
return schemas.Response(success=False, message="未搜索到任何字幕")
return schemas.Response(
success=True, data=[subtitle.to_dict() for subtitle in subtitles]
success=True, data=_serialize_signed_subtitle_results(subtitles)
)
@@ -798,7 +864,11 @@ async def search_subtitle_by_id_stream(
yield event
return StreamingResponse(
_stream_search_events(request, event_source()), media_type="text/event-stream"
_stream_search_events(
request,
_iter_signed_subtitle_search_events(event_source()),
),
media_type="text/event-stream",
)
@@ -832,7 +902,7 @@ async def search_subtitle_by_id(
if not subtitles:
return schemas.Response(success=False, message="未搜索到任何字幕")
return schemas.Response(
success=True, data=[subtitle.to_dict() for subtitle in subtitles]
success=True, data=_serialize_signed_subtitle_results(subtitles)
)
+14 -12
View File
@@ -22,6 +22,8 @@ from app.db.models.siteuserdata import SiteUserData
from app.db.site_oper import SiteOper
from app.db.systemconfig_oper import SystemConfigOper
from app.db.user_oper import (
get_current_active_manage_user,
get_current_active_manage_user_async,
get_current_active_superuser,
get_current_active_superuser_async,
)
@@ -37,7 +39,7 @@ router = APIRouter()
@router.get("/", summary="所有站点", response_model=List[schemas.Site])
async def read_sites(
db: AsyncSession = Depends(get_async_db),
_: User = Depends(get_current_active_superuser),
_: User = Depends(get_current_active_manage_user_async),
) -> List[dict]:
"""
获取站点列表
@@ -50,7 +52,7 @@ async def add_site(
*,
db: AsyncSession = Depends(get_async_db),
site_in: schemas.Site,
_: User = Depends(get_current_active_superuser),
_: User = Depends(get_current_active_manage_user_async),
) -> Any:
"""
新增站点
@@ -89,7 +91,7 @@ async def update_site(
*,
db: AsyncSession = Depends(get_async_db),
site_in: schemas.Site,
_: User = Depends(get_current_active_superuser),
_: User = Depends(get_current_active_manage_user_async),
) -> Any:
"""
更新站点信息
@@ -150,7 +152,7 @@ def reset(
async def update_sites_priority(
priorities: List[dict],
db: AsyncSession = Depends(get_async_db),
_: User = Depends(get_current_active_superuser_async),
_: User = Depends(get_current_active_manage_user_async),
) -> Any:
"""
批量更新站点优先级
@@ -203,7 +205,7 @@ def update_cookie_by_body(
site_id: int,
site_cookie_update: schemas.SiteCookieUpdate,
db: Session = Depends(get_db),
_: User = Depends(get_current_active_superuser),
_: User = Depends(get_current_active_manage_user),
) -> Any:
"""
使用请求体中的用户密码更新站点Cookie
@@ -226,7 +228,7 @@ def update_cookie(
password: str,
code: Optional[str] = None,
db: Session = Depends(get_db),
_: User = Depends(get_current_active_superuser),
_: User = Depends(get_current_active_manage_user),
) -> Any:
"""
使用用户密码更新站点Cookie
@@ -246,7 +248,7 @@ def update_cookie(
def refresh_userdata(
site_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_active_superuser),
_: User = Depends(get_current_active_manage_user),
) -> Any:
"""
刷新站点用户数据
@@ -273,7 +275,7 @@ def refresh_userdata(
)
async def read_userdata_latest(
db: AsyncSession = Depends(get_async_db),
_: User = Depends(get_current_active_superuser_async),
_: User = Depends(get_current_active_manage_user_async),
) -> Any:
"""
查询所有站点最新用户数据
@@ -291,7 +293,7 @@ async def read_userdata(
site_id: int,
workdate: Optional[str] = None,
db: AsyncSession = Depends(get_async_db),
_: User = Depends(get_current_active_superuser_async),
_: User = Depends(get_current_active_manage_user_async),
) -> Any:
"""
查询站点用户数据
@@ -395,7 +397,7 @@ async def site_resource(
cat: Optional[str] = None,
page: Optional[int] = 0,
db: AsyncSession = Depends(get_async_db),
_: User = Depends(get_current_active_superuser_async),
_: User = Depends(get_current_active_manage_user_async),
) -> Any:
"""
浏览站点资源
@@ -543,7 +545,7 @@ async def support_sites(_: User = Depends(get_current_active_superuser_async)):
async def read_site(
site_id: int,
db: AsyncSession = Depends(get_async_db),
_: User = Depends(get_current_active_superuser_async),
_: User = Depends(get_current_active_manage_user_async),
) -> Any:
"""
通过ID获取站点信息
@@ -561,7 +563,7 @@ async def read_site(
async def delete_site(
site_id: int,
db: AsyncSession = Depends(get_async_db),
_: User = Depends(get_current_active_superuser_async),
_: User = Depends(get_current_active_manage_user_async),
) -> Any:
"""
删除站点
+7 -6
View File
@@ -15,6 +15,7 @@ from app.core.config import settings
from app.core.security import verify_token
from app.db.models import User
from app.db.user_oper import (
get_current_active_manage_user,
get_current_active_superuser,
get_current_active_superuser_async,
)
@@ -91,7 +92,7 @@ def list_files(
fileitem: schemas.FileItem,
sort: Optional[str] = "updated_at",
keyword: Optional[str] = None,
_: User = Depends(get_current_active_superuser),
_: User = Depends(get_current_active_manage_user),
) -> Any:
"""
查询当前目录下所有目录和文件
@@ -117,7 +118,7 @@ def list_files(
def mkdir(
fileitem: schemas.FileItem,
name: str,
_: User = Depends(get_current_active_superuser),
_: User = Depends(get_current_active_manage_user),
) -> Any:
"""
创建目录
@@ -135,7 +136,7 @@ def mkdir(
@router.post("/delete", summary="删除文件或目录", response_model=schemas.Response)
def delete(
fileitem: schemas.FileItem, _: User = Depends(get_current_active_superuser)
fileitem: schemas.FileItem, _: User = Depends(get_current_active_manage_user)
) -> Any:
"""
删除文件或目录
@@ -150,7 +151,7 @@ def delete(
@router.post("/download", summary="下载文件")
def download(
fileitem: schemas.FileItem, _: User = Depends(get_current_active_superuser)
fileitem: schemas.FileItem, _: User = Depends(get_current_active_manage_user)
) -> Any:
"""
下载文件或目录
@@ -166,7 +167,7 @@ def download(
@router.post("/image", summary="预览图片")
def image(
fileitem: schemas.FileItem, _: User = Depends(get_current_active_superuser)
fileitem: schemas.FileItem, _: User = Depends(get_current_active_manage_user)
) -> Any:
"""
下载文件或目录
@@ -185,7 +186,7 @@ def rename(
fileitem: schemas.FileItem,
new_name: str,
recursive: Optional[bool] = False,
_: User = Depends(get_current_active_superuser),
_: User = Depends(get_current_active_manage_user),
) -> Any:
"""
重命名文件或目录
+154 -47
View File
@@ -17,7 +17,7 @@ from app.db.models.subscribe import Subscribe
from app.db.models.subscribehistory import SubscribeHistory
from app.db.models.user import User
from app.db.systemconfig_oper import SystemConfigOper
from app.db.user_oper import get_current_active_user_async
from app.db.user_oper import get_current_active_user, get_current_active_user_async
from app.helper.server import MoviePilotServerHelper
from app.log import logger
from app.scheduler import Scheduler
@@ -51,14 +51,69 @@ def build_subscribe_event_payload(subscribe: Subscribe) -> dict:
return {column.name: values.get(column.name) for column in subscribe.__table__.columns}
def can_access_subscribe(
subscribe: Subscribe | SubscribeHistory | None, current_user: User
) -> bool:
"""
判断当前用户是否可访问订阅及其历史记录
超级用户拥有全局订阅管理能力普通用户只能访问 username 精确匹配自己的订阅
username 表示无法归属的 legacy 订阅只能由超级用户管理
"""
if not subscribe:
return False
if current_user.is_superuser:
return True
username = subscribe.username
return bool(username) and username == current_user.name
async def get_accessible_subscribe(
db: AsyncSession, subscribe_id: int, current_user: User
) -> Subscribe | None:
"""
按订阅 ID 读取当前用户可访问的订阅行
"""
subscribe = await Subscribe.async_get(db, subscribe_id)
if can_access_subscribe(subscribe, current_user):
return subscribe
return None
def get_accessible_subscribe_sync(
db: Session, subscribe_id: int, current_user: User
) -> Subscribe | None:
"""
同步读取当前用户可访问的订阅行
"""
subscribe = Subscribe.get(db, subscribe_id)
if can_access_subscribe(subscribe, current_user):
return subscribe
return None
def select_accessible_subscribe(
subscribes: List[Subscribe], current_user: User
) -> Subscribe | None:
"""
从候选订阅中选择当前用户可访问的第一条记录
"""
for subscribe in subscribes or []:
if can_access_subscribe(subscribe, current_user):
return subscribe
return None
@router.get("/", summary="查询所有订阅", response_model=List[schemas.Subscribe])
async def read_subscribes(
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token),
current_user: User = Depends(get_current_active_user_async),
) -> Any:
"""
查询所有订阅
"""
if not current_user.is_superuser:
return await Subscribe.async_list_by_username(db, current_user.name)
return await Subscribe.async_list(db)
@@ -69,7 +124,7 @@ async def list_subscribes(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
"""
查询所有订阅 API_TOKEN认证?token=xxx
"""
return await read_subscribes()
return await Subscribe.async_list()
@router.post("/", summary="新增订阅", response_model=schemas.Response)
@@ -106,7 +161,11 @@ async def create_subscribe(
# completed_episode 是响应派生字段,禁止写入持久层
subscribe_dict.pop("completed_episode", None)
sid, message = await SubscribeChain().async_add(
mtype=mtype, title=title, exist_ok=True, **subscribe_dict
mtype=mtype,
title=title,
exist_ok=True,
owner_scope=not current_user.is_superuser,
**subscribe_dict,
)
return schemas.Response(success=bool(sid), message=message, data={"id": sid})
@@ -116,17 +175,18 @@ async def update_subscribe(
*,
subscribe_in: schemas.Subscribe,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token),
current_user: User = Depends(get_current_active_user_async),
) -> Any:
"""
更新订阅信息
"""
subscribe = await Subscribe.async_get(db, subscribe_in.id)
subscribe = await get_accessible_subscribe(db, subscribe_in.id, current_user)
if not subscribe:
return schemas.Response(success=False, message="订阅不存在")
# 避免更新缺失集数
old_subscribe_dict = subscribe.to_dict()
subscribe_dict = subscribe_in.model_dump()
subscribe_dict["username"] = subscribe.username
if subscribe_in.episode_priority is None:
subscribe_dict.pop("episode_priority", None)
# completed_episode 是响应派生字段,禁止写入持久层
@@ -165,12 +225,12 @@ async def update_subscribe_status(
subid: int,
state: str,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token),
current_user: User = Depends(get_current_active_user_async),
) -> Any:
"""
更新订阅状态
"""
subscribe = await Subscribe.async_get(db, subid)
subscribe = await get_accessible_subscribe(db, subid, current_user)
if not subscribe:
return schemas.Response(success=False, message="订阅不存在")
valid_states = ["R", "P", "S"]
@@ -199,7 +259,7 @@ async def subscribe_mediaid(
season: Optional[int] = None,
title: Optional[str] = None,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token),
current_user: User = Depends(get_current_active_user_async),
) -> Any:
"""
根据 TMDBID/豆瓣ID/BangumiId 查询订阅 tmdb:/douban:
@@ -209,23 +269,27 @@ async def subscribe_mediaid(
tmdbid = mediaid[5:]
if not tmdbid or not str(tmdbid).isdigit():
return Subscribe()
result = await Subscribe.async_exists(db, tmdbid=int(tmdbid), season=season)
subscribes = await Subscribe.async_get_by_tmdbid(db, int(tmdbid), season)
result = select_accessible_subscribe(subscribes, current_user)
elif mediaid.startswith("douban:"):
doubanid = mediaid[7:]
if not doubanid:
return Subscribe()
result = await Subscribe.async_get_by_doubanid(db, doubanid)
subscribes = await Subscribe.async_list_by_doubanid(db, doubanid)
result = select_accessible_subscribe(subscribes, current_user)
if not result and title:
title_check = True
elif mediaid.startswith("bangumi:"):
bangumiid = mediaid[8:]
if not bangumiid or not str(bangumiid).isdigit():
return Subscribe()
result = await Subscribe.async_get_by_bangumiid(db, int(bangumiid))
subscribes = await Subscribe.async_list_by_bangumiid(db, int(bangumiid))
result = select_accessible_subscribe(subscribes, current_user)
if not result and title:
title_check = True
else:
result = await Subscribe.async_get_by_mediaid(db, mediaid)
subscribes = await Subscribe.async_list_by_mediaid(db, mediaid)
result = select_accessible_subscribe(subscribes, current_user)
if not result and title:
title_check = True
# 使用名称检查订阅
@@ -233,18 +297,23 @@ async def subscribe_mediaid(
meta = MetaInfo(title)
if season is not None:
meta.begin_season = season
result = await Subscribe.async_get_by_title(
subscribes = await Subscribe.async_list_by_title(
db, title=meta.name, season=meta.begin_season
)
result = select_accessible_subscribe(subscribes, current_user)
return result if result else Subscribe()
@router.get("/refresh", summary="刷新订阅", response_model=schemas.Response)
def refresh_subscribes(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def refresh_subscribes(
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
刷新所有订阅
"""
if not current_user.is_superuser:
return schemas.Response(success=False, message="订阅不存在")
Scheduler().start("subscribe_refresh")
return schemas.Response(success=True)
@@ -253,12 +322,12 @@ def refresh_subscribes(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
async def reset_subscribes(
subid: int,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token),
current_user: User = Depends(get_current_active_user_async),
) -> Any:
"""
重置订阅
"""
subscribe = await Subscribe.async_get(db, subid)
subscribe = await get_accessible_subscribe(db, subid, current_user)
if subscribe:
# 在更新之前获取旧数据
old_subscribe_dict = subscribe.to_dict()
@@ -292,26 +361,43 @@ async def reset_subscribes(
@router.get("/check", summary="刷新订阅 TMDB 信息", response_model=schemas.Response)
def check_subscribes(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def check_subscribes(
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
刷新订阅 TMDB 信息
"""
if not current_user.is_superuser:
return schemas.Response(success=False, message="订阅不存在")
Scheduler().start("subscribe_tmdb")
return schemas.Response(success=True)
@router.get("/search", summary="搜索所有订阅", response_model=schemas.Response)
async def search_subscribes(
background_tasks: BackgroundTasks, _: schemas.TokenPayload = Depends(verify_token)
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_async_db),
current_user: User = Depends(get_current_active_user_async),
) -> Any:
"""
搜索所有订阅
"""
background_tasks.add_task(
Scheduler().start,
job_id="subscribe_search",
**{"sid": None, "state": "R", "manual": True},
)
if current_user.is_superuser:
background_tasks.add_task(
Scheduler().start,
job_id="subscribe_search",
**{"sid": None, "state": "R", "manual": True},
)
else:
subscribes = await Subscribe.async_list_by_username(
db, current_user.name, state="R"
)
for subscribe in subscribes:
background_tasks.add_task(
Scheduler().start,
job_id="subscribe_search",
**{"sid": subscribe.id, "state": None, "manual": True},
)
return schemas.Response(success=True)
@@ -321,11 +407,15 @@ async def search_subscribes(
async def search_subscribe(
subscribe_id: int,
background_tasks: BackgroundTasks,
_: schemas.TokenPayload = Depends(verify_token),
db: AsyncSession = Depends(get_async_db),
current_user: User = Depends(get_current_active_user_async),
) -> Any:
"""
根据订阅编号搜索订阅
"""
subscribe = await get_accessible_subscribe(db, subscribe_id, current_user)
if not subscribe:
return schemas.Response(success=False, message="订阅不存在")
background_tasks.add_task(
Scheduler().start,
job_id="subscribe_search",
@@ -339,7 +429,7 @@ async def delete_subscribe_by_mediaid(
mediaid: str,
season: Optional[int] = None,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token),
current_user: User = Depends(get_current_active_user_async),
) -> Any:
"""
根据TMDBID或豆瓣ID删除订阅 tmdb:/douban:
@@ -355,15 +445,17 @@ async def delete_subscribe_by_mediaid(
doubanid = mediaid[7:]
if not doubanid:
return schemas.Response(success=False)
subscribe = await Subscribe.async_get_by_doubanid(db, doubanid)
if subscribe:
delete_subscribes.append(subscribe)
subscribes = await Subscribe.async_list_by_doubanid(db, doubanid)
delete_subscribes.extend(subscribes)
else:
subscribe = await Subscribe.async_get_by_mediaid(db, mediaid)
if subscribe:
delete_subscribes.append(subscribe)
subscribes = await Subscribe.async_list_by_mediaid(db, mediaid)
delete_subscribes.extend(subscribes)
delete_events = []
for subscribe in delete_subscribes:
for subscribe in [
subscribe
for subscribe in delete_subscribes
if can_access_subscribe(subscribe, current_user)
]:
subscribe_info = build_subscribe_event_payload(subscribe)
subscribe_id = subscribe_info.get("id")
if not subscribe_id:
@@ -464,14 +556,19 @@ async def subscribe_history(
page: Optional[int] = 1,
count: Optional[int] = 30,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token),
current_user: User = Depends(get_current_active_user_async),
) -> Any:
"""
查询电影/电视剧订阅历史
"""
histories = await SubscribeHistory.async_list_by_type(
db, mtype=mtype, page=page, count=count
)
if current_user.is_superuser:
histories = await SubscribeHistory.async_list_by_type(
db, mtype=mtype, page=page, count=count
)
else:
histories = await SubscribeHistory.async_list_by_type_and_username(
db, mtype=mtype, username=current_user.name, page=page, count=count
)
result = []
for history in histories:
history_item = schemas.Subscribe.model_validate(history, from_attributes=True)
@@ -488,12 +585,14 @@ async def subscribe_history(
async def delete_subscribe_history(
history_id: int,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token),
current_user: User = Depends(get_current_active_user_async),
) -> Any:
"""
删除订阅历史
"""
await SubscribeHistory.async_delete(db, history_id)
history = await SubscribeHistory.async_get(db, history_id)
if can_access_subscribe(history, current_user):
await SubscribeHistory.async_delete(db, history_id)
return schemas.Response(success=True)
@@ -565,11 +664,13 @@ async def popular_subscribes(
async def user_subscribes(
username: str,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token),
current_user: User = Depends(get_current_active_user_async),
) -> Any:
"""
查询用户订阅
"""
if not current_user.is_superuser and username != current_user.name:
return []
return await Subscribe.async_list_by_username(db, username)
@@ -581,12 +682,12 @@ async def user_subscribes(
def subscribe_files(
subscribe_id: int,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token),
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
订阅相关文件信息
"""
subscribe = Subscribe.get(db, subscribe_id)
subscribe = get_accessible_subscribe_sync(db, subscribe_id, current_user)
if subscribe:
return SubscribeChain().subscribe_files_info(subscribe)
return schemas.SubscrbieInfo()
@@ -594,11 +695,16 @@ def subscribe_files(
@router.post("/share", summary="分享订阅", response_model=schemas.Response)
async def subscribe_share(
sub: schemas.SubscribeShare, _: schemas.TokenPayload = Depends(verify_token)
sub: schemas.SubscribeShare,
db: AsyncSession = Depends(get_async_db),
current_user: User = Depends(get_current_active_user_async),
) -> Any:
"""
分享订阅
"""
subscribe = await get_accessible_subscribe(db, sub.subscribe_id, current_user)
if not subscribe:
return schemas.Response(success=False, message="订阅不存在")
state, errmsg = await MoviePilotServerHelper.async_sub_share(
subscribe_id=sub.subscribe_id,
share_title=sub.share_title,
@@ -728,26 +834,27 @@ async def subscribe_share_statistics(
async def read_subscribe(
subscribe_id: int,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token),
current_user: User = Depends(get_current_active_user_async),
) -> Any:
"""
根据订阅编号查询订阅信息
"""
if not subscribe_id:
return Subscribe()
return await Subscribe.async_get(db, subscribe_id)
subscribe = await get_accessible_subscribe(db, subscribe_id, current_user)
return subscribe if subscribe else Subscribe()
@router.delete("/{subscribe_id}", summary="删除订阅", response_model=schemas.Response)
async def delete_subscribe(
subscribe_id: int,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token),
current_user: User = Depends(get_current_active_user_async),
) -> Any:
"""
删除订阅信息
"""
subscribe = await Subscribe.async_get(db, subscribe_id)
subscribe = await get_accessible_subscribe(db, subscribe_id, current_user)
if subscribe:
# 在删除之前获取订阅信息
subscribe_info = build_subscribe_event_payload(subscribe)
+54 -23
View File
@@ -35,10 +35,11 @@ from app.db.user_oper import (
get_current_active_user_async,
)
from app.helper.image import ImageHelper
from app.helper.locale import LocaleHelper
from app.helper.message import MessageHelper
from app.helper.server import MoviePilotServerHelper
from app.helper.progress import ProgressHelper
from app.helper.rule import RuleHelper
from app.helper.server import MoviePilotServerHelper
from app.helper.system import SystemHelper
from app.log import logger
from app.scheduler import Scheduler
@@ -797,13 +798,14 @@ async def get_progress(
实时获取处理进度返回格式为SSE
"""
progress = ProgressHelper(process_type)
locale = LocaleHelper.get_current_locale()
async def event_generator():
try:
while not global_vars.is_system_stopped:
if await request.is_disconnected():
break
detail = progress.get()
detail = progress.get(locale=locale)
yield f"data: {json.dumps(detail)}\n\n"
await asyncio.sleep(0.5)
except asyncio.CancelledError:
@@ -1121,33 +1123,64 @@ def ruletest(
"""
过滤规则测试规则类型 1-订阅2-洗版3-搜索
"""
metainfo = MetaInfo(title=title, subtitle=subtitle)
torrent = schemas.TorrentInfo(
title=title,
description=subtitle,
)
# 查询规则组详情
rulegroup = RuleHelper().get_rule_group(rulegroup_name)
result_data = {
"title": title,
"subtitle": subtitle,
"rulegroup_name": rulegroup_name,
"rulegroup": rulegroup.model_dump() if rulegroup else None,
"meta_info": metainfo.to_dict(),
"media_info": None,
"torrent_info": torrent.model_dump(),
"priority": None,
"matched": False,
}
if not rulegroup:
return schemas.Response(
success=False, message=f"过滤规则组 {rulegroup_name} 不存在!"
success=False,
message=f"过滤规则组 {rulegroup_name} 不存在!",
data=result_data,
)
# 根据标题查询媒体信息
media_info = MediaChain().recognize_by_meta(
MetaInfo(title=title, subtitle=subtitle),
metainfo,
obtain_images=False,
)
result_data["media_info"] = media_info.to_dict() if media_info else None
if not media_info:
return schemas.Response(success=False, message="未识别到媒体信息!")
return schemas.Response(
success=False,
message="未识别到媒体信息!",
data=result_data,
)
# 过滤
result = SearchChain().filter_torrents(
rule_groups=[rulegroup.name], torrent_list=[torrent], mediainfo=media_info
)
if not result:
return schemas.Response(success=False, message="不符合过滤规则!")
return schemas.Response(
success=False,
message="不符合过滤规则!",
data=result_data,
)
result_data.update(
{
"matched": True,
"priority": 100 - result[0].pri_order + 1,
"torrent_info": result[0].model_dump(),
}
)
return schemas.Response(
success=True, data={"priority": 100 - result[0].pri_order + 1}
success=True,
data=result_data,
)
@@ -1271,13 +1304,20 @@ def modulelist(_: schemas.TokenPayload = Depends(verify_token)):
"""
查询已加载的模块ID列表
"""
modules = [
{
"id": k,
"name": v.get_name(),
}
for k, v in ModuleManager().get_modules().items()
]
modules = []
for module_id, module in ModuleManager().get_modules().items():
name = module.get_name()
modules.append(
{
"id": module_id,
"name": name,
"name_i18n": LocaleHelper.translate(
f"system.modules.{module_id}.name",
default=name,
),
"name_key": f"system.modules.{module_id}.name",
}
)
return schemas.Response(success=True, data={"modules": modules})
@@ -1299,12 +1339,7 @@ def restart_system(_: User = Depends(get_current_active_superuser)):
"""
if not SystemHelper.can_restart():
return schemas.Response(success=False, message="当前运行环境不支持重启操作!")
# 标识停止事件
global_vars.stop_system()
# 执行重启
ret, msg = SystemHelper.restart()
if not ret:
global_vars.resume_system()
return schemas.Response(success=ret, message=msg)
@@ -1322,11 +1357,7 @@ def upgrade_system(
if not SystemHelper.can_restart():
return schemas.Response(success=False, message="当前运行环境不支持升级操作!")
# 标识停止事件
global_vars.stop_system()
ret, msg = SystemHelper.upgrade(mode=mode or "release")
if not ret:
global_vars.resume_system()
return schemas.Response(success=ret, message=msg)
+50
View File
@@ -5,11 +5,61 @@ from fastapi import APIRouter, Depends
from app import schemas
from app.chain.tmdb import TmdbChain
from app.core.security import verify_token
from app.db.models.user import User
from app.db.user_oper import get_current_active_superuser_async
from app.modules.themoviedb.tmdb_cache import TmdbCache
from app.schemas.types import MediaType
router = APIRouter()
@router.get(
"/cache", summary="查询 TheMovieDb 识别缓存", response_model=schemas.Response
)
async def tmdb_recognition_cache(
_: User = Depends(get_current_active_superuser_async),
) -> schemas.Response:
"""查询可管理的 TheMovieDb 识别缓存。"""
cache_items = TmdbCache().list_items()
recognized_count = sum(1 for item in cache_items if item["tmdb_id"])
return schemas.Response(
success=True,
data={
"count": len(cache_items),
"recognized": recognized_count,
"unrecognized": len(cache_items) - recognized_count,
"data": cache_items,
},
)
@router.delete(
"/cache/{cache_key:path}",
summary="删除指定 TheMovieDb 识别缓存",
response_model=schemas.Response,
)
async def delete_tmdb_recognition_cache(
cache_key: str,
_: User = Depends(get_current_active_superuser_async),
) -> schemas.Response:
"""按缓存键删除单条 TheMovieDb 识别缓存。"""
deleted_item = TmdbCache().delete(cache_key)
if not deleted_item:
return schemas.Response(success=False, message="TheMovieDb 识别缓存不存在")
return schemas.Response(success=True, message="TheMovieDb 识别缓存删除成功")
@router.delete(
"/cache", summary="清空 TheMovieDb 识别缓存", response_model=schemas.Response
)
async def clear_tmdb_recognition_cache(
_: User = Depends(get_current_active_superuser_async),
) -> schemas.Response:
"""清空全部 TheMovieDb 识别缓存。"""
TmdbCache().clear()
return schemas.Response(success=True, message="TheMovieDb 识别缓存清理完成")
@router.get(
"/seasons/{tmdbid}", summary="TMDB所有季", response_model=List[schemas.TmdbSeason]
)
+11 -13
View File
@@ -6,14 +6,16 @@ from sqlalchemy.orm import Session
from app import schemas
from app.chain.media import MediaChain
from app.chain.storage import StorageChain
from app.chain.transfer import TransferChain
from app.core.config import settings, global_vars
from app.core.security import verify_token, verify_apitoken
from app.db import get_db
from app.db.models import User
from app.db.models.transferhistory import TransferHistory
from app.db.user_oper import get_current_active_superuser
from app.db.user_oper import (
get_current_active_manage_user,
get_current_active_superuser,
)
from app.helper.directory import DirectoryHelper
from app.log import logger
from app.schemas import (
@@ -183,7 +185,7 @@ def _get_manual_transfer_target_key(
def match_manual_transfer_target_path(
transer_item: ManualTransferItem,
db: Session = Depends(get_db),
_: User = Depends(get_current_active_superuser),
_: User = Depends(get_current_active_manage_user),
) -> Any:
"""
根据源文件匹配手动整理目的路径
@@ -243,7 +245,7 @@ def manual_transfer(
transer_item: ManualTransferItem,
background: Optional[bool] = False,
db: Session = Depends(get_db),
_: User = Depends(get_current_active_superuser),
_: User = Depends(get_current_active_manage_user),
) -> Any:
"""
手动转移文件或历史记录支持自定义剧集识别格式
@@ -256,6 +258,7 @@ def manual_transfer(
downloader = None
download_hash = None
src_fileitems: List[FileItem] = []
cleanup_dest_fileitem: Optional[FileItem] = None
target_path = Path(transer_item.target_path) if transer_item.target_path else None
if transer_item.logid:
# 查询历史记录
@@ -274,15 +277,8 @@ def manual_transfer(
else:
# 源路径
src_fileitems = [FileItem(**history.src_fileitem)]
# 目的路径
if history.dest_fileitem and not transer_item.preview:
# 删除旧的已整理文件
dest_fileitem = FileItem(**history.dest_fileitem)
state = StorageChain().delete_media_file(dest_fileitem)
if not state:
return schemas.Response(
success=False, message=f"{dest_fileitem.path} 删除失败"
)
cleanup_dest_fileitem = FileItem(**history.dest_fileitem)
# 从历史数据获取信息
if transer_item.from_history:
@@ -427,6 +423,7 @@ def manual_transfer(
download_hash=download_hash,
preview=transer_item.preview,
sync_extra_files=False,
cleanup_dest_fileitem=cleanup_dest_fileitem,
)
if transer_item.preview:
if isinstance(errormsg, dict):
@@ -508,6 +505,7 @@ def manual_transfer(
download_hash=download_hash,
preview=transer_item.preview,
sync_extra_files=True,
cleanup_dest_fileitem=cleanup_dest_fileitem,
)
# 失败
if not state:
@@ -533,7 +531,7 @@ def manual_transfer(
)
def recommend_episode_format(
recommend_item: EpisodeFormatRecommendItem,
_: User = Depends(get_current_active_superuser),
_: User = Depends(get_current_active_manage_user),
) -> Any:
"""
根据目录样本推荐集数定位模板
+21 -18
View File
@@ -10,11 +10,14 @@ from app import schemas
from app.chain.workflow import WorkflowChain
from app.core.config import global_vars
from app.core.plugin import PluginManager
from app.core.security import verify_token
from app.workflow import WorkFlowManager
from app.db import get_async_db, get_db
from app.db.models import Workflow
from app.db.models import Workflow, User
from app.db.systemconfig_oper import SystemConfigOper
from app.db.user_oper import (
get_current_active_manage_user,
get_current_active_manage_user_async,
)
from app.db.workflow_oper import WorkflowOper
from app.helper.server import MoviePilotServerHelper
from app.scheduler import Scheduler
@@ -30,7 +33,7 @@ WORKFLOW_TRIGGER_MANUAL = "manual"
@router.get("/", summary="所有工作流", response_model=List[schemas.Workflow])
async def list_workflows(
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token),
_: User = Depends(get_current_active_manage_user_async),
) -> Any:
"""
获取工作流列表
@@ -42,7 +45,7 @@ async def list_workflows(
async def create_workflow(
workflow: schemas.Workflow,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token),
_: User = Depends(get_current_active_manage_user_async),
) -> Any:
"""
创建工作流
@@ -62,7 +65,7 @@ async def create_workflow(
@router.get("/plugin/actions", summary="查询插件动作", response_model=List[dict])
def list_plugin_actions(
plugin_id: str = None, _: schemas.TokenPayload = Depends(verify_token)
plugin_id: str = None, _: User = Depends(get_current_active_manage_user)
) -> Any:
"""
获取所有动作
@@ -71,7 +74,7 @@ def list_plugin_actions(
@router.get("/actions", summary="所有动作", response_model=List[dict])
async def list_actions(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
async def list_actions(_: User = Depends(get_current_active_manage_user_async)) -> Any:
"""
获取所有动作
"""
@@ -79,7 +82,7 @@ async def list_actions(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
@router.get("/event_types", summary="获取所有事件类型", response_model=List[dict])
async def get_event_types(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
async def get_event_types(_: User = Depends(get_current_active_manage_user_async)) -> Any:
"""
获取所有事件类型
"""
@@ -94,7 +97,7 @@ async def get_event_types(_: schemas.TokenPayload = Depends(verify_token)) -> An
@router.post("/share", summary="分享工作流", response_model=schemas.Response)
async def workflow_share(
workflow: schemas.WorkflowShare, _: schemas.TokenPayload = Depends(verify_token)
workflow: schemas.WorkflowShare, _: User = Depends(get_current_active_manage_user_async)
) -> Any:
"""
分享工作流
@@ -115,7 +118,7 @@ async def workflow_share(
@router.delete("/share/{share_id}", summary="删除分享", response_model=schemas.Response)
async def workflow_share_delete(
share_id: int, _: schemas.TokenPayload = Depends(verify_token)
share_id: int, _: User = Depends(get_current_active_manage_user_async)
) -> Any:
"""
删除分享
@@ -128,7 +131,7 @@ async def workflow_share_delete(
async def workflow_fork(
workflow: schemas.WorkflowShare,
db: AsyncSession = Depends(get_async_db),
_: schemas.User = Depends(verify_token),
_: User = Depends(get_current_active_manage_user_async),
) -> Any:
"""
复用工作流
@@ -194,7 +197,7 @@ async def workflow_shares(
name: Optional[str] = None,
page: Optional[int] = 1,
count: Optional[int] = 30,
_: schemas.TokenPayload = Depends(verify_token),
_: User = Depends(get_current_active_manage_user_async),
) -> Any:
"""
查询分享的工作流
@@ -208,7 +211,7 @@ async def workflow_shares(
def run_workflow(
workflow_id: int,
from_begin: Optional[bool] = True,
_: schemas.TokenPayload = Depends(verify_token),
_: User = Depends(get_current_active_manage_user),
) -> Any:
"""
执行工作流
@@ -225,7 +228,7 @@ def run_workflow(
def start_workflow(
workflow_id: int,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token),
_: User = Depends(get_current_active_manage_user),
) -> Any:
"""
启用工作流
@@ -259,7 +262,7 @@ def start_workflow(
def pause_workflow(
workflow_id: int,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token),
_: User = Depends(get_current_active_manage_user),
) -> Any:
"""
停用工作流
@@ -287,7 +290,7 @@ def pause_workflow(
async def reset_workflow(
workflow_id: int,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token),
_: User = Depends(get_current_active_manage_user_async),
) -> Any:
"""
重置工作流
@@ -308,7 +311,7 @@ async def reset_workflow(
async def get_workflow(
workflow_id: int,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token),
_: User = Depends(get_current_active_manage_user_async),
) -> Any:
"""
获取工作流详情
@@ -320,7 +323,7 @@ async def get_workflow(
def update_workflow(
workflow: schemas.Workflow,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token),
_: User = Depends(get_current_active_manage_user),
) -> Any:
"""
更新工作流
@@ -350,7 +353,7 @@ def update_workflow(
def delete_workflow(
workflow_id: int,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token),
_: User = Depends(get_current_active_manage_user),
) -> Any:
"""
删除工作流
+21 -2
View File
@@ -1,10 +1,11 @@
import gzip
import hmac
import json
from typing import Annotated, Callable, Any, Dict, Optional
import aiofiles
from anyio import Path as AsyncPath
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Request, Response
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Path, Request, Response
from fastapi.responses import PlainTextResponse
from fastapi.routing import APIRoute
@@ -44,6 +45,24 @@ async def verify_server_enabled():
return True
async def verify_update_auth(
x_cookiecloud_auth: Annotated[
Optional[str], Header(alias="X-CookieCloud-Auth")
] = None,
):
"""
校验CookieCloud上传接口的可选共享认证头
"""
expected_header = (settings.COOKIECLOUD_AUTH_HEADER or "").strip()
if not expected_header:
return True
provided_header = (x_cookiecloud_auth or "").strip()
if not hmac.compare_digest(provided_header, expected_header):
raise HTTPException(status_code=403, detail="CookieCloud认证失败")
return True
cookie_router = APIRouter(
route_class=GzipRoute,
tags=["servcookie"],
@@ -61,7 +80,7 @@ async def post_root():
return "Hello MoviePilot! COOKIECLOUD API ROOT = /cookiecloud"
@cookie_router.post("/update")
@cookie_router.post("/update", dependencies=[Depends(verify_update_auth)])
async def update_cookie(req: schemas.CookieData):
"""
上传Cookie数据
+285 -10
View File
@@ -1,11 +1,13 @@
import base64
import copy
import hashlib
import json
import re
import shutil
import time
from pathlib import Path
from typing import List, Optional, Tuple, Set, Dict, Union
from urllib.parse import parse_qs, urlparse
from app import schemas
from app.chain import ChainBase
@@ -16,9 +18,10 @@ from app.core.context import MediaInfo, SubtitleInfo, TorrentInfo, Context
from app.core.event import eventmanager, Event
from app.core.meta import MetaBase
from app.core.metainfo import MetaInfo
from app.db.downloadfailure_oper import DownloadFailureOper
from app.db.downloadhistory_oper import DownloadHistoryOper
from app.db.mediaserver_oper import MediaServerOper
from app.helper.directory import DirectoryHelper
from app.helper.directory import DirectoryHelper, validate_download_save_path
from app.helper.thread import ThreadHelper
from app.helper.torrent import TorrentHelper
from app.log import logger
@@ -31,6 +34,21 @@ from app.utils.string import StringUtils
from app.utils.system import SystemUtils
DOWNLOAD_FAILURE_RESOURCE_TTL_SECONDS = 24 * 60 * 60
DOWNLOAD_FAILURE_TRANSIENT_TTL_SECONDS = 60 * 60
DOWNLOAD_FAILURE_RESOURCE_ERROR_KEYWORDS = (
"无法读取种子文件",
"下载种子内容为空",
"无法获取下载地址",
"种子下载失败",
"torrent not found",
"not found",
"404",
"deleted",
"invalid torrent",
)
class DownloadChain(ChainBase):
"""
下载处理链
@@ -107,19 +125,27 @@ class DownloadChain(ChainBase):
def _resolve_media_download_dir(
media_info: MediaInfo,
save_path: Optional[str] = None,
) -> Union[str, Path]:
) -> Tuple[Optional[str], Optional[Path], str]:
"""
根据媒体信息解析下载目录
"""
storage = 'local'
if save_path:
return storage, Path(save_path)
if save_path is not None:
try:
validated_save_path = validate_download_save_path(save_path)
except ValueError as err:
logger.warn(str(err))
return None, None, str(err)
if re.match(r"^[A-Za-z]:/", validated_save_path):
return storage, Path(validated_save_path), ""
file_uri = FileURI.from_uri(validated_save_path)
return file_uri.storage or storage, Path(file_uri.path), ""
dir_info = DirectoryHelper().get_dir(media_info, include_unsorted=True)
storage = dir_info.storage if dir_info else storage
if not dir_info:
logger.error(f"未找到下载目录:{media_info.type.value} {media_info.title_year}")
return None
return None, None, "未找到下载目录"
if not dir_info.media_type and dir_info.download_type_folder:
download_dir = Path(dir_info.download_path) / media_info.type.value
@@ -129,7 +155,7 @@ class DownloadChain(ChainBase):
if not dir_info.media_category and dir_info.download_category_folder and media_info.category:
download_dir = download_dir / media_info.category
return storage, download_dir
return storage, download_dir, ""
@staticmethod
def _upload_subtitle_file(
@@ -293,12 +319,12 @@ class DownloadChain(ChainBase):
if not mediainfo:
return False, "无法识别媒体信息", []
storage, target_dir = self._resolve_media_download_dir(
storage, target_dir, error_msg = self._resolve_media_download_dir(
media_info=mediainfo,
save_path=save_path,
)
if not target_dir:
return False, "未找到下载目录", []
return False, error_msg or "未找到下载目录", []
request = RequestUtils(
cookies=subtitle.site_cookie,
@@ -357,6 +383,183 @@ class DownloadChain(ChainBase):
except Exception as err:
logger.error(f"提交下载成功后处理后台任务失败:{str(err)}")
@staticmethod
def _is_subscribe_source(source: Optional[str]) -> bool:
"""
判断下载来源是否为订阅任务
"""
return bool(source and str(source).startswith("Subscribe|"))
@staticmethod
def _format_failure_episodes(meta: Optional[MetaBase]) -> Optional[str]:
"""
从识别元数据中格式化用于失败记录的集数
"""
if not meta:
return None
if getattr(meta, "episode", None):
return meta.episode
episode_list = getattr(meta, "episode_list", None)
if episode_list:
return StringUtils.format_ep(list(episode_list))
return None
@staticmethod
def _torrent_resource_key(torrent: Optional[TorrentInfo]) -> str:
"""
生成不保存敏感下载链接的种子资源键
"""
if not torrent:
return ""
for attr_name in ("torrent_id", "info_hash"):
value = getattr(torrent, attr_name, None)
if value:
return str(value)
for attr_name in ("page_url", "enclosure"):
url = getattr(torrent, attr_name, None)
if not url:
continue
match = re.search(r"\[(.*?)](.*)", str(url))
if match:
url = match.group(2)
parsed = urlparse(str(url))
params = parse_qs(parsed.query)
for param_name in ("id", "torrentid", "torrent_id", "tid", "hash"):
values = params.get(param_name)
if values:
return f"{parsed.netloc}:{param_name}={values[0]}"
if parsed.netloc and parsed.path:
return f"{parsed.netloc}{parsed.path}"
title = getattr(torrent, "title", "") or ""
size = getattr(torrent, "size", "") or ""
return f"title={title}|size={size}"
@classmethod
def _build_download_failure_fingerprint(cls, context: Context) -> Optional[str]:
"""
根据媒体和种子资源信息生成失败冷却指纹
"""
media = getattr(context, "media_info", None)
torrent = getattr(context, "torrent_info", None)
if not media or not torrent:
return None
media_type = getattr(getattr(media, "type", None), "value", getattr(media, "type", None))
media_key = (
getattr(media, "tmdb_id", None)
or getattr(media, "douban_id", None)
or getattr(media, "imdb_id", None)
or getattr(media, "tvdb_id", None)
or f"{getattr(media, 'title', '')}:{getattr(media, 'year', '')}"
)
meta = getattr(context, "meta_info", None)
site = getattr(torrent, "site", None) or getattr(torrent, "site_name", None)
payload = {
"media_type": str(media_type or ""),
"media_key": str(media_key or ""),
"season": str(getattr(meta, "season", None) or getattr(media, "season", None) or ""),
"episodes": cls._format_failure_episodes(meta) or "",
"site": str(site or ""),
"resource": cls._torrent_resource_key(torrent),
}
if not payload["media_type"] or not payload["media_key"] or not payload["resource"]:
return None
raw_text = json.dumps(payload, ensure_ascii=False, sort_keys=True)
return hashlib.sha256(raw_text.encode("utf-8")).hexdigest()
@staticmethod
def _download_failure_ttl(error_msg: Optional[str]) -> int:
"""
按失败原因确定资源冷却时间
"""
error_text = str(error_msg or "").lower()
if any(keyword in error_text for keyword in DOWNLOAD_FAILURE_RESOURCE_ERROR_KEYWORDS):
return DOWNLOAD_FAILURE_RESOURCE_TTL_SECONDS
return DOWNLOAD_FAILURE_TRANSIENT_TTL_SECONDS
def _record_download_failure(
self,
context: Context,
error_msg: Optional[str],
downloader: Optional[str] = None,
source: Optional[str] = None,
episodes: Optional[Set[int]] = None,
) -> Optional[str]:
"""
记录资源级下载失败并返回本次失败指纹
"""
fingerprint = self._build_download_failure_fingerprint(context)
if not fingerprint:
return None
now_timestamp = time.time()
now_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(now_timestamp))
next_retry_at = time.strftime(
"%Y-%m-%d %H:%M:%S",
time.localtime(now_timestamp + self._download_failure_ttl(error_msg)),
)
media = context.media_info
meta = context.meta_info
torrent = context.torrent_info
site = getattr(torrent, "site", None)
try:
DownloadFailureOper().record_failure(
fingerprint=fingerprint,
now_time=now_time,
next_retry_at=next_retry_at,
type=getattr(getattr(media, "type", None), "value", getattr(media, "type", None)),
title=getattr(media, "title", None),
year=getattr(media, "year", None),
tmdbid=getattr(media, "tmdb_id", None),
doubanid=getattr(media, "douban_id", None),
seasons=getattr(meta, "season", None),
episodes=StringUtils.format_ep(list(episodes)) if episodes else self._format_failure_episodes(meta),
site=site if isinstance(site, int) else None,
site_name=getattr(torrent, "site_name", None),
torrent_id=self._torrent_resource_key(torrent),
torrent_name=getattr(torrent, "title", None),
torrent_size=getattr(torrent, "size", None),
downloader=downloader,
source=str(source)[:1000] if source else None,
error_message=str(error_msg or "")[:1000],
)
except Exception as err:
logger.error(f"记录下载失败冷却失败:{str(err)}")
return fingerprint
def _active_download_failure_fingerprints(
self,
contexts: List[Context],
source: Optional[str],
) -> Set[str]:
"""
查询当前订阅候选中仍处于冷却期的失败指纹
"""
if not self._is_subscribe_source(source):
return set()
fingerprints = [
fingerprint
for fingerprint in [
self._build_download_failure_fingerprint(context)
for context in contexts or []
]
if fingerprint
]
if not fingerprints:
return set()
now_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
try:
return set(
DownloadFailureOper()
.get_active_by_fingerprints(fingerprints=fingerprints, now_time=now_time)
.keys()
)
except Exception as err:
logger.error(f"查询下载失败冷却失败:{str(err)}")
return set()
def download_torrent(self, torrent: TorrentInfo,
channel: MessageChannel = None,
source: Optional[str] = None,
@@ -527,9 +730,16 @@ class DownloadChain(ChainBase):
f"Reason: {event_data.reason}")
return (None, "下载被事件取消") if return_detail else None
# 如果事件修改了下载路径,使用新路径
if event_data.options and event_data.options.get("save_path"):
if event_data.options and "save_path" in event_data.options:
save_path = event_data.options.get("save_path")
if save_path is not None:
try:
save_path = validate_download_save_path(save_path)
except ValueError as err:
logger.warn(str(err))
return (None, str(err)) if return_detail else None
# 补充完整的media数据
if not _media.genre_ids:
new_media = self.recognize_media(mtype=_media.type, tmdbid=_media.tmdb_id,
@@ -563,6 +773,13 @@ class DownloadChain(ChainBase):
torrent_content = cache_backend.get(torrent_file.as_posix(), region="torrents")
if not torrent_content:
self._record_download_failure(
context=context,
error_msg="下载种子内容为空",
downloader=downloader or _site_downloader,
source=source,
episodes=episodes,
)
return (None, "下载种子内容为空") if return_detail else None
# 获取种子文件的文件夹名和文件清单
@@ -570,7 +787,7 @@ class DownloadChain(ChainBase):
storage = 'local'
# 下载目录
if save_path:
if save_path is not None:
download_dir = Path(save_path)
else:
# 根据媒体信息查询下载目录配置
@@ -717,6 +934,13 @@ class DownloadChain(ChainBase):
# 下载失败
logger.error(f"{_media.title_year} 添加下载任务失败:"
f"{_torrent.title} - {_torrent.enclosure}{error_msg}")
self._record_download_failure(
context=context,
error_msg=error_msg,
downloader=_downloader or downloader or _site_downloader,
source=source,
episodes=episodes,
)
# 只发送给对应渠道和用户
self.post_message(Notification(
channel=channel,
@@ -882,6 +1106,28 @@ class DownloadChain(ChainBase):
# 仅排序,不提前按媒体控重;下载失败时需要继续尝试同组后续候选。
contexts = TorrentHelper().sort_torrents(contexts)
active_failure_fingerprints = self._active_download_failure_fingerprints(
contexts=contexts,
source=source,
)
def __is_context_in_failure_cooldown(_context: Context) -> bool:
"""
判断候选资源是否仍处于失败冷却期
"""
fingerprint = self._build_download_failure_fingerprint(_context)
if fingerprint and fingerprint in active_failure_fingerprints:
logger.info(f"{_context.torrent_info.title} 近期添加下载失败,暂时跳过该资源")
return True
return False
def __remember_context_failure(_context: Context) -> None:
"""
将本轮失败候选加入内存冷却集合避免同一批次重复尝试
"""
fingerprint = self._build_download_failure_fingerprint(_context)
if fingerprint:
active_failure_fingerprints.add(fingerprint)
# 如果是电影,直接下载
downloaded_movies = set()
@@ -889,6 +1135,8 @@ class DownloadChain(ChainBase):
if global_vars.is_system_stopped:
break
if context.media_info.type == MediaType.MOVIE:
if __is_context_in_failure_cooldown(context):
continue
movie_key = __get_movie_download_key(context)
if movie_key in downloaded_movies:
continue
@@ -900,6 +1148,8 @@ class DownloadChain(ChainBase):
logger.info(f"{context.torrent_info.title} 添加下载成功")
downloaded_list.append(context)
downloaded_movies.add(movie_key)
else:
__remember_context_failure(context)
# 电视剧整季匹配
if no_exists:
@@ -944,6 +1194,8 @@ class DownloadChain(ChainBase):
# 不重复添加
if context in downloaded_list:
continue
if __is_context_in_failure_cooldown(context):
continue
# 种子季是需要季或者子集
if set(torrent_season).issubset(set(need_season)):
complete_coverage_matched = False
@@ -953,6 +1205,13 @@ class DownloadChain(ChainBase):
content, _, torrent_files = self.download_torrent(torrent)
if not content:
logger.warn(f"{torrent.title} 种子下载失败!")
self._record_download_failure(
context=context,
error_msg="下载种子内容为空",
downloader=downloader,
source=source,
)
__remember_context_failure(context)
continue
if isinstance(content, str):
logger.warn(f"{meta.org_string} 下载地址是磁力链,无法确定种子文件集数")
@@ -1024,6 +1283,8 @@ class DownloadChain(ChainBase):
if not need_season:
# 全部下载完成
break
else:
__remember_context_failure(context)
# 电视剧季内的集匹配
if no_exists:
logger.info(f"开始电视剧完整集匹配:{no_exists}")
@@ -1064,6 +1325,8 @@ class DownloadChain(ChainBase):
# 不重复添加
if context in downloaded_list:
continue
if __is_context_in_failure_cooldown(context):
continue
# 种子季
torrent_season = meta.season_list
# 只处理单季含集的种子
@@ -1106,6 +1369,8 @@ class DownloadChain(ChainBase):
_sea=need_season,
_current=torrent_episodes)
logger.info(f"{need_season} 剩余需要集:{need_episodes}")
else:
__remember_context_failure(context)
# 仍然缺失的剧集,从整季中选择需要的集数文件下载,仅支持QB和TR
if no_exists:
@@ -1148,6 +1413,8 @@ class DownloadChain(ChainBase):
# 不重复添加
if context in downloaded_list:
continue
if __is_context_in_failure_cooldown(context):
continue
# 没有需要集后退出
if not need_episodes:
break
@@ -1166,6 +1433,13 @@ class DownloadChain(ChainBase):
content, _, torrent_files = self.download_torrent(torrent)
if not content:
logger.info(f"{torrent.title} 种子下载失败!")
self._record_download_failure(
context=context,
error_msg="下载种子内容为空",
downloader=downloader,
source=source,
)
__remember_context_failure(context)
continue
if isinstance(content, str):
logger.warn(f"{meta.org_string} 下载地址是磁力链,无法解析种子文件集数")
@@ -1194,6 +1468,7 @@ class DownloadChain(ChainBase):
custom_words=custom_words
)
if not download_id:
__remember_context_failure(context)
continue
# 下载成功
logger.info(f"{torrent.title} 添加下载成功")
+161 -4
View File
@@ -29,7 +29,12 @@ from app.db.models import TransferHistory
from app.db.transferhistory_oper import TransferHistoryOper
from app.db.user_oper import UserOper
from app.helper.directory import DirectoryHelper
from app.helper.interaction import agent_interaction_manager, media_interaction_manager, PendingMediaInteraction
from app.helper.interaction import (
agent_interaction_manager,
media_interaction_manager,
plugin_input_interaction_manager,
PendingMediaInteraction,
)
from app.helper.torrent import TorrentHelper
from app.log import logger
from app.schemas import CommingMessage, DownloadDirectory, FileURI, NotExistMediaInfo, Notification
@@ -136,9 +141,9 @@ class MessageChain(ChainBase):
logger.debug(f"未识别到消息内容::{body}{form}{args}")
return
# 获取原消息ID信息
original_message_id = info.message_id
original_chat_id = info.chat_id
reply_to_message_id = info.reply_to_message_id
# 处理消息
self.handle_message(
@@ -149,6 +154,7 @@ class MessageChain(ChainBase):
text=text,
original_message_id=original_message_id,
original_chat_id=original_chat_id,
reply_to_message_id=reply_to_message_id,
images=images,
audio_refs=audio_refs,
files=files,
@@ -160,12 +166,13 @@ class MessageChain(ChainBase):
source: str,
userid: Union[str, int],
username: str,
text: str,
text: Optional[str],
original_message_id: Optional[Union[str, int]] = None,
original_chat_id: Optional[str] = None,
images: Optional[List[CommingMessage.MessageImage]] = None,
audio_refs: Optional[List[str]] = None,
files: Optional[List[CommingMessage.MessageAttachment]] = None,
reply_to_message_id: Optional[Union[str, int]] = None,
) -> None:
"""
识别消息内容执行操作
@@ -201,6 +208,21 @@ class MessageChain(ChainBase):
)
return
if self._handle_plugin_input_interaction(
channel=channel,
source=source,
userid=userid,
username=username,
text=text,
original_chat_id=original_chat_id,
reply_to_message_id=reply_to_message_id,
images=images,
audio_refs=audio_refs,
files=files,
has_audio_input=has_audio_input,
):
return
is_agent_message = self._is_agent_message(
userid=userid,
text=text,
@@ -236,6 +258,7 @@ class MessageChain(ChainBase):
text=text,
original_message_id=original_message_id,
original_chat_id=original_chat_id,
reply_to_message_id=reply_to_message_id,
images=images,
audio_refs=audio_refs,
files=files,
@@ -259,7 +282,7 @@ class MessageChain(ChainBase):
source: str,
userid: Union[str, int],
username: str,
text: str,
text: Optional[str],
original_message_id: Optional[Union[str, int]] = None,
original_chat_id: Optional[str] = None,
images: Optional[List[CommingMessage.MessageImage]] = None,
@@ -267,6 +290,7 @@ class MessageChain(ChainBase):
files: Optional[List[CommingMessage.MessageAttachment]] = None,
has_audio_input: bool = False,
processing_status: Optional[_ProcessingStatus] = None,
reply_to_message_id: Optional[Union[str, int]] = None,
) -> bool:
"""执行实际消息路由,便于统一包裹处理中状态。"""
@@ -290,6 +314,21 @@ class MessageChain(ChainBase):
)
return False
if self._handle_plugin_input_interaction(
channel=channel,
source=source,
userid=userid,
username=username,
text=text,
original_chat_id=original_chat_id,
reply_to_message_id=reply_to_message_id,
images=images,
audio_refs=audio_refs,
files=files,
has_audio_input=has_audio_input,
):
return False
no_ai_requested, no_ai_text = self._strip_no_ai_prefix(text)
if no_ai_requested:
text = no_ai_text
@@ -411,10 +450,128 @@ class MessageChain(ChainBase):
"userid": userid,
"channel": channel,
"source": source,
"chat_id": original_chat_id,
"reply_to_message_id": reply_to_message_id,
},
)
return False
def _handle_plugin_input_interaction(
self,
channel: MessageChannel,
source: str,
userid: Union[str, int],
username: str,
text: str,
original_chat_id: Optional[Union[str, int]] = None,
images: Optional[List[CommingMessage.MessageImage]] = None,
audio_refs: Optional[List[str]] = None,
files: Optional[List[CommingMessage.MessageAttachment]] = None,
has_audio_input: bool = False,
reply_to_message_id: Optional[Union[str, int]] = None,
) -> bool:
"""
将插件输入会话中的下一条普通文本派发给指定插件
"""
if not text or not text.strip() or images or audio_refs or files or has_audio_input:
return False
if text.startswith("CALLBACK:"):
return False
is_cancel_text = text.strip().lower() in {"取消", "退出", "q", "quit", "exit"}
request, status = plugin_input_interaction_manager.consume_by_user(
userid,
channel,
source,
original_chat_id,
reply_to_message_id=reply_to_message_id,
bypass_reply_check=is_cancel_text,
)
if not request:
return False
if status == "expired":
self.eventmanager.send_event(
EventType.MessageAction,
{
"plugin_id": request.plugin_id,
"__mp_target_plugin_id": request.plugin_id,
"text": f"plugin_input_expired|{request.request_id}",
"userid": userid,
"channel": channel,
"source": source,
"username": username,
"chat_id": original_chat_id,
"reply_to_message_id": reply_to_message_id,
"prompt_id": request.prompt_id,
"input_session_id": request.request_id,
"expired": True,
"payload": request.payload,
},
)
self.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title="插件输入已超时,请重新发起操作。",
save_history=False,
)
)
return not text.strip().startswith("/")
if is_cancel_text:
self.eventmanager.send_event(
EventType.MessageAction,
{
"plugin_id": request.plugin_id,
"__mp_target_plugin_id": request.plugin_id,
"text": f"plugin_input_cancel|{request.request_id}",
"userid": userid,
"channel": channel,
"source": source,
"username": username,
"chat_id": original_chat_id,
"reply_to_message_id": reply_to_message_id,
"prompt_id": request.prompt_id,
"input_session_id": request.request_id,
"cancelled": True,
"payload": request.payload,
},
)
self.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title="已取消插件输入",
save_history=False,
)
)
return True
self.eventmanager.send_event(
EventType.MessageAction,
{
"plugin_id": request.plugin_id,
"__mp_target_plugin_id": request.plugin_id,
"text": f"plugin_input|{request.request_id}",
"input_text": text,
"userid": userid,
"channel": channel,
"source": source,
"username": username,
"chat_id": original_chat_id,
"reply_to_message_id": reply_to_message_id,
"prompt_id": request.prompt_id,
"input_session_id": request.request_id,
"payload": request.payload,
},
)
return True
@classmethod
def _strip_no_ai_prefix(cls, text: str) -> Tuple[bool, str]:
"""
+40 -3
View File
@@ -530,7 +530,7 @@ class SearchChain(ChainBase):
def search_by_title(self, title: str, page: Optional[int] = 0,
sites: List[int] = None, cache_local: Optional[bool] = False) -> List[Context]:
"""
根据标题搜索资源不识别不过滤直接返回站点内容
根据标题搜索资源不识别媒体信息按默认搜索过滤规则返回站点内容
:param title: 标题为空时返回所有站点首页内容
:param page: 页码
:param sites: 站点ID列表
@@ -552,6 +552,10 @@ class SearchChain(ChainBase):
if not torrents:
logger.warn(f'{title} 未搜索到资源')
return []
torrents = self.__filter_title_search_torrents(torrents=torrents)
if not torrents:
logger.warn(f'{title} 没有符合过滤规则的资源')
return []
# 组装上下文
contexts = [
Context(
@@ -791,7 +795,7 @@ class SearchChain(ChainBase):
async def async_search_by_title(self, title: str, page: Optional[int] = 0,
sites: List[int] = None, cache_local: Optional[bool] = False) -> List[Context]:
"""
根据标题异步搜索资源不识别不过滤直接返回站点内容
根据标题异步搜索资源不识别媒体信息按默认搜索过滤规则返回站点内容
:param title: 标题为空时返回所有站点首页内容
:param page: 页码
:param sites: 站点ID列表
@@ -813,6 +817,10 @@ class SearchChain(ChainBase):
if not torrents:
logger.warn(f'{title} 未搜索到资源')
return []
torrents = await run_in_threadpool(self.__filter_title_search_torrents, torrents=torrents)
if not torrents:
logger.warn(f'{title} 没有符合过滤规则的资源')
return []
# 组装上下文
contexts = [
Context(
@@ -830,7 +838,7 @@ class SearchChain(ChainBase):
sites: List[int] = None,
cache_local: Optional[bool] = False) -> AsyncIterator[dict]:
"""
根据标题渐进式搜索资源不识别不过滤站点完成顺序返回结果
根据标题渐进式搜索资源不识别媒体信息默认搜索过滤规则返回结果
"""
if cache_local:
self.cancel_ai_recommend()
@@ -845,8 +853,14 @@ class SearchChain(ChainBase):
logger.info(f'开始渐进式浏览资源,站点:{sites} ...')
contexts: List[Context] = []
rule_groups: List[str] = SystemConfigOper().get(SystemConfigKey.SearchFilterRuleGroups) or []
async for event in self.__async_search_all_sites_stream(keyword=title, sites=sites, page=page):
result = event.pop("items", []) or []
result = await run_in_threadpool(
self.__filter_title_search_torrents,
torrents=result,
rule_groups=rule_groups,
)
batch_contexts = [
Context(
meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description),
@@ -876,6 +890,29 @@ class SearchChain(ChainBase):
"total_items": len(contexts)
}
def __filter_title_search_torrents(self,
torrents: List[TorrentInfo],
rule_groups: Optional[List[str]] = None) -> List[TorrentInfo]:
"""
对标题搜索结果应用默认搜索过滤规则不执行媒体识别和标题精确匹配
"""
if not torrents:
return []
if rule_groups is None:
rule_groups = SystemConfigOper().get(SystemConfigKey.SearchFilterRuleGroups) or []
if not rule_groups:
return torrents
logger.info(f'开始过滤标题搜索结果,使用规则组:{rule_groups} ...')
filtered_torrents = self.filter_torrents(
rule_groups=rule_groups,
torrent_list=torrents,
mediainfo=None,
) or []
logger.info(f'标题搜索过滤完成,剩余 {len(filtered_torrents)} 个资源')
return filtered_torrents
async def async_search_by_id_stream(self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
mtype: MediaType = None, area: Optional[str] = "title",
season: Optional[int] = None, sites: List[int] = None,
+193 -48
View File
@@ -317,6 +317,71 @@ class SubscribeChain(ChainBase):
update_data.update(cls.__prepare_subscribe_progress_fields(subscribe=subscribe, no_exists={}))
return update_data
@classmethod
def __prepare_best_version_total_change_fields(
cls,
subscribe: Subscribe,
total_episode: int,
old_total_episode: int,
) -> Dict[str, Any]:
"""
准备洗版电视剧总集数变化后需要写库的字段
总集数变化会改变目标范围按集优先级只保留新范围内的目标集避免范围外
旧状态继续参与完成集缺失集和当前优先级计算
"""
update_data: Dict[str, Any] = {"total_episode": total_episode}
target_episodes = set(cls.__get_best_version_target_episodes(
subscribe,
total_episode=total_episode,
))
episode_priority = cls.__get_episode_priority(
subscribe,
total_episode=old_total_episode,
)
filtered_priority = {
str(episode): priority
for episode, priority in episode_priority.items()
if int(episode) in target_episodes
}
subscribe.total_episode = total_episode
subscribe.episode_priority = filtered_priority
current_priority = 0 if not target_episodes else cls.get_best_version_current_priority(
subscribe,
episode_priority=filtered_priority,
)
subscribe.current_priority = current_priority
update_data["episode_priority"] = filtered_priority
update_data["current_priority"] = current_priority
update_data.update(cls.__prepare_subscribe_progress_fields(subscribe=subscribe, no_exists={}))
return update_data
@classmethod
def __prepare_total_episode_change_fields(
cls,
subscribe: Subscribe,
total_episode: int,
old_total_episode: int,
) -> Dict[str, Any]:
"""
准备已有订阅总集数持久化字段并同步内存对象上的总集数快照
"""
if subscribe.best_version and subscribe.type == MediaType.TV.value:
return cls.__prepare_best_version_total_change_fields(
subscribe=subscribe,
total_episode=total_episode,
old_total_episode=old_total_episode,
)
subscribe.total_episode = total_episode
return {
"total_episode": total_episode,
"lack_episode": max(
(subscribe.lack_episode or 0) + (total_episode - old_total_episode),
0,
),
}
@classmethod
def __is_best_version_complete(cls, subscribe: Subscribe) -> bool:
"""
@@ -387,9 +452,10 @@ class SubscribeChain(ChainBase):
获取已完成洗版的剧集
"""
episode_priority = cls.__get_episode_priority(subscribe)
target_episodes = set(cls.__get_best_version_target_episodes(subscribe))
return sorted(
int(episode) for episode, priority in episode_priority.items()
if str(episode).isdigit() and priority == 100
if str(episode).isdigit() and int(episode) in target_episodes and priority == 100
)
@classmethod
@@ -773,11 +839,13 @@ class SubscribeChain(ChainBase):
if not mediainfo.seasons:
logger.error(f"媒体信息中没有季集信息,标题:{title}tmdbid{tmdbid}doubanid{doubanid}")
return None, "媒体信息中没有季集信息"
total_episode = len(mediainfo.seasons.get(season) or [])
# 允许外部覆盖按 TMDB 算出的总集数(如待定集数)
current_total_episode = len(mediainfo.seasons.get(season) or [])
# 创建场景没有旧订阅事实,仅允许外部补正未知或扩展总集数。
total_episode = self.__apply_episodes_refresh(
total_episode, season=season, mediainfo=mediainfo,
current_total_episode, season=season, mediainfo=mediainfo,
tmdbid=mediainfo.tmdb_id, doubanid=mediainfo.douban_id, scene="create")
if current_total_episode and total_episode < current_total_episode:
total_episode = current_total_episode
if not total_episode:
logger.error(f'未获取到总集数,标题:{title}tmdbid{tmdbid}, doubanid{doubanid}')
return None, f"未获取到第 {season} 季的总集数"
@@ -958,11 +1026,13 @@ class SubscribeChain(ChainBase):
if not mediainfo.seasons:
logger.error(f"媒体信息中没有季集信息,标题:{title}tmdbid{tmdbid}doubanid{doubanid}")
return None, "媒体信息中没有季集信息"
total_episode = len(mediainfo.seasons.get(season) or [])
# 允许外部覆盖按 TMDB 算出的总集数(如待定集数)
current_total_episode = len(mediainfo.seasons.get(season) or [])
# 创建场景没有旧订阅事实,仅允许外部补正未知或扩展总集数。
total_episode = await self.__async_apply_episodes_refresh(
total_episode, season=season, mediainfo=mediainfo,
current_total_episode, season=season, mediainfo=mediainfo,
tmdbid=mediainfo.tmdb_id, doubanid=mediainfo.douban_id, scene="create")
if current_total_episode and total_episode < current_total_episode:
total_episode = current_total_episode
if not total_episode:
logger.error(f'未获取到总集数,标题:{title}tmdbid{tmdbid}, doubanid{doubanid}')
return None, f"未获取到第 {season} 季的总集数"
@@ -1116,6 +1186,7 @@ class SubscribeChain(ChainBase):
)
mediakey = subscribe.tmdbid or subscribe.doubanid
custom_word_list = subscribe.custom_words.split("\n") if subscribe.custom_words else None
search_attempted = False
# 校验当前时间减订阅创建时间是否大于1分钟,否则跳过先,留出编辑订阅的时间
if subscribe.date:
now = datetime.now()
@@ -1133,6 +1204,7 @@ class SubscribeChain(ChainBase):
)
time.sleep(sleep_time)
try:
search_attempted = True
logger.info(f'开始搜索订阅,标题:{subscribe.name} ...')
try:
meta = build_subscribe_meta(subscribe)
@@ -1276,7 +1348,7 @@ class SubscribeChain(ChainBase):
downloads=downloads, lefts=lefts)
finally:
# 如果状态为N则更新为R
if subscribe and subscribe.state == 'N':
if search_attempted and subscribe and subscribe.state == 'N':
subscribeoper.update(subscribe.id, {'state': 'R'})
if progress_callback:
progress_callback(
@@ -1913,28 +1985,28 @@ class SubscribeChain(ChainBase):
# 对于电视剧,获取当前季的总集数
episodes = mediainfo.seasons.get(subscribe.season) or []
progress_update = {}
if not subscribe.manual_total_episode and len(episodes):
total_episode = len(episodes)
# 允许外部覆盖按 TMDB 算出的总集数(如待定集数)
if subscribe.type == MediaType.TV.value and not subscribe.manual_total_episode and len(episodes):
current_total_episode = len(episodes)
# 外部事件只能向上覆盖主程序本次识别到的 TMDB 当前季总集数,已有订阅按最终 total 跟随持久化。
total_episode = self.__apply_episodes_refresh(
total_episode, season=subscribe.season, mediainfo=mediainfo,
current_total_episode, season=subscribe.season, mediainfo=mediainfo,
tmdbid=subscribe.tmdbid, doubanid=subscribe.doubanid,
subscribe_id=subscribe.id, scene="refresh")
if total_episode > (subscribe.total_episode or 0):
if subscribe.best_version and subscribe.type == MediaType.TV.value:
progress_update = self.__prepare_best_version_total_expansion_fields(
subscribe=subscribe,
total_episode=total_episode,
)
else:
old_total_episode = subscribe.total_episode or 0
progress_update = {
"total_episode": total_episode,
"lack_episode": max(
(subscribe.lack_episode or 0) + (total_episode - old_total_episode),
0,
),
}
old_total_episode = subscribe.total_episode or 0
if total_episode and total_episode < old_total_episode:
total_episode = self.__resolve_total_episode_decrease(
subscribe=subscribe,
candidate_total=total_episode,
meta=meta,
mediainfo=mediainfo,
mediakey=subscribe.tmdbid or subscribe.doubanid,
)
if total_episode and total_episode != old_total_episode:
progress_update = self.__prepare_total_episode_change_fields(
subscribe=subscribe,
total_episode=total_episode,
old_total_episode=old_total_episode,
)
else:
total_episode = subscribe.total_episode
progress_update = {"lack_episode": subscribe.lack_episode}
@@ -3591,7 +3663,12 @@ class SubscribeChain(ChainBase):
- exist_flag (bool): 布尔值表示媒体是否已经完全下载或已存在
- no_exists (dict): 缺失的媒体信息包含缺失的集数或其他相关信息
"""
self.__refresh_total_episode_before_completion(subscribe=subscribe, mediainfo=mediainfo)
self.__refresh_total_episode_before_completion(
subscribe=subscribe,
mediainfo=mediainfo,
meta=meta,
mediakey=mediakey,
)
exist_flag, no_exists = self.resolve_subscribe_missing(
subscribe=subscribe,
@@ -3695,6 +3772,66 @@ class SubscribeChain(ChainBase):
return bool(downloaded), no_exists
return False, no_exists
def __resolve_total_episode_decrease(
self,
subscribe: Subscribe,
candidate_total: int,
meta: MetaBase,
mediainfo: MediaInfo,
mediakey: Optional[Union[str, int]] = None,
) -> int:
"""以旧目标范围内已确认存在的最高集号限制总集数回落。"""
old_total = subscribe.total_episode or 0
if candidate_total >= old_total or not old_total:
return candidate_total
if subscribe.type != MediaType.TV.value or self.__is_full_best_version_enabled(subscribe):
return candidate_total
target_key = mediakey or subscribe.tmdbid or subscribe.doubanid
target_season = subscribe.season
target_start = subscribe.start_episode or 1
snapshot = copy.copy(subscribe)
snapshot.total_episode = old_total
try:
satisfied, no_exists = self.resolve_subscribe_missing(
subscribe=snapshot,
meta=meta,
mediainfo=mediainfo,
mediakey=target_key,
best_version_accept_downloaded=bool(subscribe.best_version),
)
except Exception as err:
logger.warning(f"订阅 {subscribe.name} 已存在分集事实查询失败,按元数据总集数继续:{err}")
return candidate_total
if satisfied:
return old_total
if not isinstance(no_exists, dict):
return candidate_total
seasons = no_exists.get(target_key)
if not isinstance(seasons, dict):
return candidate_total
missing_info = seasons.get(target_season)
if not missing_info:
return candidate_total
try:
scope_matches = missing_info.season == target_season \
and missing_info.start_episode == target_start \
and missing_info.total_episode == old_total
episodes = missing_info.episodes
except AttributeError:
return candidate_total
if not scope_matches:
return candidate_total
if not isinstance(episodes, list) or not episodes:
return candidate_total
if any(isinstance(episode, bool) or not isinstance(episode, int)
or episode < target_start or episode > old_total for episode in episodes):
return candidate_total
confirmed = set(range(target_start, old_total + 1)).difference(episodes)
return max(candidate_total, max(confirmed) if confirmed else 0)
@staticmethod
def __resolve_effective_total_episode(subscribe: Subscribe, mediainfo: MediaInfo) -> int:
"""
@@ -3724,11 +3861,11 @@ class SubscribeChain(ChainBase):
subscribe_id: Optional[int] = None,
scene: Optional[str] = None) -> int:
"""
发送订阅总集数推算事件允许外部据自身策略覆盖按 TMDB 季集数算出的总集数
发送订阅总集数推算事件允许外部把主程序本次识别到的 TMDB 当前季总集数向上覆盖
用途插件在"待定集数"等场景经事件注入 total_episode
无监听者或外部未覆盖时返回入参原值保证零行为变更
:param current_total: 主程序 TMDB 季集数算出的默认总集数
:param current_total: 主程序本次识别到的 TMDB 当前季总集数
:param season: 季号
:return: 最终采用的总集数
"""
@@ -3739,6 +3876,7 @@ class SubscribeChain(ChainBase):
if event and event.event_data:
result: SubscribeEpisodesRefreshEventData = event.event_data
if result.updated and result.total_episode:
result.total_episode = max(current_total or 0, result.total_episode)
return result.total_episode
return current_total
@@ -3759,10 +3897,17 @@ class SubscribeChain(ChainBase):
if event and event.event_data:
result: SubscribeEpisodesRefreshEventData = event.event_data
if result.updated and result.total_episode:
result.total_episode = max(current_total or 0, result.total_episode)
return result.total_episode
return current_total
def __refresh_total_episode_before_completion(self, subscribe: Subscribe, mediainfo: MediaInfo):
def __refresh_total_episode_before_completion(
self,
subscribe: Subscribe,
mediainfo: MediaInfo,
meta: Optional[MetaBase] = None,
mediakey: Optional[Union[str, int]] = None,
) -> None:
"""
在完成判断前按最新识别结果兜底修正订阅总集数防止旧总集数导致误完成
"""
@@ -3773,30 +3918,30 @@ class SubscribeChain(ChainBase):
if subscribe.season is None:
return
new_total_episode = len((mediainfo.seasons or {}).get(subscribe.season) or [])
# 允许外部覆盖按 TMDB 算出的总集数(如待定集数),后续“只增不减”仍作用于覆盖后的结果,避免误减导致提前完成
current_total_episode = len((mediainfo.seasons or {}).get(subscribe.season) or [])
# 外部事件只能向上覆盖主程序本次识别到的 TMDB 当前季总集数,已有订阅回落由主程序跟随本次识别结果持久化
new_total_episode = self.__apply_episodes_refresh(
new_total_episode, season=subscribe.season, mediainfo=mediainfo,
current_total_episode, season=subscribe.season, mediainfo=mediainfo,
tmdbid=subscribe.tmdbid, doubanid=subscribe.doubanid,
subscribe_id=subscribe.id, scene="precheck")
old_total_episode = subscribe.total_episode or 0
if not new_total_episode or new_total_episode <= old_total_episode:
if meta is not None and new_total_episode and new_total_episode < old_total_episode:
new_total_episode = self.__resolve_total_episode_decrease(
subscribe=subscribe,
candidate_total=new_total_episode,
meta=meta,
mediainfo=mediainfo,
mediakey=mediakey,
)
if not new_total_episode or new_total_episode == old_total_episode:
return
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
if subscribe.best_version and subscribe.type == MediaType.TV.value:
update_data = self.__prepare_best_version_total_expansion_fields(
subscribe=subscribe,
total_episode=new_total_episode,
)
else:
update_data = {
"total_episode": new_total_episode,
"lack_episode": max(
(subscribe.lack_episode or 0) + (new_total_episode - old_total_episode),
0,
),
}
update_data = self.__prepare_total_episode_change_fields(
subscribe=subscribe,
total_episode=new_total_episode,
old_total_episode=old_total_episode,
)
update_data["last_update"] = now
SubscribeOper().update(subscribe.id, update_data)
for key, value in update_data.items():
-4
View File
@@ -38,8 +38,6 @@ class SystemChain(ChainBase):
"""
重启系统
"""
from app.core.config import global_vars
if channel and userid:
self.post_message(Notification(
channel=channel,
@@ -54,8 +52,6 @@ class SystemChain(ChainBase):
}, self._restart_file)
# 主动备份一次插件
self.backup_plugins()
# 设置停止标志,通知所有模块准备停止
global_vars.stop_system()
# 重启
SystemHelper.restart()
+233
View File
@@ -1,3 +1,4 @@
import copy
import re
import traceback
from typing import Callable, Dict, List, Union, Optional
@@ -92,6 +93,238 @@ class TorrentsChain(ChainBase):
return torrents_cache
def get_subscribe_cache_candidates(
self,
subscribe,
stype: Optional[str] = None,
allow_title_match: bool = False,
) -> List[Context]:
"""
按订阅身份读取 RSS/spider 缓存候选返回不会回写缓存的 Context 副本
主程序只提供缓存读取与轻量候选筛选不在这里判断站点证据能否扩展
订阅目标或放行完成标题兜底候选会显式标记为低置信来源
"""
results: List[Context] = []
for contexts in (self.get_torrents(stype=stype) or {}).values():
for context in contexts or []:
if not context:
continue
copied = copy.deepcopy(context)
if self._context_matches_subscribe(copied, subscribe):
results.append(copied)
continue
if allow_title_match and self._context_title_matches_subscribe(copied, subscribe):
self._mark_title_match_candidate(copied, subscribe)
results.append(copied)
return results
@classmethod
def _context_matches_subscribe(cls, context: Context, subscribe) -> bool:
"""
严格身份匹配候选自身识别出的媒体 ID 命中订阅且季信息不排除订阅季
"""
if not cls._context_media_type_matches(context, subscribe):
return False
if not cls._context_season_matches_subscribe(context, subscribe):
return False
subscribe_tmdbid = cls._normalize_id(getattr(subscribe, "tmdbid", None))
subscribe_doubanid = cls._normalize_id(getattr(subscribe, "doubanid", None))
context_tmdbids = cls._context_tmdb_ids(context)
context_doubanids = cls._context_douban_ids(context)
return bool(
subscribe_tmdbid and subscribe_tmdbid in context_tmdbids
or subscribe_doubanid and subscribe_doubanid in context_doubanids
)
@classmethod
def _context_title_matches_subscribe(cls, context: Context, subscribe) -> bool:
"""
标题兜底只服务诊断仅允许身份缺失候选按标题命中显式冲突 ID 不兜底
"""
if cls._context_has_media_identity(context):
return False
if not cls._context_media_type_matches(context, subscribe):
return False
if not cls._context_season_matches_subscribe(context, subscribe):
return False
subscribe_title = cls._normalize_title(getattr(subscribe, "name", None))
if not subscribe_title:
return False
meta_info = getattr(context, "meta_info", None)
torrent_info = getattr(context, "torrent_info", None)
candidate_titles = [
getattr(torrent_info, "title", None),
getattr(meta_info, "title", None),
getattr(meta_info, "name", None),
]
return any(
subscribe_title in candidate_title
for candidate_title in (cls._normalize_title(title) for title in candidate_titles)
if candidate_title
)
@staticmethod
def _mark_title_match_candidate(context: Context, subscribe) -> None:
"""
标记标题兜底候选避免下游把目标媒体回填误认为候选自身识别结果
"""
context.match_source = "title"
context.candidate_recognized = False
context.media_info_is_target = True
context.media_info = MediaInfo(
type=getattr(subscribe, "type", None),
title=getattr(subscribe, "name", None),
tmdb_id=getattr(subscribe, "tmdbid", None),
douban_id=getattr(subscribe, "doubanid", None),
season=getattr(subscribe, "season", None),
)
@classmethod
def _context_media_type_matches(cls, context: Context, subscribe) -> bool:
"""
类型已知且冲突时拒绝缺失类型不作为缓存候选过滤条件
"""
subscribe_type = cls._normalize_media_type(getattr(subscribe, "type", None))
media_info = getattr(context, "media_info", None)
meta_info = getattr(context, "meta_info", None)
context_types = {
cls._normalize_media_type(value)
for value in (
getattr(media_info, "type", None),
getattr(meta_info, "type", None),
)
}
context_types.discard(None)
return not subscribe_type or not context_types or all(
context_type == subscribe_type for context_type in context_types
)
@classmethod
def _context_season_matches_subscribe(cls, context: Context, subscribe) -> bool:
"""
资源季信息只要明确排除订阅季就拒绝跨季覆盖目标季留给插件诊断
"""
target_season = cls._normalize_int(getattr(subscribe, "season", None))
if target_season is None:
return True
meta_info = getattr(context, "meta_info", None)
explicit_meta_seasons = cls._context_meta_seasons(meta_info)
if explicit_meta_seasons:
return target_season in explicit_meta_seasons
media_info = getattr(context, "media_info", None)
media_season = cls._normalize_int(getattr(media_info, "season", None))
return media_season is None or target_season == media_season
@classmethod
def _context_meta_seasons(cls, meta_info) -> set[int]:
"""
提取标题解析出的显式季范围多季包以该范围为准
"""
meta_fields = vars(meta_info) if meta_info else {}
if "season_list" in meta_fields:
season_list = {
season
for season in (
cls._normalize_int(item)
for item in (meta_fields.get("season_list") or [])
)
if season is not None
}
if season_list:
return season_list
begin_season = cls._normalize_int(getattr(meta_info, "begin_season", None))
end_season = cls._normalize_int(getattr(meta_info, "end_season", None))
if begin_season is not None and end_season is not None:
start, end = sorted((begin_season, end_season))
return set(range(start, end + 1))
if begin_season is not None:
return {begin_season}
if end_season is not None:
return {end_season}
return set()
@staticmethod
def _context_has_media_identity(context: Context) -> bool:
"""
判断候选是否已经带有明确媒体 ID
"""
return bool(TorrentsChain._context_tmdb_ids(context) or TorrentsChain._context_douban_ids(context))
@staticmethod
def _context_tmdb_ids(context: Context) -> set[str]:
"""
提取候选已有 TMDB ID兼容 media_info 与标题显式标签
"""
media_info = getattr(context, "media_info", None)
meta_info = getattr(context, "meta_info", None)
return {
value for value in (
TorrentsChain._normalize_id(getattr(media_info, "tmdb_id", None)),
TorrentsChain._normalize_id(getattr(meta_info, "tmdbid", None)),
) if value
}
@staticmethod
def _context_douban_ids(context: Context) -> set[str]:
"""
提取候选已有豆瓣 ID兼容 media_info 与标题显式标签
"""
media_info = getattr(context, "media_info", None)
meta_info = getattr(context, "meta_info", None)
return {
value for value in (
TorrentsChain._normalize_id(getattr(media_info, "douban_id", None)),
TorrentsChain._normalize_id(getattr(meta_info, "doubanid", None)),
) if value
}
@staticmethod
def _normalize_id(value) -> Optional[str]:
"""
统一比较媒体 ID避免 int/string 形态差异影响缓存候选筛选
"""
if value is None:
return None
value = str(value).strip()
return value or None
@staticmethod
def _normalize_int(value) -> Optional[int]:
"""
将季号等动态字段转为 int无法解析时视为缺失
"""
if value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
@staticmethod
def _normalize_media_type(value) -> Optional[str]:
"""
统一 MediaType 枚举与字符串形态
"""
if isinstance(value, MediaType):
value = value.value
if value == MediaType.UNKNOWN.value:
return None
return value
@staticmethod
def _normalize_title(value) -> str:
"""
归一标题用于低置信标题兜底匹配
"""
return (StringUtils.clear_upper(value or "") or "").strip()
def clear_torrents(self):
"""
清理种子缓存数据
+97 -27
View File
@@ -1240,14 +1240,44 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
history_exists: bool = True,
):
"""
当同一种子的任务都已结束时回写下载器已整理标签
当同一种子的任务都已结束且种子已完成下载回写下载器已整理标签
"""
if (
history_exists
and download_hash
and self.jobview.is_torrent_done(download_hash)
not history_exists
or not download_hash
or not self.jobview.is_torrent_done(download_hash)
):
self.transfer_completed(hashs=download_hash, downloader=downloader)
return
# 作业视图只包含已登记的整理任务;多集种子部分文件先下载完成时,
# 剩余文件尚未产生任务,此时打已整理标签会使下载器轮询永久跳过
# 剩余文件(#6009),因此必须确认种子已整体下载完成。
if not self.__is_torrent_download_completed(download_hash, downloader):
logger.debug(
f"种子 {download_hash} 尚未下载完成或状态未知,暂不设置已整理标签"
)
return
if not self.jobview.is_torrent_done(download_hash):
logger.debug(
f"种子 {download_hash} 存在新登记的整理任务,暂不设置已整理标签"
)
return
self.transfer_completed(hashs=download_hash, downloader=downloader)
def __is_torrent_download_completed(
self, download_hash: str, downloader: Optional[str]
) -> bool:
"""
检查种子在下载器中是否已完成下载查询不到或查询失败时视为未完成
留待下载器定时轮询兜底避免误打已整理标签
"""
try:
torrents = self.list_torrents(hashs=download_hash, downloader=downloader)
if not torrents:
return False
return all((torrent.progress or 0) >= 100 for torrent in torrents)
except Exception as e:
logger.error(f"检查种子 {download_hash} 下载进度失败:{e}")
return False
def __send_metadata_scrape_event(
self, task: TransferTask, transferinfo: TransferInfo
@@ -2530,6 +2560,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
manual: Optional[bool] = False,
preview: Optional[bool] = False,
sync_extra_files: Optional[bool] = False,
cleanup_dest_fileitem: Optional[FileItem] = None,
continue_callback: Callable = None,
) -> Tuple[bool, Union[str, dict]]:
"""
@@ -2554,6 +2585,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
:param manual: 是否手动整理
:param preview: 是否仅预览
:param sync_extra_files: 是否在整理主视频文件时同步整理同媒体附加文件
:param cleanup_dest_fileitem: 确认存在待整理任务后需要清理的旧目标文件
:param continue_callback: 继续处理回调
返回成功标识错误信息
"""
@@ -2563,9 +2595,8 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
if preview:
# 预览模式始终同步执行,避免进入异步队列
background = False
manual_single_file = bool(manual and fileitem and fileitem.type == "file")
# 自定义格式
has_episode_format_template = bool(epformat and epformat.format)
formaterHandler = (
FormatParser(
eformat=epformat.format,
@@ -2583,6 +2614,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
)
# 汇总错误信息
err_msgs: List[str] = []
matched_episode_format_template = False
def _build_file_meta(
source_path: Path,
@@ -2641,29 +2673,20 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
return current_meta
def _filter(item: FileItem, is_bluray_dir: bool) -> bool:
def _is_allowed_transfer_item(item: FileItem, is_bluray_dir: bool) -> bool:
"""
过滤文件项
判断候选文件项是否允许进入整理规划
:return: True 表示保留False 表示排除
"""
nonlocal matched_episode_format_template
if continue_callback and not continue_callback():
raise OperationInterrupted()
is_extra_file = self.__is_subtitle_file(item) or self.__is_audio_file(item)
# 手动单文件整理时,前端可能把同目录文件拆成多个根文件提交;
# 此时应优先信任用户显式选择的根文件,并允许附加文件进入后续同媒体匹配流程,
# 避免仅因模板未覆盖字幕/音轨后缀而被提前过滤。
should_bypass_epformat_match = (
(manual_single_file and item.path == fileitem.path)
or (sync_extra_files and is_extra_file)
)
# 有集自定义格式,过滤文件
if (
formaterHandler
and not should_bypass_epformat_match
and not formaterHandler.match(item.name)
):
return False
# 存在集数定位模板时,模板匹配结果作为手动整理的硬过滤条件。
if has_episode_format_template and formaterHandler:
if not formaterHandler.match(item.name):
return False
matched_episode_format_template = True
# 过滤后缀和大小(蓝光目录、附加文件不过滤)
if (
not is_bluray_dir
@@ -2690,6 +2713,32 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
return False
return True
def _keep_candidate_item(item: FileItem, is_bluray_dir: bool) -> bool:
"""
收集候选文件时仅检查中断状态不套用整理业务过滤
"""
if continue_callback and not continue_callback():
raise OperationInterrupted()
return True
def _collect_candidate_file_items() -> List[Tuple[FileItem, bool]]:
"""
收集来源下的候选文件项不在此阶段套用整理业务过滤
"""
return self.__get_trans_fileitems(fileitem, predicate=_keep_candidate_item)
def _filter_allowed_file_items(
candidates: List[Tuple[FileItem, bool]]
) -> List[Tuple[FileItem, bool]]:
"""
将候选文件项筛选为本轮允许整理的文件项
"""
return [
(candidate_item, candidate_bluray_dir)
for candidate_item, candidate_bluray_dir in candidates
if _is_allowed_transfer_item(candidate_item, candidate_bluray_dir)
]
def _build_main_meta(
main_fileitem: FileItem,
main_bluray_dir: bool,
@@ -2771,7 +2820,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
continue
if not (self.__is_subtitle_file(item) or self.__is_audio_file(item)):
continue
if not _filter(item, False):
if not _is_allowed_transfer_item(item, False):
continue
extra_items.append((item, False))
return main_fileitems, extra_items
@@ -2918,19 +2967,36 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
return planned_items, inherited_map
candidate_file_items: List[Tuple[FileItem, bool]] = []
try:
# 获取经过筛选后的待整理文件项列表
file_items = self.__get_trans_fileitems(fileitem, predicate=_filter)
candidate_file_items = _collect_candidate_file_items()
file_items = _filter_allowed_file_items(candidate_file_items)
except OperationInterrupted:
return False, f"{fileitem.name} 已取消"
finally:
candidate_file_items.clear()
if not file_items:
if has_episode_format_template and not matched_episode_format_template:
logger.info(f"{fileitem.path} 未匹配到集数定位模板,跳过整理")
if preview:
return True, {
"summary": {"total": 0, "success": 0, "failed": 0},
"items": [],
"message": "",
}
return True, ""
logger.warn(f"{fileitem.path} 没有找到可整理的媒体文件")
return False, f"{fileitem.name} 没有找到可整理的媒体文件"
file_items, inherited_meta_map = _plan_file_items(file_items)
planned_file_count = len(file_items)
if cleanup_dest_fileitem and planned_file_count and not preview:
state = StorageChain().delete_media_file(cleanup_dest_fileitem)
if not state:
return False, f"{cleanup_dest_fileitem.path} 删除失败"
if preview:
logger.info(f"正在预览 {planned_file_count} 个文件的整理路径...")
else:
@@ -3394,6 +3460,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
download_hash: Optional[str] = None,
preview: Optional[bool] = False,
sync_extra_files: Optional[bool] = True,
cleanup_dest_fileitem: Optional[FileItem] = None,
) -> Tuple[bool, Union[str, dict]]:
"""
手动整理支持复杂条件带进度显示
@@ -3417,6 +3484,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
:param download_hash: 下载任务哈希
:param preview: 是否仅预览
:param sync_extra_files: 是否同步整理同媒体附加文件
:param cleanup_dest_fileitem: 确认存在待整理任务后需要清理的旧目标文件
"""
logger.info(f"手动整理:{fileitem.path} ...")
if tmdbid or doubanid:
@@ -3457,6 +3525,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
download_hash=download_hash,
preview=preview,
sync_extra_files=sync_extra_files,
cleanup_dest_fileitem=cleanup_dest_fileitem,
)
if not state:
return False, errmsg
@@ -3483,6 +3552,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
download_hash=download_hash,
preview=preview,
sync_extra_files=sync_extra_files,
cleanup_dest_fileitem=cleanup_dest_fileitem,
)
return state, errmsg
+22
View File
@@ -1214,8 +1214,19 @@ def cached(region: Optional[str] = None, maxsize: Optional[int] = 1024, ttl: Opt
"""
await cache_backend.clear(region=cache_region)
async def cache_exists(*args, **kwargs) -> bool:
"""
判断当前参数对应的有效缓存是否存在
"""
cache_key = __get_cache_key(args, kwargs)
cached_value = await cache_backend.get(cache_key, region=cache_region)
return should_cache(cached_value) and await async_is_valid_cache_value(
cache_key, cached_value, cache_region
)
async_wrapper.cache_region = cache_region
async_wrapper.cache_clear = cache_clear
async_wrapper.cache_exists = cache_exists
return async_wrapper
else:
# 同步函数使用同步缓存后端
@@ -1246,8 +1257,19 @@ def cached(region: Optional[str] = None, maxsize: Optional[int] = 1024, ttl: Opt
"""
cache_backend.clear(region=cache_region)
def cache_exists(*args, **kwargs) -> bool:
"""
判断当前参数对应的有效缓存是否存在
"""
cache_key = __get_cache_key(args, kwargs)
cached_value = cache_backend.get(cache_key, region=cache_region)
return should_cache(cached_value) and is_valid_cache_value(
cache_key, cached_value, cache_region
)
wrapper.cache_region = cache_region
wrapper.cache_clear = cache_clear
wrapper.cache_exists = cache_exists
return wrapper
return decorator
+2 -6
View File
@@ -377,6 +377,8 @@ class ConfigModel(BaseModel):
COOKIECLOUD_KEY: Optional[str] = None
# CookieCloud端对端加密密码
COOKIECLOUD_PASSWORD: Optional[str] = None
# CookieCloud本地上传接口的X-CookieCloud-Auth期望值,留空表示不校验
COOKIECLOUD_AUTH_HEADER: Optional[str] = None
# CookieCloud同步间隔(分钟)
COOKIECLOUD_INTERVAL: Optional[int] = 60 * 24
# CookieCloud同步黑名单,多个域名,分割
@@ -1209,12 +1211,6 @@ class GlobalVar(object):
"""
self.STOP_EVENT.set()
def resume_system(self):
"""
恢复系统运行标记
"""
self.STOP_EVENT.clear()
@property
def is_system_stopped(self):
"""
+4
View File
@@ -12,6 +12,10 @@ from app.utils.string import StringUtils
@dataclass
class TorrentInfo:
"""
种子搜索结果信息
"""
# 站点ID
site: int = None
# 站点名称
+36
View File
@@ -439,11 +439,19 @@ class EventManager(metaclass=Singleton):
if not handlers:
logger.debug(f"No handlers found for broadcast event: {event}")
return
target_plugin_id = None
if event.event_type == EventType.MessageAction and isinstance(event.event_data, dict):
target_plugin_id = event.event_data.get("__mp_target_plugin_id")
# 为每个处理器提供独立的事件实例,防止某个处理器对 event_data 的修改影响其他处理器
for handler_id, handler in handlers.items():
if target_plugin_id and not self.__should_dispatch_to_target_plugin(
handler, handler_id, str(target_plugin_id)
):
continue
# 仅浅拷贝顶层字典,避免不必要的深拷贝开销;这样可以隔离键级别的替换/赋值
if isinstance(event.event_data, dict):
event_data_copy = event.event_data.copy()
event_data_copy.pop("__mp_target_plugin_id", None)
else:
event_data_copy = event.event_data
isolated_event = Event(event_type=event.event_type,
@@ -459,6 +467,34 @@ class EventManager(metaclass=Singleton):
# 对于同步函数,在线程池中运行
self.__executor.submit(self.__safe_invoke_handler, handler, isolated_event)
@classmethod
def __should_dispatch_to_target_plugin(
cls,
handler: Callable,
handler_identifier: str,
target_plugin_id: str,
) -> bool:
"""
限定插件输入事件只投递给目标插件避免自由文本被其他插件观察到
"""
class_name, method_name = cls.__parse_handler_names(handler)
if class_name != target_plugin_id:
return False
identifier_parts = (handler_identifier or "").split(".")
if len(identifier_parts) < 2:
logger.debug(
"Target plugin dispatch skipped because handler identifier is invalid: "
f"target={target_plugin_id}, handler={handler_identifier}"
)
return False
if identifier_parts[-2:] != [class_name, method_name]:
logger.debug(
"Target plugin dispatch skipped because handler identifier does not match handler: "
f"target={target_plugin_id}, handler={handler_identifier}, parsed={class_name}.{method_name}"
)
return False
return True
def __safe_invoke_handler(self, handler: Callable, event: Event):
"""
调用处理器处理链式或广播事件
+9 -2
View File
@@ -17,7 +17,7 @@ class CustomizationMatcher(metaclass=Singleton):
self._customization_re_cache = {}
@staticmethod
def _normalize_customization(customization):
def normalize_customization(customization):
"""
规范化自定义占位符配置兼容历史字符串与列表两种保存格式
"""
@@ -27,6 +27,13 @@ class CustomizationMatcher(metaclass=Singleton):
return []
return list(filter(None, customization))
@staticmethod
def _normalize_customization(customization):
"""
兼容旧调用统一转到公开的自定义占位符规范化入口
"""
return CustomizationMatcher.normalize_customization(customization)
def match(self, title=None):
"""
:param title: 资源标题或文件名
@@ -35,7 +42,7 @@ class CustomizationMatcher(metaclass=Singleton):
if not title:
return ""
# 自定义占位符需要跟随系统配置实时生效,避免单例缓存导致保存后仍沿用旧规则。
customization = self._normalize_customization(
customization = self.normalize_customization(
self.systemconfig.get(SystemConfigKey.Customization)
)
if not customization:
+37
View File
@@ -24,6 +24,13 @@ SUBTITLE_EPISODE_ALL_RE = re.compile(
r"([0-9一二三四五六七八九十百零]+)\s*集\s*全|[全共]\s*([0-9一二三四五六七八九十百零]+)\s*[集话話期幕]",
re.IGNORECASE,
)
# 结尾分支显式区分有无右方括号,避免可选括号回溯后绕过数字后缀边界
SUBTITLE_EPISODE_RANGE_FIN_RE = re.compile(
r"(?<!\d)\[?\s*(\d{1,4})\s*-\s*(\d{1,4})\s*"
r"(?:(?:Fin|End)(?![a-z0-9])|完结(?![\u4e00-\u9fff]))"
r"(?:\s*\](?!\d)|(?!\s*(?:\]\d|\d))\s*)",
re.IGNORECASE,
)
VIDEO_BIT_RE = re.compile(
r"(?<![A-Za-z0-9])(?P<bit>8|10|12|16)[\s._-]*bits?(?![A-Za-z0-9])",
re.IGNORECASE,
@@ -292,6 +299,36 @@ class MetaBase(object):
self.type = MediaType.TV
self._subtitle_flag = True
return
# 01-26Fin 等数字范围+完结标记
self.__init_episode_range_fin(title_text)
else:
# 副标题无中文季集标记时,仍识别 01-26Fin 等数字范围+完结标记
self.__init_episode_range_fin(title_text)
def __init_episode_range_fin(self, title_text: str):
"""
识别 01-26Fin / [01-38 END] "数字范围+完结标记"格式的集数信息
"""
episode_range_str = SUBTITLE_EPISODE_RANGE_FIN_RE.search(title_text)
if not episode_range_str:
return
try:
begin_episode = int(episode_range_str.group(1))
end_episode = int(episode_range_str.group(2))
except Exception as err:
logger.debug(f'识别集失败:{str(err)} - {traceback.format_exc()}')
return
if begin_episode < 1 or begin_episode > end_episode or end_episode >= 10000:
return
# 两个数字都落在常见年份区间时视为年份范围而非集数(如 2019-2020完结)
if begin_episode >= 1900 and end_episode <= 2155:
return
if self.begin_episode is None:
self.begin_episode = begin_episode
self.end_episode = end_episode
self.total_episode = end_episode
self.type = MediaType.TV
self._subtitle_flag = True
@property
def season(self) -> str:
+1 -1
View File
@@ -56,7 +56,7 @@ class MetaVideo(MetaBase):
_name_se_words = ['', '', '', '', '', '', '']
_name_movie_words = ['剧场版', '劇場版', '电影版', '電影版']
_name_nostring_re = r"^PTS|^JADE|^AOD|^CHC|^[A-Z]{1,4}TV[\-0-9UVHDK]*" \
r"|\d{1,2}th|\d{1,2}bit|IMAX|^3D|\s+3D|XXX|\s+DC$" \
r"|\d{1,2}th|\d{1,2}bit|IMAX|^3D|\s+3D|\s+DC$" \
r"|[第\s共]+[0-9一二三四五六七八九十\-\s]+季" \
r"|[第\s共]+[0-9一二三四五六七八九十百零\-\s]+[集话話]" \
r"|连载|日剧|美剧|电视剧|动画片|动漫|欧美|西德|日韩|超高清|高清|无水印|下载|蓝光|翡翠台|梦幻天堂·龙网|★?\d*月?新番" \
+13 -9
View File
@@ -89,6 +89,18 @@ class ReleaseGroupsMatcher(metaclass=Singleton):
self.systemconfig = SystemConfigOper()
self.__groups_re_cache = {}
def get_release_groups(self) -> str:
"""
返回内置与用户自定义制作组组成的匹配规则
"""
custom_release_groups = self.systemconfig.get(SystemConfigKey.CustomReleaseGroups)
if isinstance(custom_release_groups, list):
custom_release_groups = list(filter(None, custom_release_groups))
if custom_release_groups:
custom_release_groups_str = '|'.join(custom_release_groups)
return f"{self.__release_groups}|{custom_release_groups_str}"
return self.__release_groups
def __get_groups_re(self, groups: str):
"""
发布组规则通常很长按规则文本缓存编译结果避免每个标题都重复编译
@@ -108,15 +120,7 @@ class ReleaseGroupsMatcher(metaclass=Singleton):
if not title:
return ""
if not groups:
# 自定义组
custom_release_groups = self.systemconfig.get(SystemConfigKey.CustomReleaseGroups)
if isinstance(custom_release_groups, list):
custom_release_groups = list(filter(None, custom_release_groups))
if custom_release_groups:
custom_release_groups_str = '|'.join(custom_release_groups)
groups = f"{self.__release_groups}|{custom_release_groups_str}"
else:
groups = self.__release_groups
groups = self.get_release_groups()
title = f"{title} "
groups_re = self.__get_groups_re(groups)
unique_groups = []
+6
View File
@@ -297,6 +297,12 @@ class StreamingPlatforms(metaclass=Singleton):
if alias:
self._lookup_cache[alias.upper()] = canonical_name
def get_lookup_cache(self) -> dict:
"""
返回流媒体平台查询表副本供批量解析配置复用
"""
return dict(self._lookup_cache)
def get_streaming_platform_name(self, platform_code: str) -> Optional[str]:
"""
根据流媒体平台简称或全称获取标准名称
+31 -10
View File
@@ -1,3 +1,4 @@
import hashlib
from pathlib import Path
from functools import lru_cache
from typing import Tuple, List, Optional
@@ -40,6 +41,7 @@ _EMBY_TMDB_RE_LIST = (
re.compile(r'\{tmdbid[=\-](\d+)\}'),
re.compile(r'\{tmdb[=\-](\d+)\}'),
)
_RUST_PARSE_OPTIONS_CACHE_KEY = "_cache_key"
def _empty_metainfo() -> dict:
@@ -72,6 +74,28 @@ def _apply_range_total(metainfo: dict, begin_key: str, end_key: str, total_key:
metainfo[total_key] = 1
def _rust_parse_options_cache_key(options: dict) -> str:
"""
生成 Rust Meta 配置缓存键避免扩展层每次重新展开大配置
"""
digest = hashlib.blake2b(digest_size=16)
def update(value) -> None:
digest.update(repr(value).encode("utf-8"))
digest.update(b"\0")
streaming_platforms = options.get("streaming_platforms") or {}
update(tuple(options.get("custom_words") or []))
update(tuple(options.get("media_exts") or []))
update(options.get("release_groups") or "")
update(tuple(options.get("customization") or []))
update(tuple(sorted(
(str(key), str(value))
for key, value in streaming_platforms.items()
)))
return digest.hexdigest()
def _find_metainfo_python(title: str) -> Tuple[str, dict]:
"""
使用 Python 解析标题中的显式媒体标签作为 Rust 入口不可用时的兜底
@@ -209,24 +233,20 @@ def _rust_default_parse_options() -> dict:
from app.schemas.types import SystemConfigKey
systemconfig = SystemConfigOper()
custom_release_groups = systemconfig.get(SystemConfigKey.CustomReleaseGroups)
if isinstance(custom_release_groups, list):
custom_release_groups = list(filter(None, custom_release_groups))
release_matcher = ReleaseGroupsMatcher()
release_groups = release_matcher._ReleaseGroupsMatcher__release_groups
if custom_release_groups:
release_groups = f"{release_groups}|{'|'.join(custom_release_groups)}"
release_groups = ReleaseGroupsMatcher().get_release_groups()
customization = CustomizationMatcher._normalize_customization(
customization = CustomizationMatcher.normalize_customization(
systemconfig.get(SystemConfigKey.Customization)
)
return {
options = {
"custom_words": systemconfig.get(SystemConfigKey.CustomIdentifiers) or [],
"media_exts": settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIOEXT,
"release_groups": release_groups,
"customization": customization,
"streaming_platforms": StreamingPlatforms()._lookup_cache,
"streaming_platforms": StreamingPlatforms().get_lookup_cache(),
}
options[_RUST_PARSE_OPTIONS_CACHE_KEY] = _rust_parse_options_cache_key(options)
return options
@lru_cache(maxsize=256)
@@ -236,6 +256,7 @@ def _rust_custom_parse_options(custom_words: Tuple[str, ...]) -> dict:
"""
options = dict(_rust_default_parse_options())
options["custom_words"] = list(custom_words)
options[_RUST_PARSE_OPTIONS_CACHE_KEY] = _rust_parse_options_cache_key(options)
return options
+5 -6
View File
@@ -58,12 +58,11 @@ class ModuleManager(metaclass=Singleton):
"""
logger.info("正在停止所有模块...")
for module_id, module in self._running_modules.items():
if hasattr(module, "stop"):
try:
module.stop()
logger.debug(f"Moudle Stoped{module_id}")
except Exception as err:
logger.error(f"Stop Moudle Error{module_id}{str(err)} - {traceback.format_exc()}", exc_info=True)
try:
module.stop()
logger.debug(f"Moudle Stoped{module_id}")
except Exception as err:
logger.error(f"Stop Moudle Error{module_id}{str(err)} - {traceback.format_exc()}", exc_info=True)
logger.info("所有模块停止完成")
def reload(self):
+85 -5
View File
@@ -363,6 +363,20 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
logger.warn(f"检测到本地插件 {candidate.get('id')} 依赖文件变化,请重新安装本地插件以安装依赖")
continue
federated_change = self._get_federated_plugin_change(event_path)
if federated_change:
pid, candidate, remote_entry_ready = federated_change
# 运行目录由构建方直接写入;外部本地仓库只在入口完整时同步运行副本。
if candidate and remote_entry_ready:
if candidate.get("compatible") is False:
logger.info(
f"检测到本地插件 {pid} 联邦构建产物变化,"
f"但跳过同步:{candidate.get('skip_reason')}"
)
elif pid not in local_plugins_to_sync:
local_plugins_to_sync[pid] = (candidate, event_path, False)
continue
# 跳过非 .py 文件
if not event_path.name.endswith(".py"):
continue
@@ -385,13 +399,14 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
f"文件:{event_path},但跳过同步:{local_candidate.get('skip_reason')}"
)
continue
local_plugins_to_sync[local_candidate.get("id")] = (local_candidate, event_path)
local_plugins_to_sync[local_candidate.get("id")] = (local_candidate, event_path, True)
for pid, (candidate, event_path) in local_plugins_to_sync.items():
for pid, (candidate, event_path, should_reload) in local_plugins_to_sync.items():
package_version = candidate.get("package_version")
source_root = f"plugins.{package_version}" if package_version else "plugins"
logger.info(f"检测到本地插件 {pid} 文件变化,来源:{source_root},文件:{event_path}")
if self._sync_local_plugin_if_installed(pid, candidate):
change_name = "Python 文件" if should_reload else "联邦构建产物"
logger.info(f"检测到本地插件 {pid} {change_name}变化,来源:{source_root},文件:{event_path}")
if self._sync_local_plugin_if_installed(pid, candidate) and should_reload:
plugins_to_reload.add(pid)
# 触发重载
@@ -403,6 +418,71 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
except Exception as e:
logger.error(f"插件 {pid} 热重载失败: {e}", exc_info=True)
def _get_federated_plugin_change(
self,
event_path: Path,
) -> Optional[Tuple[str, Optional[dict], bool]]:
"""
识别运行态 Vue 插件声明目录内的构建产物变化
:return: 插件 ID本地仓库候选和联邦入口是否完整非联邦目录变化返回 None
"""
try:
event_path = event_path.resolve()
candidate = self._get_local_plugin_candidate_from_path(event_path)
if candidate:
pid = candidate.get("id")
plugin_dir = Path(candidate.get("path")).resolve()
else:
runtime_root = (settings.ROOT_PATH / "app" / "plugins").resolve()
if not event_path.is_relative_to(runtime_root):
return None
relative_parts = event_path.relative_to(runtime_root).parts
if not relative_parts:
return None
plugin_dir = runtime_root / relative_parts[0]
pid = next(
(
plugin_id
for plugin_id in self._running_plugins
if plugin_id.lower() == relative_parts[0].lower()
),
None,
)
if not pid:
return None
plugin = self._running_plugins.get(pid)
if not plugin:
return None
render_mode, dist_path = plugin.get_render_mode()
if render_mode != "vue" or not isinstance(dist_path, str) or not dist_path:
return None
relative_dist_path = Path(dist_path)
if relative_dist_path.is_absolute() or ".." in relative_dist_path.parts or "\\" in dist_path:
return None
plugin_dir = plugin_dir.resolve()
dist_dir = (plugin_dir / relative_dist_path).resolve()
if (
dist_dir == plugin_dir
or not dist_dir.is_relative_to(plugin_dir)
or not event_path.is_relative_to(dist_dir)
):
return None
remote_entry = dist_dir / "remoteEntry.js"
remote_entry_ready = (
remote_entry.is_file()
and remote_entry.resolve().is_relative_to(plugin_dir)
)
return pid, candidate, remote_entry_ready
except Exception as e:
logger.error(f"识别插件联邦构建产物变化时出错: {e}")
return None
@staticmethod
def _get_plugin_id_from_path(event_path: Path) -> Optional[str]:
"""
@@ -517,7 +597,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
source_dir,
dest_dir,
dirs_exist_ok=True,
ignore=shutil.ignore_patterns("__pycache__", "*.pyc", ".DS_Store")
ignore=shutil.ignore_patterns("__pycache__", "*.pyc", ".DS_Store", "node_modules")
)
PluginManager()._recent_local_sync[pid] = time.time()
logger.info(f"已同步本地插件 {pid}{source_dir} -> {dest_dir}")
+15 -2
View File
@@ -169,6 +169,15 @@ def set_or_refresh_resource_token_cookie(
# 根据剩余时长提前刷新令牌
if remaining_time < timedelta(seconds=(settings.RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS / 3)):
raise jwt.ExpiredSignatureError
expected_claims = {
"sub": str(payload.sub),
"username": payload.username,
"super_user": payload.super_user,
"level": payload.level,
"purpose": "resource",
}
if any(decoded_token.get(claim) != value for claim, value in expected_claims.items()):
raise jwt.InvalidTokenError("资源令牌身份或权限上下文不匹配")
except jwt.PyJWTError:
logger.debug(f"Token error occurred. refreshing token")
except Exception as e:
@@ -316,7 +325,9 @@ def __verify_key(key: str | None, expected_key: str, key_type: str) -> str:
def verify_apitoken(token: Annotated[str | None, Security(__get_api_token)]) -> str:
"""
使用 API Token 进行身份认证
使用 API Token 进行受信第三方集成认证
校验值来自 settings.API_TOKEN通过后只确认集成凭据有效不生成 per-user 权限上下文
:param token: API Token URL 查询参数中获取 token=xxx
:return: 返回校验通过的 API Token
"""
@@ -325,7 +336,9 @@ def verify_apitoken(token: Annotated[str | None, Security(__get_api_token)]) ->
def verify_apikey(apikey: Annotated[str | None, Security(__get_api_key)]) -> str:
"""
使用 API Key 进行身份认证
使用 API Key 形式进行受信第三方集成认证
请求字段名兼容 API Key实际校验值来自 settings.API_TOKEN不生成 per-user 权限上下文
:param apikey: API Key URL 查询参数中获取 apikey=xxx或请求头中获取 X-API-KEY=xxx
:return: 返回校验通过的 API Key
"""
+53 -1
View File
@@ -1,12 +1,60 @@
import asyncio
from typing import Any, Generator, List, Optional, Self, Tuple, AsyncGenerator, Union
from sqlalchemy import NullPool, QueuePool, and_, create_engine, inspect, text, select, delete, Column, Integer, \
from sqlalchemy import NullPool, QueuePool, and_, create_engine, event, inspect, text, select, delete, Column, Integer, \
Sequence, Identity
from sqlalchemy.engine import Engine as SQLAlchemyEngine, ExceptionContext
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import Session, as_declarative, declared_attr, scoped_session, sessionmaker
from app.core.config import settings
from app.log import logger
def _database_error_metadata(error: BaseException) -> Optional[dict[str, Any]]:
"""提取 SQLite 与 PostgreSQL 驱动提供的稳定错误分类字段。"""
metadata = {"error_type": type(error).__name__}
# DBAPI 驱动字段并不共享统一类型,动态读取可同时兼容 sqlite3、psycopg2 与 asyncpg。
sqlite_errorcode = getattr(error, "sqlite_errorcode", None)
sqlite_errorname = getattr(error, "sqlite_errorname", None)
if sqlite_errorcode is not None or sqlite_errorname:
if sqlite_errorcode is not None:
metadata["error_code"] = sqlite_errorcode
if sqlite_errorname:
metadata["error_name"] = sqlite_errorname
return metadata
sqlstate = getattr(error, "sqlstate", None) or getattr(error, "pgcode", None)
if not sqlstate:
sqlstate = getattr(getattr(error, "diag", None), "sqlstate", None)
if sqlstate:
metadata["sqlstate"] = sqlstate
return metadata
return None
def _log_database_error(exception_context: ExceptionContext) -> None:
"""记录非敏感驱动错误码,并保持 SQLAlchemy 原有异常传播。"""
metadata = _database_error_metadata(exception_context.original_exception)
if not metadata:
return
dialect = exception_context.dialect
fields = {
"database": dialect.name,
"driver": dialect.driver,
**metadata,
}
logger.error(
"数据库驱动异常:" + ", ".join(f"{key}={value}" for key, value in fields.items())
)
def _register_database_error_logging(engine: SQLAlchemyEngine) -> None:
"""为主程序 Engine 注册统一的底层驱动错误诊断。"""
event.listen(engine, "handle_error", _log_database_error)
def get_id_column():
@@ -71,6 +119,7 @@ def _get_sqlite_engine(is_async: bool = False):
# 创建数据库引擎
engine = create_engine(**_db_kwargs)
_register_database_error_logging(engine)
# 设置WAL模式
_journal_mode = "WAL" if settings.DB_WAL_ENABLE else "DELETE"
@@ -91,6 +140,7 @@ def _get_sqlite_engine(is_async: bool = False):
}
# 创建异步数据库引擎
async_engine = create_async_engine(**_db_kwargs)
_register_database_error_logging(async_engine.sync_engine)
# 设置WAL模式
_journal_mode = "WAL" if settings.DB_WAL_ENABLE else "DELETE"
@@ -146,6 +196,7 @@ def _get_postgresql_engine(is_async: bool = False):
# 创建数据库引擎
engine = create_engine(**_db_kwargs)
_register_database_error_logging(engine)
print(f"PostgreSQL database connected to {settings.DB_POSTGRESQL_TARGET}/{settings.DB_POSTGRESQL_DATABASE}")
return engine
@@ -163,6 +214,7 @@ def _get_postgresql_engine(is_async: bool = False):
}
# 创建异步数据库引擎
async_engine = create_async_engine(**_db_kwargs)
_register_database_error_logging(async_engine.sync_engine)
print(f"Async PostgreSQL database connected to {settings.DB_POSTGRESQL_TARGET}/{settings.DB_POSTGRESQL_DATABASE}")
return async_engine
+61
View File
@@ -0,0 +1,61 @@
from typing import Dict, List, Optional
from app.db import DbOper
from app.db.models.downloadfailure import DownloadFailure
class DownloadFailureOper(DbOper):
"""
下载失败冷却记录管理
"""
def get_active_by_fingerprints(
self,
fingerprints: List[str],
now_time: str,
) -> Dict[str, DownloadFailure]:
"""
批量按指纹查询仍在冷却期的失败记录
"""
failures = DownloadFailure.get_active_by_fingerprints(
self._db,
fingerprints=fingerprints,
now_time=now_time,
)
return {
failure.fingerprint: failure
for failure in failures
if failure and failure.fingerprint
}
def record_failure(
self,
fingerprint: str,
now_time: str,
next_retry_at: str,
**kwargs: object,
) -> DownloadFailure:
"""
新增或更新资源失败记录
"""
return DownloadFailure.record_failure(
self._db,
fingerprint=fingerprint,
now_time=now_time,
next_retry_at=next_retry_at,
**kwargs,
)
def delete_expired(
self,
before_time: str,
limit: Optional[int] = 500,
) -> int:
"""
删除已过期较久的失败记录
"""
return DownloadFailure.delete_expired(
self._db,
before_time=before_time,
limit=limit,
)
+1
View File
@@ -1,4 +1,5 @@
from .agentchat import AgentChat
from .downloadfailure import DownloadFailure
from .downloadhistory import DownloadHistory, DownloadFiles
from .mediaserver import MediaServerItem
from .message import Message
+137
View File
@@ -0,0 +1,137 @@
from typing import List, Optional
from sqlalchemy import Column, Float, Index, Integer, String
from sqlalchemy.orm import Session
from app.db import Base, db_query, db_update, get_id_column
class DownloadFailure(Base):
"""
下载失败冷却记录
"""
id = get_id_column()
# 资源失败指纹
fingerprint = Column(String, nullable=False)
# 类型 电影/电视剧
type = Column(String)
# 标题
title = Column(String)
# 年份
year = Column(String)
# TMDBID
tmdbid = Column(Integer)
# 豆瓣ID
doubanid = Column(String)
# Sxx
seasons = Column(String)
# Exx
episodes = Column(String)
# 站点ID
site = Column(Integer)
# 站点名称
site_name = Column(String)
# 种子资源键
torrent_id = Column(String)
# 种子名称
torrent_name = Column(String)
# 种子大小
torrent_size = Column(Float)
# 下载器
downloader = Column(String)
# 下载来源
source = Column(String)
# 失败原因
error_message = Column(String)
# 重试次数
retry_count = Column(Integer, default=0)
# 首次失败时间
first_failed_at = Column(String)
# 最近失败时间
last_failed_at = Column(String)
# 下次允许重试时间
next_retry_at = Column(String)
__table_args__ = (
Index("ux_downloadfailure_fingerprint", "fingerprint", unique=True),
Index("ix_downloadfailure_next_retry_at", "next_retry_at"),
Index("ix_downloadfailure_media_site", "type", "tmdbid", "doubanid", "site"),
)
@classmethod
@db_query
def get_active_by_fingerprints(
cls,
db: Session,
fingerprints: List[str],
now_time: str,
) -> List["DownloadFailure"]:
"""
按指纹批量查询仍处于冷却期的失败记录
"""
normalized = list(dict.fromkeys([fingerprint for fingerprint in fingerprints if fingerprint]))
if not normalized:
return []
return (
db.query(cls)
.filter(cls.fingerprint.in_(normalized), cls.next_retry_at > now_time)
.all()
)
@classmethod
@db_update
def record_failure(
cls,
db: Session,
fingerprint: str,
now_time: str,
next_retry_at: str,
**kwargs: object,
) -> "DownloadFailure":
"""
新增或更新资源失败记录
"""
failure = db.query(cls).filter(cls.fingerprint == fingerprint).first()
payload = {
**kwargs,
"fingerprint": fingerprint,
"last_failed_at": now_time,
"next_retry_at": next_retry_at,
}
if failure:
payload["retry_count"] = (failure.retry_count or 0) + 1
for key, value in payload.items():
setattr(failure, key, value)
return failure
failure = cls(
**payload,
retry_count=1,
first_failed_at=now_time,
)
db.add(failure)
return failure
@classmethod
@db_update
def delete_expired(
cls,
db: Session,
before_time: str,
limit: Optional[int] = 500,
) -> int:
"""
分批清理已过期较久的失败冷却记录
"""
ids = [
row[0]
for row in db.query(cls.id)
.filter(cls.next_retry_at < before_time)
.order_by(cls.id.asc())
.limit(limit)
.all()
]
if not ids:
return 0
return db.query(cls).filter(cls.id.in_(ids)).delete(synchronize_session=False)
+89
View File
@@ -130,6 +130,46 @@ class Subscribe(Base):
return None
return result.scalars().first()
@classmethod
@db_query
def exists_by_username(cls, db: Session, username: str, tmdbid: Optional[int] = None,
doubanid: Optional[str] = None, season: Optional[int] = None):
"""
按订阅 owner 查询同一媒体的订阅行
"""
if not username:
return None
if tmdbid:
query = db.query(cls).filter(cls.username == username, cls.tmdbid == tmdbid)
if season is not None:
query = query.filter(cls.season == season)
return query.first()
elif doubanid:
return db.query(cls).filter(cls.username == username, cls.doubanid == doubanid).first()
return None
@classmethod
@async_db_query
async def async_exists_by_username(cls, db: AsyncSession, username: str, tmdbid: Optional[int] = None,
doubanid: Optional[str] = None, season: Optional[int] = None):
"""
异步按订阅 owner 查询同一媒体的订阅行
"""
if not username:
return None
if tmdbid:
query = select(cls).filter(cls.username == username, cls.tmdbid == tmdbid)
if season is not None:
query = query.filter(cls.season == season)
result = await db.execute(query)
elif doubanid:
result = await db.execute(
select(cls).filter(cls.username == username, cls.doubanid == doubanid)
)
else:
return None
return result.scalars().first()
@classmethod
@db_query
def get_by_state(cls, db: Session, state: str):
@@ -174,6 +214,22 @@ class Subscribe(Base):
)
return result.scalars().first()
@classmethod
@async_db_query
async def async_list_by_title(cls, db: AsyncSession, title: str, season: Optional[int] = None):
"""
异步按标题查询候选订阅列表
"""
if season is not None:
result = await db.execute(
select(cls).filter(cls.name == title, cls.season == season)
)
else:
result = await db.execute(
select(cls).filter(cls.name == title)
)
return result.scalars().all()
@classmethod
@db_query
def get_by_tmdbid(cls, db: Session, tmdbid: int, season: Optional[int] = None):
@@ -209,6 +265,17 @@ class Subscribe(Base):
)
return result.scalars().first()
@classmethod
@async_db_query
async def async_list_by_doubanid(cls, db: AsyncSession, doubanid: str):
"""
异步按豆瓣 ID 查询候选订阅列表
"""
result = await db.execute(
select(cls).filter(cls.doubanid == doubanid)
)
return result.scalars().all()
@classmethod
@db_query
def get_by_bangumiid(cls, db: Session, bangumiid: int):
@@ -222,6 +289,17 @@ class Subscribe(Base):
)
return result.scalars().first()
@classmethod
@async_db_query
async def async_list_by_bangumiid(cls, db: AsyncSession, bangumiid: int):
"""
异步按 Bangumi ID 查询候选订阅列表
"""
result = await db.execute(
select(cls).filter(cls.bangumiid == bangumiid)
)
return result.scalars().all()
@classmethod
@db_query
def get_by_mediaid(cls, db: Session, mediaid: str):
@@ -235,6 +313,17 @@ class Subscribe(Base):
)
return result.scalars().first()
@classmethod
@async_db_query
async def async_list_by_mediaid(cls, db: AsyncSession, mediaid: str):
"""
异步按自定义媒体 ID 查询候选订阅列表
"""
result = await db.execute(
select(cls).filter(cls.mediaid == mediaid)
)
return result.scalars().all()
@classmethod
@db_query
def get_by(cls, db: Session, type: str, season: Optional[str] = None,
+25
View File
@@ -102,6 +102,31 @@ class SubscribeHistory(Base):
)
return result.scalars().all()
@classmethod
@async_db_query
async def async_list_by_type_and_username(
cls,
db: AsyncSession,
mtype: str,
username: str,
page: Optional[int] = 1,
count: Optional[int] = 30
):
"""
按订阅 owner 查询指定类型的历史分页
"""
if not username:
return []
result = await db.execute(
select(cls).filter(
cls.type == mtype,
cls.username == username
).order_by(
cls.date.desc()
).offset((page - 1) * count).limit(count)
)
return result.scalars().all()
@classmethod
@db_query
def exists(cls, db: Session, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
+48 -16
View File
@@ -29,10 +29,19 @@ class SubscribeOper(DbOper):
"""
新增订阅
"""
subscribe = Subscribe.exists(self._db,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=kwargs.get('season'))
owner_scope = bool(kwargs.pop("owner_scope", False))
username = kwargs.get("username") if owner_scope else None
if username:
subscribe = Subscribe.exists_by_username(self._db,
username=username,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=kwargs.get('season'))
else:
subscribe = Subscribe.exists(self._db,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=kwargs.get('season'))
kwargs.update({
"name": mediainfo.title,
"year": mediainfo.year,
@@ -55,10 +64,17 @@ class SubscribeOper(DbOper):
subscribe = Subscribe(**kwargs)
subscribe.create(self._db)
# 查询订阅
subscribe = Subscribe.exists(self._db,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=kwargs.get('season'))
if username:
subscribe = Subscribe.exists_by_username(self._db,
username=username,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=kwargs.get('season'))
else:
subscribe = Subscribe.exists(self._db,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=kwargs.get('season'))
return subscribe.id, "新增订阅成功"
else:
return subscribe.id, "订阅已存在"
@@ -67,10 +83,19 @@ class SubscribeOper(DbOper):
"""
异步新增订阅
"""
subscribe = await Subscribe.async_exists(self._db,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=kwargs.get('season'))
owner_scope = bool(kwargs.pop("owner_scope", False))
username = kwargs.get("username") if owner_scope else None
if username:
subscribe = await Subscribe.async_exists_by_username(self._db,
username=username,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=kwargs.get('season'))
else:
subscribe = await Subscribe.async_exists(self._db,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=kwargs.get('season'))
kwargs.update({
"name": mediainfo.title,
"year": mediainfo.year,
@@ -93,10 +118,17 @@ class SubscribeOper(DbOper):
subscribe = Subscribe(**kwargs)
await subscribe.async_create(self._db)
# 查询订阅
subscribe = await Subscribe.async_exists(self._db,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=kwargs.get('season'))
if username:
subscribe = await Subscribe.async_exists_by_username(self._db,
username=username,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=kwargs.get('season'))
else:
subscribe = await Subscribe.async_exists(self._db,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=kwargs.get('season'))
return subscribe.id, "新增订阅成功"
else:
return subscribe.id, "订阅已存在"
+30
View File
@@ -58,6 +58,36 @@ async def get_current_active_user_async(
return current_user
def _ensure_manage_user(current_user: User) -> User:
"""
校验用户具备全局管理权限
"""
permissions = current_user.permissions or {}
if not current_user.is_superuser and not bool(permissions.get("manage")):
raise HTTPException(
status_code=400, detail="用户权限不足"
)
return current_user
def get_current_active_manage_user(
current_user: User = Depends(get_current_active_user),
) -> User:
"""
获取当前拥有管理权限的激活用户
"""
return _ensure_manage_user(current_user)
async def get_current_active_manage_user_async(
current_user: User = Depends(get_current_active_user_async),
) -> User:
"""
异步获取当前拥有管理权限的激活用户
"""
return _ensure_manage_user(current_user)
def get_current_active_superuser(
current_user: User = Depends(get_current_user),
) -> User:
+44 -1
View File
@@ -1,10 +1,35 @@
from fastapi import FastAPI
from typing import Awaitable, Callable
from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.core.config import settings
from app.helper.locale import LocaleHelper
from app.startup.lifecycle import lifespan
async def localized_http_exception_handler(
_request: Request,
exc: HTTPException,
) -> JSONResponse:
"""
HTTPException 响应补充多语言错误详情
:param _request: 当前 HTTP 请求
:param exc: FastAPI HTTP 异常
:return: detail_i18n JSON 错误响应
"""
content = {"detail": exc.detail}
if isinstance(exc.detail, str):
content["detail_i18n"] = LocaleHelper.translate_text(exc.detail)
return JSONResponse(
status_code=exc.status_code,
content=content,
headers=exc.headers,
)
def create_app() -> FastAPI:
"""
创建并配置 FastAPI 应用实例
@@ -15,6 +40,8 @@ def create_app() -> FastAPI:
lifespan=lifespan
)
_app.add_exception_handler(HTTPException, localized_http_exception_handler)
# 配置 CORS 中间件
_app.add_middleware(
CORSMiddleware, # noqa
@@ -24,6 +51,22 @@ def create_app() -> FastAPI:
allow_headers=["*"],
)
@_app.middleware("http")
async def locale_context_middleware(
request: Request,
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
"""
为每个请求设置后端多语言上下文
"""
token = LocaleHelper.set_current_locale(
LocaleHelper.get_locale_from_request(request)
)
try:
return await call_next(request)
finally:
LocaleHelper.reset_current_locale(token)
return _app
+45 -4
View File
@@ -1,4 +1,5 @@
import base64
import time
from typing import Tuple, Optional
from lxml import etree
@@ -57,6 +58,36 @@ class CookieHelper:
]
}
@staticmethod
def get_page_content(page: BrowserPage, retries: int = 3, interval: float = 1.0) -> Optional[str]:
"""
获取页面源码页面跳转中如登录前后的重定向会导致 page.content() 抛出
"Unable to retrieve content because the page is navigating" 异常等待加载完成后重试
:param page: 浏览器页面
:param retries: 最大重试次数
:param interval: 重试间隔
:return: 页面源码
"""
for i in range(retries):
# 等待加载失败不代表源码不可读取,最后一次等待失败时仍尝试直接获取源码
try:
page.wait_for_load_state("domcontentloaded", timeout=10 * 1000)
except Exception as e:
if i < retries - 1:
logger.warning(f"等待页面加载完成失败:{str(e)}{interval}秒后重试 ({i + 1}/{retries - 1})")
time.sleep(interval)
continue
logger.warning(f"等待页面加载完成失败:{str(e)},尝试直接获取源码")
try:
return page.content()
except Exception as e:
if i >= retries - 1:
logger.error(f"获取页面源码失败:{str(e)}")
return None
logger.warning(f"获取页面源码失败:{str(e)}{interval}秒后重试 ({i + 1}/{retries - 1})")
time.sleep(interval)
return None
@staticmethod
def parse_cookies(cookies: list) -> str:
"""
@@ -93,11 +124,13 @@ class CookieHelper:
:return: Cookie和UA
"""
# 登录页面代码
html_text = page.content()
html_text = self.get_page_content(page)
if not html_text:
return None, None, "获取源码失败"
# 查找用户名输入框
html = etree.HTML(html_text)
if html is None:
return None, None, "解析网页源码失败"
try:
username_xpath = None
for xpath in self._SITE_LOGIN_XPATH.get("username"):
@@ -189,7 +222,12 @@ class CookieHelper:
if "verify" in page.url:
if not otp_code:
return None, None, "需要二次验证码"
html = etree.HTML(page.content())
html_text = self.get_page_content(page)
if not html_text:
return None, None, "获取网页源码失败"
html = etree.HTML(html_text)
if html is None:
return None, None, "解析网页源码失败"
for xpath in self._SITE_LOGIN_XPATH.get("twostep"):
if html.xpath(xpath):
try:
@@ -205,14 +243,17 @@ class CookieHelper:
break
# 登录后的源码
html_text = page.content()
html_text = self.get_page_content(page)
if not html_text:
return None, None, "获取网页源码失败"
if SiteUtils.is_logged_in(html_text):
return self.parse_cookies(page.context.cookies()), \
page.evaluate("() => window.navigator.userAgent"), ""
else:
# 读取错误信息
# 从登录后的页面读取错误信息
html = etree.HTML(html_text)
if html is None:
return None, None, "登录失败"
error_xpath = None
for xpath in self._SITE_LOGIN_XPATH.get("error"):
if html.xpath(xpath):
+119 -2
View File
@@ -1,15 +1,17 @@
import re
from pathlib import Path
from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath
from typing import List, Optional, Tuple
from app import schemas
from app.core.context import MediaInfo
from app.db.systemconfig_oper import SystemConfigOper
from app.log import logger
from app.schemas.types import SystemConfigKey
from app.schemas.types import StorageSchema, SystemConfigKey
from app.utils.system import SystemUtils
JINJA2_VAR_PATTERN = re.compile(r"\{\{.*?}}", re.DOTALL)
WINDOWS_DRIVE_PATTERN = re.compile(r"^[A-Za-z]:[\\/]")
WINDOWS_DRIVE_PREFIX_PATTERN = re.compile(r"^[A-Za-z]:")
class DirectoryHelper:
@@ -169,3 +171,118 @@ class DirectoryHelper:
# 媒体根路径
media_root = rename_path.parents[rename_format_level - 1]
return media_root
def _split_file_uri(value: str) -> Tuple[str, str]:
"""
拆分 FileURI 字符串保留原始路径用于安全校验
"""
for storage in StorageSchema:
protocol = f"{storage.value}:"
if value.startswith(protocol):
return storage.value, value[len(protocol):]
return "local", value
def _normalize_safe_posix_path(raw_path: str) -> PurePosixPath:
"""
规范化保存目录路径并拒绝跨目录或跨平台歧义写法
"""
if not raw_path:
raise ValueError("保存路径不能为空")
if "\\" in raw_path:
raise ValueError("保存路径不能包含反斜杠")
if raw_path.startswith("//"):
raise ValueError("保存路径不能使用 UNC 路径")
if WINDOWS_DRIVE_PATTERN.match(raw_path):
raise ValueError("保存路径不能使用 Windows 盘符路径")
if not raw_path.startswith("/"):
raise ValueError("保存路径必须是绝对路径")
path = PurePosixPath(raw_path)
parts = [part for part in path.parts if part != "/"]
if ".." in parts:
raise ValueError("保存路径不能包含上级目录")
if parts and re.fullmatch(r"[A-Za-z]:", parts[0]):
raise ValueError("保存路径不能使用 Windows 盘符路径")
return path
def _normalize_safe_windows_path(raw_path: str) -> PureWindowsPath:
"""
规范化已配置的 Windows 盘符路径UNC 与反斜杠写法不参与下载目录 allowlist
"""
if not raw_path:
raise ValueError("保存路径不能为空")
if "\\" in raw_path:
raise ValueError("保存路径不能包含反斜杠")
if raw_path.startswith("//"):
raise ValueError("保存路径不能使用 UNC 路径")
if not WINDOWS_DRIVE_PATTERN.match(raw_path):
raise ValueError("保存路径必须是 Windows 绝对路径")
path = PureWindowsPath(raw_path)
if ".." in path.parts:
raise ValueError("保存路径不能包含上级目录")
return path
def _normalize_download_path(raw_path: str, storage: str) -> Tuple[str, PurePath]:
"""
按存储类型解析下载路径本地允许 POSIX 或已配置的 Windows drive远端保持 FileURI POSIX 语义
"""
path_value = str(raw_path or "").strip()
if storage == "local" and WINDOWS_DRIVE_PREFIX_PATTERN.match(path_value):
return "windows", _normalize_safe_windows_path(path_value)
return "posix", _normalize_safe_posix_path(path_value)
def _download_path_uri(storage: str, path: PurePath) -> str:
"""
生成可传给下载器的 save_path保持 /download/paths 暴露的本地和远端路径风格
"""
path_value = path.as_posix()
if storage == "local":
return path_value
return schemas.FileURI(storage=storage, path=path_value).uri
def _normalize_download_root(dir_info: schemas.TransferDirectoryConf) -> Optional[Tuple[str, str, PurePath]]:
"""
读取下载目录配置中的根路径无效配置不参与用户 save_path allowlist
"""
if not dir_info.download_path:
return None
storage = dir_info.storage or "local"
try:
path_style, root_path = _normalize_download_path(dir_info.download_path, storage)
return storage, path_style, root_path
except ValueError as err:
logger.warn(f"跳过无效下载目录配置:{str(err)}")
return None
def validate_download_save_path(save_path: str) -> str:
"""
校验用户传入的下载保存目录/download/paths 暴露的下载目录配置是允许写入的公共合同
:param save_path: 下载保存目录支持本地 /path 或远端 <storage>:/path
:return: 可直接传给下载接口的规范化保存目录
"""
value = str(save_path or "").strip()
storage, raw_path = _split_file_uri(value)
target_style, target_path = _normalize_download_path(raw_path, storage)
for dir_info in DirectoryHelper().get_download_dirs():
root = _normalize_download_root(dir_info)
if not root:
continue
root_storage, root_style, root_path = root
if storage != root_storage:
continue
if target_style != root_style:
continue
if target_path == root_path or target_path.is_relative_to(root_path):
return _download_path_uri(storage, target_path)
raise ValueError("保存路径不在允许的下载目录范围内")
+44 -11
View File
@@ -18,8 +18,10 @@ from app.log import logger
from app.utils.mixins import ConfigReloadMixin
from app.utils.singleton import Singleton
# 定义一个全局线程池执行器
_executor = concurrent.futures.ThreadPoolExecutor()
# DoH 关闭时需要释放线程池;保持惰性创建可避免未启用 DoH 时占用进程级资源
_executor: Optional[concurrent.futures.ThreadPoolExecutor] = None
_executor_lock = Lock()
_doh_enabled = False
# 定义默认的DoH配置
_doh_timeout = 5
@@ -29,11 +31,21 @@ _doh_lock = Lock()
_orig_getaddrinfo = socket.getaddrinfo
def _get_executor_locked() -> concurrent.futures.ThreadPoolExecutor:
"""在持有执行器锁时按需获取 DoH 查询线程池"""
global _executor
if _executor is None:
_executor = concurrent.futures.ThreadPoolExecutor()
return _executor
def enable_doh(enable: bool) -> None:
"""
socket.getaddrinfo 进行补丁
"""
global _doh_enabled
def _patched_getaddrinfo(host: str, *args, **kwargs):
"""
socket.getaddrinfo的补丁版本
@@ -47,9 +59,15 @@ def enable_doh(enable: bool) -> None:
logger.info(f"已解析 [{host}] 为 [{ip}] (缓存)")
return _orig_getaddrinfo(ip, *args, **kwargs)
# 使用DoH解析主机
futures = []
for resolver in settings.DOH_RESOLVERS.split(","):
futures.append(_executor.submit(_doh_query, resolver, host))
with _executor_lock:
if not _doh_enabled:
return _orig_getaddrinfo(host, *args, **kwargs)
executor = _get_executor_locked()
# 一次解析的任务必须在同一临界区提交完,避免关闭过程中部分任务落入新线程池
futures = [
executor.submit(_doh_query, resolver, host)
for resolver in settings.DOH_RESOLVERS.split(",")
]
for future in concurrent.futures.as_completed(futures):
ip = future.result()
if ip is not None:
@@ -60,11 +78,9 @@ def enable_doh(enable: bool) -> None:
break
return _orig_getaddrinfo(host, *args, **kwargs)
if enable:
# 替换 socket.getaddrinfo 方法
socket.getaddrinfo = _patched_getaddrinfo
else:
socket.getaddrinfo = _orig_getaddrinfo
with _executor_lock:
_doh_enabled = enable
socket.getaddrinfo = _patched_getaddrinfo if enable else _orig_getaddrinfo
class DohHelper(ConfigReloadMixin, metaclass=Singleton):
@@ -77,14 +93,31 @@ class DohHelper(ConfigReloadMixin, metaclass=Singleton):
enable_doh(settings.DOH_ENABLE)
def on_config_changed(self) -> None:
if not settings.DOH_ENABLE:
self.shutdown()
return
with _doh_lock:
# DOH配置有变动的情况下,清空缓存
_doh_cache.clear()
enable_doh(settings.DOH_ENABLE)
enable_doh(True)
def get_reload_name(self) -> str:
return 'DoH'
def shutdown(self) -> None:
"""恢复系统 DNS 并释放 DoH 查询线程池"""
global _executor, _doh_enabled
with _executor_lock:
_doh_enabled = False
socket.getaddrinfo = _orig_getaddrinfo
executor = _executor
_executor = None
with _doh_lock:
_doh_cache.clear()
if executor:
executor.shutdown(wait=True)
def _doh_query(resolver: str, host: str) -> Optional[str]:
"""
使用给定的DoH解析器查询给定主机的IP地址
+341
View File
@@ -398,6 +398,347 @@ class MediaInteractionManager:
media_interaction_manager = MediaInteractionManager()
@dataclass
class PendingPluginInputInteraction:
"""
记录插件临时接管用户下一条文本输入的会话
"""
request_id: str
user_id: str
plugin_id: str
channel: Optional[MessageChannel]
source: Optional[str]
username: Optional[str]
chat_id: Optional[str] = None
prompt_id: Optional[str] = None
payload: Optional[Any] = None
timeout_seconds: int = 120
created_at: datetime = field(default_factory=datetime.now)
# Optional reply binding for channels that can report reply_to_message_id.
prompt_message_id: Optional[str] = None
@property
def expires_at(self) -> datetime:
return self.created_at + timedelta(seconds=max(1, self.timeout_seconds))
class PluginInputInteractionManager:
"""
管理插件输入会话
会话按用户和渠道绑定同一用户在同一渠道只保留一个待输入会话
"""
EXPIRED_GRACE_SECONDS = 300
def __init__(self):
self._by_id: Dict[str, PendingPluginInputInteraction] = {}
self._by_user_channel: Dict[Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]], str] = {}
self._expired_by_user_channel: Dict[
Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]],
PendingPluginInputInteraction,
] = {}
self._lock = Lock()
@staticmethod
def _user_channel_source_key(
user_id: Union[str, int],
channel: Optional[MessageChannel],
source: Optional[str] = None,
chat_id: Optional[Union[str, int]] = None,
) -> Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]]:
return str(user_id), channel, source, str(chat_id) if chat_id not in (None, "") else None
@classmethod
def _keys_overlap(
cls,
left: Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]],
right: Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]],
) -> bool:
left_user, left_channel, left_source, left_chat_id = left
right_user, right_channel, right_source, right_chat_id = right
if left_user != right_user:
return False
if left_chat_id and right_chat_id and left_chat_id != right_chat_id:
return False
if (left_channel is None and left_source is None) or (right_channel is None and right_source is None):
return left_channel == right_channel and left_source == right_source
channel_overlap = left_channel == right_channel or left_channel is None or right_channel is None
source_overlap = left_source == right_source or left_source is None or right_source is None
return channel_overlap and source_overlap
def _cleanup_locked(self) -> None:
now = datetime.now()
expired_tombstones = [
key
for key, request in self._expired_by_user_channel.items()
if request.expires_at + timedelta(seconds=self.EXPIRED_GRACE_SECONDS) < now
]
for key in expired_tombstones:
self._expired_by_user_channel.pop(key, None)
expired = [
request_id
for request_id, request in self._by_id.items()
if request.expires_at < now
]
for request_id in expired:
request = self._by_id.pop(request_id, None)
if request:
key = self._user_channel_source_key(
request.user_id,
request.channel,
request.source,
request.chat_id,
)
self._by_user_channel.pop(key, None)
self._expired_by_user_channel[key] = request
def create_or_replace(
self,
user_id: Union[str, int],
plugin_id: str,
channel: Optional[MessageChannel],
source: Optional[str],
username: Optional[str],
chat_id: Optional[Union[str, int]] = None,
prompt_id: Optional[str] = None,
timeout_seconds: int = 120,
payload: Optional[Any] = None,
*,
prompt_message_id: Optional[Union[str, int]] = None,
) -> PendingPluginInputInteraction:
with self._lock:
self._cleanup_locked()
key = self._user_channel_source_key(user_id, channel, source, chat_id)
old_request_ids = [
request_id
for stored_key, request_id in self._by_user_channel.items()
if self._keys_overlap(stored_key, key)
]
for old_request_id in old_request_ids:
self._by_id.pop(old_request_id, None)
self._by_user_channel = {
stored_key: request_id
for stored_key, request_id in self._by_user_channel.items()
if request_id not in old_request_ids
}
self._expired_by_user_channel = {
stored_key: request
for stored_key, request in self._expired_by_user_channel.items()
if not self._keys_overlap(stored_key, key)
}
normalized_chat_id = str(chat_id) if chat_id not in (None, "") else None
normalized_prompt_message_id = (
str(prompt_message_id)
if channel == MessageChannel.Telegram and normalized_chat_id and prompt_message_id not in (None, "")
else None
)
request = PendingPluginInputInteraction(
request_id=uuid.uuid4().hex[:12],
user_id=str(user_id),
plugin_id=plugin_id,
channel=channel,
source=source,
username=username,
chat_id=normalized_chat_id,
prompt_id=prompt_id,
prompt_message_id=normalized_prompt_message_id,
timeout_seconds=timeout_seconds,
payload=payload,
)
self._by_id[request.request_id] = request
self._by_user_channel[key] = request.request_id
return request
def get_by_user(
self,
user_id: Union[str, int],
channel: Optional[MessageChannel] = None,
source: Optional[str] = None,
chat_id: Optional[Union[str, int]] = None,
) -> Optional[PendingPluginInputInteraction]:
with self._lock:
self._cleanup_locked()
request_id = self._find_request_id_locked(user_id, channel, source, chat_id)
if request_id:
return self._by_id.get(request_id)
return None
def pop_by_user(
self,
user_id: Union[str, int],
channel: Optional[MessageChannel] = None,
source: Optional[str] = None,
chat_id: Optional[Union[str, int]] = None,
) -> Optional[PendingPluginInputInteraction]:
with self._lock:
self._cleanup_locked()
key, request_id = self._find_key_and_request_id_locked(user_id, channel, source, chat_id)
if request_id:
self._by_user_channel.pop(key, None)
return self._by_id.pop(request_id, None)
expired_key, request = self._find_expired_key_and_request_locked(user_id, channel, source, chat_id)
if expired_key:
self._expired_by_user_channel.pop(expired_key, None)
return request
def consume_by_user(
self,
user_id: Union[str, int],
channel: Optional[MessageChannel] = None,
source: Optional[str] = None,
chat_id: Optional[Union[str, int]] = None,
*,
reply_to_message_id: Optional[Union[str, int]] = None,
bypass_reply_check: bool = False,
) -> Tuple[Optional[PendingPluginInputInteraction], Optional[str]]:
with self._lock:
key, request_id = self._find_key_and_request_id_locked(user_id, channel, source, chat_id)
if request_id:
request = self._by_id.get(request_id)
if not request:
self._by_user_channel.pop(key, None)
elif request.expires_at < datetime.now():
self._by_user_channel.pop(key, None)
self._by_id.pop(request_id, None)
if request.prompt_message_id:
return None, None
return request, "expired"
elif not self._reply_matches_prompt(
request,
chat_id,
reply_to_message_id,
ignore_reply_to_message_id=bypass_reply_check,
):
return None, None
else:
self._by_user_channel.pop(key, None)
self._by_id.pop(request_id, None)
return request, "active"
self._cleanup_locked()
key, request = self._find_expired_key_and_request_locked(user_id, channel, source, chat_id)
if request:
self._expired_by_user_channel.pop(key, None)
if request.prompt_message_id:
return None, None
return request, "expired"
self._cleanup_locked()
return None, None
@staticmethod
def _reply_matches_prompt(
request: PendingPluginInputInteraction,
chat_id: Optional[Union[str, int]],
reply_to_message_id: Optional[Union[str, int]],
*,
ignore_reply_to_message_id: bool = False,
) -> bool:
if not request.prompt_message_id:
return True
if not request.chat_id or chat_id in (None, ""):
return False
if str(chat_id) != str(request.chat_id):
return False
if ignore_reply_to_message_id:
return True
if reply_to_message_id in (None, ""):
return False
return str(reply_to_message_id) == str(request.prompt_message_id)
def _find_request_id_locked(
self,
user_id: Union[str, int],
channel: Optional[MessageChannel],
source: Optional[str],
chat_id: Optional[Union[str, int]] = None,
) -> Optional[str]:
_, request_id = self._find_key_and_request_id_locked(user_id, channel, source, chat_id)
return request_id
def _find_key_and_request_id_locked(
self,
user_id: Union[str, int],
channel: Optional[MessageChannel],
source: Optional[str],
chat_id: Optional[Union[str, int]] = None,
) -> Tuple[Optional[Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]]], Optional[str]]:
for key in self._candidate_keys(user_id, channel, source, chat_id):
request_id = self._by_user_channel.get(key)
if request_id:
return key, request_id
return None, None
def _find_expired_key_and_request_locked(
self,
user_id: Union[str, int],
channel: Optional[MessageChannel],
source: Optional[str],
chat_id: Optional[Union[str, int]] = None,
) -> Tuple[Optional[Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]]],
Optional[PendingPluginInputInteraction]]:
now = datetime.now()
for key in self._candidate_keys(user_id, channel, source, chat_id):
request = self._expired_by_user_channel.get(key)
if not request:
continue
if request.expires_at + timedelta(seconds=self.EXPIRED_GRACE_SECONDS) < now:
self._expired_by_user_channel.pop(key, None)
continue
return key, request
return None, None
def _candidate_keys(
self,
user_id: Union[str, int],
channel: Optional[MessageChannel],
source: Optional[str],
chat_id: Optional[Union[str, int]] = None,
) -> List[Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]]]:
chat_key = str(chat_id) if chat_id not in (None, "") else None
candidates = [
self._user_channel_source_key(user_id, channel, source, chat_key),
]
if source is not None:
candidates.append(self._user_channel_source_key(user_id, channel, None, chat_key))
if channel is not None and source is not None:
candidates.append(self._user_channel_source_key(user_id, None, source, chat_key))
if channel is None and source is None:
wildcard_key = self._user_channel_source_key(user_id, None, None, chat_key)
candidates.append(wildcard_key)
if chat_key is not None:
candidates.append(self._user_channel_source_key(user_id, channel, source, None))
if source is not None:
candidates.append(self._user_channel_source_key(user_id, channel, None, None))
if channel is not None and source is not None:
candidates.append(self._user_channel_source_key(user_id, None, source, None))
if channel is None and source is None:
candidates.append(self._user_channel_source_key(user_id, None, None, None))
return candidates
def remove(self, request_id: str) -> None:
with self._lock:
request = self._by_id.pop(request_id, None)
if request:
self._by_user_channel.pop(
self._user_channel_source_key(request.user_id, request.channel, request.source, request.chat_id),
None,
)
def clear(self) -> None:
with self._lock:
self._by_id.clear()
self._by_user_channel.clear()
self._expired_by_user_channel.clear()
plugin_input_interaction_manager = PluginInputInteractionManager()
@dataclass(frozen=True)
class AgentInteractionOption:
"""
+302
View File
@@ -0,0 +1,302 @@
import json
import re
from contextvars import ContextVar, Token
from functools import lru_cache
from pathlib import Path
from typing import Any, Optional
class LocaleHelper:
"""
后端多语言文本辅助器
该类只为需要返回给前端展示的文本生成并行多语言字段旧有中文字段仍由调用方保留
"""
DEFAULT_LOCALE = "zh-CN"
SUPPORTED_LOCALES = ("zh-CN", "zh-TW", "en-US")
HEADER_NAMES = ("x-moviepilot-locale", "x-locale")
_PATTERN_FIELD = re.compile(r"\{([A-Za-z_][A-Za-z0-9_]*)\}")
_CURRENT_LOCALE: ContextVar[str] = ContextVar("moviepilot_locale", default=DEFAULT_LOCALE)
_LOCALES_DIR = Path(__file__).resolve().parents[1] / "locales"
_LOCALE_ALIASES = {
"zh": "zh-CN",
"zh-cn": "zh-CN",
"zh-hans": "zh-CN",
"zh-hans-cn": "zh-CN",
"zh-tw": "zh-TW",
"zh-hant": "zh-TW",
"zh-hant-tw": "zh-TW",
"en": "en-US",
"en-us": "en-US",
}
@classmethod
def normalize_locale(cls, locale: Optional[str]) -> str:
"""
规范化语言标识无法识别时返回默认简体中文
:param locale: 原始语言标识 zh-CNzh_CNen-US
:return: 项目支持的语言标识
"""
return cls._match_locale(locale) or cls.DEFAULT_LOCALE
@classmethod
def get_locale_from_request(cls, request: Any) -> str:
"""
从请求参数或请求头解析前端期望语言
:param request: FastAPI Request 或带 headers 属性的兼容对象
:return: 项目支持的语言标识
"""
query_params = getattr(request, "query_params", {}) or {}
query_locale = query_params.get("locale") if hasattr(query_params, "get") else None
if query_locale:
return cls.normalize_locale(query_locale)
headers = getattr(request, "headers", {}) or {}
for header_name in cls.HEADER_NAMES:
value = headers.get(header_name)
if value:
return cls.normalize_locale(value)
accept_language = headers.get("accept-language")
if not accept_language:
return cls.DEFAULT_LOCALE
choices = []
for index, item in enumerate(accept_language.split(",")):
parts = [part.strip() for part in item.split(";") if part.strip()]
if not parts:
continue
quality = 1.0
for part in parts[1:]:
if part.startswith("q="):
try:
quality = float(part[2:])
except ValueError:
quality = 0.0
choices.append((-quality, index, parts[0]))
for _, _, candidate in sorted(choices):
locale = cls._match_locale(candidate)
if locale:
return locale
return cls.DEFAULT_LOCALE
@classmethod
def get_current_locale(cls) -> str:
"""
获取当前请求上下文中的语言标识
:return: 项目支持的语言标识
"""
return cls._CURRENT_LOCALE.get()
@classmethod
def set_current_locale(cls, locale: Optional[str]) -> Token[str]:
"""
设置当前请求上下文中的语言标识
:param locale: 原始语言标识
:return: 用于恢复上下文的令牌
"""
return cls._CURRENT_LOCALE.set(cls.normalize_locale(locale))
@classmethod
def reset_current_locale(cls, token: Token[str]) -> None:
"""
恢复当前请求上下文中的语言标识
:param token: set_current_locale 返回的上下文令牌
"""
cls._CURRENT_LOCALE.reset(token)
@classmethod
def translate(
cls,
key: str,
locale: Optional[str] = None,
default: Optional[str] = None,
**kwargs: Any,
) -> str:
"""
根据翻译键获取多语言文本
:param key: 点分隔翻译键
:param locale: 目标语言未传入或无法识别时使用默认语言
:param default: 翻译缺失时返回的默认文本
:param kwargs: 字符串格式化参数
:return: 翻译后的文本
"""
normalized_locale = cls.normalize_locale(locale) if locale else cls.get_current_locale()
template = cls._lookup(cls._load_catalog(normalized_locale), key)
if template is None and normalized_locale != cls.DEFAULT_LOCALE:
template = cls._lookup(cls._load_catalog(cls.DEFAULT_LOCALE), key)
if template is None:
template = default or key
return cls._format(template, kwargs)
@classmethod
def translate_text(cls, text: Optional[str], locale: Optional[str] = None) -> str:
"""
翻译存量接口返回的中文文本
:param text: 原始中文文本
:param locale: 目标语言未传入或无法识别时使用默认语言
:return: 翻译后的文本缺失翻译时返回原文
"""
if not text:
return ""
normalized_locale = cls.normalize_locale(locale) if locale else cls.get_current_locale()
translated = cls._lookup_message(cls._load_catalog(normalized_locale), text)
if translated is None and cls._contains_chinese(text):
translated = cls._lookup_pattern(normalized_locale, text)
if translated is None and normalized_locale != cls.DEFAULT_LOCALE:
translated = cls._lookup_message(cls._load_catalog(cls.DEFAULT_LOCALE), text)
if (
translated is None
and normalized_locale != cls.DEFAULT_LOCALE
and cls._contains_chinese(text)
):
translated = cls._lookup_pattern(cls.DEFAULT_LOCALE, text)
return translated or text
@classmethod
def _match_locale(cls, locale: Optional[str]) -> Optional[str]:
"""
将原始语言标识匹配为项目支持的语言
"""
if not locale:
return None
normalized = locale.strip().replace("_", "-").lower()
if not normalized:
return None
return cls._LOCALE_ALIASES.get(normalized)
@staticmethod
@lru_cache(maxsize=16)
def _load_catalog(locale: str) -> dict[str, Any]:
"""
加载指定语言的翻译表
"""
catalog_path = LocaleHelper._LOCALES_DIR / f"{locale}.json"
try:
with catalog_path.open("r", encoding="utf-8") as file:
return json.load(file)
except (FileNotFoundError, json.JSONDecodeError):
return {}
@staticmethod
def _lookup(catalog: dict[str, Any], key: str) -> Optional[str]:
"""
按点分隔键从结构化翻译表中查找文本
"""
current: Any = catalog
for part in key.split("."):
if not isinstance(current, dict) or part not in current:
return None
current = current[part]
return current if isinstance(current, str) else None
@staticmethod
def _lookup_message(catalog: dict[str, Any], text: str) -> Optional[str]:
"""
从精确消息表中查找存量中文文本
"""
messages = catalog.get("messages")
if not isinstance(messages, dict):
return None
translated = messages.get(text)
return translated if isinstance(translated, str) else None
@classmethod
def _lookup_pattern(cls, locale: str, text: str) -> Optional[str]:
"""
使用动态模板匹配存量中文文本
"""
for pattern, target in cls._load_pattern_matchers(locale):
matched = pattern.fullmatch(text)
if matched:
return cls._format(
target,
cls._build_pattern_values(locale, matched.groupdict()),
)
return None
@classmethod
def _build_pattern_values(cls, locale: str, values: dict[str, str]) -> dict[str, str]:
"""
为动态模板补充可选的占位值翻译
"""
pattern_values = dict(values)
catalog = cls._load_catalog(locale)
default_catalog = (
cls._load_catalog(cls.DEFAULT_LOCALE)
if locale != cls.DEFAULT_LOCALE
else catalog
)
for name, value in values.items():
translated = cls._lookup_message(catalog, value)
if translated is None and locale != cls.DEFAULT_LOCALE:
translated = cls._lookup_message(default_catalog, value)
pattern_values[f"{name}_i18n"] = translated or value
return pattern_values
@staticmethod
@lru_cache(maxsize=16)
def _load_pattern_matchers(locale: str) -> list[tuple[re.Pattern[str], str]]:
"""
加载并缓存指定语言的动态文本匹配器
"""
catalog = LocaleHelper._load_catalog(locale)
patterns = catalog.get("message_patterns")
if not isinstance(patterns, list):
return []
matchers = []
for item in patterns:
if not isinstance(item, dict):
continue
source = item.get("source")
target = item.get("target")
if not isinstance(source, str) or not isinstance(target, str):
continue
pattern = LocaleHelper._compile_pattern(source)
if pattern is None:
continue
matchers.append((pattern, target))
return matchers
@classmethod
def _compile_pattern(cls, source: str) -> Optional[re.Pattern[str]]:
"""
将带命名占位符的中文模板编译为正则
"""
field_names = cls._PATTERN_FIELD.findall(source)
if not field_names:
return None
pattern = cls._PATTERN_FIELD.sub(
lambda match: f"(?P<{match.group(1)}>.+?)",
re.escape(source).replace(r"\{", "{").replace(r"\}", "}"),
)
return re.compile(pattern)
@staticmethod
def _contains_chinese(text: str) -> bool:
"""
判断文本是否包含中文字符
"""
return any("\u4e00" <= char <= "\u9fff" for char in text)
@staticmethod
def _format(template: str, kwargs: dict[str, Any]) -> str:
"""
格式化翻译模板参数缺失时保留模板原文
"""
if not kwargs:
return template
try:
return template.format(**kwargs)
except (KeyError, AttributeError, IndexError):
return template
+9 -5
View File
@@ -605,6 +605,7 @@ class MessageQueueManager(metaclass=SingletonClass):
self.check_interval = check_interval
self._running = True
self._stop_event = threading.Event()
self.thread = threading.Thread(target=self._monitor_loop, daemon=True)
self.thread.start()
@@ -752,13 +753,15 @@ class MessageQueueManager(metaclass=SingletonClass):
logger.info(f"队列剩余消息:{self.queue.qsize()}")
except queue.Empty:
break
time.sleep(self.check_interval)
if self._stop_event.wait(self.check_interval):
break
def stop(self) -> None:
"""
停止队列管理器
"""
self._running = False
self._stop_event.set()
logger.info("正在停止消息队列...")
self.thread.join()
logger.info("消息队列已停止")
@@ -841,7 +844,8 @@ def stop_message():
"""
停止消息服务
"""
# 停止消息队列
MessageQueueManager().stop()
# 关闭消息演染器
TemplateHelper().close()
# 只关闭已启动的服务,避免清理路径反向创建后台线程和缓存
if queue_manager := MessageQueueManager.get_existing_instance():
queue_manager.stop()
if template_helper := TemplateHelper.get_existing_instance():
template_helper.close()
+140 -57
View File
@@ -5,13 +5,14 @@ import io
import json
import shutil
import site
import stat
import sys
import tempfile
import threading
import time
import traceback
import zipfile
from pathlib import Path
from pathlib import Path, PurePosixPath, PureWindowsPath
from typing import Dict, List, Optional, Tuple, Set, Callable, Awaitable
from urllib.parse import parse_qs, quote, unquote, urlsplit
@@ -757,7 +758,7 @@ class PluginHelper(metaclass=WeakSingleton):
source_dir,
dest_dir,
dirs_exist_ok=True,
ignore=shutil.ignore_patterns("__pycache__", "*.pyc", ".DS_Store")
ignore=shutil.ignore_patterns("__pycache__", "*.pyc", ".DS_Store", "node_modules")
)
return True, ""
except Exception as e:
@@ -1723,6 +1724,95 @@ class PluginHelper(metaclass=WeakSingleton):
self.refresh_persistent_plugin_backup(pid)
return True, ""
@staticmethod
def __validate_release_zip_name(name: str) -> None:
"""
校验 release zip 成员名在 POSIX Windows 语义下都只能表示相对路径
"""
if not name:
raise ValueError("非法 Release 压缩包成员:成员名为空")
if "\x00" in name:
raise ValueError(f"非法 Release 压缩包成员:{name}")
if "\\" in name:
raise ValueError(f"非法 Release 压缩包成员:{name}")
posix_path = PurePosixPath(name)
windows_path = PureWindowsPath(name)
if (
name.startswith("//")
or posix_path.is_absolute()
or windows_path.is_absolute()
or windows_path.drive
):
raise ValueError(f"非法 Release 压缩包成员:{name}")
parts = [part for part in posix_path.parts if part not in ("", ".")]
if not parts:
raise ValueError(f"非法 Release 压缩包成员:{name}")
if ".." in parts:
raise ValueError(f"非法 Release 压缩包成员:{name}")
@staticmethod
def __validate_release_zip_type(info: zipfile.ZipInfo) -> None:
"""
release zip 只接受普通文件和目录避免归档内的符号链接或设备文件影响安装边界
"""
mode = info.external_attr >> 16
file_type = stat.S_IFMT(mode)
if not file_type:
return
if stat.S_ISREG(mode) or stat.S_ISDIR(mode):
return
raise ValueError(f"非法 Release 压缩包成员:{info.filename}")
@staticmethod
def __get_release_zip_base_prefix(infos: List[zipfile.ZipInfo]) -> str:
"""
识别 release zip 的单一顶层目录用于保持插件包根目录剥离行为
"""
names = [info.filename for info in infos]
names_with_slash = [name for name in names if "/" in name]
if names_with_slash and len(names_with_slash) == len(names):
first_seg = names_with_slash[0].split("/", 1)[0]
if first_seg and all(name.startswith(first_seg + "/") for name in names):
return first_seg + "/"
return ""
@classmethod
def __iter_release_zip_targets(
cls, zf: zipfile.ZipFile, dest_base: Path
) -> List[Tuple[zipfile.ZipInfo, Path, bool]]:
"""
release zip 成员解析为安装目标路径并保证目标路径不会逃逸插件目录
"""
infos = zf.infolist()
for info in infos:
cls.__validate_release_zip_type(info)
cls.__validate_release_zip_name(info.filename)
base_prefix = cls.__get_release_zip_base_prefix(infos)
dest_root = dest_base.resolve()
targets = []
for info in infos:
raw_name = info.filename
rel_name = raw_name[len(base_prefix):] if base_prefix else raw_name
if not rel_name:
if base_prefix and raw_name == base_prefix:
continue
raise ValueError(f"非法 Release 压缩包成员:{raw_name}")
cls.__validate_release_zip_name(rel_name)
rel_parts = [part for part in PurePosixPath(rel_name).parts if part not in ("", ".")]
if not rel_parts:
raise ValueError(f"非法 Release 压缩包成员:{raw_name}")
dest_path = (dest_root / Path(*rel_parts)).resolve()
try:
dest_path.relative_to(dest_root)
except ValueError as exc:
raise ValueError(f"非法 Release 压缩包成员:{raw_name}") from exc
targets.append((info, dest_path, info.is_dir()))
return targets
def __install_from_release(self, pid: str, user_repo: str, release_tag: str) -> Tuple[bool, str]:
"""
通过 GitHub Release 资产文件安装插件
@@ -1766,29 +1856,18 @@ class PluginHelper(metaclass=WeakSingleton):
try:
with zipfile.ZipFile(io.BytesIO(res.content)) as zf:
namelist = zf.namelist()
if not namelist:
infos = zf.infolist()
if not infos:
return False, "压缩包内容为空"
# 若所有条目均在同一顶层目录下(如 pid/),则剥离这一层,避免出现双层目录
names_with_slash = [n for n in namelist if '/' in n]
base_prefix = ''
if names_with_slash and len(names_with_slash) == len(namelist):
first_seg = names_with_slash[0].split('/')[0]
if all(n.startswith(first_seg + '/') for n in namelist):
base_prefix = first_seg + '/'
dest_base = Path(settings.ROOT_PATH) / "app" / "plugins" / pid.lower()
targets = self.__iter_release_zip_targets(zf, dest_base)
wrote_any = False
for name in namelist:
rel_path = name[len(base_prefix):]
if not rel_path:
for info, dest_path, is_dir in targets:
if is_dir:
dest_path.mkdir(parents=True, exist_ok=True)
continue
if rel_path.endswith('/'):
(dest_base / rel_path.rstrip('/')).mkdir(parents=True, exist_ok=True)
continue
dest_path = dest_base / rel_path
dest_path.parent.mkdir(parents=True, exist_ok=True)
with zf.open(name, 'r') as src, open(dest_path, 'wb') as dst:
with zf.open(info, 'r') as src, open(dest_path, 'wb') as dst:
dst.write(src.read())
wrote_any = True
if not wrote_any:
@@ -2139,35 +2218,48 @@ class PluginHelper(metaclass=WeakSingleton):
normal_task_key = (loop, normalized_repo_url, False)
force_task_key = (loop, normalized_repo_url, True)
with self._release_task_lock:
force_task = self._release_tasks.get(force_task_key)
if force_task and not force_task.done():
task_key = force_task_key
task = force_task
elif is_fresh():
pending_normal_task = self._release_tasks.get(normal_task_key)
if pending_normal_task and pending_normal_task.done():
pending_normal_task = None
task_key = force_task_key
task = loop.create_task(
self._async_refresh_plugin_repo_releases(normalized_repo_url, pending_normal_task)
)
self._release_tasks[task_key] = task
task.add_done_callback(
lambda completed_task: self._remove_release_task(task_key, completed_task)
)
if is_fresh():
force_task = self._release_tasks.get(force_task_key)
if force_task and not force_task.done():
task_key = force_task_key
task = force_task
else:
pending_normal_task = self._release_tasks.get(normal_task_key)
if pending_normal_task and pending_normal_task.done():
pending_normal_task = None
task_key = force_task_key
task = loop.create_task(
self._async_refresh_plugin_repo_releases(normalized_repo_url, pending_normal_task)
)
self._release_tasks[task_key] = task
task.add_done_callback(
lambda completed_task: self._remove_release_task(task_key, completed_task)
)
else:
task_key = normal_task_key
task = self._release_tasks.get(task_key)
if task is None or task.done():
pending_normal_task = self._release_tasks.get(normal_task_key)
if pending_normal_task is None or pending_normal_task.done():
task = loop.create_task(self._async_get_plugin_repo_releases(normalized_repo_url))
self._release_tasks[task_key] = task
task.add_done_callback(
lambda completed_task: self._remove_release_task(task_key, completed_task)
)
else:
task = pending_normal_task
payload = await asyncio.shield(task)
return self.__parse_plugin_release_response(pid, payload)
async def async_has_plugin_release_cache(self, repo_url: str) -> bool:
"""
判断指定仓库的 Release 列表缓存是否已经存在
"""
if not repo_url:
return False
return await self._async_get_plugin_repo_releases.cache_exists(
self, repo_url.rstrip("/")
)
async def _async_refresh_plugin_repo_releases(
self,
repo_url: str,
@@ -2783,28 +2875,19 @@ class PluginHelper(metaclass=WeakSingleton):
try:
with zipfile.ZipFile(io.BytesIO(res.content)) as zf:
namelist = zf.namelist()
if not namelist:
infos = zf.infolist()
if not infos:
return False, "压缩包内容为空"
names_with_slash = [n for n in namelist if '/' in n]
base_prefix = ''
if names_with_slash and len(names_with_slash) == len(namelist):
first_seg = names_with_slash[0].split('/')[0]
if all(n.startswith(first_seg + '/') for n in namelist):
base_prefix = first_seg + '/'
dest_base = AsyncPath(settings.ROOT_PATH) / "app" / "plugins" / pid.lower()
dest_base = Path(settings.ROOT_PATH) / "app" / "plugins" / pid.lower()
targets = self.__iter_release_zip_targets(zf, dest_base)
wrote_any = False
for name in namelist:
rel_path = name[len(base_prefix):]
if not rel_path:
for info, dest_path, is_dir in targets:
async_dest_path = AsyncPath(dest_path)
if is_dir:
await async_dest_path.mkdir(parents=True, exist_ok=True)
continue
if rel_path.endswith('/'):
await (dest_base / rel_path.rstrip('/')).mkdir(parents=True, exist_ok=True)
continue
dest_path = dest_base / rel_path
await dest_path.parent.mkdir(parents=True, exist_ok=True)
with zf.open(name, 'r') as src:
await async_dest_path.parent.mkdir(parents=True, exist_ok=True)
with zf.open(info, 'r') as src:
data = src.read()
async with aiofiles.open(dest_path, 'wb') as dst:
await dst.write(data)
+31 -4
View File
@@ -1,7 +1,8 @@
from enum import Enum
from typing import Union, Optional
from typing import Optional, Union
from app.core.cache import TTLCache
from app.helper.locale import LocaleHelper
from app.schemas.types import ProgressKey
@@ -82,8 +83,34 @@ class ProgressHelper:
current['data'].update(data)
self._progress[self._key] = current
def get(self) -> Optional[dict]:
def get(self, locale: Optional[str] = None) -> Optional[dict]:
"""
获取当前进度
获取当前进度并按语言补充前端展示字段
:param locale: 目标语言未传入时使用当前请求上下文语言
:return: 当前进度字典
"""
return self._progress.get(self._key)
current = self._progress.get(self._key)
if not current:
return current
detail = current.copy()
text = detail.get("text")
if isinstance(text, str):
detail["text_i18n"] = LocaleHelper.translate_text(text, locale=locale)
data = detail.get("data")
if isinstance(data, dict):
localized_data = data.copy()
error = localized_data.get("error")
message = localized_data.get("message")
if isinstance(error, str):
localized_data["error_i18n"] = LocaleHelper.translate_text(
error, locale=locale
)
if isinstance(message, str):
localized_data["message_i18n"] = LocaleHelper.translate_text(
message, locale=locale
)
detail["data"] = localized_data
return detail
File diff suppressed because it is too large Load Diff
+198
View File
@@ -0,0 +1,198 @@
{
"system": {
"modules": {
"BangumiModule": {
"name": "Bangumi"
},
"DiscordModule": {
"name": "Discord"
},
"DoubanModule": {
"name": "豆瓣"
},
"EmbyModule": {
"name": "Emby"
},
"FanartModule": {
"name": "Fanart"
},
"FeishuModule": {
"name": "飞书"
},
"FileManagerModule": {
"name": "文件整理"
},
"FilterModule": {
"name": "过滤器"
},
"IndexerModule": {
"name": "站点索引"
},
"JellyfinModule": {
"name": "Jellyfin"
},
"PlexModule": {
"name": "Plex"
},
"PostgreSQLModule": {
"name": "PostgreSQL"
},
"QbittorrentModule": {
"name": "Qbittorrent"
},
"QQBotModule": {
"name": "QQ"
},
"RedisModule": {
"name": "Redis缓存"
},
"RtorrentModule": {
"name": "Rtorrent"
},
"SlackModule": {
"name": "Slack"
},
"SubtitleModule": {
"name": "站点字幕"
},
"SynologyChatModule": {
"name": "Synology Chat"
},
"TelegramModule": {
"name": "Telegram"
},
"TheMovieDbModule": {
"name": "TheMovieDb"
},
"TheTvDbModule": {
"name": "TheTvDb"
},
"TransmissionModule": {
"name": "Transmission"
},
"TrimeMediaModule": {
"name": "飞牛影视"
},
"UgreenModule": {
"name": "绿联影视"
},
"VoceChatModule": {
"name": "VoceChat"
},
"WebPushModule": {
"name": "WebPush"
},
"WechatModule": {
"name": "企业微信"
},
"WechatClawBotModule": {
"name": "微信 ClawBot"
},
"ZSpaceModule": {
"name": "极影视"
}
},
"module_test": {
"unsupported": "模块不支持测试"
}
},
"messages": {
"模块不支持测试": "模块不支持测试",
"网络请求失败": "网络请求失败",
"豆瓣网络连接失败": "豆瓣网络连接失败",
"Bangumi网络连接失败": "Bangumi网络连接失败",
"fanart网络连接失败": "fanart网络连接失败",
"保存MCP配置成功": "保存MCP配置成功",
"保存MCP配置失败": "保存MCP配置失败",
"未配置站点或未通过用户认证": "未配置站点或未通过用户认证",
"Redis连接失败,请检查配置": "Redis连接失败,请检查配置",
"TheMovieDb 识别缓存不存在": "TheMovieDb 识别缓存不存在",
"TheMovieDb 识别缓存删除成功": "TheMovieDb 识别缓存删除成功",
"TheMovieDb 识别缓存清理完成": "TheMovieDb 识别缓存清理完成",
"豆瓣识别缓存不存在": "豆瓣识别缓存不存在",
"豆瓣识别缓存删除成功": "豆瓣识别缓存删除成功",
"豆瓣识别缓存清理完成": "豆瓣识别缓存清理完成"
},
"message_patterns": [
{
"source": "无法连接Qbittorrent下载器:{name}",
"target": "无法连接Qbittorrent下载器:{name}"
},
{
"source": "无法连接Transmission下载器:{name}",
"target": "无法连接Transmission下载器:{name}"
},
{
"source": "无法连接rTorrent下载器:{name}",
"target": "无法连接rTorrent下载器:{name}"
},
{
"source": "无法连接Emby服务器:{name}",
"target": "无法连接Emby服务器:{name}"
},
{
"source": "无法连接Jellyfin服务器:{name}",
"target": "无法连接Jellyfin服务器:{name}"
},
{
"source": "无法连接Plex服务器:{name}",
"target": "无法连接Plex服务器:{name}"
},
{
"source": "飞牛影视配置不完整:{name}",
"target": "飞牛影视配置不完整:{name}"
},
{
"source": "无法连接飞牛影视:{name}",
"target": "无法连接飞牛影视:{name}"
},
{
"source": "绿联影视配置不完整:{name}",
"target": "绿联影视配置不完整:{name}"
},
{
"source": "无法连接绿联影视:{name}",
"target": "无法连接绿联影视:{name}"
},
{
"source": "无法连接极影视服务器:{name}",
"target": "无法连接极影视服务器:{name}"
},
{
"source": "Telegram {name} 未就绪",
"target": "Telegram {name} 未就绪"
},
{
"source": "飞书 {name} 未就绪",
"target": "飞书 {name} 未就绪"
},
{
"source": "Discord {name} Bot 未就绪",
"target": "Discord {name} Bot 未就绪"
},
{
"source": "Slack {name} 未就绪",
"target": "Slack {name} 未就绪"
},
{
"source": "无法连接Bangumi,错误码:{code}",
"target": "无法连接Bangumi,错误码:{code}"
},
{
"source": "无法连接fanart,错误码:{code}",
"target": "无法连接fanart,错误码:{code}"
},
{
"source": "无法连接 {domain},错误码:{code}",
"target": "无法连接 {domain},错误码:{code}"
},
{
"source": "{domain} 网络连接失败",
"target": "{domain} 网络连接失败"
},
{
"source": "测试MCP服务器失败: {reason}",
"target": "测试MCP服务器失败: {reason}"
}
]
}
File diff suppressed because it is too large Load Diff
+58 -35
View File
@@ -124,7 +124,7 @@ class NonBlockingFileHandler:
"""
_instance = None
_lock = threading.Lock()
_rotating_handlers = {}
_stop_sentinel = object()
def __new__(cls):
if cls._instance is None:
@@ -138,6 +138,9 @@ class NonBlockingFileHandler:
return
self._initialized = True
self._state_lock = threading.RLock()
self._handlers_lock = threading.Lock()
self._rotating_handlers = {}
self._write_queue = queue.Queue(maxsize=log_settings.ASYNC_FILE_QUEUE_SIZE)
self._executor = ThreadPoolExecutor(max_workers=log_settings.ASYNC_FILE_WORKERS,
thread_name_prefix="LogWriter")
@@ -151,27 +154,28 @@ class NonBlockingFileHandler:
"""
获取或创建RotatingFileHandler实例
"""
if file_path not in self._rotating_handlers:
# 确保目录存在
file_path.parent.mkdir(parents=True, exist_ok=True)
with self._handlers_lock:
if file_path not in self._rotating_handlers:
# 确保目录存在
file_path.parent.mkdir(parents=True, exist_ok=True)
# 创建RotatingFileHandler
handler = RotatingFileHandler(
filename=str(file_path),
maxBytes=log_settings.LOG_MAX_FILE_SIZE_BYTES,
backupCount=log_settings.LOG_BACKUP_COUNT,
encoding='utf-8'
)
# 创建RotatingFileHandler
handler = RotatingFileHandler(
filename=str(file_path),
maxBytes=log_settings.LOG_MAX_FILE_SIZE_BYTES,
backupCount=log_settings.LOG_BACKUP_COUNT,
encoding='utf-8'
)
# 设置格式化器
formatter = logging.Formatter(log_settings.LOG_FILE_FORMAT)
handler.setFormatter(formatter)
# 设置格式化器
formatter = logging.Formatter(log_settings.LOG_FILE_FORMAT)
handler.setFormatter(formatter)
self._rotating_handlers[file_path] = handler
self._rotating_handlers[file_path] = handler
return self._rotating_handlers[file_path]
return self._rotating_handlers[file_path]
def write_log(self, level: str, message: str, file_path: Path):
def write_log(self, level: str, message: str, file_path: Path) -> None:
"""
写入日志 - 自动检测协程环境并使用合适的方式
"""
@@ -181,8 +185,11 @@ class NonBlockingFileHandler:
if self._is_in_event_loop():
# 在协程环境中,使用非阻塞方式
self._write_non_blocking(entry)
else:
# 不在协程环境中,直接同步写入
return
with self._state_lock:
if not self._running:
return
# 不在协程环境中,持锁同步写入,避免关闭文件处理器时仍有写操作进行
self._write_sync(entry)
@staticmethod
@@ -196,15 +203,19 @@ class NonBlockingFileHandler:
except RuntimeError:
return False
def _write_non_blocking(self, entry: LogEntry):
def _write_non_blocking(self, entry: LogEntry) -> bool:
"""
非阻塞写入用于协程环境
"""
try:
self._write_queue.put_nowait(entry)
except queue.Full:
# 队列满时,使用线程池处理
self._executor.submit(self._write_sync, entry)
with self._state_lock:
if not self._running:
return False
try:
self._write_queue.put_nowait(entry)
except queue.Full:
# 队列满时,使用线程池处理
self._executor.submit(self._write_sync, entry)
return True
@staticmethod
def _write_sync(entry: LogEntry):
@@ -215,8 +226,7 @@ class NonBlockingFileHandler:
# 获取RotatingFileHandler实例
handler = NonBlockingFileHandler()._get_rotating_handler(entry.file_path)
# 使用RotatingFileHandler的emit方法,只传递原始消息
handler.emit(logging.LogRecord(
handler.handle(logging.LogRecord(
name='',
level=getattr(logging, entry.level.upper(), logging.INFO),
pathname='',
@@ -235,22 +245,28 @@ class NonBlockingFileHandler:
"""
后台批量写入线程
"""
while self._running:
while True:
try:
# 收集一批日志条目
batch = []
should_stop = False
end_time = time.time() + log_settings.WRITE_TIMEOUT
while len(batch) < log_settings.BATCH_WRITE_SIZE and time.time() < end_time:
try:
remaining_time = max(0, end_time - time.time())
entry = self._write_queue.get(timeout=remaining_time)
if entry is self._stop_sentinel:
should_stop = True
break
batch.append(entry)
except queue.Empty:
break
if batch:
self._write_batch(batch)
if should_stop:
break
except Exception as e:
print(f"批量写入线程错误: {e}")
@@ -275,8 +291,7 @@ class NonBlockingFileHandler:
# 批量写入
for entry in entries:
# 使用RotatingFileHandler的emit方法,只传递原始消息
handler.emit(logging.LogRecord(
handler.handle(logging.LogRecord(
name='',
level=getattr(logging, entry.level.upper(), logging.INFO),
pathname='',
@@ -294,15 +309,23 @@ class NonBlockingFileHandler:
def shutdown(self):
"""
关闭文件处理器
排空异步日志并关闭文件处理器
"""
self._running = False
if hasattr(self, '_write_thread'):
self._write_thread.join(timeout=5)
with self._state_lock:
if not self._running:
return
self._running = False
if hasattr(self, '_write_thread') and self._write_thread.is_alive():
# 状态锁保证停止标记之后不会再有生产者入队
self._write_queue.put(self._stop_sentinel)
if hasattr(self, '_write_thread') and self._write_thread.is_alive():
self._write_thread.join()
if self._executor:
self._executor.shutdown(wait=True)
# 清理缓存
for handler in self._rotating_handlers.values():
handler.flush()
handler.close()
self._rotating_handlers.clear()
+27 -7
View File
@@ -30,16 +30,31 @@ elif SystemUtils.is_frozen():
sys.stderr = open(os.devnull, 'w')
from app.factory import app
from app.core.config import settings
from app.core.config import global_vars, settings
from app.db.init import init_db, update_db
# 设置进程名
setproctitle.setproctitle(settings.PROJECT_NAME)
class MoviePilotServer(uvicorn.Server):
"""在 Uvicorn 开始优雅退出前发布应用协作停止标志"""
def handle_exit(self, sig, frame) -> None:
global_vars.stop_system()
super().handle_exit(sig, frame)
# uvicorn服务
Server = uvicorn.Server(Config(app, host=settings.HOST, port=settings.PORT,
reload=settings.DEV, workers=multiprocessing.cpu_count() * 2 + 1,
timeout_graceful_shutdown=60))
Server = MoviePilotServer(Config(app, host=settings.HOST, port=settings.PORT,
reload=settings.DEV, workers=multiprocessing.cpu_count() * 2 + 1,
timeout_graceful_shutdown=60))
def request_shutdown() -> None:
"""发布协作停止标志并请求 Uvicorn 退出"""
global_vars.stop_system()
Server.should_exit = True
def start_tray():
@@ -64,8 +79,8 @@ def start_tray():
"""
退出程序
"""
request_shutdown()
TrayIcon.stop()
Server.should_exit = True
import pystray
@@ -93,10 +108,11 @@ def signal_handler(signum, frame):
信号处理函数用于优雅停止服务
"""
print(f"收到信号 {signum},开始优雅停止服务...")
Server.should_exit = True
request_shutdown()
if __name__ == '__main__':
def run_application() -> None:
"""初始化进程并启动 API 服务"""
# 注册信号处理器
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)
@@ -109,3 +125,7 @@ if __name__ == '__main__':
update_db()
# 启动API服务
Server.run()
if __name__ == '__main__':
run_application()
+17 -2
View File
@@ -1,8 +1,10 @@
import threading
from abc import abstractmethod, ABCMeta
from typing import Generic, Tuple, Union, TypeVar, Type, Dict, Optional, Callable
from pathlib import Path
from app.helper.service import ServiceConfigHelper
from app.log import logger
from app.schemas import Notification, NotificationConf, MediaServerConf, DownloaderConf
from app.schemas.types import ModuleType, DownloaderType, MediaServerType, MessageChannel, StorageSchema, \
OtherModulesType, SystemConfigKey
@@ -15,8 +17,21 @@ class _ModuleBase(ConfigReloadMixin, metaclass=ABCMeta):
输入参数与输出参数一致的或没有输出的可以被多个模块重复实现
"""
def on_config_changed(self):
self.init_module()
def __init__(self) -> None:
"""初始化模块生命周期锁"""
super().__init__()
self._reload_lock = threading.RLock()
def on_config_changed(self) -> None:
"""串行停止旧资源并按最新配置重新初始化模块"""
with self._reload_lock:
try:
self.stop()
except Exception as err:
logger.error(
f"停止 {self.get_reload_name()} 旧资源失败,继续按最新配置初始化:{err}"
)
self.init_module()
def get_reload_name(self):
return self.get_name()
+6 -6
View File
@@ -58,7 +58,6 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]):
if not Discord:
logger.error("Discord 依赖未就绪(需要安装 discord.py==2.6.4),模块未启动")
return
self.stop()
super().init_service(
service_name=Discord.__name__.lower(), service_type=Discord
)
@@ -89,12 +88,13 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]):
"""
return 4
def stop(self):
"""
停止模块
"""
def stop(self) -> None:
"""停止模块"""
for client in self.get_instances().values():
client.stop()
try:
client.stop()
except Exception as err:
logger.error(f"停止Discord模块实例失败:{err}")
def test(self) -> Optional[Tuple[bool, str]]:
"""
+28 -1
View File
@@ -25,10 +25,11 @@ class DoubanCache(metaclass=WeakSingleton):
"type": MediaType
}
"""
# TMDB缓存过期
# 豆瓣缓存过期
_douban_cache_expire: bool = True
def __init__(self):
"""初始化豆瓣识别缓存并恢复本地持久化数据。"""
self.maxsize = settings.CONF.douban
self.ttl = settings.CONF.meta
self.region = "__douban_cache__"
@@ -46,6 +47,30 @@ class DoubanCache(metaclass=WeakSingleton):
"""
with lock:
self._cache.clear()
self.save(force=True)
def list_items(self) -> list[dict]:
"""返回可供管理界面展示的豆瓣识别缓存列表。"""
with lock:
cache_items = []
for key, value in self._cache.items():
if not isinstance(value, dict):
continue
media_type = value.get("type")
if not isinstance(media_type, MediaType):
try:
media_type = MediaType(media_type)
except (TypeError, ValueError):
media_type = None
cache_items.append({
"key": key,
"douban_id": value.get("id") or 0,
"title": value.get("title") or "",
"year": value.get("year") or "",
"media_type": media_type.to_agent() if media_type else "unknown",
"poster_path": value.get("poster_path") or "",
})
return sorted(cache_items, key=lambda item: item["key"])
@staticmethod
def __get_key(meta: MetaBase) -> str:
@@ -73,6 +98,7 @@ class DoubanCache(metaclass=WeakSingleton):
redis_data = self._cache.get(key)
if redis_data:
self._cache.delete(key)
self.save(force=True)
return redis_data
return {}
@@ -169,4 +195,5 @@ class DoubanCache(metaclass=WeakSingleton):
pickle.dump(new_meta_data, f, pickle.HIGHEST_PROTOCOL) # noqa
def __del__(self):
"""实例释放前保存非 Redis 缓存。"""
self.save()
+6 -7
View File
@@ -10,7 +10,6 @@ from app.schemas.types import ModuleType
class FeishuModule(_ModuleBase, _MessageBase[Feishu]):
def init_module(self) -> None:
self.stop()
super().init_service(service_name=Feishu.__name__.lower(), service_type=Feishu)
self._channel = MessageChannel.Feishu
@@ -30,13 +29,13 @@ class FeishuModule(_ModuleBase, _MessageBase[Feishu]):
def get_priority() -> int:
return 2
def stop(self):
def stop(self) -> None:
"""停止模块"""
for client in self.get_instances().values():
if hasattr(client, "stop"):
try:
client.stop()
except Exception as err:
logger.error(f"停止飞书模块实例失败:{err}")
try:
client.stop()
except Exception as err:
logger.error(f"停止飞书模块实例失败:{err}")
def test(self) -> Optional[Tuple[bool, str]]:
if not self.get_instances():
+9 -42
View File
@@ -479,57 +479,24 @@ class Alist(StorageBase, metaclass=WeakSingleton):
"""
return self.get_folder(Path(fileitem.path).parent)
def __is_empty_dir(self, fileitem: schemas.FileItem) -> bool:
"""
判断目录是否为空
:param fileitem: 文件项
:return: 是否为空目录
"""
if fileitem.type != "dir":
return False
# 获取目录内容
items = self.list(fileitem)
return len(items) == 0
def delete(self, fileitem: schemas.FileItem) -> bool:
"""
删除文件或目录空目录用专用API
删除文件或目录
:param fileitem: 文件项
:return: 是否删除成功
"""
# 如果是空目录,优先用 remove_empty_directory
if fileitem.type == "dir" and self.__is_empty_dir(fileitem):
resp = RequestUtils(headers=self.__get_header_with_token()).post_res(
self.__get_api_url("/api/fs/remove_empty_directory"),
json={
"src_dir": fileitem.path,
},
)
if resp is None:
logger.warn(
f"【OpenList】请求删除空目录 {fileitem.path} 失败,无法连接alist服务"
)
return False
if resp.status_code != 200:
logger.warn(
f"【OpenList】请求删除空目录 {fileitem.path} 失败,状态码:{resp.status_code}"
)
return False
result = resp.json()
if result["code"] != 200:
logger.warn(
f"【OpenList】删除空目录 {fileitem.path} 失败,错误信息:{result['message']}"
)
return False
return True
# 其它情况(文件或非空目录)
path = Path(fileitem.path)
name = fileitem.name or path.name
if not name:
logger.warn(f"【OpenList】删除路径 {fileitem.path} 无效")
return False
resp = RequestUtils(headers=self.__get_header_with_token()).post_res(
self.__get_api_url("/api/fs/remove"),
json={
"dir": Path(fileitem.path).parent.as_posix(),
"names": [fileitem.name],
"dir": path.parent.as_posix(),
"names": [name],
},
)
if resp is None:
+24 -11
View File
@@ -61,6 +61,10 @@ def _parse_publish_time(publish_time: str) -> Tuple[float, ...]:
class FilterModule(_ModuleBase):
"""
过滤器模块负责按内置和自定义规则筛选种子资源
"""
CONFIG_WATCH = {
SystemConfigKey.CustomFilterRules.value,
SystemConfigKey.CustomIdentifiers.value,
@@ -73,22 +77,21 @@ class FilterModule(_ModuleBase):
# 运行期规则集 = 内置规则 + 自定义规则覆盖。
rule_set: Dict[str, dict] = {}
def __init__(self):
def __init__(self) -> None:
"""
初始化过滤器模块依赖的规则仓库
"""
super().__init__()
self.rulehelper = RuleHelper()
def init_module(self) -> None:
"""
初始化过滤规则集合并内置规则和用户自定义规则
"""
# 每次重载都先恢复为纯内置规则,避免旧的自定义规则残留在内存里。
self.rule_set = deepcopy(self.builtin_rule_set)
self.__init_custom_rules()
def on_config_changed(self):
"""
自定义过滤或 Meta 识别配置变更后重建规则集并刷新 Rust Meta 配置缓存
"""
clear_rust_parse_options_cache()
self.init_module()
def __init_custom_rules(self):
"""
加载用户自定义规则如跟内置规则冲突以用户自定义规则为准
@@ -100,6 +103,9 @@ class FilterModule(_ModuleBase):
@staticmethod
def get_name() -> str:
"""
获取模块名称
"""
return "过滤器"
@staticmethod
@@ -123,13 +129,20 @@ class FilterModule(_ModuleBase):
"""
return 4
def stop(self):
pass
def stop(self) -> None:
"""停止模块"""
clear_rust_parse_options_cache()
def test(self):
def test(self) -> None:
"""
测试过滤器模块状态
"""
pass
def init_setting(self) -> Tuple[str, Union[str, bool]]:
"""
返回过滤器模块启用配置
"""
pass
def filter_torrents(self, rule_groups: List[str],
+1 -1
View File
@@ -34,7 +34,7 @@ BUILTIN_RULE_SET: Dict[str, dict] = {
r"[中国國繁简](/|\s|\\|\|)?[繁简英粤]|[英简繁](/|\s|\\|\|)?[中繁简]"
r"|繁體|简体|[中国國][字配]|国语|國語|中文|中字|简日|繁日|简繁|繁体"
r"|([\s,.-\[])(chs|cht)(|[\s,.-\]])"
r"|(?<![a-z0-9])(gb|big5)(?![a-z0-9])"
r"|(?<![a-z0-9])(?<!\d\s)(gb|big5)(?![a-z0-9])"
],
"exclude": [],
"tmdb": {
+40 -4
View File
@@ -317,10 +317,18 @@ class Jellyfin:
def get_medias_count(self) -> schemas.Statistic:
"""
获得电影电视剧动漫媒体数量
:return: MovieCount SeriesCount SongCount
优先遍历用户媒体库视图逐库统计全局 `Items/Counts` 按数据库原始条目
计数同一影片在库内有多个版本/多个文件夹拷贝时会重复累计#5915),
而用户级 `Users/{user}/Items` 查询会折叠版本 Jellyfin 页面显示一致
仅在用户视图不可用时回退到 `Items/Counts`
:return: MovieCount SeriesCount EpisodeCount
"""
if not self._host or not self._apikey:
return schemas.Statistic()
stat = self.__count_medias_by_librarys()
if stat is not None:
return stat
url = f"{self._host}Items/Counts"
params = {
'api_key': self._apikey
@@ -341,6 +349,32 @@ class Jellyfin:
logger.error(f"连接Items/Counts出错:" + str(e))
return schemas.Statistic()
def __count_medias_by_librarys(self) -> Optional[schemas.Statistic]:
"""
遍历用户媒体库视图逐库统计媒体数量
`Users/{user}/Views` 每个媒体库仅返回一条记录库包含多个文件夹时
也不会重复 `CollectionType` 分桶后用用户级条目查询累计
:return: 统计结果用户或媒体库视图不可用时返回None由调用方回退
"""
if not self.user:
return None
librarys = self.__get_jellyfin_librarys()
if not librarys:
return None
stat = schemas.Statistic()
for library in librarys:
library_id = library.get("Id")
if not library_id:
continue
collection_type = library.get("CollectionType")
if collection_type == "movies":
stat.movie_count += self.get_items_count(library_id, include_item_types="Movie") or 0
elif collection_type == "tvshows":
stat.tv_count += self.get_items_count(library_id, include_item_types="Series") or 0
stat.episode_count += self.get_items_count(library_id, include_item_types="Episode") or 0
return stat
def __get_jellyfin_series_id_by_name(self, name: str, year: str) -> Optional[str]:
"""
根据名称查询Jellyfin中剧集的SeriesId
@@ -809,11 +843,13 @@ class Jellyfin:
logger.error(f"连接Users/{self.user}/Items/{itemid}" + str(e))
return None
def get_items_count(self, parent: Union[str, int]) -> Optional[int]:
def get_items_count(self, parent: Union[str, int],
include_item_types: str = "Movie,Series") -> Optional[int]:
"""
获取指定媒体库可同步的电影和剧集总数
获取指定媒体库可同步的媒体条目总数
:param parent: 媒体库ID
:param include_item_types: 统计的条目类型默认电影和剧集
:return: 媒体条目总数查询失败时返回None
"""
if not parent or not self._host or not self._apikey or not self.user:
@@ -822,7 +858,7 @@ class Jellyfin:
params = {
"ParentId": parent,
"Recursive": "true",
"IncludeItemTypes": "Movie,Series",
"IncludeItemTypes": include_item_types,
"Limit": 0,
"api_key": self._apikey,
}
+7 -6
View File
@@ -44,13 +44,14 @@ class PlexModule(_ModuleBase, _MediaServerBase[Plex]):
"""
return 3
def stop(self):
"""
停止模块服务
"""
def stop(self) -> None:
"""停止模块"""
for server in self.get_instances().values():
if server:
server.close()
try:
if server:
server.close()
except Exception as err:
logger.error(f"停止Plex模块实例失败:{err}")
def test(self) -> Optional[Tuple[bool, str]]:
"""
+26 -6
View File
@@ -37,9 +37,14 @@ _QBITTORRENT_PAUSED_STATES = {
"stoppeddl",
"stoppedup",
}
_TORRENT_FILES_RETRY_TIMES = 5
_TORRENT_FILES_RETRY_INTERVAL = 1
class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
"""
qBittorrent 下载器模块负责下载任务添加文件选择和任务管理
"""
def init_module(self) -> None:
"""
@@ -50,6 +55,9 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
@staticmethod
def get_name() -> str:
"""
获取模块名称
"""
return "Qbittorrent"
@staticmethod
@@ -73,7 +81,10 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
"""
return 1
def stop(self):
def stop(self) -> None:
"""
停止模块
"""
pass
def test(self) -> Optional[Tuple[bool, str]]:
@@ -90,6 +101,9 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
return True, ""
def init_setting(self) -> Tuple[str, Union[str, bool]]:
"""
返回控制模块启用状态的配置项
"""
pass
def scheduler_job(self) -> None:
@@ -181,9 +195,6 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
ignore_category_check=False
)
# 获取种子内容布局: `Original: 原始, Subfolder: 创建子文件夹, NoSubfolder: 不创建子文件夹`
torrent_layout = server.get_content_layout()
if not state:
# 查询所有下载器的种子
torrents, error = server.get_torrents()
@@ -196,7 +207,8 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
if torrent.get("name") == getattr(torrent_from_file, 'name', '') \
and torrent.get("total_size") == getattr(torrent_from_file, 'total_size', 0):
torrent_hash = torrent.get("hash")
torrent_tags = [str(tag).strip() for tag in torrent.get("tags").split(',')]
server.delete_torrents_tag(torrent_hash, tag)
torrent_tags = [str(tag).strip() for tag in (torrent.get("tags") or "").split(',')]
logger.warn(f"下载器中已存在该种子任务:{torrent_hash} - {torrent.get('name')}")
# 给种子打上标签
if "已整理" in torrent_tags:
@@ -204,6 +216,8 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
if settings.TORRENT_TAG and settings.TORRENT_TAG not in torrent_tags:
logger.info(f"给种子 {torrent_hash} 打上标签:{settings.TORRENT_TAG}")
server.set_torrents_tag(ids=torrent_hash, tags=[settings.TORRENT_TAG])
# 获取种子内容布局: `Original: 原始, Subfolder: 创建子文件夹, NoSubfolder: 不创建子文件夹`
torrent_layout = server.get_content_layout()
return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, f"下载任务已存在"
finally:
torrents.clear()
@@ -219,9 +233,15 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
if not torrent_hash:
return None, None, None, f"下载任务添加成功,但获取Qbittorrent任务信息失败:{content}"
else:
# 获取种子内容布局: `Original: 原始, Subfolder: 创建子文件夹, NoSubfolder: 不创建子文件夹`
torrent_layout = server.get_content_layout()
if is_paused:
# 种子文件
torrent_files = server.get_files(torrent_hash)
torrent_files = server.get_files(
torrent_hash,
retry=_TORRENT_FILES_RETRY_TIMES,
interval=_TORRENT_FILES_RETRY_INTERVAL,
)
if not torrent_files:
return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, "获取种子文件失败,下载任务可能在暂停状态"
+51 -11
View File
@@ -259,9 +259,34 @@ class Qbittorrent:
"""
if not self.qbc:
return None
# completed会包含移动状态 改为获取seeding状态 包含活动上传, 正在做种, 及强制做种
torrents, error = self.get_torrents(status="seeding", ids=ids, tags=tags)
return None if error else torrents or []
torrents, error = self.get_torrents(status="completed", ids=ids, tags=tags)
if error:
return None
ret_torrents = []
for torrent in torrents or []:
state = str(torrent.get("state") or "").strip().lower()
progress = torrent.get("progress") or 0
amount_left = torrent.get("amount_left") or 0
if (
progress >= 1
and amount_left <= 0
and state not in {
"allocating",
"checkingdl",
"checkingup",
"downloading",
"error",
"forceddl",
"missingfiles",
"metadl",
"moving",
"queueddl",
"stalleddl",
"unknown",
}
):
ret_torrents.append(torrent)
return ret_torrents
def get_downloading_torrents(self, ids: Union[str, list] = None,
tags: Union[str, list] = None) -> Optional[List[TorrentDictionary]]:
@@ -278,14 +303,16 @@ class Qbittorrent:
def delete_torrents_tag(self, ids: Union[str, list], tag: Union[str, list]) -> bool:
"""
删除Tag
从指定种子移除标签并删除全局标签定义
:param ids: 种子Hash列表
:param tag: 标签内容
:return: 是否删除成功
"""
if not self.qbc:
return False
try:
self.qbc.torrents_delete_tags(torrent_hashes=ids, tags=tag)
self.qbc.torrents_remove_tags(torrent_hashes=ids, tags=tag)
self.qbc.torrents_delete_tags(tags=tag)
return True
except Exception as err:
logger.error(f"删除种子Tag出错:{str(err)}")
@@ -488,17 +515,30 @@ class Qbittorrent:
logger.error(f"删除种子出错:{str(err)}")
return False
def get_files(self, tid: str) -> Optional[TorrentFilesList]:
def get_files(self, tid: str, retry: int = 1, interval: float = 0) -> Optional[TorrentFilesList]:
"""
获取种子文件清单
:param tid: 种子Hash
:param retry: 最多尝试次数
:param interval: 重试间隔单位秒
:return: 种子文件清单
"""
if not self.qbc:
return None
try:
return self.qbc.torrents_files(torrent_hash=tid)
except Exception as err:
logger.error(f"获取种子文件列表出错:{str(err)}")
return None
last_error = None
retry_times = max(retry, 1)
for index in range(retry_times):
try:
torrent_files = self.qbc.torrents_files(torrent_hash=tid)
if torrent_files:
return torrent_files
except Exception as err:
last_error = err
if index < retry_times - 1 and interval:
time.sleep(interval)
if last_error:
logger.error(f"获取种子文件列表出错:{str(last_error)}")
return None
def set_files(self, **kwargs) -> bool:
"""
+4 -2
View File
@@ -46,7 +46,6 @@ class QQBotModule(_ModuleBase, _MessageBase[QQBot]):
)
def init_module(self) -> None:
self.stop()
super().init_service(service_name=QQBot.__name__.lower(), service_type=QQBot)
self._channel = MessageChannel.QQ
@@ -67,9 +66,12 @@ class QQBotModule(_ModuleBase, _MessageBase[QQBot]):
return 10
def stop(self) -> None:
"""停止模块"""
for client in self.get_instances().values():
if hasattr(client, "stop"):
try:
client.stop()
except Exception as err:
logger.error(f"停止QQ Bot模块实例失败:{err}")
def test(self) -> Optional[Tuple[bool, str]]:
if not self.get_instances():
+6 -5
View File
@@ -69,12 +69,13 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
"""
return 3
def stop(self):
"""
停止模块
"""
def stop(self) -> None:
"""停止模块"""
for client in self.get_instances().values():
client.stop()
try:
client.stop()
except Exception as err:
logger.error(f"停止Slack模块实例失败:{err}")
def test(self) -> Optional[Tuple[bool, str]]:
"""
+25 -6
View File
@@ -62,12 +62,13 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
"""
return 0
def stop(self):
"""
停止模块
"""
def stop(self) -> None:
"""停止模块"""
for client in self.get_instances().values():
client.stop()
try:
client.stop()
except Exception as err:
logger.error(f"停止Telegram模块实例失败:{err}")
def test(self) -> Optional[Tuple[bool, str]]:
"""
@@ -252,9 +253,11 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
处理普通文本消息
"""
text = msg.get("text") or msg.get("caption")
message_id = msg.get("message_id")
user_id = msg.get("from", {}).get("id")
user_name = msg.get("from", {}).get("username")
chat_id = msg.get("chat", {}).get("id")
reply_to_message_id = (msg.get("reply_to_message") or {}).get("message_id")
# 将 text_link 实体中的 URL 嵌入到文本中
if text:
@@ -309,7 +312,9 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
userid=user_id,
username=user_name,
text=cleaned_text,
message_id=message_id,
chat_id=str(chat_id) if chat_id else None,
reply_to_message_id=reply_to_message_id,
images=images if images else None,
audio_refs=audio_refs if audio_refs else None,
files=files if files else None,
@@ -514,6 +519,12 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
parse_mode=message.parse_mode,
)
else:
# Telegram 的 reply_markup 不能同时承载 InlineKeyboard 和 ForceReply。
# 普通通知只清空可编辑消息 ID,仍保留原会话作为新消息目标。
has_interaction_context = bool(message.buttons or message.force_reply)
original_message_id = (
message.original_message_id if has_interaction_context else None
)
client.send_msg(
title=message.title,
text=message.text,
@@ -521,7 +532,8 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
userid=userid,
link=message.link,
buttons=message.buttons,
original_message_id=message.original_message_id,
force_reply=message.force_reply,
original_message_id=original_message_id,
original_chat_id=message.original_chat_id,
disable_web_page_preview=message.disable_web_page_preview,
parse_mode=message.parse_mode,
@@ -734,12 +746,19 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
parse_mode=message.parse_mode,
)
else:
# direct message 只禁用编辑旧消息;仅 ForceReply 使用 original_chat_id
# 发回原会话,并保留 original_message_id 让 client reply_to 原消息。
original_chat_id = message.original_chat_id if message.force_reply else None
original_message_id = message.original_message_id if message.force_reply else None
result = client.send_msg(
title=message.title,
text=message.text,
image=message.image,
userid=userid,
link=message.link,
force_reply=message.force_reply,
original_message_id=original_message_id,
original_chat_id=original_chat_id,
disable_web_page_preview=message.disable_web_page_preview,
parse_mode=message.parse_mode,
)
+24
View File
@@ -0,0 +1,24 @@
def ensure_urllib3_header_param_compat() -> None:
"""
pyTelegramBotAPI imports urllib3.fields.format_header_param at import time.
Some urllib3-future builds only expose newer formatter names.
RFC 2231 formatting is kept as the last fallback because it encodes
non-ASCII values differently from urllib3's old default.
"""
try:
from urllib3 import fields
except ImportError:
return
if hasattr(fields, "format_header_param"):
return
for fallback_name in (
"format_header_param_html5",
"format_multipart_header_param",
"format_header_param_rfc2231",
):
fallback = getattr(fields, fallback_name, None)
if fallback is not None:
fields.format_header_param = fallback
return
+73 -23
View File
@@ -8,32 +8,41 @@ from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Union
from urllib.parse import urljoin, quote
from telebot import TeleBot, apihelper
from telebot.types import (
from app.modules.telegram.compat import ensure_urllib3_header_param_compat
# Must run before importing pyTelegramBotAPI.
ensure_urllib3_header_param_compat()
from telebot import TeleBot, apihelper # noqa: E402
from telebot.types import ( # noqa: E402
BotCommand,
InlineKeyboardMarkup,
InlineKeyboardButton,
InputMediaPhoto,
)
from telegramify_markdown import standardize, telegramify # noqa
try:
from telegramify_markdown import entities_to_markdownv2 # noqa
from telebot.types import ForceReply # noqa: E402
except ImportError:
ForceReply = None
from telegramify_markdown import standardize, telegramify # noqa: E402
try:
from telegramify_markdown import entities_to_markdownv2 # noqa: E402
except ImportError:
entities_to_markdownv2 = None
try:
from telegramify_markdown.content import ContentTypes, File, Photo, Text
from telegramify_markdown.content import ContentTypes, File, Photo, Text # noqa: E402
except ImportError:
from telegramify_markdown.type import ContentTypes, File, Photo, Text
from telegramify_markdown.type import ContentTypes, File, Photo, Text # noqa: E402
from app.core.config import settings
from app.core.context import MediaInfo, Context
from app.core.metainfo import MetaInfo
from app.helper.image import ImageHelper
from app.helper.thread import ThreadHelper
from app.log import logger
from app.utils.common import retry
from app.utils.http import RequestUtils
from app.utils.string import StringUtils
from app.core.config import settings # noqa: E402
from app.core.context import MediaInfo, Context # noqa: E402
from app.core.metainfo import MetaInfo # noqa: E402
from app.helper.image import ImageHelper # noqa: E402
from app.helper.thread import ThreadHelper # noqa: E402
from app.log import logger # noqa: E402
from app.utils.common import retry # noqa: E402
from app.utils.http import RequestUtils # noqa: E402
from app.utils.string import StringUtils # noqa: E402
TELEGRAM_PARSE_MODE_MARKDOWN = "MarkdownV2"
@@ -270,8 +279,6 @@ class Telegram:
@staticmethod
def _telegramify_item_text(item: Text) -> str:
"""将 telegramify 文本片段转换为 Telegram MarkdownV2 字符串。"""
if hasattr(item, "content"):
return item.content
if entities_to_markdownv2:
return entities_to_markdownv2(item.text, item.entities)
return standardize(item.text)
@@ -281,8 +288,6 @@ class Telegram:
"""将 telegramify 文本或媒体片段转换为 Telegram MarkdownV2 caption。"""
if isinstance(item, Text):
return Telegram._telegramify_item_text(item)
if hasattr(item, "caption"):
return item.caption
if entities_to_markdownv2:
return entities_to_markdownv2(item.caption_text, item.caption_entities)
return standardize(item.caption_text)
@@ -584,6 +589,7 @@ class Telegram:
userid: Optional[str] = None,
link: Optional[str] = None,
buttons: Optional[List[List[dict]]] = None,
force_reply: bool = False,
original_message_id: Optional[int] = None,
original_chat_id: Optional[str] = None,
disable_web_page_preview: Optional[bool] = None,
@@ -598,6 +604,7 @@ class Telegram:
:param userid: 用户ID如有则只发消息给该用户
:param link: 跳转链接
:param buttons: 按钮列表格式[[{"text": "按钮文本", "callback_data": "回调数据"}]]
:param force_reply: 是否请求 Telegram 客户端强制回复
:param original_message_id: 原消息ID如果提供则编辑原消息
:param original_chat_id: 原消息的聊天ID编辑消息时需要
:param disable_web_page_preview: 是否禁用链接预览
@@ -634,9 +641,31 @@ class Telegram:
reply_markup = None
if buttons:
reply_markup = self._create_inline_keyboard(buttons)
elif force_reply and ForceReply:
reply_markup = self._create_force_reply_markup()
# 判断是编辑消息还是发送新消息
if original_message_id and original_chat_id:
if force_reply and reply_markup and not buttons:
sent = self.__send_request(
userid=original_chat_id,
image=image,
caption=caption,
reply_markup=reply_markup,
disable_web_page_preview=disable_web_page_preview,
parse_mode=parse_mode,
reply_to_message_id=original_message_id,
)
self._stop_typing_if_needed(chat_id, stop_typing)
if sent and hasattr(sent, "message_id"):
return {
"success": True,
"message_id": sent.message_id,
"chat_id": sent.chat.id if hasattr(sent, "chat") else chat_id,
}
elif sent:
return {"success": True}
return {"success": False}
# 编辑消息
result = self.__edit_message(
original_chat_id,
@@ -679,6 +708,18 @@ class Telegram:
self._stop_typing_if_needed(chat_id, stop_typing)
return {"success": False}
@staticmethod
def _create_force_reply_markup():
if not ForceReply:
return None
try:
return ForceReply(selective=True, input_field_placeholder="请输入内容")
except TypeError:
try:
return ForceReply(selective=True)
except TypeError:
return ForceReply()
def send_voice(
self,
voice_path: str,
@@ -1285,12 +1326,14 @@ class Telegram:
reply_markup: Optional[InlineKeyboardMarkup] = None,
disable_web_page_preview: Optional[bool] = None,
parse_mode: Optional[str] = None,
reply_to_message_id: Optional[int] = None,
):
"""
向Telegram发送报文返回发送的消息对象
:param reply_markup: 内联键盘
:param disable_web_page_preview: 是否禁用链接预览
:param parse_mode: Telegram 消息格式类型默认 MarkdownV2可传 HTML
:param reply_to_message_id: 回复的原消息ID
:return: 发送成功返回消息对象失败返回None
"""
parse_mode = self._normalize_parse_mode(parse_mode)
@@ -1299,6 +1342,8 @@ class Telegram:
"parse_mode": parse_mode,
"reply_markup": reply_markup,
}
if reply_to_message_id:
kwargs["reply_to_message_id"] = reply_to_message_id
# 处理图片
image = self.__process_image(image)
@@ -1491,14 +1536,19 @@ class Telegram:
# 清理菜单命令
self._bot.delete_my_commands()
def stop(self):
def stop(self) -> None:
"""
停止Telegram消息接收服务
"""
# 停止所有typing任务
for chat_id in list(self._typing_tasks.keys()):
self._stop_typing_task(chat_id)
if self._bot:
self._bot.stop_polling()
if not self._bot:
return
self._bot.stop_bot()
if self._polling_thread:
self._polling_thread.join()
logger.info("Telegram消息接收服务已停止")
self._polling_thread = None
self._bot = None
logger.info("Telegram消息接收服务已停止")
+7 -9
View File
@@ -43,12 +43,6 @@ class TheMovieDbModule(_ModuleBase):
self.category = CategoryHelper()
self.scraper = TmdbScraper()
def on_config_changed(self):
# 停止模块
self.stop()
# 初始化模块
self.init_module()
@staticmethod
def get_name() -> str:
return "TheMovieDb"
@@ -74,9 +68,13 @@ class TheMovieDbModule(_ModuleBase):
"""
return 1
def stop(self):
self.cache.save()
self.tmdb.close()
def stop(self) -> None:
"""停止模块"""
# 缓存持久化失败不能阻断 HTTP 客户端关闭
try:
self.cache.save()
finally:
self.tmdb.close()
def test(self) -> Tuple[bool, str]:
"""
+30
View File
@@ -27,6 +27,7 @@ class TmdbCache(metaclass=WeakSingleton):
_tmdb_cache_expire: bool = True
def __init__(self):
"""初始化 TMDB 识别缓存并恢复本地持久化数据。"""
self.maxsize = settings.CONF.douban
self.ttl = settings.CONF.meta
self.region = "__tmdb_cache__"
@@ -44,6 +45,33 @@ class TmdbCache(metaclass=WeakSingleton):
"""
with lock:
self._cache.clear()
self.save(force=True)
def list_items(self) -> list[dict]:
"""
返回可供管理界面展示的 TMDB 识别缓存列表
"""
with lock:
cache_items = []
for key, value in self._cache.items():
if not isinstance(value, dict):
continue
media_type = value.get("type")
if not isinstance(media_type, MediaType):
try:
media_type = MediaType(media_type)
except (TypeError, ValueError):
media_type = None
cache_items.append({
"key": key,
"tmdb_id": value.get("id") or 0,
"title": value.get("title") or "",
"year": value.get("year") or "",
"media_type": media_type.to_agent() if media_type else "unknown",
"poster_path": value.get("poster_path") or "",
"backdrop_path": value.get("backdrop_path") or "",
})
return sorted(cache_items, key=lambda item: item["key"])
@staticmethod
def __get_key(meta: MetaBase) -> str:
@@ -71,6 +99,7 @@ class TmdbCache(metaclass=WeakSingleton):
redis_data = self._cache.get(key)
if redis_data:
self._cache.delete(key)
self.save(force=True)
return redis_data
return {}
@@ -156,4 +185,5 @@ class TmdbCache(metaclass=WeakSingleton):
pickle.dump(new_meta_data, f, pickle.HIGHEST_PROTOCOL) # type: ignore
def __del__(self):
"""实例释放前保存非 Redis 缓存。"""
self.save()
-1
View File
@@ -114,7 +114,6 @@ class TheTvDbModule(_ModuleBase):
return 4
def stop(self):
logger.info("TheTvDbModule 停止。正在清除 TVDB 会话。")
with self.__auth_lock:
self.tvdb = None
+7 -3
View File
@@ -61,10 +61,14 @@ class TrimeMediaModule(_ModuleBase, _MediaServerBase[TrimeMedia]):
logger.info(f"飞牛影视 {name} 连接断开,尝试重连 ...")
server.reconnect()
def stop(self):
def stop(self) -> None:
"""停止模块"""
for server in self.get_instances().values():
if server.is_authenticated():
server.disconnect()
try:
if server.is_authenticated():
server.disconnect()
except Exception as err:
logger.error(f"停止飞牛影视模块实例失败:{err}")
def test(self) -> Optional[Tuple[bool, str]]:
"""
+7 -3
View File
@@ -60,10 +60,14 @@ class UgreenModule(_ModuleBase, _MediaServerBase[Ugreen]):
logger.info(f"绿联影视 {name} 连接断开,尝试重连 ...")
server.reconnect()
def stop(self):
def stop(self) -> None:
"""停止模块"""
for server in self.get_instances().values():
if server.is_authenticated():
server.disconnect()
try:
if server.is_authenticated():
server.disconnect()
except Exception as err:
logger.error(f"停止绿联影视模块实例失败:{err}")
def test(self) -> Optional[Tuple[bool, str]]:
"""
+50 -11
View File
@@ -1,7 +1,7 @@
import base64
import uuid
from dataclasses import dataclass
from typing import Any, Dict, Mapping, Optional, Union
from typing import Any, Mapping, Optional, Union
from urllib.parse import urlsplit, urlunsplit
from requests import Session
@@ -13,6 +13,10 @@ from app.utils.url import UrlUtils
@dataclass
class ApiResult:
"""
绿联接口标准响应封装
"""
code: int = -1
msg: str = ""
data: Any = None
@@ -21,6 +25,7 @@ class ApiResult:
@property
def success(self) -> bool:
"""判断绿联接口是否返回成功状态"""
return self.code == 200
@@ -59,7 +64,17 @@ class Api:
ug_agent: str = "PC/WEB",
timeout: int = 20,
verify_ssl: bool = True,
):
) -> None:
"""
初始化绿联影视 API 客户端
:param host: 绿联服务端地址
:param client_version: 绿联 Web 客户端版本号
:param language: 请求语言
:param ug_agent: 绿联客户端标识
:param timeout: HTTP 请求超时时间
:param verify_ssl: 是否校验 HTTPS 证书
"""
self._host = self._normalize_base_url(host)
self._session = Session()
@@ -80,25 +95,30 @@ class Api:
@property
def host(self) -> str:
"""获取规范化后的绿联服务端地址"""
return self._host
@property
def token(self) -> Optional[str]:
"""获取当前登录会话 token"""
return self._token
@property
def static_token(self) -> Optional[str]:
"""获取可用于静态资源访问的 token"""
return self._static_token
@property
def is_ugk(self) -> bool:
"""判断当前会话是否使用 ugk 访问参数"""
return self._is_ugk
@property
def public_key(self) -> Optional[str]:
"""获取当前会话加密公钥"""
return self._public_key
def close(self):
def close(self) -> None:
"""
关闭底层 HTTP 会话
"""
@@ -141,13 +161,14 @@ class Api:
def _common_headers(self) -> dict[str, str]:
"""
获取绿联 Web 端通用请求头
获取绿联 Web 端通用请求头兼容新版登录客户端标识
"""
return {
"Accept": "application/json, text/plain, */*",
"Client-Id": self._client_id,
"Client-Version": self._client_version,
"UG-Agent": self._ug_agent,
"UG-Client-Id": self._client_id,
"X-Specify-Language": self._language,
}
@@ -262,14 +283,32 @@ class Api:
logger.error(f"绿联登录失败:{login_result.msg}")
return None
token = str(login_result.data.get("token") or "").strip()
public_key = self._decode_public_key(str(login_result.data.get("public_key") or ""))
token = str(
login_result.data.get("token")
or login_result.data.get("token_id")
or login_result.data.get("tokenId")
or ""
).strip()
public_key = (
self._decode_public_key(
str(
login_result.data.get("public_key")
or login_result.data.get("publicKey")
or ""
)
)
or login_public_key
)
if not token or not public_key:
logger.error("绿联登录失败:未返回 token/public_key")
logger.error("绿联登录失败:未返回 token/token_id 或可用公钥")
return None
self._token = token
static_token = str(login_result.data.get("static_token") or "").strip()
static_token = str(
login_result.data.get("static_token")
or login_result.data.get("staticToken")
or ""
).strip()
self._static_token = static_token or self._token
self._is_ugk = bool(login_result.data.get("is_ugk"))
self._public_key = public_key
@@ -365,7 +404,7 @@ class Api:
)
return True
def logout(self):
def logout(self) -> None:
"""
登出并清理本地认证状态
"""
@@ -569,7 +608,7 @@ class Api:
"""
获取海报墙文件夹与条目可按目录路径递归展开
"""
params: Dict[str, Any] = {
params: dict[str, Any] = {
"page": page,
"page_size": page_size,
"sort_type": sort_type,
@@ -590,7 +629,7 @@ class Api:
"""
获取电影详情
"""
params: Dict[str, Any] = {
params: dict[str, Any] = {
"id": item_id,
"media_lib_set_id": media_lib_set_id,
"fileVersion": "true",

Some files were not shown because too many files have changed in this diff Show More