mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-28 03:27:31 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
f3e5be37fd | ||
|
|
d8f7fa70af | ||
|
|
6916ee0988 | ||
|
|
6fef533527 | ||
|
|
c57985d553 | ||
|
|
ec07379a67 | ||
|
|
2764742b86 | ||
|
|
a0f613fa1e | ||
|
|
73d5c95f4e | ||
|
|
4d30dee74c | ||
|
|
302d8bbf5c | ||
|
|
b646cbb4f6 | ||
|
|
dd73b97095 | ||
|
|
0cb0bac0e1 | ||
|
|
9eb71c744b | ||
|
|
8bf826faa0 | ||
|
|
df4e45c644 | ||
|
|
494f809ef0 | ||
|
|
a4f6e13881 | ||
|
|
36fb82b7aa | ||
|
|
9b1bdb0cb2 |
@@ -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:
|
||||
|
||||
+481
-68
@@ -1,4 +1,4 @@
|
||||
name: PR Agent
|
||||
name: PR-Agent
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
@@ -11,8 +11,7 @@ on:
|
||||
- review_requested
|
||||
- synchronize
|
||||
issue_comment:
|
||||
# 手动命令如 "/review"、"/describe"、"/improve" 和 "/ask ..." 只在 PR 评论中有意义。
|
||||
# issue_comment 同时覆盖普通 issue,因此 job 里还会再判断是否属于 PR。
|
||||
# 手动命令只在 PR 评论中有意义;编辑后的命令也可重新触发。
|
||||
types:
|
||||
- created
|
||||
- edited
|
||||
@@ -22,102 +21,516 @@ permissions:
|
||||
contents: read
|
||||
# 更新 PR 描述、发布 PR Review 或修改 PR 相关元数据。
|
||||
pull-requests: write
|
||||
# PR 评论在 GitHub API 中属于 issue comments,手动命令和总结评论需要该权限。
|
||||
# PR 评论在 GitHub API 中属于 issue comments,手动问答需要该权限。
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
pr-agent:
|
||||
name: PR-Agent review and describe
|
||||
# PR 事件自动处理;评论命令仅允许指定身份在 PR 下触发,避免任意评论消耗模型配额。
|
||||
if: >-
|
||||
github.event.sender.type != 'Bot' &&
|
||||
(
|
||||
github.event_name == 'pull_request_target' ||
|
||||
(
|
||||
github.event_name == 'pull_request_target'
|
||||
) ||
|
||||
(
|
||||
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 == '/review' ||
|
||||
startsWith(github.event.comment.body, '/review ') ||
|
||||
github.event.comment.body == '/ask' ||
|
||||
startsWith(github.event.comment.body, '/ask ')
|
||||
)
|
||||
)
|
||||
)
|
||||
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: Capture PR context
|
||||
id: pr_context
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pull="$(mktemp)"
|
||||
gh api "repos/${REPO}/pulls/${PR_NUMBER}" > "${pull}"
|
||||
python3 - "${pull}" >> "${GITHUB_OUTPUT}" <<'PY'
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
pull = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||
title = pull.get("title") or ""
|
||||
body = pull.get("body") or ""
|
||||
labels = {item.get("name", "") for item in pull.get("labels") or []}
|
||||
skip_pr_agent = "true" if "skip pr-agent" in labels or re.search(r"^(?:\[Auto\]|Auto)", title) else "false"
|
||||
body = re.sub(
|
||||
r"<!-- pr-agent-summary:start -->.*?<!-- pr-agent-summary:end -->",
|
||||
" ",
|
||||
body,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
text = f"{title}\n{body}"
|
||||
cjk_count = len(re.findall(r"[\u4e00-\u9fff]", text))
|
||||
latin_words = len(re.findall(r"\b[A-Za-z][A-Za-z]{2,}\b", text))
|
||||
if cjk_count >= 4 or latin_words < 8:
|
||||
response_language = "zh-CN"
|
||||
summary_heading = "PR-Agent 摘要"
|
||||
else:
|
||||
response_language = "en-US"
|
||||
summary_heading = "PR-Agent Summary"
|
||||
print(f"head_sha={pull['head']['sha']}")
|
||||
print(f"changed_files={pull.get('changed_files') or 0}")
|
||||
print(f"response_language={response_language}")
|
||||
print(f"summary_heading={summary_heading}")
|
||||
print(f"skip_pr_agent={skip_pr_agent}")
|
||||
PY
|
||||
|
||||
- name: Prepare PR-Agent description markers
|
||||
if: >-
|
||||
steps.pr_context.outputs.skip_pr_agent != 'true' &&
|
||||
(
|
||||
github.event_name == 'pull_request_target' ||
|
||||
github.event.comment.body == '/describe' ||
|
||||
startsWith(github.event.comment.body, '/describe ')
|
||||
)
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
SUMMARY_HEADING: ${{ steps.pr_context.outputs.summary_heading }}
|
||||
CHANGED_FILES: ${{ steps.pr_context.outputs.changed_files }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
payload="$(mktemp)"
|
||||
python3 - "${payload}" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
pull = json.loads(subprocess.check_output(
|
||||
["gh", "api", f"repos/{os.environ['REPO']}/pulls/{os.environ['PR_NUMBER']}"],
|
||||
text=True,
|
||||
))
|
||||
body = pull.get("body") or ""
|
||||
start = "<!-- pr-agent-summary:start -->"
|
||||
end = "<!-- pr-agent-summary:end -->"
|
||||
heading = os.environ.get("SUMMARY_HEADING") or "PR-Agent 摘要"
|
||||
changed_files = int(os.environ.get("CHANGED_FILES") or 0)
|
||||
block = f"## {heading}\n\n{start}\npr_agent:summary\n{end}"
|
||||
start_index = body.find(start)
|
||||
end_index = body.find(end, start_index + len(start)) if start_index >= 0 else -1
|
||||
owned_block = re.compile(
|
||||
r"(?ims)^##\s+(?:PR-Agent\s+摘要|PR-Agent\s+Summary)\s*\n\s*"
|
||||
r"<!-- pr-agent-summary:start -->.*?<!-- pr-agent-summary:end -->\s*"
|
||||
)
|
||||
if changed_files == 0:
|
||||
updated = owned_block.sub("", body)
|
||||
if updated != body:
|
||||
updated = updated.rstrip()
|
||||
elif start_index >= 0 and end_index >= 0:
|
||||
body = re.sub(
|
||||
r"(?im)^##\s+(PR-Agent\s+摘要|PR-Agent\s+Summary)\s*\n\s*(?=<!-- pr-agent-summary:start -->)",
|
||||
f"## {heading}\n\n",
|
||||
body,
|
||||
)
|
||||
start_index = body.find(start)
|
||||
end_index = body.find(end, start_index + len(start))
|
||||
updated = body[:start_index] + f"{start}\npr_agent:summary\n{end}" + body[end_index + len(end):]
|
||||
elif start_index >= 0 or end in body:
|
||||
updated = body
|
||||
elif body.strip():
|
||||
updated = f"{body.rstrip()}\n\n{block}\n"
|
||||
else:
|
||||
updated = f"{block}\n"
|
||||
if updated != body:
|
||||
with open(sys.argv[1], "w", encoding="utf-8") as handle:
|
||||
json.dump({"body": updated}, handle, ensure_ascii=False)
|
||||
PY
|
||||
if [ -s "${payload}" ]; then
|
||||
gh api --method PATCH "repos/${REPO}/pulls/${PR_NUMBER}" --input "${payload}" >/dev/null
|
||||
fi
|
||||
|
||||
- name: Update PR description
|
||||
if: >-
|
||||
steps.pr_context.outputs.skip_pr_agent != 'true' &&
|
||||
steps.pr_context.outputs.changed_files != '0' &&
|
||||
(
|
||||
github.event_name == 'pull_request_target' ||
|
||||
github.event.comment.body == '/describe' ||
|
||||
startsWith(github.event.comment.body, '/describe ')
|
||||
)
|
||||
uses: docker://pragent/pr-agent:0.39.0-github_action@sha256:b253845caa8c7ff5ce8be78f32996647982bdd4890826a962b78eff2e385a825
|
||||
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.model: 'gpt-5.6-terra'
|
||||
config.fallback_models: '["gpt-5.5", "gpt-5.4"]'
|
||||
config.custom_model_max_tokens: '1050000'
|
||||
config.reasoning_effort: 'medium'
|
||||
config.ai_timeout: '900'
|
||||
config.response_language: ${{ steps.pr_context.outputs.response_language }}
|
||||
config.large_patch_policy: 'clip'
|
||||
config.ignore_pr_title: '["^\\[Auto\\]", "^Auto"]'
|
||||
config.ignore_pr_labels: '["skip pr-agent"]'
|
||||
github_action_config.auto_review: 'false'
|
||||
github_action_config.auto_describe: 'true'
|
||||
github_action_config.auto_improve: 'false'
|
||||
github_action_config.pr_actions: '["opened", "reopened", "ready_for_review", "review_requested"]'
|
||||
github_action_config.handle_push_trigger: 'true'
|
||||
github_action_config.push_commands: '["/describe"]'
|
||||
pr_description.generate_ai_title: 'false'
|
||||
pr_description.publish_labels: 'false'
|
||||
pr_description.publish_description_as_comment: 'false'
|
||||
pr_description.publish_description_as_comment_persistent: 'false'
|
||||
pr_description.enable_pr_diagram: 'false'
|
||||
pr_description.enable_pr_type: 'false'
|
||||
pr_description.enable_help_text: 'false'
|
||||
pr_description.enable_help_comment: 'false'
|
||||
pr_description.enable_semantic_files_types: 'false'
|
||||
pr_description.collapsible_file_list: 'adaptive'
|
||||
pr_description.add_original_user_description: 'true'
|
||||
pr_description.use_description_markers: 'true'
|
||||
pr_description.final_update_message: 'false'
|
||||
pr_description.extra_instructions: |
|
||||
Match the configured response language.
|
||||
Summarize the change goal, key implementation details, compatibility impact, tests, and notable risks.
|
||||
Use 2-4 bullets for small pull requests and 4-8 bullets for larger changes.
|
||||
Avoid file lists and local command transcripts.
|
||||
|
||||
# pull_request_target 事件默认自动执行 /review 和 /describe;/improve 保持手动触发。
|
||||
github_action_config.auto_review: "true"
|
||||
github_action_config.auto_describe: "true"
|
||||
github_action_config.auto_improve: "false"
|
||||
- name: Remove unfilled PR-Agent description marker
|
||||
if: >-
|
||||
always() &&
|
||||
steps.pr_context.outputs.skip_pr_agent != 'true' &&
|
||||
(
|
||||
github.event_name == 'pull_request_target' ||
|
||||
github.event.comment.body == '/describe' ||
|
||||
startsWith(github.event.comment.body, '/describe ')
|
||||
)
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
payload="$(mktemp)"
|
||||
python3 - "${payload}" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# 允许触发自动工具的 PR 动作。包含 synchronize,便于新 commit 推送后刷新结果。
|
||||
github_action_config.pr_actions: '["opened", "reopened", "ready_for_review", "review_requested", "synchronize"]'
|
||||
pull = json.loads(subprocess.check_output(
|
||||
["gh", "api", f"repos/{os.environ['REPO']}/pulls/{os.environ['PR_NUMBER']}"],
|
||||
text=True,
|
||||
))
|
||||
body = pull.get("body") or ""
|
||||
placeholder = "<!-- pr-agent-summary:start -->\npr_agent:summary\n<!-- pr-agent-summary:end -->"
|
||||
owned_block = re.compile(
|
||||
r"(?ims)^##\s+(?:PR-Agent\s+摘要|PR-Agent\s+Summary)\s*\n\s*"
|
||||
r"<!-- pr-agent-summary:start -->\s*pr_agent:summary\s*<!-- pr-agent-summary:end -->\s*"
|
||||
)
|
||||
if placeholder in body:
|
||||
updated = owned_block.sub("", body).rstrip()
|
||||
if updated != body:
|
||||
with open(sys.argv[1], "w", encoding="utf-8") as handle:
|
||||
json.dump({"body": updated}, handle, ensure_ascii=False)
|
||||
PY
|
||||
if [ -s "${payload}" ]; then
|
||||
gh api --method PATCH "repos/${REPO}/pulls/${PR_NUMBER}" --input "${payload}" >/dev/null
|
||||
fi
|
||||
|
||||
# 保留 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 输出策略,聚焦维护者需要处理的风险和缺口。
|
||||
- name: Analyze PR review
|
||||
id: review_analysis
|
||||
if: >-
|
||||
steps.pr_context.outputs.skip_pr_agent != 'true' &&
|
||||
(
|
||||
github.event_name == 'pull_request_target' ||
|
||||
github.event.comment.body == '/review' ||
|
||||
startsWith(github.event.comment.body, '/review ')
|
||||
)
|
||||
uses: docker://pragent/pr-agent:0.39.0-github_action@sha256:b253845caa8c7ff5ce8be78f32996647982bdd4890826a962b78eff2e385a825
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
|
||||
OPENAI.API_BASE: ${{ secrets.OPENAI_API_BASE }}
|
||||
config.model: ${{ github.event_name == 'issue_comment' && 'gpt-5.6-sol' || 'gpt-5.6-terra' }}
|
||||
config.fallback_models: '["gpt-5.5", "gpt-5.4"]'
|
||||
config.custom_model_max_tokens: '1050000'
|
||||
config.reasoning_effort: 'xhigh'
|
||||
config.ai_timeout: '900'
|
||||
config.response_language: ${{ steps.pr_context.outputs.response_language }}
|
||||
config.large_patch_policy: 'clip'
|
||||
config.ignore_pr_title: '["^\\[Auto\\]", "^Auto"]'
|
||||
config.ignore_pr_labels: '["skip pr-agent"]'
|
||||
config.publish_output: 'false'
|
||||
github_action_config.auto_review: 'true'
|
||||
github_action_config.auto_describe: 'false'
|
||||
github_action_config.auto_improve: 'false'
|
||||
github_action_config.pr_actions: '["opened", "reopened", "ready_for_review", "review_requested"]'
|
||||
github_action_config.handle_push_trigger: 'true'
|
||||
github_action_config.push_commands: '["/review"]'
|
||||
github_action_config.enable_output: 'true'
|
||||
pr_reviewer.num_max_findings: '4'
|
||||
pr_reviewer.require_score_review: 'false'
|
||||
pr_reviewer.require_tests_review: 'false'
|
||||
pr_reviewer.require_security_review: 'false'
|
||||
pr_reviewer.require_estimate_effort_to_review: 'false'
|
||||
pr_reviewer.require_estimate_contribution_time_cost: 'false'
|
||||
pr_reviewer.require_can_be_split_review: 'false'
|
||||
pr_reviewer.require_todo_scan: 'false'
|
||||
pr_reviewer.require_ticket_analysis_review: 'false'
|
||||
pr_reviewer.enable_review_labels_effort: 'false'
|
||||
pr_reviewer.enable_review_labels_security: 'false'
|
||||
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"
|
||||
Return key_issues_to_review only for concrete behavior defects introduced by this pull request.
|
||||
Each finding must identify the affected behavior, a reachable trigger, and the existing contract or invariant it violates.
|
||||
Use issue_content to state the smallest correction boundary, not a code patch.
|
||||
Do not report style preferences, comments, refactors, architecture alternatives, speculative races, extra hardening, optional tests, or hypothetical concerns.
|
||||
Return no findings when the evidence is incomplete.
|
||||
|
||||
# /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"
|
||||
- name: Publish review comments and summary
|
||||
if: >-
|
||||
steps.pr_context.outputs.skip_pr_agent != 'true' &&
|
||||
steps.review_analysis.outcome == 'success' &&
|
||||
(
|
||||
github.event_name == 'pull_request_target' ||
|
||||
github.event.comment.body == '/review' ||
|
||||
startsWith(github.event.comment.body, '/review ')
|
||||
)
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
REVIEWED_HEAD_SHA: ${{ steps.pr_context.outputs.head_sha }}
|
||||
CHANGED_FILES: ${{ steps.pr_context.outputs.changed_files }}
|
||||
RESPONSE_LANGUAGE: ${{ steps.pr_context.outputs.response_language }}
|
||||
REVIEW_JSON: ${{ steps.review_analysis.outputs.review }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
current_head_sha="$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.head.sha')"
|
||||
if [ "${current_head_sha}" != "${REVIEWED_HEAD_SHA}" ]; then
|
||||
echo "PR head changed during analysis; skip stale review publication."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 可选成本和噪音控制:
|
||||
# github_action_config.auto_improve: "true"
|
||||
# config.verbosity_level: "1"
|
||||
# pr_reviewer.num_max_findings: "3"
|
||||
review_payload="$(mktemp)"
|
||||
python3 - "${review_payload}" <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from urllib.parse import quote
|
||||
|
||||
review_raw = os.environ.get("REVIEW_JSON") or "{}"
|
||||
review = json.loads(review_raw)
|
||||
if not review_raw.strip() or review == {}:
|
||||
if int(os.environ.get("CHANGED_FILES") or 0):
|
||||
raise SystemExit("Review analysis produced no structured output for a non-empty PR.")
|
||||
review = {}
|
||||
|
||||
repo = os.environ["REPO"]
|
||||
number = os.environ["PR_NUMBER"]
|
||||
head_sha = os.environ["REVIEWED_HEAD_SHA"]
|
||||
language = os.environ.get("RESPONSE_LANGUAGE") or "zh-CN"
|
||||
|
||||
def paged(endpoint):
|
||||
result = json.loads(subprocess.check_output(
|
||||
["gh", "api", "--paginate", "--slurp", endpoint], text=True
|
||||
))
|
||||
if result and all(isinstance(page, list) for page in result):
|
||||
return [item for page in result for item in page]
|
||||
return result
|
||||
|
||||
files = paged(f"repos/{repo}/pulls/{number}/files?per_page=100")
|
||||
comments = paged(f"repos/{repo}/pulls/{number}/comments?per_page=100")
|
||||
reviews = paged(f"repos/{repo}/pulls/{number}/reviews?per_page=100")
|
||||
|
||||
hunk_pattern = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@")
|
||||
changed_lines = {}
|
||||
for file_data in files:
|
||||
path = str(file_data.get("filename") or "")
|
||||
line = None
|
||||
lines = set()
|
||||
for patch_line in (file_data.get("patch") or "").splitlines():
|
||||
hunk = hunk_pattern.match(patch_line)
|
||||
if hunk:
|
||||
line = int(hunk.group(1))
|
||||
continue
|
||||
if line is None or patch_line.startswith("\\"):
|
||||
continue
|
||||
if patch_line.startswith("+") and not patch_line.startswith("+++"):
|
||||
lines.add(line)
|
||||
line += 1
|
||||
elif patch_line.startswith("-") and not patch_line.startswith("---"):
|
||||
continue
|
||||
else:
|
||||
line += 1
|
||||
changed_lines[path] = lines
|
||||
|
||||
def fingerprint(path, line):
|
||||
normalized = "\n".join((path, str(line)))
|
||||
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
current_fingerprints = set()
|
||||
current_locations = set()
|
||||
marker_pattern = re.compile(r"<!-- pr-agent-review:([0-9a-f]{16}) -->")
|
||||
for comment in comments:
|
||||
if comment.get("user", {}).get("login") != "github-actions[bot]":
|
||||
continue
|
||||
if comment.get("line") is None:
|
||||
continue
|
||||
match = marker_pattern.search(str(comment.get("body") or ""))
|
||||
if match:
|
||||
current_fingerprints.add(match.group(1))
|
||||
path = str(comment.get("path") or "")
|
||||
try:
|
||||
line = int(comment.get("line") or 0)
|
||||
except (TypeError, ValueError):
|
||||
line = 0
|
||||
if path and line > 0:
|
||||
current_locations.add((path, line))
|
||||
|
||||
def code_url(path, line):
|
||||
return f"https://github.com/{repo}/blob/{head_sha}/{quote(path, safe='/')}#L{line}"
|
||||
|
||||
issues = review.get("key_issues_to_review") or []
|
||||
findings = []
|
||||
seen = set()
|
||||
for issue in issues:
|
||||
if not isinstance(issue, dict):
|
||||
continue
|
||||
path = str(issue.get("relevant_file") or "").strip()
|
||||
header = str(issue.get("issue_header") or "").strip()
|
||||
content = str(issue.get("issue_content") or "").strip()
|
||||
try:
|
||||
line = int(issue.get("start_line") or 0)
|
||||
except (TypeError, ValueError):
|
||||
line = 0
|
||||
if not path or not header or not content or line < 1:
|
||||
continue
|
||||
finding_key = (path, line, header.lower(), " ".join(content.split()).lower())
|
||||
if finding_key in seen:
|
||||
continue
|
||||
seen.add(finding_key)
|
||||
findings.append({
|
||||
"path": path,
|
||||
"line": line,
|
||||
"header": header,
|
||||
"content": content,
|
||||
"fingerprint": fingerprint(path, line),
|
||||
})
|
||||
|
||||
new_comments = []
|
||||
for finding in findings:
|
||||
if finding["line"] not in changed_lines.get(finding["path"], set()):
|
||||
continue
|
||||
if finding["fingerprint"] in current_fingerprints or (finding["path"], finding["line"]) in current_locations:
|
||||
continue
|
||||
new_comments.append({
|
||||
"path": finding["path"],
|
||||
"line": finding["line"],
|
||||
"side": "RIGHT",
|
||||
"body": "\n".join([
|
||||
f"<!-- pr-agent-review:{finding['fingerprint']} -->",
|
||||
f"**{finding['header']}**",
|
||||
"",
|
||||
finding["content"],
|
||||
]),
|
||||
})
|
||||
|
||||
marker = "<!-- pr-agent-review-summary -->"
|
||||
short_sha = head_sha[:7]
|
||||
commit_url = f"https://github.com/{repo}/commit/{head_sha}"
|
||||
chinese = language == "zh-CN"
|
||||
lines = [marker, "## PR-Agent Code Review", ""]
|
||||
if findings:
|
||||
for finding in findings:
|
||||
location = f"{finding['path']}:{finding['line']}"
|
||||
concise = " ".join(finding["content"].split())[:360]
|
||||
separator = ":" if chinese else ":"
|
||||
lines.append(f"- [{location}]({code_url(finding['path'], finding['line'])}){separator} **{finding['header']}** - {concise}")
|
||||
elif chinese:
|
||||
lines.append("本次变更无需提出审查意见,暂无其他反馈。")
|
||||
else:
|
||||
lines.append("There are no review comments for the current changes. I have no additional feedback to provide.")
|
||||
lines.extend([
|
||||
"",
|
||||
f"审查提交:[{short_sha}]({commit_url})" if chinese else f"Reviewed commit: [{short_sha}]({commit_url})",
|
||||
"",
|
||||
])
|
||||
payload = {
|
||||
"body": "\n".join(lines),
|
||||
"commit_id": head_sha,
|
||||
"event": "COMMENT",
|
||||
}
|
||||
if new_comments:
|
||||
payload["comments"] = new_comments
|
||||
has_matching_summary = not new_comments and any(
|
||||
existing.get("user", {}).get("login") == "github-actions[bot]"
|
||||
and existing.get("commit_id") == head_sha
|
||||
and str(existing.get("body") or "") == payload["body"]
|
||||
for existing in reviews
|
||||
)
|
||||
# 同一提交的手工重审仍会完成分析;完全相同的结果不重复发布 Review。
|
||||
if not has_matching_summary:
|
||||
with open(sys.argv[1], "w", encoding="utf-8") as handle:
|
||||
json.dump(payload, handle, ensure_ascii=False)
|
||||
PY
|
||||
|
||||
latest_head_sha="$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.head.sha')"
|
||||
if [ "${latest_head_sha}" != "${REVIEWED_HEAD_SHA}" ]; then
|
||||
echo "PR head changed while rendering review; skip stale review publication."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -s "${review_payload}" ]; then
|
||||
gh api --method POST "repos/${REPO}/pulls/${PR_NUMBER}/reviews" --input "${review_payload}" >/dev/null
|
||||
fi
|
||||
old_summary_ids="$(gh api --paginate "repos/${REPO}/issues/${PR_NUMBER}/comments?per_page=100" --jq ".[] | select(.user.login == \"github-actions[bot]\" and ((.body | startswith(\"<!-- pr-agent-update-notification -->\")) or (.body | startswith(\"<!-- pr-agent-code-review-summary -->\")) or (.body | startswith(\"<!-- pr-agent-review-summary -->\")) or (.body | startswith(\"<!-- pr-agent-lab:review -->\")))) | .id")"
|
||||
while IFS= read -r comment_id; do
|
||||
[ -z "${comment_id}" ] && continue
|
||||
gh api --method DELETE "repos/${REPO}/issues/comments/${comment_id}" >/dev/null
|
||||
done <<< "${old_summary_ids}"
|
||||
|
||||
- name: Answer PR question
|
||||
if: >-
|
||||
steps.pr_context.outputs.skip_pr_agent != 'true' &&
|
||||
github.event_name == 'issue_comment' &&
|
||||
(
|
||||
github.event.comment.body == '/ask' ||
|
||||
startsWith(github.event.comment.body, '/ask ')
|
||||
)
|
||||
uses: docker://pragent/pr-agent:0.39.0-github_action@sha256:b253845caa8c7ff5ce8be78f32996647982bdd4890826a962b78eff2e385a825
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
|
||||
OPENAI.API_BASE: ${{ secrets.OPENAI_API_BASE }}
|
||||
config.model: 'gpt-5.6-terra'
|
||||
config.fallback_models: '["gpt-5.5", "gpt-5.4"]'
|
||||
config.custom_model_max_tokens: '1050000'
|
||||
config.reasoning_effort: 'high'
|
||||
config.ai_timeout: '900'
|
||||
config.response_language: ${{ steps.pr_context.outputs.response_language }}
|
||||
config.large_patch_policy: 'clip'
|
||||
config.ignore_pr_title: '["^\\[Auto\\]", "^Auto"]'
|
||||
config.ignore_pr_labels: '["skip pr-agent"]'
|
||||
github_action_config.auto_review: 'false'
|
||||
github_action_config.auto_describe: 'false'
|
||||
github_action_config.auto_improve: 'false'
|
||||
|
||||
@@ -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)
|
||||
@@ -509,8 +508,8 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
return (
|
||||
"抱歉,您没有执行此工具的权限。"
|
||||
"只有渠道管理员或系统管理员才能执行工具操作。"
|
||||
"如需执行工具,请联系渠道管理员将您的用户ID添加到渠道管理员列表中,"
|
||||
"或联系系统管理员为您设置权限。"
|
||||
"如需执行工具,请联系管理员将您的用户ID添加到渠道管理员列表中(设定 -> 通知 -> 对应渠道配置 -> 管理员名单),"
|
||||
"或联系系统管理员为您设置管理员权限。"
|
||||
)
|
||||
|
||||
async def _has_channel_admin_permission(self) -> bool:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -18,7 +18,7 @@ from app.utils.system import SystemUtils
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _build_statistic(name: Optional[str] = None) -> schemas.Statistic:
|
||||
def _build_statistic(db: Session, name: Optional[str] = None) -> schemas.Statistic:
|
||||
"""
|
||||
构建媒体数量统计信息。
|
||||
"""
|
||||
@@ -39,8 +39,14 @@ def _build_statistic(name: Optional[str] = None) -> schemas.Statistic:
|
||||
if not has_episode_count:
|
||||
# 所有媒体服务都未提供剧集统计时,返回 None 供前端展示“未获取”。
|
||||
ret_statistic.episode_count = None
|
||||
return ret_statistic
|
||||
return schemas.Statistic()
|
||||
else:
|
||||
ret_statistic = schemas.Statistic()
|
||||
|
||||
movie_count_month, tv_count_month, episode_count_month = TransferHistory.monthly_media_statistics(db)
|
||||
ret_statistic.movie_count_month = movie_count_month
|
||||
ret_statistic.tv_count_month = tv_count_month
|
||||
ret_statistic.episode_count_month = episode_count_month
|
||||
return ret_statistic
|
||||
|
||||
|
||||
def _build_storage() -> schemas.Storage:
|
||||
@@ -84,22 +90,27 @@ def _build_downloader(name: Optional[str] = None) -> schemas.DownloaderInfo:
|
||||
|
||||
@router.get("/statistic", summary="媒体数量统计", response_model=schemas.Statistic)
|
||||
def statistic(
|
||||
name: Optional[str] = None, _: Any = Depends(get_current_active_superuser)
|
||||
name: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
_: Any = Depends(get_current_active_superuser),
|
||||
) -> Any:
|
||||
"""
|
||||
查询媒体数量统计信息
|
||||
"""
|
||||
return _build_statistic(name)
|
||||
return _build_statistic(db, name)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/statistic2", summary="媒体数量统计(API_TOKEN)", response_model=schemas.Statistic
|
||||
)
|
||||
def statistic2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
|
||||
def statistic2(
|
||||
_: Annotated[str, Depends(verify_apitoken)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
"""
|
||||
查询媒体数量统计信息 API_TOKEN认证(?token=xxx)
|
||||
"""
|
||||
return _build_statistic()
|
||||
return _build_statistic(db)
|
||||
|
||||
|
||||
@router.get("/storage", summary="本地存储空间", response_model=schemas.Storage)
|
||||
@@ -128,6 +139,14 @@ def processes(_: Any = Depends(get_current_active_superuser)) -> Any:
|
||||
return SystemUtils.processes()
|
||||
|
||||
|
||||
@router.get("/system", summary="系统摘要信息", response_model=schemas.DashboardSystemInfo)
|
||||
def system_info(_: Any = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
查询仪表板系统摘要信息
|
||||
"""
|
||||
return SystemUtils.dashboard_system_info()
|
||||
|
||||
|
||||
@router.get("/downloader", summary="下载器信息", response_model=schemas.DownloaderInfo)
|
||||
def downloader(
|
||||
name: Optional[str] = None, _: Any = Depends(get_current_active_superuser)
|
||||
@@ -158,6 +177,23 @@ async def schedule(_: Any = Depends(get_current_active_superuser)) -> Any:
|
||||
return Scheduler().list()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/schedule/{job_id}/progress",
|
||||
summary="后台服务进度",
|
||||
response_model=schemas.Response,
|
||||
)
|
||||
async def schedule_progress(
|
||||
job_id: str, _: Any = Depends(get_current_active_superuser)
|
||||
) -> Any:
|
||||
"""
|
||||
查询指定后台服务的执行进度。
|
||||
"""
|
||||
progress = Scheduler().get_progress(job_id)
|
||||
if not progress:
|
||||
return schemas.Response(success=False, message="后台服务不存在")
|
||||
return schemas.Response(success=True, data=progress.model_dump())
|
||||
|
||||
|
||||
@router.get(
|
||||
"/schedule2",
|
||||
summary="后台服务(API_TOKEN)",
|
||||
@@ -170,6 +206,23 @@ async def schedule2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
|
||||
return Scheduler().list()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/schedule2/{job_id}/progress",
|
||||
summary="后台服务进度(API_TOKEN)",
|
||||
response_model=schemas.Response,
|
||||
)
|
||||
async def schedule_progress2(
|
||||
job_id: str, _: Annotated[str, Depends(verify_apitoken)]
|
||||
) -> Any:
|
||||
"""
|
||||
查询指定后台服务的执行进度 API_TOKEN认证(?token=xxx)
|
||||
"""
|
||||
progress = Scheduler().get_progress(job_id)
|
||||
if not progress:
|
||||
return schemas.Response(success=False, message="后台服务不存在")
|
||||
return schemas.Response(success=True, data=progress.model_dump())
|
||||
|
||||
|
||||
@router.get("/transfer", summary="文件整理统计", response_model=List[int])
|
||||
async def transfer(
|
||||
days: Optional[int] = 7,
|
||||
@@ -199,22 +252,26 @@ def cpu2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
|
||||
return SystemUtils.cpu_usage()
|
||||
|
||||
|
||||
@router.get("/memory", summary="获取当前内存使用量和使用率", response_model=List[int])
|
||||
@router.get(
|
||||
"/memory",
|
||||
summary="获取当前应用与系统内存信息",
|
||||
response_model=schemas.DashboardMemoryInfo,
|
||||
)
|
||||
def memory(_: Any = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
获取当前内存使用率
|
||||
获取当前应用与系统内存信息
|
||||
"""
|
||||
return SystemUtils.memory_usage()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/memory2",
|
||||
summary="获取当前内存使用量和使用率(API_TOKEN)",
|
||||
response_model=List[int],
|
||||
summary="获取当前应用与系统内存信息(API_TOKEN)",
|
||||
response_model=schemas.DashboardMemoryInfo,
|
||||
)
|
||||
def memory2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
|
||||
"""
|
||||
获取当前内存使用率 API_TOKEN认证(?token=xxx)
|
||||
获取当前应用与系统内存信息 API_TOKEN认证(?token=xxx)
|
||||
"""
|
||||
return SystemUtils.memory_usage()
|
||||
|
||||
|
||||
@@ -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 批量重新整理,并返回进度键。
|
||||
|
||||
+28
-17
@@ -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
|
||||
|
||||
|
||||
@@ -48,7 +49,7 @@ class LlmProviderAuthStartRequest(BaseModel):
|
||||
method: str
|
||||
|
||||
|
||||
def _sanitize_llm_test_error(message: str, api_key: Optional[str] = None) -> str:
|
||||
def _sanitize_llm_error(message: str, api_key: Optional[str] = None) -> str:
|
||||
"""
|
||||
清理错误信息中的敏感字段,避免回显密钥。
|
||||
"""
|
||||
@@ -70,11 +71,14 @@ def _sanitize_llm_test_error(message: str, api_key: Optional[str] = None) -> str
|
||||
)
|
||||
|
||||
normalized_message = sanitized.lower().replace("_", "").replace(" ", "")
|
||||
if "str" in normalized_message and "modeldump" in normalized_message:
|
||||
if "str" in normalized_message and (
|
||||
"modeldump" in normalized_message
|
||||
or "setprivateattributes" in normalized_message
|
||||
):
|
||||
return (
|
||||
"服务返回内容不是兼容的模型响应,"
|
||||
"请检查基础地址是否填写为 API Base URL,不要填写网页地址或完整的 "
|
||||
"chat/completions 路径"
|
||||
"服务返回内容不是兼容的模型响应,请检查基础地址是否填写为 "
|
||||
"API Base URL,如果服务要求 /v1 等版本路径,请包含在基础地址中,"
|
||||
"不要填写网页地址或完整的 chat/completions 路径"
|
||||
)
|
||||
return sanitized
|
||||
|
||||
@@ -113,7 +117,10 @@ async def get_llm_models(
|
||||
},
|
||||
)
|
||||
except Exception as err:
|
||||
return schemas.Response(success=False, message=str(err))
|
||||
return schemas.Response(
|
||||
success=False,
|
||||
message=_sanitize_llm_error(str(err), api_key),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/providers", summary="获取LLM提供商目录", response_model=schemas.Response)
|
||||
@@ -286,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,
|
||||
@@ -312,5 +323,5 @@ async def llm_test(
|
||||
except Exception as err:
|
||||
return schemas.Response(
|
||||
success=False,
|
||||
message=_sanitize_llm_test_error(str(err), payload.api_key),
|
||||
message=_sanitize_llm_error(str(err), payload.api_key),
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -17,7 +17,7 @@ from app.db.message_oper import MessageOper
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.db.user_oper import get_current_active_superuser
|
||||
from app.helper.service import ServiceConfigHelper
|
||||
from app.helper.webpush import is_webpush_subscription_gone
|
||||
from app.helper.webpush import is_webpush_subscription_gone, webpush_options_for_endpoint
|
||||
from app.log import logger
|
||||
from app.modules.wechat.WXBizMsgCrypt3 import WXBizMsgCrypt
|
||||
from app.schemas.types import MessageChannel, SystemConfigKey
|
||||
@@ -316,6 +316,7 @@ def send_notification(
|
||||
data=json.dumps(payload.model_dump()),
|
||||
vapid_private_key=settings.VAPID.get("privateKey"),
|
||||
vapid_claims={"sub": settings.VAPID.get("subject")},
|
||||
**webpush_options_for_endpoint(sub.get("endpoint")),
|
||||
)
|
||||
except WebPushException as err:
|
||||
logger.error(f"WebPush发送失败: {str(err)}")
|
||||
|
||||
@@ -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,
|
||||
|
||||
+84
-27
@@ -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")
|
||||
@@ -615,9 +672,9 @@ def reset_plugin(
|
||||
# 事件处理器需要运行中插件完成补偿;补偿后先停止插件,避免删除数据时仍有任务读写旧状态。
|
||||
plugin_manager.stop(plugin_id)
|
||||
# 删除配置
|
||||
plugin_manager.delete_plugin_config(plugin_id)
|
||||
plugin_manager.delete_plugin_config(plugin_id, force=True)
|
||||
# 删除插件所有数据
|
||||
plugin_manager.delete_plugin_data(plugin_id)
|
||||
plugin_manager.delete_plugin_data(plugin_id, force=True)
|
||||
# 重新加载插件
|
||||
reload_plugin(plugin_id)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
@@ -1271,13 +1273,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})
|
||||
|
||||
|
||||
|
||||
@@ -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数据
|
||||
|
||||
+314
-19
@@ -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,
|
||||
@@ -478,7 +681,8 @@ class DownloadChain(ChainBase):
|
||||
userid: Union[str, int] = None,
|
||||
username: Optional[str] = None,
|
||||
label: Optional[str] = None,
|
||||
return_detail: bool = False) -> Union[Optional[str], Tuple[Optional[str], Optional[str]]]:
|
||||
return_detail: bool = False,
|
||||
custom_words: Optional[str] = None) -> Union[Optional[str], Tuple[Optional[str], Optional[str]]]:
|
||||
"""
|
||||
下载及发送通知
|
||||
:param context: 资源上下文
|
||||
@@ -493,6 +697,7 @@ class DownloadChain(ChainBase):
|
||||
:param username: 调用下载的用户名/插件名
|
||||
:param label: 自定义标签
|
||||
:param return_detail: 是否返回详细结果;False 时返回下载任务 hash 或 None,True 时返回 (hash, error_msg)
|
||||
:param custom_words: 下载来源(如订阅)的完整自定义识别词文本,随下载记录存档,供整理时原样复现识别
|
||||
:return: return_detail=False 时返回下载任务 hash 或 None;return_detail=True 时返回 (hash, error_msg)
|
||||
"""
|
||||
_torrent = context.torrent_info
|
||||
@@ -525,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,
|
||||
@@ -561,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
|
||||
|
||||
# 获取种子文件的文件夹名和文件清单
|
||||
@@ -568,7 +787,7 @@ class DownloadChain(ChainBase):
|
||||
|
||||
storage = 'local'
|
||||
# 下载目录
|
||||
if save_path:
|
||||
if save_path is not None:
|
||||
download_dir = Path(save_path)
|
||||
else:
|
||||
# 根据媒体信息查询下载目录配置
|
||||
@@ -649,7 +868,8 @@ class DownloadChain(ChainBase):
|
||||
date=time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
|
||||
media_category=_media.category,
|
||||
episode_group=_media.episode_group,
|
||||
note={"source": source}
|
||||
note={"source": source},
|
||||
custom_words=custom_words
|
||||
)
|
||||
|
||||
# 登记下载文件
|
||||
@@ -714,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,
|
||||
@@ -738,7 +965,8 @@ class DownloadChain(ChainBase):
|
||||
source: Optional[str] = None,
|
||||
userid: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
downloader: Optional[str] = None
|
||||
downloader: Optional[str] = None,
|
||||
custom_words: Optional[str] = None
|
||||
) -> Tuple[List[Context], Dict[Union[int, str], Dict[int, NotExistMediaInfo]]]:
|
||||
"""
|
||||
根据缺失数据,自动种子列表中组合择优下载
|
||||
@@ -750,6 +978,7 @@ class DownloadChain(ChainBase):
|
||||
:param userid: 用户ID
|
||||
:param username: 调用下载的用户名/插件名
|
||||
:param downloader: 下载器
|
||||
:param custom_words: 下载来源(如订阅)的完整自定义识别词文本,随下载记录存档,供整理时原样复现识别
|
||||
:return: 已经下载的资源列表、剩余未下载到的剧集 no_exists[tmdb_id/douban_id] = {season: NotExistMediaInfo}
|
||||
"""
|
||||
# 已下载的项目
|
||||
@@ -877,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()
|
||||
@@ -884,17 +1135,21 @@ 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
|
||||
logger.info(f"开始下载电影 {context.torrent_info.title} ...")
|
||||
if self.download_single(context, save_path=save_path, channel=channel,
|
||||
source=source, userid=userid, username=username,
|
||||
downloader=downloader):
|
||||
downloader=downloader, custom_words=custom_words):
|
||||
# 下载成功
|
||||
logger.info(f"{context.torrent_info.title} 添加下载成功")
|
||||
downloaded_list.append(context)
|
||||
downloaded_movies.add(movie_key)
|
||||
else:
|
||||
__remember_context_failure(context)
|
||||
|
||||
# 电视剧整季匹配
|
||||
if no_exists:
|
||||
@@ -939,14 +1194,24 @@ 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
|
||||
if len(torrent_season) == 1:
|
||||
# 只有一季的可能是命名错误,需要打开种子鉴别,只有实际集数大于等于总集数才下载
|
||||
logger.info(f"开始下载种子 {torrent.title} ...")
|
||||
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} 下载地址是磁力链,无法确定种子文件集数")
|
||||
@@ -965,7 +1230,13 @@ class DownloadChain(ChainBase):
|
||||
required_episodes = __get_required_episodes(need_mid, torrent_season[0]) \
|
||||
if __requires_complete_coverage(need_tv_info) else set()
|
||||
need_total = __get_season_episodes(need_mid, torrent_season[0])
|
||||
if required_episodes and not required_episodes.issubset(torrent_episodes_set):
|
||||
complete_coverage_matched = bool(required_episodes) \
|
||||
and required_episodes.issubset(torrent_episodes_set)
|
||||
if complete_coverage_matched:
|
||||
logger.info(
|
||||
f"{meta.org_string} 解析文件集数已完整覆盖目标范围:"
|
||||
f"{StringUtils.format_ep(sorted(required_episodes))}")
|
||||
if required_episodes and not complete_coverage_matched:
|
||||
missing_episodes = sorted(required_episodes.difference(torrent_episodes_set))
|
||||
logger.info(
|
||||
f"{meta.org_string} 解析文件集数未覆盖目标范围,"
|
||||
@@ -986,7 +1257,8 @@ class DownloadChain(ChainBase):
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=username,
|
||||
downloader=downloader
|
||||
downloader=downloader,
|
||||
custom_words=custom_words
|
||||
)
|
||||
else:
|
||||
# 下载
|
||||
@@ -994,10 +1266,13 @@ class DownloadChain(ChainBase):
|
||||
download_id = self.download_single(context, save_path=save_path,
|
||||
channel=channel, source=source,
|
||||
userid=userid, username=username,
|
||||
downloader=downloader)
|
||||
downloader=downloader,
|
||||
custom_words=custom_words)
|
||||
|
||||
if download_id:
|
||||
# 下载成功
|
||||
if complete_coverage_matched:
|
||||
context.confirmed_full_coverage = True
|
||||
logger.info(f"{torrent.title} 添加下载成功")
|
||||
downloaded_list.append(context)
|
||||
# 更新仍需季集
|
||||
@@ -1008,6 +1283,8 @@ class DownloadChain(ChainBase):
|
||||
if not need_season:
|
||||
# 全部下载完成
|
||||
break
|
||||
else:
|
||||
__remember_context_failure(context)
|
||||
# 电视剧季内的集匹配
|
||||
if no_exists:
|
||||
logger.info(f"开始电视剧完整集匹配:{no_exists}")
|
||||
@@ -1048,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
|
||||
# 只处理单季含集的种子
|
||||
@@ -1076,9 +1355,12 @@ class DownloadChain(ChainBase):
|
||||
download_id = self.download_single(context, save_path=save_path,
|
||||
channel=channel, source=source,
|
||||
userid=userid, username=username,
|
||||
downloader=downloader)
|
||||
downloader=downloader,
|
||||
custom_words=custom_words)
|
||||
if download_id:
|
||||
# 下载成功
|
||||
if __requires_complete_coverage(tv):
|
||||
context.confirmed_full_coverage = True
|
||||
logger.info(f"{meta.title} 添加下载成功")
|
||||
downloaded_list.append(context)
|
||||
# 更新仍需集数
|
||||
@@ -1087,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:
|
||||
@@ -1129,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
|
||||
@@ -1147,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} 下载地址是磁力链,无法解析种子文件集数")
|
||||
@@ -1171,9 +1464,11 @@ class DownloadChain(ChainBase):
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=username,
|
||||
downloader=downloader
|
||||
downloader=downloader,
|
||||
custom_words=custom_words
|
||||
)
|
||||
if not download_id:
|
||||
__remember_context_failure(context)
|
||||
continue
|
||||
# 下载成功
|
||||
logger.info(f"{torrent.title} 添加下载成功")
|
||||
|
||||
+247
-11
@@ -1,6 +1,6 @@
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from typing import List, Union, Optional, Generator, Any
|
||||
from typing import Callable, List, Union, Optional, Generator, Any
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.core.config import global_vars
|
||||
@@ -109,6 +109,35 @@ class MediaServerChain(ChainBase):
|
||||
yield from self.run_module("mediaserver_items", server=server, library_id=library_id,
|
||||
start_index=start_index, limit=limit)
|
||||
|
||||
def items_count(self, server: str, library_id: Union[str, int]) -> Optional[int]:
|
||||
"""
|
||||
获取指定媒体库可同步的媒体条目总数
|
||||
|
||||
:param server: 媒体服务器名称
|
||||
:param library_id: 媒体库ID
|
||||
:return: 媒体条目总数,无法获取时返回None
|
||||
"""
|
||||
return self.run_module(
|
||||
"mediaserver_items_count",
|
||||
server=server,
|
||||
library_id=library_id,
|
||||
)
|
||||
|
||||
def media_count(self, server: str) -> Optional[int]:
|
||||
"""
|
||||
获取指定媒体服务器可同步的电影和电视剧总数
|
||||
|
||||
:param server: 媒体服务器名称
|
||||
:return: 电影和电视剧总数,无法获取时返回None
|
||||
"""
|
||||
statistics = self.run_module("media_statistic", server=server)
|
||||
if not statistics:
|
||||
return None
|
||||
return sum(
|
||||
(statistic.movie_count or 0) + (statistic.tv_count or 0)
|
||||
for statistic in statistics
|
||||
)
|
||||
|
||||
def iteminfo(self, server: str, item_id: Union[str, int]) -> MediaServerItem:
|
||||
"""
|
||||
获取媒体服务器项目信息
|
||||
@@ -191,13 +220,17 @@ class MediaServerChain(ChainBase):
|
||||
"mediaserver_image_cookies", server=server, image_url=image_url
|
||||
)
|
||||
|
||||
def sync(self):
|
||||
def sync(self, progress_callback: Optional[Callable[..., None]] = None) -> None:
|
||||
"""
|
||||
同步媒体库所有数据到本地数据库
|
||||
|
||||
:param progress_callback: 定时服务进度更新回调
|
||||
"""
|
||||
# 设置的媒体服务器
|
||||
mediaservers = ServiceConfigHelper.get_mediaserver_configs()
|
||||
if not mediaservers:
|
||||
if progress_callback:
|
||||
progress_callback(value=100, text="未配置媒体服务器,跳过同步")
|
||||
return
|
||||
with lock:
|
||||
# 汇总统计
|
||||
@@ -206,7 +239,86 @@ class MediaServerChain(ChainBase):
|
||||
enabled_servers = [mediaserver.name for mediaserver in mediaservers
|
||||
if mediaserver and mediaserver.enabled and mediaserver.name]
|
||||
dboper.delete_excluded_servers(enabled_servers)
|
||||
total_servers = len(enabled_servers)
|
||||
if progress_callback:
|
||||
progress_callback(
|
||||
value=0,
|
||||
text=f"开始同步媒体服务器,共 {total_servers} 个 ...",
|
||||
data={"total": total_servers, "finished": 0},
|
||||
)
|
||||
if not total_servers:
|
||||
if progress_callback:
|
||||
progress_callback(value=100, text="没有已启用的媒体服务器")
|
||||
return
|
||||
|
||||
server_sync_contexts = {}
|
||||
global_media_total = 0
|
||||
global_counts_available = True
|
||||
for mediaserver in mediaservers:
|
||||
if not mediaserver or not mediaserver.enabled:
|
||||
continue
|
||||
server_name = mediaserver.name
|
||||
logger.info(f"正在统计媒体服务器 {server_name} 的待同步媒体数量")
|
||||
libraries = self.librarys(server_name)
|
||||
if not libraries:
|
||||
server_sync_contexts[server_name] = None
|
||||
continue
|
||||
|
||||
sync_libraries = mediaserver.sync_libraries or []
|
||||
selected_libraries = []
|
||||
for library in libraries:
|
||||
if sync_libraries \
|
||||
and "all" not in sync_libraries \
|
||||
and str(library.id) not in sync_libraries:
|
||||
logger.info(f"{library.name} 未在 {server_name} 同步媒体库列表中,跳过")
|
||||
continue
|
||||
selected_libraries.append(library)
|
||||
|
||||
library_media_counts = {
|
||||
str(library.id): None for library in selected_libraries
|
||||
}
|
||||
sync_all_libraries = (
|
||||
not sync_libraries or "all" in sync_libraries
|
||||
)
|
||||
server_media_count = (
|
||||
self.media_count(server_name)
|
||||
if sync_all_libraries else None
|
||||
)
|
||||
if server_media_count:
|
||||
global_media_total += server_media_count
|
||||
logger.info(
|
||||
f"媒体服务器 {server_name} 共 {server_media_count} 个媒体待同步"
|
||||
)
|
||||
else:
|
||||
for library in selected_libraries:
|
||||
media_count = self.items_count(
|
||||
server=server_name,
|
||||
library_id=library.id,
|
||||
)
|
||||
library_media_counts[str(library.id)] = media_count
|
||||
if media_count is None:
|
||||
global_counts_available = False
|
||||
logger.warning(
|
||||
f"未获取到 {server_name} 媒体库 {library.name} 的媒体总数,"
|
||||
f"同步进度将按媒体库完成度计算"
|
||||
)
|
||||
else:
|
||||
global_media_total += media_count
|
||||
logger.info(
|
||||
f"{server_name} 媒体库 {library.name}"
|
||||
f"共 {media_count} 个媒体待同步"
|
||||
)
|
||||
server_sync_contexts[server_name] = (
|
||||
selected_libraries,
|
||||
library_media_counts,
|
||||
)
|
||||
|
||||
if not global_counts_available:
|
||||
global_media_total = None
|
||||
|
||||
# 遍历媒体服务器
|
||||
server_index = 0
|
||||
global_media_finished = 0
|
||||
for mediaserver in mediaservers:
|
||||
if not mediaserver:
|
||||
continue
|
||||
@@ -214,21 +326,55 @@ class MediaServerChain(ChainBase):
|
||||
if not mediaserver.enabled:
|
||||
logger.info(f"媒体服务器 {mediaserver.name} 未启用,跳过")
|
||||
continue
|
||||
server_index += 1
|
||||
server_name = mediaserver.name
|
||||
sync_libraries = mediaserver.sync_libraries or []
|
||||
if progress_callback:
|
||||
progress_value = (
|
||||
global_media_finished / global_media_total * 100
|
||||
if global_media_total else
|
||||
(server_index - 1) / total_servers * 100
|
||||
)
|
||||
progress_callback(
|
||||
value=progress_value,
|
||||
text=(
|
||||
f"正在同步媒体服务器"
|
||||
f"({server_index}/{total_servers}){server_name} ..."
|
||||
),
|
||||
data={
|
||||
"total": total_servers,
|
||||
"finished": server_index - 1,
|
||||
"current": server_name,
|
||||
"media_total": global_media_total,
|
||||
"media_finished": global_media_finished,
|
||||
},
|
||||
)
|
||||
logger.info(f"开始同步媒体服务器 {server_name} 的数据 ...")
|
||||
libraries = self.librarys(server_name)
|
||||
if not libraries:
|
||||
sync_context = server_sync_contexts.get(server_name)
|
||||
if sync_context is None:
|
||||
logger.info(f"没有获取到媒体服务器 {server_name} 的媒体库,跳过")
|
||||
if progress_callback:
|
||||
progress_value = (
|
||||
global_media_finished / global_media_total * 100
|
||||
if global_media_total else
|
||||
server_index / total_servers * 100
|
||||
)
|
||||
progress_callback(
|
||||
value=progress_value,
|
||||
text=f"媒体服务器 {server_name} 无可同步媒体库",
|
||||
data={
|
||||
"total": total_servers,
|
||||
"finished": server_index,
|
||||
"media_total": global_media_total,
|
||||
"media_finished": global_media_finished,
|
||||
},
|
||||
)
|
||||
continue
|
||||
sync_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
|
||||
for library in libraries:
|
||||
if sync_libraries \
|
||||
and "all" not in sync_libraries \
|
||||
and str(library.id) not in sync_libraries:
|
||||
logger.info(f"{library.name} 未在 {server_name} 同步媒体库列表中,跳过")
|
||||
continue
|
||||
selected_libraries, library_media_counts = sync_context
|
||||
total_libraries = len(selected_libraries)
|
||||
for library_index, library in enumerate(selected_libraries, start=1):
|
||||
logger.info(f"正在同步 {server_name} 媒体库 {library.name} ...")
|
||||
library_media_total = library_media_counts.get(str(library.id))
|
||||
library_count = 0
|
||||
for item in self.items(server=server_name, library_id=library.id):
|
||||
if global_vars.is_system_stopped:
|
||||
@@ -238,6 +384,7 @@ class MediaServerChain(ChainBase):
|
||||
logger.debug(f"正在同步 {item.title} ...")
|
||||
# 计数
|
||||
library_count += 1
|
||||
global_media_finished += 1
|
||||
seasoninfo = {}
|
||||
# 类型
|
||||
item_type = "电视剧" if item.item_type in ["Series", "show"] else "电影"
|
||||
@@ -252,9 +399,98 @@ class MediaServerChain(ChainBase):
|
||||
item_dict["item_type"] = item_type
|
||||
item_dict["lst_mod_date"] = sync_time
|
||||
dboper.upsert(**item_dict)
|
||||
if progress_callback:
|
||||
if global_media_total:
|
||||
progress_value = min(
|
||||
global_media_finished / global_media_total,
|
||||
1,
|
||||
) * 100
|
||||
else:
|
||||
library_progress = (
|
||||
min(library_count / library_media_total, 1)
|
||||
if library_media_total else 0
|
||||
)
|
||||
server_progress = (
|
||||
library_index - 1 + library_progress
|
||||
) / total_libraries
|
||||
progress_value = (
|
||||
server_index - 1 + server_progress
|
||||
) / total_servers * 100
|
||||
progress_callback(
|
||||
value=progress_value,
|
||||
text=(
|
||||
f"正在同步 {server_name} 媒体库 {library.name}"
|
||||
f"({library_count}/{library_media_total})"
|
||||
if library_media_total is not None
|
||||
else f"正在同步 {server_name} 媒体库 {library.name}"
|
||||
),
|
||||
data={
|
||||
"total": total_servers,
|
||||
"finished": server_index - 1,
|
||||
"current": server_name,
|
||||
"library_total": total_libraries,
|
||||
"library_finished": library_index - 1,
|
||||
"current_library": library.name,
|
||||
"library_media_total": library_media_total,
|
||||
"library_media_finished": library_count,
|
||||
"media_total": global_media_total,
|
||||
"media_finished": global_media_finished,
|
||||
},
|
||||
)
|
||||
logger.info(f"{server_name} 媒体库 {library.name} 同步完成,共同步数量:{library_count}")
|
||||
# 总数累加
|
||||
total_count += library_count
|
||||
if progress_callback:
|
||||
if global_media_total:
|
||||
progress_value = min(
|
||||
global_media_finished / global_media_total,
|
||||
1,
|
||||
) * 100
|
||||
else:
|
||||
server_progress = library_index / total_libraries
|
||||
progress_value = (
|
||||
server_index - 1 + server_progress
|
||||
) / total_servers * 100
|
||||
progress_callback(
|
||||
value=progress_value,
|
||||
text=(
|
||||
f"{server_name} 媒体库"
|
||||
f"({library_index}/{total_libraries}){library.name} 同步完成"
|
||||
),
|
||||
data={
|
||||
"total": total_servers,
|
||||
"finished": server_index - 1,
|
||||
"current": server_name,
|
||||
"library_total": total_libraries,
|
||||
"library_finished": library_index,
|
||||
"current_library": library.name,
|
||||
"library_media_total": library_media_total,
|
||||
"library_media_finished": library_count,
|
||||
"media_total": global_media_total,
|
||||
"media_finished": global_media_finished,
|
||||
},
|
||||
)
|
||||
stale_count = dboper.delete_stale(server=server_name, sync_time=sync_time)
|
||||
logger.info(f"媒体服务器 {server_name} 清理陈旧数据完成,删除数量:{stale_count}")
|
||||
logger.info(f"媒体服务器 {server_name} 数据同步完成,总同步数量:{total_count}")
|
||||
if progress_callback:
|
||||
progress_value = (
|
||||
min(global_media_finished / global_media_total, 1) * 100
|
||||
if global_media_total else
|
||||
server_index / total_servers * 100
|
||||
)
|
||||
progress_callback(
|
||||
value=progress_value,
|
||||
text=(
|
||||
f"媒体服务器({server_index}/{total_servers})"
|
||||
f"{server_name} 同步完成"
|
||||
),
|
||||
data={
|
||||
"total": total_servers,
|
||||
"finished": server_index,
|
||||
"media_total": global_media_total,
|
||||
"media_finished": global_media_finished,
|
||||
},
|
||||
)
|
||||
if progress_callback:
|
||||
progress_callback(value=100, text="媒体服务器同步完成")
|
||||
|
||||
+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]:
|
||||
"""
|
||||
|
||||
+49
-5
@@ -1,4 +1,4 @@
|
||||
from typing import List, Optional
|
||||
from typing import Callable, List, Optional
|
||||
|
||||
import pillow_avif # noqa 用于自动注册AVIF支持
|
||||
|
||||
@@ -27,11 +27,16 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
||||
# 推荐缓存区域
|
||||
recommend_cache_region = "recommend"
|
||||
|
||||
def refresh_recommend(self, manual: bool = False):
|
||||
def refresh_recommend(
|
||||
self,
|
||||
manual: bool = False,
|
||||
progress_callback: Optional[Callable[..., None]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
刷新推荐
|
||||
|
||||
:param manual: 手动触发
|
||||
:param progress_callback: 定时服务进度更新回调
|
||||
"""
|
||||
logger.debug("Starting to refresh Recommend data.")
|
||||
|
||||
@@ -56,6 +61,14 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
||||
recommends = []
|
||||
# 记录哪些方法已完成
|
||||
methods_finished = set()
|
||||
total_requests = len(recommend_methods) * self.cache_max_pages
|
||||
finished_requests = 0
|
||||
if progress_callback:
|
||||
progress_callback(
|
||||
value=0,
|
||||
text=f"开始刷新推荐缓存,共 {total_requests} 个数据分页 ...",
|
||||
data={"total": total_requests, "finished": 0},
|
||||
)
|
||||
# 这里避免区间内连续调用相同来源,因此遍历方案为每页遍历所有推荐来源,再进行页数遍历
|
||||
for page in range(1, self.cache_max_pages + 1):
|
||||
for method in recommend_methods:
|
||||
@@ -67,6 +80,21 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
||||
# 手动触发的刷新,总是需要获取最新数据
|
||||
with fresh(manual):
|
||||
data = method(page=page)
|
||||
finished_requests += 1
|
||||
if progress_callback:
|
||||
progress_callback(
|
||||
value=finished_requests / total_requests * 90,
|
||||
text=(
|
||||
f"正在刷新推荐缓存"
|
||||
f"({finished_requests}/{total_requests})..."
|
||||
),
|
||||
data={
|
||||
"total": total_requests,
|
||||
"finished": finished_requests,
|
||||
"current": method.__name__,
|
||||
"page": page,
|
||||
},
|
||||
)
|
||||
if not data:
|
||||
logger.debug("All recommendation methods have finished fetching data. Ending pagination early.")
|
||||
methods_finished.add(method)
|
||||
@@ -77,24 +105,40 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
||||
break
|
||||
|
||||
# 缓存收集到的海报
|
||||
self.__cache_posters(recommends)
|
||||
if progress_callback:
|
||||
progress_callback(value=90, text="推荐数据刷新完成,正在缓存海报 ...")
|
||||
self.__cache_posters(recommends, progress_callback=progress_callback)
|
||||
logger.debug("Recommend data refresh completed.")
|
||||
if progress_callback:
|
||||
progress_callback(value=100, text="推荐缓存刷新完成")
|
||||
|
||||
def __cache_posters(self, datas: List[dict]):
|
||||
def __cache_posters(
|
||||
self,
|
||||
datas: List[dict],
|
||||
progress_callback: Optional[Callable[..., None]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
提取 poster_path 并缓存图片
|
||||
:param datas: 数据列表
|
||||
:param progress_callback: 定时服务进度更新回调
|
||||
"""
|
||||
if not settings.GLOBAL_IMAGE_CACHE:
|
||||
return
|
||||
|
||||
for data in datas:
|
||||
total_num = len(datas)
|
||||
for index, data in enumerate(datas, start=1):
|
||||
if global_vars.is_system_stopped:
|
||||
return
|
||||
poster_path = data.get("poster_path")
|
||||
if poster_path:
|
||||
poster_url = poster_path.replace("original", "w500")
|
||||
self.__fetch_and_save_image(poster_url)
|
||||
if progress_callback:
|
||||
progress_callback(
|
||||
value=90 + (index / total_num * 10 if total_num else 10),
|
||||
text=f"正在缓存推荐海报({index}/{total_num})...",
|
||||
data={"poster_total": total_num, "poster_finished": index},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def __fetch_and_save_image(url: 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,
|
||||
|
||||
+70
-10
@@ -1,7 +1,7 @@
|
||||
import base64
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Tuple, Union, Dict
|
||||
from typing import Callable, List, Optional, Tuple, Union, Dict
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from app.helper.sites import SitesHelper # noqa
|
||||
@@ -103,24 +103,54 @@ class SiteChain(ChainBase):
|
||||
))
|
||||
return userdata
|
||||
|
||||
def refresh_userdatas(self) -> Optional[Dict[str, SiteUserData]]:
|
||||
def refresh_userdatas(
|
||||
self,
|
||||
progress_callback: Optional[Callable[..., None]] = None,
|
||||
) -> Optional[Dict[str, SiteUserData]]:
|
||||
"""
|
||||
刷新所有站点的用户数据
|
||||
|
||||
:param progress_callback: 定时服务进度更新回调
|
||||
"""
|
||||
any_site_updated = False
|
||||
result = {}
|
||||
for site in SitesHelper().get_indexers():
|
||||
sites = [site for site in SitesHelper().get_indexers() if site.get("is_active")]
|
||||
total_num = len(sites)
|
||||
if progress_callback:
|
||||
progress_callback(
|
||||
value=0,
|
||||
text=f"开始刷新站点数据,共 {total_num} 个站点 ...",
|
||||
data={"total": total_num, "finished": 0},
|
||||
)
|
||||
for index, site in enumerate(sites, start=1):
|
||||
if global_vars.is_system_stopped:
|
||||
return None
|
||||
if site.get("is_active"):
|
||||
userdata = self.refresh_userdata(site)
|
||||
if userdata:
|
||||
any_site_updated = True
|
||||
result[site.get("name")] = userdata
|
||||
if progress_callback:
|
||||
progress_callback(
|
||||
value=(index - 1) / total_num * 100 if total_num else 100,
|
||||
text=f"正在刷新站点数据({index}/{total_num}){site.get('name')} ...",
|
||||
data={
|
||||
"total": total_num,
|
||||
"finished": index - 1,
|
||||
"current": site.get("id"),
|
||||
},
|
||||
)
|
||||
userdata = self.refresh_userdata(site)
|
||||
if userdata:
|
||||
any_site_updated = True
|
||||
result[site.get("name")] = userdata
|
||||
if progress_callback:
|
||||
progress_callback(
|
||||
value=index / total_num * 100 if total_num else 100,
|
||||
text=f"站点数据({index}/{total_num})刷新完成",
|
||||
data={"total": total_num, "finished": index},
|
||||
)
|
||||
if any_site_updated:
|
||||
eventmanager.send_event(EventType.SiteRefreshed, {
|
||||
"site_id": "*"
|
||||
})
|
||||
if progress_callback:
|
||||
progress_callback(value=100, text="站点数据刷新完成")
|
||||
|
||||
return result
|
||||
|
||||
@@ -323,9 +353,16 @@ class SiteChain(ChainBase):
|
||||
del html
|
||||
return favicon_url, None
|
||||
|
||||
def sync_cookies(self, manual=False) -> Tuple[bool, str]:
|
||||
def sync_cookies(
|
||||
self,
|
||||
manual: bool = False,
|
||||
progress_callback: Optional[Callable[..., None]] = None,
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
通过CookieCloud同步站点Cookie
|
||||
|
||||
:param manual: 是否手动同步
|
||||
:param progress_callback: 定时服务进度更新回调
|
||||
"""
|
||||
|
||||
def __indexer_domain(inx: dict, sub_domain: str) -> str:
|
||||
@@ -340,9 +377,13 @@ class SiteChain(ChainBase):
|
||||
return sub_domain
|
||||
|
||||
logger.info("开始同步CookieCloud站点 ...")
|
||||
if progress_callback:
|
||||
progress_callback(value=0, text="开始下载 CookieCloud 数据 ...")
|
||||
cookies, msg = CookieCloudHelper().download()
|
||||
if not cookies:
|
||||
logger.error(f"CookieCloud同步失败:{msg}")
|
||||
if progress_callback:
|
||||
progress_callback(value=100, text=f"CookieCloud同步失败:{msg}")
|
||||
if manual:
|
||||
self.messagehelper.put(msg, title="CookieCloud同步失败", role="system")
|
||||
return False, msg
|
||||
@@ -353,11 +394,22 @@ class SiteChain(ChainBase):
|
||||
siteshelper = SitesHelper()
|
||||
siteoper = SiteOper()
|
||||
rsshelper = RssHelper()
|
||||
for domain, cookie in cookies.items():
|
||||
total_num = len(cookies)
|
||||
for index, (domain, cookie) in enumerate(cookies.items(), start=1):
|
||||
# 检查系统是否停止
|
||||
if global_vars.is_system_stopped:
|
||||
logger.info("系统正在停止,中断CookieCloud同步")
|
||||
return False, "系统正在停止,同步被中断"
|
||||
if progress_callback:
|
||||
progress_callback(
|
||||
value=(index - 1) / total_num * 100 if total_num else 100,
|
||||
text=f"正在同步 CookieCloud 站点({index}/{total_num}){domain} ...",
|
||||
data={
|
||||
"total": total_num,
|
||||
"finished": index - 1,
|
||||
"current": domain,
|
||||
},
|
||||
)
|
||||
|
||||
# 索引器信息
|
||||
indexer = siteshelper.get_indexer(domain)
|
||||
@@ -465,6 +517,12 @@ class SiteChain(ChainBase):
|
||||
eventmanager.send_event(EventType.SiteUpdated, {
|
||||
"domain": domain,
|
||||
})
|
||||
if progress_callback:
|
||||
progress_callback(
|
||||
value=index / total_num * 100 if total_num else 100,
|
||||
text=f"CookieCloud 站点({index}/{total_num})同步完成",
|
||||
data={"total": total_num, "finished": index},
|
||||
)
|
||||
# 处理完成
|
||||
ret_msg = f"更新了{_update_count}个站点,新增了{_add_count}个站点"
|
||||
if _fail_count > 0:
|
||||
@@ -472,6 +530,8 @@ class SiteChain(ChainBase):
|
||||
if manual:
|
||||
self.messagehelper.put(ret_msg, title="CookieCloud同步成功", role="system")
|
||||
logger.info(f"CookieCloud同步成功:{ret_msg}")
|
||||
if progress_callback:
|
||||
progress_callback(value=100, text=f"CookieCloud同步成功:{ret_msg}")
|
||||
return True, ret_msg
|
||||
|
||||
@eventmanager.register(EventType.SiteUpdated)
|
||||
|
||||
+867
-237
File diff suppressed because it is too large
Load Diff
+273
-6
@@ -1,6 +1,7 @@
|
||||
import copy
|
||||
import re
|
||||
import traceback
|
||||
from typing import Dict, List, Union, Optional
|
||||
from typing import Callable, Dict, List, Union, Optional
|
||||
|
||||
from app.helper.sites import SitesHelper # noqa
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
清理种子缓存数据
|
||||
@@ -192,11 +425,17 @@ class TorrentsChain(ChainBase):
|
||||
del rss_items
|
||||
return ret_torrents
|
||||
|
||||
def refresh(self, stype: Optional[str] = None, sites: List[int] = None) -> Dict[str, List[Context]]:
|
||||
def refresh(
|
||||
self,
|
||||
stype: Optional[str] = None,
|
||||
sites: List[int] = None,
|
||||
progress_callback: Optional[Callable[..., None]] = None,
|
||||
) -> Dict[str, List[Context]]:
|
||||
"""
|
||||
刷新站点最新资源,识别并缓存起来
|
||||
:param stype: 强制指定缓存类型,spider:爬虫缓存,rss:rss缓存
|
||||
:param sites: 强制指定站点ID列表,为空则读取设置的订阅站点
|
||||
:param progress_callback: 资源刷新进度更新回调
|
||||
"""
|
||||
|
||||
def __is_no_cache_site(_domain: str) -> bool:
|
||||
@@ -226,13 +465,34 @@ class TorrentsChain(ChainBase):
|
||||
|
||||
# 需要刷新的站点domain
|
||||
domains = []
|
||||
indexers = [
|
||||
indexer for indexer in SitesHelper().get_indexers()
|
||||
if not sites or indexer.get("id") in sites
|
||||
]
|
||||
total_indexers = len(indexers)
|
||||
if progress_callback:
|
||||
progress_callback(
|
||||
value=0,
|
||||
text=f"开始刷新站点资源,共 {total_indexers} 个站点 ...",
|
||||
data={"total": total_indexers, "finished": 0},
|
||||
)
|
||||
# 遍历站点缓存资源
|
||||
for indexer in SitesHelper().get_indexers():
|
||||
for index, indexer in enumerate(indexers, start=1):
|
||||
if global_vars.is_system_stopped:
|
||||
break
|
||||
# 未开启的站点不刷新
|
||||
if sites and indexer.get("id") not in sites:
|
||||
continue
|
||||
if progress_callback:
|
||||
progress_callback(
|
||||
value=(index - 1) / total_indexers * 100 if total_indexers else 100,
|
||||
text=(
|
||||
f"正在刷新站点资源({index}/{total_indexers})"
|
||||
f"{indexer.get('name')} ..."
|
||||
),
|
||||
data={
|
||||
"total": total_indexers,
|
||||
"finished": index - 1,
|
||||
"current": indexer.get("id"),
|
||||
},
|
||||
)
|
||||
domain = StringUtils.get_url_domain(indexer.get("domain"))
|
||||
domains.append(domain)
|
||||
if stype == "spider":
|
||||
@@ -335,6 +595,13 @@ class TorrentsChain(ChainBase):
|
||||
if sites and torrents_cache:
|
||||
torrents_cache = {k: v for k, v in torrents_cache.items() if k in domains}
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(
|
||||
value=100,
|
||||
text="站点资源刷新完成",
|
||||
data={"total": total_indexers, "finished": total_indexers},
|
||||
)
|
||||
|
||||
return torrents_cache
|
||||
|
||||
@staticmethod
|
||||
|
||||
+220
-55
@@ -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
|
||||
@@ -1543,7 +1573,13 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
if download_history:
|
||||
task.username = download_history.username
|
||||
# 识别媒体信息
|
||||
if download_history.tmdbid or download_history.doubanid:
|
||||
history_year_conflict = self._is_movie_year_conflict(
|
||||
task.meta, download_history
|
||||
)
|
||||
if (
|
||||
(download_history.tmdbid or download_history.doubanid)
|
||||
and not history_year_conflict
|
||||
):
|
||||
# 下载记录中已存在识别信息
|
||||
mediainfo: Optional[MediaInfo] = self.recognize_media(
|
||||
mtype=MediaType(download_history.type),
|
||||
@@ -1556,6 +1592,18 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
# 更新自定义媒体类别
|
||||
if download_history.media_category:
|
||||
mediainfo.category = download_history.media_category
|
||||
else:
|
||||
if history_year_conflict:
|
||||
logger.info(
|
||||
f"{task.fileitem.name} 文件年份 {task.meta.year} 与下载记录年份 "
|
||||
f"{download_history.year} 不一致,按文件名重新识别"
|
||||
)
|
||||
mediainfo = MediaChain().recognize_by_meta(
|
||||
task.meta,
|
||||
obtain_images=True,
|
||||
)
|
||||
if mediainfo and download_history.media_category:
|
||||
mediainfo.category = download_history.media_category
|
||||
else:
|
||||
# 识别媒体信息
|
||||
mediainfo = MediaChain().recognize_by_meta(
|
||||
@@ -1917,9 +1965,11 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
sample_files.append(item)
|
||||
return sample_files
|
||||
|
||||
def process(self) -> bool:
|
||||
def process(self, progress_callback: Optional[Callable[..., None]] = None) -> bool:
|
||||
"""
|
||||
获取下载器中的种子列表,并执行整理
|
||||
|
||||
:param progress_callback: 定时服务进度更新回调
|
||||
"""
|
||||
# 全局锁,避免定时服务重复
|
||||
with downloader_lock:
|
||||
@@ -1931,9 +1981,13 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
dir_info.monitor_type == "downloader" and dir_info.storage == "local"
|
||||
for dir_info in download_dirs
|
||||
):
|
||||
if progress_callback:
|
||||
progress_callback(value=100, text="未配置下载器监控目录,跳过整理")
|
||||
return True
|
||||
|
||||
logger.info("开始整理下载器中已经完成下载的文件 ...")
|
||||
if progress_callback:
|
||||
progress_callback(value=0, text="正在查询已完成下载任务 ...")
|
||||
|
||||
# 从下载器获取种子列表
|
||||
if torrents_list := self.list_torrents(status=TorrentStatus.TRANSFER):
|
||||
@@ -1951,14 +2005,38 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
|
||||
if not torrents:
|
||||
logger.info("没有已完成下载但未整理的任务")
|
||||
if progress_callback:
|
||||
progress_callback(value=100, text="没有已完成下载但未整理的任务")
|
||||
return False
|
||||
|
||||
logger.info(f"获取到 {len(torrents)} 个已完成的下载任务")
|
||||
if progress_callback:
|
||||
progress_callback(
|
||||
value=0,
|
||||
text=f"获取到 {len(torrents)} 个已完成下载任务",
|
||||
data={"total": len(torrents), "finished": 0},
|
||||
)
|
||||
|
||||
try:
|
||||
for torrent in torrents:
|
||||
total_num = len(torrents)
|
||||
for index, torrent in enumerate(torrents, start=1):
|
||||
if global_vars.is_system_stopped:
|
||||
break
|
||||
if progress_callback:
|
||||
torrent_name = (
|
||||
getattr(torrent, "title", None)
|
||||
or getattr(torrent, "name", None)
|
||||
or torrent.hash
|
||||
)
|
||||
progress_callback(
|
||||
value=(index - 1) / total_num * 100,
|
||||
text=f"正在整理下载任务({index}/{total_num}){torrent_name} ...",
|
||||
data={
|
||||
"total": total_num,
|
||||
"finished": index - 1,
|
||||
"current": torrent.hash,
|
||||
},
|
||||
)
|
||||
|
||||
# 文件路径
|
||||
file_path = torrent.path
|
||||
@@ -2022,9 +2100,15 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
extension=file_path.suffix.lstrip("."),
|
||||
),
|
||||
mediainfo=mediainfo,
|
||||
downloader=torrent.downloader,
|
||||
download_hash=torrent.hash,
|
||||
)
|
||||
downloader=torrent.downloader,
|
||||
download_hash=torrent.hash,
|
||||
)
|
||||
if progress_callback:
|
||||
progress_callback(
|
||||
value=index / total_num * 100,
|
||||
text=f"下载任务({index}/{total_num})整理处理完成",
|
||||
data={"total": total_num, "finished": index},
|
||||
)
|
||||
|
||||
finally:
|
||||
torrents.clear()
|
||||
@@ -2268,6 +2352,31 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _is_movie_year_conflict(
|
||||
file_meta: MetaBase, media: Union[DownloadHistory, MediaInfo]
|
||||
) -> bool:
|
||||
"""
|
||||
判断文件名年份是否与已识别电影年份冲突。
|
||||
|
||||
多电影合集只保存一条下载历史,不能把合集首部电影的媒体 ID 套用到其它年份的文件;
|
||||
电视剧季包仍应继续复用同一条下载历史。
|
||||
"""
|
||||
file_year = getattr(file_meta, "year", None)
|
||||
media_year = getattr(media, "year", None)
|
||||
if not file_meta or not media or not file_year or not media_year:
|
||||
return False
|
||||
media_type = getattr(media, "type", None)
|
||||
if not isinstance(media_type, MediaType):
|
||||
try:
|
||||
media_type = MediaType(media_type)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return (
|
||||
media_type == MediaType.MOVIE
|
||||
and str(file_year) != str(media_year)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def __optional_attr_equal(
|
||||
source: MetaBase,
|
||||
@@ -2403,6 +2512,32 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
self.__normalize_dir_path(Path(current_item.path).parent),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_subscribe_custom_words(
|
||||
history_record: Optional[DownloadHistory],
|
||||
) -> Optional[List[str]]:
|
||||
"""
|
||||
获取整理用自定义识别词:优先使用下载时保存的快照,无快照(历史旧记录)时再按来源实时反查订阅。
|
||||
|
||||
快照优先可避免整理阶段因订阅季号漂移、来源解析失败或订阅完成被删导致识别词丢失,从而原样入库到偏移前的季集。
|
||||
"""
|
||||
if not history_record:
|
||||
return None
|
||||
# 下载时保存的完整订阅识别词快照优先
|
||||
if history_record.custom_words:
|
||||
return history_record.custom_words.split("\n")
|
||||
# 兜底:历史旧记录无快照时,按下载来源实时反查订阅
|
||||
if not isinstance(history_record.note, dict):
|
||||
return None
|
||||
subscribe = SubscribeChain().get_subscribe_by_source(
|
||||
history_record.note.get("source")
|
||||
)
|
||||
return (
|
||||
subscribe.custom_words.split("\n")
|
||||
if subscribe and subscribe.custom_words
|
||||
else None
|
||||
)
|
||||
|
||||
def do_transfer(
|
||||
self,
|
||||
fileitem: FileItem,
|
||||
@@ -2425,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]]:
|
||||
"""
|
||||
@@ -2449,6 +2585,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
:param manual: 是否手动整理
|
||||
:param preview: 是否仅预览
|
||||
:param sync_extra_files: 是否在整理主视频文件时同步整理同媒体附加文件
|
||||
:param cleanup_dest_fileitem: 确认存在待整理任务后需要清理的旧目标文件
|
||||
:param continue_callback: 继续处理回调
|
||||
返回:成功标识,错误信息
|
||||
"""
|
||||
@@ -2458,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,
|
||||
@@ -2478,24 +2614,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
)
|
||||
# 汇总错误信息
|
||||
err_msgs: List[str] = []
|
||||
|
||||
def _get_subscribe_custom_words(
|
||||
history_record: Optional[DownloadHistory],
|
||||
) -> Optional[List[str]]:
|
||||
"""
|
||||
根据下载记录获取订阅自定义识别词。
|
||||
"""
|
||||
if not history_record or not isinstance(history_record.note, dict):
|
||||
return None
|
||||
# 使用source动态获取订阅
|
||||
subscribe = SubscribeChain().get_subscribe_by_source(
|
||||
history_record.note.get("source")
|
||||
)
|
||||
return (
|
||||
subscribe.custom_words.split("\n")
|
||||
if subscribe and subscribe.custom_words
|
||||
else None
|
||||
)
|
||||
matched_episode_format_template = False
|
||||
|
||||
def _build_file_meta(
|
||||
source_path: Path,
|
||||
@@ -2554,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
|
||||
@@ -2603,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,
|
||||
@@ -2620,7 +2756,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
)
|
||||
return _build_file_meta(
|
||||
main_path,
|
||||
custom_word_list=_get_subscribe_custom_words(main_download_history),
|
||||
custom_word_list=self._get_subscribe_custom_words(main_download_history),
|
||||
)
|
||||
|
||||
def _append_item(
|
||||
@@ -2684,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
|
||||
@@ -2778,7 +2914,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
bluray_dir=main_bluray_dir,
|
||||
download_hash=download_hash,
|
||||
)
|
||||
subscribe_custom_words = _get_subscribe_custom_words(
|
||||
subscribe_custom_words = self._get_subscribe_custom_words(
|
||||
main_download_history
|
||||
)
|
||||
main_meta = _build_file_meta(
|
||||
@@ -2831,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:
|
||||
@@ -2901,7 +3054,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
else:
|
||||
file_meta = _build_file_meta(
|
||||
file_path,
|
||||
custom_word_list=_get_subscribe_custom_words(download_history),
|
||||
custom_word_list=self._get_subscribe_custom_words(download_history),
|
||||
)
|
||||
else:
|
||||
file_meta = _build_file_meta(file_path)
|
||||
@@ -2920,11 +3073,19 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
_downloader = downloader
|
||||
_download_hash = download_hash
|
||||
|
||||
# 自动整理预载的媒体信息来自整条下载历史;电影合集内文件年份冲突时逐文件识别。
|
||||
task_mediainfo = mediainfo
|
||||
if (
|
||||
not manual
|
||||
and self._is_movie_year_conflict(file_meta, task_mediainfo)
|
||||
):
|
||||
task_mediainfo = None
|
||||
|
||||
# 后台整理
|
||||
transfer_task = TransferTask(
|
||||
fileitem=file_item,
|
||||
meta=file_meta,
|
||||
mediainfo=mediainfo,
|
||||
mediainfo=task_mediainfo,
|
||||
target_directory=target_directory,
|
||||
target_storage=target_storage,
|
||||
target_path=target_path,
|
||||
@@ -3299,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]]:
|
||||
"""
|
||||
手动整理,支持复杂条件,带进度显示
|
||||
@@ -3322,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:
|
||||
@@ -3362,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
|
||||
@@ -3388,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
|
||||
|
||||
|
||||
+31
-1
@@ -1155,11 +1155,16 @@ class WorkflowChain(ChainBase):
|
||||
self.process(workflow_id, from_begin=False)
|
||||
|
||||
@staticmethod
|
||||
def process(workflow_id: int, from_begin: Optional[bool] = True) -> Tuple[bool, str]:
|
||||
def process(
|
||||
workflow_id: int,
|
||||
from_begin: Optional[bool] = True,
|
||||
progress_callback: Optional[Callable[..., None]] = None,
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
处理工作流
|
||||
:param workflow_id: 工作流ID
|
||||
:param from_begin: 是否从头开始,默认为True
|
||||
:param progress_callback: 定时服务进度更新回调
|
||||
"""
|
||||
workflowoper = WorkflowOper()
|
||||
|
||||
@@ -1173,6 +1178,23 @@ class WorkflowChain(ChainBase):
|
||||
context=_serialize_workflow_context(context),
|
||||
execution_state=_serialize_workflow_value(execution_state)
|
||||
)
|
||||
if progress_callback:
|
||||
runtime = execution_state.get("runtime") or {}
|
||||
finished_actions = int(runtime.get("finished_actions") or 0)
|
||||
total_actions = len(workflow.actions)
|
||||
progress_callback(
|
||||
value=finished_actions / total_actions * 100,
|
||||
text=(
|
||||
f"工作流动作({finished_actions}/{total_actions})"
|
||||
f"{action.name or action.type or action.id} "
|
||||
f"{'执行完成' if completed else '执行中'}"
|
||||
),
|
||||
data={
|
||||
"total": total_actions,
|
||||
"finished": finished_actions,
|
||||
"current": action.id,
|
||||
},
|
||||
)
|
||||
|
||||
# 重置工作流
|
||||
if from_begin:
|
||||
@@ -1191,6 +1213,12 @@ class WorkflowChain(ChainBase):
|
||||
return False, "工作流无流程"
|
||||
|
||||
logger.info(f"开始执行工作流 {workflow.name},共 {len(workflow.actions)} 个动作 ...")
|
||||
if progress_callback:
|
||||
progress_callback(
|
||||
value=0,
|
||||
text=f"开始执行工作流 {workflow.name} ...",
|
||||
data={"total": len(workflow.actions), "finished": 0},
|
||||
)
|
||||
workflowoper.start(workflow_id)
|
||||
|
||||
# 执行工作流
|
||||
@@ -1207,6 +1235,8 @@ class WorkflowChain(ChainBase):
|
||||
return False, executor.errmsg
|
||||
logger.info(f"工作流 {workflow.name} 执行完成")
|
||||
workflowoper.success(workflow_id)
|
||||
if progress_callback:
|
||||
progress_callback(value=100, text=f"工作流 {workflow.name} 执行完成")
|
||||
return True, ""
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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同步黑名单,多个域名,分割
|
||||
|
||||
@@ -12,6 +12,10 @@ from app.utils.string import StringUtils
|
||||
|
||||
@dataclass
|
||||
class TorrentInfo:
|
||||
"""
|
||||
种子搜索结果信息。
|
||||
"""
|
||||
|
||||
# 站点ID
|
||||
site: int = None
|
||||
# 站点名称
|
||||
@@ -919,6 +923,8 @@ class Context:
|
||||
media_info_is_target: bool = False
|
||||
# 调用方对本候选允许下载的剧集集合,None 表示不限制,空集合表示拒绝交付任何集。
|
||||
allowed_episodes: Optional[Set[int]] = None
|
||||
# 下载层确认候选资源覆盖完整目标范围,供订阅事实写入判断整包资源。
|
||||
confirmed_full_coverage: bool = False
|
||||
|
||||
def to_dict(self):
|
||||
"""
|
||||
@@ -935,4 +941,5 @@ class Context:
|
||||
"media_info_is_target": self.media_info_is_target,
|
||||
# 保留 None / 空集 / 非空集 三态语义,避免下游误把"显式拒绝"当成"不限制"。
|
||||
"allowed_episodes": sorted(self.allowed_episodes) if self.allowed_episodes is not None else None,
|
||||
"confirmed_full_coverage": self.confirmed_full_coverage,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
+163
-42
@@ -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}")
|
||||
@@ -723,21 +803,23 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
||||
await SystemConfigOper().async_set(self._config_key % pid, conf)
|
||||
return True
|
||||
|
||||
def delete_plugin_config(self, pid: str) -> bool:
|
||||
def delete_plugin_config(self, pid: str, force: bool = False) -> bool:
|
||||
"""
|
||||
删除插件配置
|
||||
:param pid: 插件ID
|
||||
:param force: 插件停止后仍允许按插件 ID 删除持久化配置
|
||||
"""
|
||||
if not self._plugins.get(pid):
|
||||
if not force and not self._plugins.get(pid):
|
||||
return False
|
||||
return SystemConfigOper().delete(self._config_key % pid)
|
||||
|
||||
def delete_plugin_data(self, pid: str) -> bool:
|
||||
def delete_plugin_data(self, pid: str, force: bool = False) -> bool:
|
||||
"""
|
||||
删除插件数据
|
||||
:param pid: 插件ID
|
||||
:param force: 插件停止后仍允许按插件 ID 删除持久化数据
|
||||
"""
|
||||
if not self._plugins.get(pid):
|
||||
if not force and not self._plugins.get(pid):
|
||||
return False
|
||||
PluginDataOper().del_data(pid)
|
||||
return True
|
||||
@@ -1624,60 +1706,99 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
||||
return " ".join(normalized_labels) or None
|
||||
return None
|
||||
|
||||
async def async_get_online_plugins(self, force: bool = False) -> List[schemas.Plugin]:
|
||||
async def async_get_online_plugins(
|
||||
self,
|
||||
force: bool = False,
|
||||
progress_callback: Optional[Callable[..., None]] = None,
|
||||
) -> List[schemas.Plugin]:
|
||||
"""
|
||||
异步获取所有在线插件信息
|
||||
:param force: 是否强制刷新(忽略缓存)
|
||||
:param progress_callback: 定时服务进度更新回调
|
||||
"""
|
||||
if not settings.PLUGIN_MARKET:
|
||||
if progress_callback:
|
||||
progress_callback(value=100, text="未配置插件市场,跳过刷新")
|
||||
return []
|
||||
|
||||
# 用于存储高于 v1 版本的插件(如 v2, v3 等)
|
||||
async def fetch_market(
|
||||
market: str,
|
||||
package_version: Optional[str],
|
||||
result_version: str,
|
||||
task_index: int,
|
||||
) -> Tuple[int, str, List[schemas.Plugin]]:
|
||||
"""
|
||||
获取单个市场版本的插件列表并保留结果分组。
|
||||
"""
|
||||
plugins = await self.async_get_plugins_from_market(
|
||||
market,
|
||||
package_version,
|
||||
force,
|
||||
)
|
||||
return task_index, result_version, plugins or []
|
||||
|
||||
higher_version_plugins = []
|
||||
# 用于存储 v1 版本插件
|
||||
base_version_plugins = []
|
||||
|
||||
# 使用异步并发获取线上插件
|
||||
import asyncio
|
||||
tasks = []
|
||||
task_to_version = {}
|
||||
|
||||
for m in settings.PLUGIN_MARKET.split(","):
|
||||
if not m:
|
||||
for market in settings.PLUGIN_MARKET.split(","):
|
||||
if not market:
|
||||
continue
|
||||
# 创建任务获取 v1 版本插件
|
||||
base_task = asyncio.create_task(self.async_get_plugins_from_market(m, None, force))
|
||||
tasks.append(base_task)
|
||||
task_to_version[base_task] = "base_version"
|
||||
|
||||
# 创建任务获取高版本插件(如 v2、v3)
|
||||
tasks.append(
|
||||
asyncio.create_task(
|
||||
fetch_market(market, None, "base_version", len(tasks))
|
||||
)
|
||||
)
|
||||
if settings.VERSION_FLAG:
|
||||
higher_version_task = asyncio.create_task(
|
||||
self.async_get_plugins_from_market(m, settings.VERSION_FLAG, force))
|
||||
tasks.append(higher_version_task)
|
||||
task_to_version[higher_version_task] = "higher_version"
|
||||
tasks.append(
|
||||
asyncio.create_task(
|
||||
fetch_market(
|
||||
market,
|
||||
settings.VERSION_FLAG,
|
||||
"higher_version",
|
||||
len(tasks),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# 并发执行所有任务
|
||||
if tasks:
|
||||
completed_tasks = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
for i, result in enumerate(completed_tasks):
|
||||
task = tasks[i]
|
||||
version = task_to_version[task]
|
||||
|
||||
# 检查是否有异常
|
||||
if isinstance(result, Exception):
|
||||
logger.error(f"获取插件市场数据失败:{str(result)}")
|
||||
continue
|
||||
|
||||
plugins = result
|
||||
total_tasks = len(tasks)
|
||||
finished_tasks = 0
|
||||
task_results = {}
|
||||
if progress_callback:
|
||||
progress_callback(
|
||||
value=0,
|
||||
text=f"开始刷新插件市场,共 {total_tasks} 个请求 ...",
|
||||
data={"total": total_tasks, "finished": 0},
|
||||
)
|
||||
for completed_task in asyncio.as_completed(tasks):
|
||||
try:
|
||||
task_index, version, plugins = await completed_task
|
||||
task_results[task_index] = (version, plugins)
|
||||
except Exception as err:
|
||||
logger.error(f"获取插件市场数据失败:{str(err)}")
|
||||
finished_tasks += 1
|
||||
if progress_callback:
|
||||
progress_callback(
|
||||
value=finished_tasks / total_tasks * 100,
|
||||
text=(
|
||||
f"插件市场请求"
|
||||
f"({finished_tasks}/{total_tasks})处理完成"
|
||||
),
|
||||
data={"total": total_tasks, "finished": finished_tasks},
|
||||
)
|
||||
for task_index in sorted(task_results):
|
||||
version, plugins = task_results[task_index]
|
||||
if plugins:
|
||||
if version == "higher_version":
|
||||
higher_version_plugins.extend(plugins) # 收集高版本插件
|
||||
higher_version_plugins.extend(plugins)
|
||||
else:
|
||||
base_version_plugins.extend(plugins) # 收集 v1 版本插件
|
||||
base_version_plugins.extend(plugins)
|
||||
|
||||
result = self.process_plugins_list(higher_version_plugins, base_version_plugins)
|
||||
logger.info(f"获取到 {len(result)} 个线上插件")
|
||||
if progress_callback:
|
||||
progress_callback(value=100, text="插件市场缓存刷新完成")
|
||||
return result
|
||||
|
||||
async def async_get_plugins_from_market(self, market: str,
|
||||
|
||||
+23
-3
@@ -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:
|
||||
@@ -188,12 +197,19 @@ def set_or_refresh_resource_token_cookie(
|
||||
purpose="resource"
|
||||
)
|
||||
|
||||
# 判断请求是否为 HTTPS:直连协议为 https,或经反向代理转发时携带 X-Forwarded-Proto: https。
|
||||
# 无法确认为明文 HTTP 时按 fail-safe 默认设置 secure=True,避免代理终止 HTTPS 后以 HTTP 转发导致 Cookie 明文传输。
|
||||
is_https = (
|
||||
request.url.scheme == "https"
|
||||
or request.headers.get("x-forwarded-proto", "").lower() == "https"
|
||||
)
|
||||
|
||||
# 设置会话级别的 HttpOnly Cookie
|
||||
response.set_cookie(
|
||||
key=settings.PROJECT_NAME,
|
||||
value=resource_token,
|
||||
httponly=True,
|
||||
secure=request.url.scheme == "https", # 根据当前请求的协议设置 secure 属性
|
||||
secure=is_https, # 根据当前请求协议(含反向代理转发标识)设置 secure 属性
|
||||
samesite="lax" # 不同浏览器对 "Strict" 的处理可能不同,设置 SameSite 为 "Lax",以平衡安全性和兼容性
|
||||
)
|
||||
|
||||
@@ -309,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
|
||||
"""
|
||||
@@ -318,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
|
||||
"""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import re
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
@@ -6,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import db_query, db_update, get_id_column, Base, async_db_query
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
|
||||
def _text_like(column, pattern: str, wildcard: bool = False):
|
||||
@@ -205,6 +207,49 @@ class TransferHistory(Base):
|
||||
time.localtime(time.time() - 86400 * days))).subquery()
|
||||
return db.query(sub_query.c.date, func.count(sub_query.c.id)).group_by(sub_query.c.date).all()
|
||||
|
||||
@classmethod
|
||||
@db_query
|
||||
def monthly_media_statistics(cls, db: Session):
|
||||
"""
|
||||
统计当月成功整理的电影、电视剧和剧集数量。
|
||||
|
||||
电影和电视剧按媒体身份去重;剧集优先按历史记录中的集数字段计算,
|
||||
缺少集数时按单条成功整理记录计数。
|
||||
"""
|
||||
month_prefix = time.strftime("%Y-%m-", time.localtime())
|
||||
histories = db.query(cls).filter(
|
||||
cls.status.is_(True),
|
||||
cls.date.like(f"{month_prefix}%"),
|
||||
cls.type.in_([MediaType.MOVIE.value, MediaType.TV.value]),
|
||||
).all()
|
||||
movie_identities = set()
|
||||
tv_identities = set()
|
||||
episode_count = 0
|
||||
|
||||
for history in histories:
|
||||
identity = (history.tmdbid or 0, history.title or "", history.year or "")
|
||||
if history.type == MediaType.MOVIE.value:
|
||||
movie_identities.add(identity)
|
||||
continue
|
||||
|
||||
tv_identities.add(identity)
|
||||
episode_count += cls._history_episode_count(history)
|
||||
|
||||
return len(movie_identities), len(tv_identities), episode_count
|
||||
|
||||
@staticmethod
|
||||
def _history_episode_count(history: "TransferHistory") -> int:
|
||||
"""从单条整理历史中估算成功入库的剧集数量。"""
|
||||
episode_numbers = [int(value) for value in re.findall(r"\d+", history.episodes or "")]
|
||||
if len(episode_numbers) >= 2 and "-" in (history.episodes or ""):
|
||||
return max(1, episode_numbers[-1] - episode_numbers[0] + 1)
|
||||
if episode_numbers:
|
||||
return len(set(episode_numbers))
|
||||
if isinstance(history.files, list) and history.files:
|
||||
return len(history.files)
|
||||
|
||||
return 1
|
||||
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_statistic(cls, db: AsyncSession, days: Optional[int] = 7):
|
||||
|
||||
+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("保存路径不在允许的下载目录范围内")
|
||||
|
||||
@@ -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
|
||||
+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)
|
||||
|
||||
+60
-19
@@ -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
|
||||
|
||||
|
||||
@@ -10,13 +11,13 @@ class ProgressHelper:
|
||||
处理进度辅助类
|
||||
"""
|
||||
|
||||
def __init__(self, key: Union[ProgressKey, str]):
|
||||
def __init__(self, key: Union[ProgressKey, str]) -> None:
|
||||
if isinstance(key, Enum):
|
||||
key = key.value
|
||||
self._key = key
|
||||
self._progress = TTLCache(region="progress", maxsize=1024, ttl=24 * 60 * 60)
|
||||
|
||||
def __reset(self):
|
||||
def __reset(self) -> None:
|
||||
"""
|
||||
重置进度
|
||||
"""
|
||||
@@ -27,7 +28,7 @@ class ProgressHelper:
|
||||
"data": {}
|
||||
}
|
||||
|
||||
def start(self):
|
||||
def start(self) -> None:
|
||||
"""
|
||||
开始进度
|
||||
"""
|
||||
@@ -38,38 +39,78 @@ class ProgressHelper:
|
||||
current['enable'] = True
|
||||
self._progress[self._key] = current
|
||||
|
||||
def end(self):
|
||||
def end(
|
||||
self,
|
||||
text: Optional[str] = "",
|
||||
data: Optional[dict] = None,
|
||||
value: Optional[Union[float, int]] = 100,
|
||||
) -> None:
|
||||
"""
|
||||
结束进度
|
||||
"""
|
||||
current = self._progress.get(self._key)
|
||||
if not current:
|
||||
return
|
||||
current.update(
|
||||
{
|
||||
"enable": False,
|
||||
"value": 100,
|
||||
"text": ""
|
||||
}
|
||||
)
|
||||
if data is not None:
|
||||
if not current.get('data'):
|
||||
current['data'] = {}
|
||||
current['data'].update(data)
|
||||
current["enable"] = False
|
||||
if value is not None:
|
||||
current["value"] = max(min(float(value), 100), 0)
|
||||
current["text"] = text or ""
|
||||
self._progress[self._key] = current
|
||||
|
||||
def update(self, value: Union[float, int] = None, text: Optional[str] = None, data: dict = None):
|
||||
def update(
|
||||
self,
|
||||
value: Optional[Union[float, int]] = None,
|
||||
text: Optional[str] = None,
|
||||
data: Optional[dict] = None,
|
||||
) -> None:
|
||||
"""
|
||||
更新进度
|
||||
"""
|
||||
current = self._progress.get(self._key)
|
||||
if not current or not current.get('enable'):
|
||||
return
|
||||
if value:
|
||||
current['value'] = value
|
||||
if text:
|
||||
if value is not None:
|
||||
current['value'] = max(min(float(value), 100), 0)
|
||||
if text is not None:
|
||||
current['text'] = text
|
||||
if data:
|
||||
if data is not None:
|
||||
if not current.get('data'):
|
||||
current['data'] = {}
|
||||
current['data'].update(data)
|
||||
self._progress[self._key] = current
|
||||
|
||||
def get(self) -> dict:
|
||||
return self._progress.get(self._key)
|
||||
def get(self, locale: Optional[str] = None) -> Optional[dict]:
|
||||
"""
|
||||
获取当前进度,并按语言补充前端展示字段。
|
||||
|
||||
:param locale: 目标语言,未传入时使用当前请求上下文语言
|
||||
:return: 当前进度字典
|
||||
"""
|
||||
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
|
||||
|
||||
@@ -2,6 +2,9 @@ from typing import Any
|
||||
|
||||
from pywebpush import WebPushException
|
||||
|
||||
# WNS 默认 TTL(秒);ttl>0 时需配 X-WNS-Cache-Policy: cache
|
||||
_WNS_DEFAULT_TTL = 86400
|
||||
|
||||
|
||||
def is_webpush_subscription_gone(error: WebPushException) -> bool:
|
||||
"""
|
||||
@@ -10,3 +13,25 @@ def is_webpush_subscription_gone(error: WebPushException) -> bool:
|
||||
response: Any = getattr(error, "response", None)
|
||||
status_code = getattr(response, "status_code", None) or getattr(response, "status", None)
|
||||
return status_code in {404, 410}
|
||||
|
||||
|
||||
def is_wns_endpoint(endpoint: str | None) -> bool:
|
||||
"""
|
||||
判断是否为 Microsoft WNS(Edge/Windows)推送端点。
|
||||
"""
|
||||
return bool(endpoint and "notify.windows.com" in endpoint)
|
||||
|
||||
|
||||
def webpush_options_for_endpoint(endpoint: str | None) -> dict[str, Any]:
|
||||
"""
|
||||
按推送服务返回 pywebpush 额外参数。
|
||||
|
||||
WNS 要求 TTL 与 X-WNS-Cache-Policy 一致,否则返回 400。
|
||||
见 https://github.com/web-push-libs/pywebpush/issues/162
|
||||
"""
|
||||
if not is_wns_endpoint(endpoint):
|
||||
return {}
|
||||
return {
|
||||
"ttl": _WNS_DEFAULT_TTL,
|
||||
"headers": {"X-WNS-Cache-Policy": "cache"},
|
||||
}
|
||||
|
||||
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
@@ -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()
|
||||
|
||||
@@ -243,6 +243,19 @@ class EmbyModule(_ModuleBase, _MediaServerBase[Emby]):
|
||||
return server_obj.get_items(library_id, start_index, limit)
|
||||
return None
|
||||
|
||||
def mediaserver_items_count(self, server: str, library_id: Union[str, int]) -> Optional[int]:
|
||||
"""
|
||||
获取指定媒体库可同步的媒体条目总数
|
||||
|
||||
:param server: 媒体服务器名称
|
||||
:param library_id: 媒体库ID
|
||||
:return: 媒体条目总数,查询失败时返回None
|
||||
"""
|
||||
server_obj: Emby = self.get_instance(server)
|
||||
if server_obj:
|
||||
return server_obj.get_items_count(library_id)
|
||||
return None
|
||||
|
||||
def mediaserver_iteminfo(self, server: str, item_id: str) -> Optional[schemas.MediaServerItem]:
|
||||
"""
|
||||
媒体库项目详情
|
||||
|
||||
@@ -171,6 +171,7 @@ class Emby:
|
||||
name=library.get("Name"),
|
||||
path=library.get("Path"),
|
||||
type=library_type,
|
||||
item_count=self.get_items_count(library.get("Id")),
|
||||
image=image,
|
||||
link=f'{self._playhost or self._host}web/index.html'
|
||||
f'#!/videos?{server_query}parentId={library.get("Id")}',
|
||||
@@ -693,6 +694,33 @@ class Emby:
|
||||
logger.error(f"连接/Users/{self.user}/Items/{itemid}出错:" + str(e))
|
||||
return None
|
||||
|
||||
def get_items_count(self, parent: Union[str, int]) -> Optional[int]:
|
||||
"""
|
||||
获取指定媒体库可同步的电影和剧集总数
|
||||
|
||||
:param parent: 媒体库ID
|
||||
:return: 媒体条目总数,查询失败时返回None
|
||||
"""
|
||||
if not parent or not self._host or not self._apikey:
|
||||
return None
|
||||
url = f"{self._host}emby/Users/{self.user}/Items"
|
||||
params = {
|
||||
"ParentId": parent,
|
||||
"Recursive": "true",
|
||||
"IncludeItemTypes": "Movie,Series",
|
||||
"Limit": 0,
|
||||
"api_key": self._apikey,
|
||||
}
|
||||
try:
|
||||
res = RequestUtils().get_res(url, params)
|
||||
if not res or res.status_code != 200:
|
||||
return None
|
||||
total_count = res.json().get("TotalRecordCount")
|
||||
return int(total_count) if total_count is not None else None
|
||||
except Exception as e:
|
||||
logger.error(f"查询媒体库 {parent} 的媒体总数出错:{str(e)}")
|
||||
return None
|
||||
|
||||
def get_items(self, parent: Union[str, int], start_index: Optional[int] = 0,
|
||||
limit: Optional[int] = -1) -> Generator[MediaServerItem | None | Any, Any, None]:
|
||||
"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -536,6 +536,7 @@ class TransHandler:
|
||||
# 整理文件
|
||||
new_item, err_msg = self.__transfer_file(
|
||||
fileitem=fileitem,
|
||||
meta=in_meta,
|
||||
mediainfo=mediainfo,
|
||||
target_storage=target_storage,
|
||||
target_file=new_file,
|
||||
@@ -962,6 +963,7 @@ class TransHandler:
|
||||
def __transfer_file(
|
||||
self,
|
||||
fileitem: FileItem,
|
||||
meta: Optional[MetaBase],
|
||||
mediainfo: MediaInfo,
|
||||
source_oper: StorageBase,
|
||||
target_oper: StorageBase,
|
||||
@@ -974,6 +976,7 @@ class TransHandler:
|
||||
"""
|
||||
整理一个文件,同时处理其他相关文件
|
||||
:param fileitem: 原文件
|
||||
:param meta: 元数据
|
||||
:param mediainfo: 媒体信息
|
||||
:param source_oper: 源存储操作对象
|
||||
:param target_oper: 目标存储操作对象
|
||||
@@ -990,6 +993,7 @@ class TransHandler:
|
||||
)
|
||||
event_data = TransferInterceptEventData(
|
||||
fileitem=fileitem,
|
||||
meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
target_storage=target_storage,
|
||||
target_path=target_file,
|
||||
|
||||
@@ -61,6 +61,10 @@ def _parse_publish_time(publish_time: str) -> Tuple[float, ...]:
|
||||
|
||||
|
||||
class FilterModule(_ModuleBase):
|
||||
"""
|
||||
过滤器模块,负责按内置和自定义规则筛选种子资源。
|
||||
"""
|
||||
|
||||
CONFIG_WATCH = {
|
||||
SystemConfigKey.CustomFilterRules.value,
|
||||
SystemConfigKey.CustomIdentifiers.value,
|
||||
@@ -73,16 +77,22 @@ 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):
|
||||
def on_config_changed(self) -> None:
|
||||
"""
|
||||
自定义过滤或 Meta 识别配置变更后重建规则集并刷新 Rust Meta 配置缓存。
|
||||
"""
|
||||
@@ -100,6 +110,9 @@ class FilterModule(_ModuleBase):
|
||||
|
||||
@staticmethod
|
||||
def get_name() -> str:
|
||||
"""
|
||||
获取模块名称。
|
||||
"""
|
||||
return "过滤器"
|
||||
|
||||
@staticmethod
|
||||
@@ -123,13 +136,22 @@ class FilterModule(_ModuleBase):
|
||||
"""
|
||||
return 4
|
||||
|
||||
def stop(self):
|
||||
def stop(self) -> None:
|
||||
"""
|
||||
停止过滤器模块。
|
||||
"""
|
||||
pass
|
||||
|
||||
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": {
|
||||
|
||||
@@ -242,6 +242,19 @@ class JellyfinModule(_ModuleBase, _MediaServerBase[Jellyfin]):
|
||||
return server_obj.get_items(library_id, start_index, limit)
|
||||
return None
|
||||
|
||||
def mediaserver_items_count(self, server: str, library_id: Union[str, int]) -> Optional[int]:
|
||||
"""
|
||||
获取指定媒体库可同步的媒体条目总数
|
||||
|
||||
:param server: 媒体服务器名称
|
||||
:param library_id: 媒体库ID
|
||||
:return: 媒体条目总数,查询失败时返回None
|
||||
"""
|
||||
server_obj: Jellyfin = self.get_instance(server)
|
||||
if server_obj:
|
||||
return server_obj.get_items_count(library_id)
|
||||
return None
|
||||
|
||||
def mediaserver_iteminfo(self, server: str, item_id: str) -> Optional[schemas.MediaServerItem]:
|
||||
"""
|
||||
媒体库项目详情
|
||||
|
||||
@@ -173,6 +173,7 @@ class Jellyfin:
|
||||
name=library.get("Name"),
|
||||
path=library.get("Path"),
|
||||
type=library_type,
|
||||
item_count=self.get_items_count(library.get("Id")),
|
||||
image=image,
|
||||
link=link,
|
||||
server_type="jellyfin"
|
||||
@@ -316,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
|
||||
@@ -340,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
|
||||
@@ -808,6 +843,35 @@ class Jellyfin:
|
||||
logger.error(f"连接Users/{self.user}/Items/{itemid}:" + str(e))
|
||||
return None
|
||||
|
||||
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:
|
||||
return None
|
||||
url = f"{self._host}Users/{self.user}/Items"
|
||||
params = {
|
||||
"ParentId": parent,
|
||||
"Recursive": "true",
|
||||
"IncludeItemTypes": include_item_types,
|
||||
"Limit": 0,
|
||||
"api_key": self._apikey,
|
||||
}
|
||||
try:
|
||||
res = RequestUtils().get_res(url, params)
|
||||
if not res or res.status_code != 200:
|
||||
return None
|
||||
total_count = res.json().get("TotalRecordCount")
|
||||
return int(total_count) if total_count is not None else None
|
||||
except Exception as e:
|
||||
logger.error(f"查询媒体库 {parent} 的媒体总数出错:{str(e)}")
|
||||
return None
|
||||
|
||||
def get_items(self, parent: Union[str, int], start_index: Optional[int] = 0, limit: Optional[int] = -1) \
|
||||
-> Generator[MediaServerItem | None | Any, Any, None]:
|
||||
"""
|
||||
|
||||
@@ -253,6 +253,19 @@ class PlexModule(_ModuleBase, _MediaServerBase[Plex]):
|
||||
return server_obj.get_items(library_id, start_index, limit)
|
||||
return None
|
||||
|
||||
def mediaserver_items_count(self, server: str, library_id: Union[str, int]) -> Optional[int]:
|
||||
"""
|
||||
获取指定媒体库可同步的媒体条目总数
|
||||
|
||||
:param server: 媒体服务器名称
|
||||
:param library_id: 媒体库ID
|
||||
:return: 媒体条目总数,查询失败时返回None
|
||||
"""
|
||||
server_obj: Plex = self.get_instance(server)
|
||||
if server_obj:
|
||||
return server_obj.get_items_count(library_id)
|
||||
return None
|
||||
|
||||
def mediaserver_iteminfo(self, server: str, item_id: str) -> Optional[schemas.MediaServerItem]:
|
||||
"""
|
||||
媒体库项目详情
|
||||
|
||||
@@ -152,6 +152,7 @@ class Plex:
|
||||
name=library.title,
|
||||
path=library.locations,
|
||||
type=library_type,
|
||||
item_count=self.get_items_count(library.key),
|
||||
image_list=image_list,
|
||||
link=f"{self._playhost or self._host}web/index.html#!/media/{self._plex.machineIdentifier}"
|
||||
f"/com.plexapp.plugins.library?source={library.key}&X-Plex-Token={self._token}",
|
||||
@@ -575,6 +576,22 @@ class Plex:
|
||||
user_state=user_state,
|
||||
)
|
||||
|
||||
def get_items_count(self, parent: Union[str, int]) -> Optional[int]:
|
||||
"""
|
||||
获取指定媒体库可同步的媒体条目总数
|
||||
|
||||
:param parent: 媒体库ID
|
||||
:return: 媒体条目总数,查询失败时返回None
|
||||
"""
|
||||
if not parent or not self._plex:
|
||||
return None
|
||||
try:
|
||||
section = self._plex.library.sectionByID(int(parent))
|
||||
return int(section.totalSize) if section else None
|
||||
except Exception as err:
|
||||
logger.error(f"查询媒体库 {parent} 的媒体总数出错:{str(err)}")
|
||||
return None
|
||||
|
||||
def get_items(self, parent: Union[str, int], start_index: Optional[int] = 0, limit: Optional[int] = -1) \
|
||||
-> Generator[MediaServerItem | None, Any, None]:
|
||||
"""
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -21,6 +21,10 @@ from app.utils.structures import DictUtils
|
||||
|
||||
|
||||
class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
|
||||
"""
|
||||
Telegram 通知模块,负责模块生命周期、消息解析和通知发送。
|
||||
"""
|
||||
|
||||
def init_module(self) -> None:
|
||||
"""
|
||||
初始化模块
|
||||
@@ -32,6 +36,9 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
|
||||
|
||||
@staticmethod
|
||||
def get_name() -> str:
|
||||
"""
|
||||
获取模块名称
|
||||
"""
|
||||
return "Telegram"
|
||||
|
||||
@staticmethod
|
||||
@@ -75,6 +82,9 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
|
||||
return True, ""
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
"""
|
||||
获取模块初始化配置项。
|
||||
"""
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
@@ -242,9 +252,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:
|
||||
@@ -299,7 +311,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,
|
||||
@@ -504,6 +518,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,
|
||||
@@ -511,7 +531,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,
|
||||
@@ -724,12 +745,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"
|
||||
@@ -46,10 +55,18 @@ TELEGRAM_PARSE_MODE_ALIASES = {
|
||||
|
||||
|
||||
class RetryException(Exception):
|
||||
"""
|
||||
Telegram 消息发送重试异常。
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class Telegram:
|
||||
"""
|
||||
Telegram 消息客户端,负责发送、编辑、接收和转发 Telegram 消息。
|
||||
"""
|
||||
|
||||
_ds_url = (
|
||||
f"http://127.0.0.1:{settings.PORT}/api/v1/message?token={settings.API_TOKEN}"
|
||||
)
|
||||
@@ -576,6 +593,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,
|
||||
@@ -590,6 +608,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: 是否禁用链接预览
|
||||
@@ -626,9 +645,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,
|
||||
@@ -671,6 +712,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,
|
||||
@@ -1129,6 +1182,69 @@ class Telegram:
|
||||
"""
|
||||
return "there is no text in the message to edit" in str(err).lower()
|
||||
|
||||
@staticmethod
|
||||
def __is_message_not_modified_error(err: Exception) -> bool:
|
||||
"""
|
||||
判断 Telegram 是否因为消息内容未变化而拒绝编辑。
|
||||
"""
|
||||
return "message is not modified" in str(err).lower()
|
||||
|
||||
@staticmethod
|
||||
def __is_http_url_content_error(err: Exception) -> bool:
|
||||
"""
|
||||
判断 Telegram 是否因为无法获取远端图片 URL 而拒绝编辑。
|
||||
"""
|
||||
return "failed to get http url content" in str(err).lower()
|
||||
|
||||
def __edit_message_text_or_caption(
|
||||
self,
|
||||
chat_id: str,
|
||||
message_id: int,
|
||||
text: str,
|
||||
reply_markup: Optional[InlineKeyboardMarkup] = None,
|
||||
disable_web_page_preview: Optional[bool] = None,
|
||||
parse_mode: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
编辑 Telegram 文本消息,原消息无文本时回退为 caption 编辑。
|
||||
"""
|
||||
prepared_text = self._prepare_text(text, parse_mode)
|
||||
edit_text_kwargs: Dict[str, Any] = {
|
||||
"chat_id": chat_id,
|
||||
"message_id": message_id,
|
||||
"text": prepared_text,
|
||||
"parse_mode": parse_mode,
|
||||
"reply_markup": reply_markup,
|
||||
}
|
||||
if disable_web_page_preview is not None:
|
||||
edit_text_kwargs["disable_web_page_preview"] = (
|
||||
disable_web_page_preview
|
||||
)
|
||||
try:
|
||||
self._bot.edit_message_text(**edit_text_kwargs)
|
||||
except Exception as err:
|
||||
if self.__is_message_not_modified_error(err):
|
||||
logger.debug(f"Telegram消息内容未变化,跳过编辑:{str(err)}")
|
||||
return True
|
||||
if not self.__is_no_text_edit_error(err):
|
||||
raise
|
||||
try:
|
||||
self._bot.edit_message_caption(
|
||||
chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
caption=prepared_text,
|
||||
parse_mode=parse_mode,
|
||||
reply_markup=reply_markup,
|
||||
)
|
||||
except Exception as caption_err:
|
||||
if self.__is_message_not_modified_error(caption_err):
|
||||
logger.debug(
|
||||
f"Telegram消息内容未变化,跳过编辑:{str(caption_err)}"
|
||||
)
|
||||
return True
|
||||
raise
|
||||
return True
|
||||
|
||||
def __edit_message(
|
||||
self,
|
||||
chat_id: str,
|
||||
@@ -1175,31 +1291,34 @@ class Telegram:
|
||||
)
|
||||
else:
|
||||
# 如果没有图片,使用edit_message_text
|
||||
edit_text_kwargs: Dict[str, Any] = {
|
||||
"chat_id": chat_id,
|
||||
"message_id": message_id,
|
||||
"text": self._prepare_text(text, parse_mode),
|
||||
"parse_mode": parse_mode,
|
||||
"reply_markup": reply_markup,
|
||||
}
|
||||
if disable_web_page_preview is not None:
|
||||
edit_text_kwargs["disable_web_page_preview"] = (
|
||||
disable_web_page_preview
|
||||
)
|
||||
try:
|
||||
self._bot.edit_message_text(**edit_text_kwargs)
|
||||
except Exception as err:
|
||||
if not self.__is_no_text_edit_error(err):
|
||||
raise
|
||||
self._bot.edit_message_caption(
|
||||
chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
caption=self._prepare_text(text, parse_mode),
|
||||
parse_mode=parse_mode,
|
||||
reply_markup=reply_markup,
|
||||
)
|
||||
return self.__edit_message_text_or_caption(
|
||||
chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
text=text,
|
||||
reply_markup=reply_markup,
|
||||
disable_web_page_preview=disable_web_page_preview,
|
||||
parse_mode=parse_mode,
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
if self.__is_message_not_modified_error(e):
|
||||
logger.debug(f"Telegram消息内容未变化,跳过编辑:{str(e)}")
|
||||
return True
|
||||
if image and self.__is_http_url_content_error(e):
|
||||
logger.warning(
|
||||
f"Telegram图片编辑失败,降级为文本编辑:{str(e)}"
|
||||
)
|
||||
try:
|
||||
return self.__edit_message_text_or_caption(
|
||||
chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
text=text,
|
||||
reply_markup=reply_markup,
|
||||
disable_web_page_preview=disable_web_page_preview,
|
||||
parse_mode=parse_mode,
|
||||
)
|
||||
except Exception as fallback_err:
|
||||
e = fallback_err
|
||||
logger.error(f"编辑消息失败:{str(e)}")
|
||||
return False
|
||||
|
||||
@@ -1211,12 +1330,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)
|
||||
@@ -1225,6 +1346,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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -281,6 +281,21 @@ class TrimeMediaModule(_ModuleBase, _MediaServerBase[TrimeMedia]):
|
||||
return server_obj.get_items(library_id, start_index, limit)
|
||||
return None
|
||||
|
||||
def mediaserver_items_count(
|
||||
self, server: str, library_id: Union[str, int]
|
||||
) -> Optional[int]:
|
||||
"""
|
||||
获取指定媒体库可同步的媒体条目总数
|
||||
|
||||
:param server: 媒体服务器名称
|
||||
:param library_id: 媒体库ID
|
||||
:return: 媒体条目总数,查询失败时返回None
|
||||
"""
|
||||
server_obj: Optional[TrimeMedia] = self.get_instance(server)
|
||||
if server_obj:
|
||||
return server_obj.get_items_count(library_id)
|
||||
return None
|
||||
|
||||
def mediaserver_iteminfo(
|
||||
self, server: str, item_id: str
|
||||
) -> Optional[schemas.MediaServerItem]:
|
||||
|
||||
@@ -347,6 +347,32 @@ class Api:
|
||||
return [self.__build_item(info) for info in res.data.get("list", [])]
|
||||
return None
|
||||
|
||||
def item_count(self, guid: str, types=None) -> Optional[int]:
|
||||
"""
|
||||
获取指定媒体库的媒体条目总数
|
||||
|
||||
:param guid: 媒体库GUID
|
||||
:param types: 需要统计的媒体类型
|
||||
:return: 媒体条目总数,查询失败时返回None
|
||||
"""
|
||||
if types is None:
|
||||
types = [Type.MOVIE, Type.TV]
|
||||
post = {
|
||||
"ancestor_guid": guid,
|
||||
"tags": {"type": types},
|
||||
"exclude_grouped_video": 1,
|
||||
"page": 1,
|
||||
"page_size": 1,
|
||||
}
|
||||
if (res := self.request("/item/list", data=post)) and res.success:
|
||||
if not res.data:
|
||||
return 0
|
||||
total_count = res.data.get("total")
|
||||
if total_count is None:
|
||||
total_count = res.data.get("total_count")
|
||||
return int(total_count) if total_count is not None else None
|
||||
return None
|
||||
|
||||
def search_list(self, keywords: str) -> Optional[list[Item]]:
|
||||
"""
|
||||
搜索影片、演员
|
||||
|
||||
@@ -194,6 +194,7 @@ class TrimeMedia:
|
||||
name=library.name,
|
||||
type=library_type,
|
||||
path=library.dir_list,
|
||||
item_count=self.get_items_count(library.guid),
|
||||
image_list=[
|
||||
f"{self._api.host}{img_path}?w=256"
|
||||
for img_path in library.posters or []
|
||||
@@ -510,6 +511,20 @@ class TrimeMedia:
|
||||
use_cookies=True,
|
||||
)
|
||||
|
||||
def get_items_count(self, parent: Union[str, int]) -> Optional[int]:
|
||||
"""
|
||||
获取指定媒体库可同步的媒体条目总数
|
||||
|
||||
:param parent: 媒体库ID
|
||||
:return: 媒体条目总数,查询失败时返回None
|
||||
"""
|
||||
if not self.is_authenticated():
|
||||
return None
|
||||
return self._api.item_count(
|
||||
guid=str(parent),
|
||||
types=[fnapi.Type.MOVIE, fnapi.Type.TV],
|
||||
)
|
||||
|
||||
def get_items(
|
||||
self,
|
||||
parent: Union[str, int],
|
||||
|
||||
@@ -253,6 +253,21 @@ class UgreenModule(_ModuleBase, _MediaServerBase[Ugreen]):
|
||||
return server_obj.get_items(library_id, start_index, limit)
|
||||
return None
|
||||
|
||||
def mediaserver_items_count(
|
||||
self, server: str, library_id: Union[str, int]
|
||||
) -> Optional[int]:
|
||||
"""
|
||||
获取指定媒体库可同步的媒体条目总数
|
||||
|
||||
:param server: 媒体服务器名称
|
||||
:param library_id: 媒体库ID
|
||||
:return: 媒体条目总数,查询失败时返回None
|
||||
"""
|
||||
server_obj: Optional[Ugreen] = self.get_instance(server)
|
||||
if server_obj:
|
||||
return server_obj.get_items_count(library_id)
|
||||
return None
|
||||
|
||||
def mediaserver_iteminfo(
|
||||
self, server: str, item_id: str
|
||||
) -> Optional[schemas.MediaServerItem]:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user