mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-28 03:27:31 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0ee99aacc | ||
|
|
92918ce380 | ||
|
|
a4335fe753 | ||
|
|
107ba37834 | ||
|
|
c27678ce06 | ||
|
|
7725342a80 | ||
|
|
893269f8c1 | ||
|
|
00d46f3aab | ||
|
|
077241b6ed | ||
|
|
b24a07e388 | ||
|
|
f814c271cc | ||
|
|
e015c67689 | ||
|
|
98b16bda8d | ||
|
|
b8233e1789 | ||
|
|
83107bf447 | ||
|
|
3a2f90c567 | ||
|
|
4826e3301c | ||
|
|
1855ba81ec | ||
|
|
96ef431efc | ||
|
|
4f2935c85e | ||
|
|
2a49495e27 | ||
|
|
b628bc7209 | ||
|
|
29068a5846 | ||
|
|
51a7120c79 | ||
|
|
476dfef7d9 | ||
|
|
bd5ddd6158 | ||
|
|
a30a48b8f4 | ||
|
|
8e60e5571b | ||
|
|
18c1ec4b82 | ||
|
|
30b932e07e | ||
|
|
54be1143fc | ||
|
|
13f27854fd | ||
|
|
770201c48c | ||
|
|
685f044312 | ||
|
|
8c0afac5d1 | ||
|
|
099ef7d5bf | ||
|
|
f3ac69669c | ||
|
|
eb4ecd990a | ||
|
|
b51971ee7d | ||
|
|
6f6ed998bb | ||
|
|
844407dc41 | ||
|
|
c54605f8ce | ||
|
|
0fbf05d72f | ||
|
|
09bb32f681 | ||
|
|
a37f118576 | ||
|
|
e635bc8e04 | ||
|
|
8245124e82 | ||
|
|
827ed8330c | ||
|
|
136c1baed3 | ||
|
|
992031ef95 | ||
|
|
b16c50b03a | ||
|
|
76803ae7a3 | ||
|
|
56bda11947 | ||
|
|
1b12d7664e | ||
|
|
db9960d9b9 | ||
|
|
2f0c1252da | ||
|
|
36d4434596 | ||
|
|
93e907d032 | ||
|
|
132f27c1c6 | ||
|
|
b231ad415f | ||
|
|
0f183ae08e | ||
|
|
a71d3ea03f | ||
|
|
7f82a9ea4d | ||
|
|
d977e4c48a | ||
|
|
95b6adbeee | ||
|
|
964fee1106 | ||
|
|
656473f3aa | ||
|
|
ab5995a609 | ||
|
|
064e6535d5 | ||
|
|
cab2ac400a | ||
|
|
d14d401c86 | ||
|
|
6c3c5e042d |
@@ -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:
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
# 仓库设置中添加的 Secret:Settings -> 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"
|
||||
@@ -37,6 +37,7 @@ coverage.json
|
||||
htmlcov/
|
||||
.vscode
|
||||
venv
|
||||
moviepilot-site-capture-*.zip
|
||||
|
||||
# Pylint
|
||||
pylint-report.json
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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()]
|
||||
|
||||
|
||||
@@ -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": "自定义识别词",
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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:
|
||||
"""
|
||||
删除站点
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
根据目录样本推荐集数定位模板
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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():
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -12,6 +12,10 @@ from app.utils.string import StringUtils
|
||||
|
||||
@dataclass
|
||||
class TorrentInfo:
|
||||
"""
|
||||
种子搜索结果信息。
|
||||
"""
|
||||
|
||||
# 站点ID
|
||||
site: int = None
|
||||
# 站点名称
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
调用处理器,处理链式或广播事件
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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*月?新番" \
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
from .agentchat import AgentChat
|
||||
from .downloadfailure import DownloadFailure
|
||||
from .downloadhistory import DownloadHistory, DownloadFiles
|
||||
from .mediaserver import MediaServerItem
|
||||
from .message import Message
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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, "订阅已存在"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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地址。
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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-CN、zh_CN、en-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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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]]:
|
||||
"""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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]]:
|
||||
"""
|
||||
|
||||
@@ -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, "获取种子文件失败,下载任务可能在暂停状态"
|
||||
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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]]:
|
||||
"""
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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消息接收服务已停止")
|
||||
|
||||
@@ -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]:
|
||||
"""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -114,7 +114,6 @@ class TheTvDbModule(_ModuleBase):
|
||||
return 4
|
||||
|
||||
def stop(self):
|
||||
logger.info("TheTvDbModule 停止。正在清除 TVDB 会话。")
|
||||
with self.__auth_lock:
|
||||
self.tvdb = None
|
||||
|
||||
|
||||
@@ -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]]:
|
||||
"""
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user