Compare commits

...
80 Commits
Author SHA1 Message Date
jxxghp b8233e1789 v2.14.3 2026-07-13 18:46:35 +08:00
InfinityPacer 83107bf447 ci(pr-agent): publish native code reviews (#6110) 2026-07-13 18:41:18 +08:00
jxxghp 3a2f90c567 fix(metainfo): improve regex for episode range recognition with end markers 2026-07-13 18:02:44 +08:00
DDSRem 4826e3301c chore: bump moviepilot-rust to 0.2.2 (#6109) 2026-07-13 18:00:50 +08:00
freeman 1855ba81ec fix(meta): 副标题识别 01-26Fin 等数字范围完结标记集数 (#6105) 2026-07-13 16:50:00 +08:00
jxxghp 96ef431efc feat: 支持豆瓣识别缓存管理 2026-07-13 12:33:56 +08:00
freeman 4f2935c85e fix(transfer): 种子未下载完成时不回写已整理标签 (#6106) 2026-07-13 12:05:01 +08:00
freeman 2a49495e27 fix(jellyfin): 媒体统计改为按用户视图逐库累计 (#5915) (#6104) 2026-07-13 11:51:16 +08:00
jxxghp b628bc7209 fix: 补齐识别缓存多语言响应 2026-07-13 09:58:11 +08:00
jxxghp 29068a5846 feat: 支持 TheMovieDb 识别缓存管理 2026-07-13 09:48:08 +08:00
jxxghp 51a7120c79 完善 qBittorrent 临时标签清理 (#6093) 2026-07-12 16:51:46 +08:00
jxxghp 476dfef7d9 修复 qBittorrent 重复任务临时标签残留 (#6093) 2026-07-12 16:47:12 +08:00
jxxghp bd5ddd6158 fix: remove standalone site collector download section from README 2026-07-12 16:38:53 +08:00
jxxghp a30a48b8f4 fix: allow publishing collector artifacts manually 2026-07-12 16:37:05 +08:00
jxxghp 8e60e5571b fix: support Windows collector console encoding 2026-07-12 16:30:30 +08:00
jxxghp 18c1ec4b82 feat: add standalone site adapter collector 2026-07-12 13:51:07 +08:00
InfinityPacer 30b932e07e fix(subscribe): preserve confirmed episode floor (#6102) 2026-07-12 07:18:29 +08:00
InfinityPacer 54be1143fc feat(plugin): sync federated assets during local development (#6100) 2026-07-11 21:59:53 +08:00
秋澪Akimio 13f27854fd fix: clear Rust parse options cache after updating custom identifiers (#6097) 2026-07-11 18:15:26 +08:00
InfinityPacer 770201c48c fix(plugin): exclude build dependencies from runtime copies (#6096) 2026-07-11 18:15:00 +08:00
Xuanjie Xia 685f044312 fix: 模拟登录时页面跳转导致 page.content() 竞态失败(未知错误) (#6091) 2026-07-10 12:44:36 +08:00
qqcomeup 8c0afac5d1 feat: support prompt-bound plugin input replies (#6087) 2026-07-09 12:52:22 +08:00
qqcomeup 099ef7d5bf fix: avoid blocking plugin release history refresh (#6084) 2026-07-08 12:51:24 +08:00
InfinityPacer f3ac69669c ci(pr-agent): simplify review workflow (#6082) 2026-07-08 12:49:19 +08:00
jxxghp eb4ecd990a fix: restore full test suite 2026-07-08 08:54:53 +08:00
jxxghp b51971ee7d feat: add agent MCP support 2026-07-08 08:44:33 +08:00
InfinityPacer 6f6ed998bb ci(pr-agent): align inline review workflow (#6079) 2026-07-08 07:04:39 +08:00
drdon1234 844407dc41 修复 qBittorrent 已完成但未做种任务识别 (#6076) 2026-07-08 07:01:03 +08:00
jxxghp c54605f8ce fix: support ugreen token_id login response 2026-07-07 20:15:19 +08:00
qqcomeup 0fbf05d72f fix: handle Telegram urllib3 header formatter compatibility (#6074) 2026-07-07 19:58:49 +08:00
jxxghp 09bb32f681 fix: cool down failed subscription resources 2026-07-07 17:07:13 +08:00
qqcomeup a37f118576 perf(docker): skip image path chown by default (#6071) 2026-07-07 16:25:43 +08:00
jxxghp e635bc8e04 chore: bump version to v2.14.2 2026-07-07 12:48:23 +08:00
jxxghp 8245124e82 support scheduler progress i18n (#6069) 2026-07-07 06:04:45 +08:00
qqcomeup 827ed8330c feat: add plugin-scoped text input sessions (#6068) 2026-07-07 05:15:18 +08:00
jxxghp 136c1baed3 fix: keep new subscribe state during guard 2026-07-06 23:10:25 +08:00
jxxghp 992031ef95 Revert "fix: trigger initial subscribe search"
This reverts commit 76803ae7a3.
2026-07-06 19:15:16 +08:00
jxxghp b16c50b03a feat: add backend i18n response support 2026-07-06 19:13:50 +08:00
jxxghp 76803ae7a3 fix: trigger initial subscribe search 2026-07-06 19:02:01 +08:00
jxxghp 56bda11947 fix python metainfo xxx title parsing 2026-07-06 12:36:06 +08:00
jxxghp 1b12d7664e fix: avoid cnsub matching file size unit 2026-07-06 11:58:03 +08:00
jxxghp db9960d9b9 refactor: enhance customization and streaming platform handling 2026-07-06 09:33:49 +08:00
InfinityPacer 2f0c1252da feat(subscribe): expose cached site candidates (#6062) 2026-07-06 06:54:19 +08:00
jxxghp 36d4434596 fix: allow manage users to access management APIs 2026-07-05 20:23:04 +08:00
jxxghp 93e907d032 fix: remove openlist directory via fs remove 2026-07-05 17:24:44 +08:00
jxxghp 132f27c1c6 fix: retry qbittorrent files after add 2026-07-05 17:15:07 +08:00
jxxghp b231ad415f fix title search filter rules 2026-07-05 17:02:19 +08:00
jxxghp 0f183ae08e test: fix download save path fixtures 2026-07-05 14:56:19 +08:00
jxxghp a71d3ea03f feat: support llm temperature override 2026-07-05 13:04:51 +08:00
InfinityPacer 7f82a9ea4d fix(security): scope subscriptions to owner (#6056) 2026-07-05 09:45:27 +08:00
InfinityPacer d977e4c48a fix(security): require signed subtitle downloads (#6055) 2026-07-05 09:43:58 +08:00
InfinityPacer 95b6adbeee fix(security): restrict download save paths (#6054) 2026-07-05 09:31:01 +08:00
InfinityPacer 964fee1106 fix(security): protect cookiecloud update uploads (#6053) 2026-07-05 09:28:07 +08:00
InfinityPacer 656473f3aa fix(security): require manage access for workflows (#6052) 2026-07-05 09:27:24 +08:00
InfinityPacer ab5995a609 fix(security): contain plugin zip extraction (#6051) 2026-07-05 09:26:09 +08:00
InfinityPacer 064e6535d5 fix(security): restrict agent log file access (#6050) 2026-07-05 09:25:25 +08:00
InfinityPacer cab2ac400a fix(security): rebind resource cookie on user changes (#6049) 2026-07-05 09:24:27 +08:00
InfinityPacer d14d401c86 docs(security): clarify api token admin contract (#6048) 2026-07-05 09:22:59 +08:00
Album 6c3c5e042d 修复手动整理按集数定位模板过滤 (#6043) 2026-07-03 07:55:59 +08:00
Copilot f3e5be37fd chore: bump backend and frontend versions (#6042) 2026-07-02 20:50:15 +08:00
jxxghp d8f7fa70af fix: normalize question marks in title matching 2026-07-02 08:51:16 +08:00
jxxghp 6916ee0988 修复电影合集整理识别错误 2026-07-02 08:06:44 +08:00
LeChristopher Blackwell 6fef533527 fix(security): honor X-Forwarded-Proto when setting resource_token cookie secure flag (#6038) 2026-07-01 22:00:22 +08:00
G0m3eandCursor c57985d553 fix(webpush): add WNS cache policy for Windows Edge push (#6034)
WNS rejects pywebpush default ttl=0 with 400 Bad Request unless X-WNS-Cache-Policy matches TTL; iOS/APNs endpoints are unaffected.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 10:45:24 +08:00
InfinityPacer ec07379a67 fix(subscribe): record movie completion priority (#6033) 2026-07-01 06:01:32 +08:00
InfinityPacer 2764742b86 fix(plugin): reset stopped plugin config and data (#6031) 2026-06-30 22:37:07 +08:00
jxxghp a0f613fa1e chore(version): update app and frontend versions to v2.14.0 2026-06-30 20:32:17 +08:00
jxxghp 73d5c95f4e Fix Telegram edit fallback 2026-06-29 22:48:13 +08:00
jxxghp 4d30dee74c feat(mediaserver): add item count to media library details across multiple servers 2026-06-29 21:37:58 +08:00
Pollo3470 302d8bbf5c fix(transfer): 修复订阅自定义识别词在整理时失效 (#6018) 2026-06-29 15:49:02 +08:00
jxxghp b646cbb4f6 Merge remote-tracking branch 'origin/v2' into v2 2026-06-29 12:55:19 +08:00
jxxghp dd73b97095 fix(base): improve permission error message for tool access 2026-06-29 12:55:14 +08:00
InfinityPacer 0cb0bac0e1 fix(llm): sanitize model endpoint errors (#6021) 2026-06-29 11:19:38 +08:00
jxxghp 9eb71c744b feat(mediaserver): add item count retrieval for various media servers 2026-06-29 10:45:10 +08:00
jxxghp 8bf826faa0 fix(dashboard): report application memory accurately 2026-06-29 07:37:03 +08:00
jxxghp df4e45c644 Merge remote-tracking branch 'origin/v2' into v2 2026-06-29 07:08:08 +08:00
jxxghp 494f809ef0 Refine transfer history batch actions 2026-06-29 07:07:33 +08:00
InfinityPacer a4f6e13881 fix(transfer): expose file meta in intercept event (#6019) 2026-06-29 06:44:40 +08:00
InfinityPacer 36fb82b7aa feat(subscribe): record episode download facts (#6015) 2026-06-29 06:43:51 +08:00
jxxghp 9b1bdb0cb2 feat(dashboard): add system summary endpoint and monthly media statistics 2026-06-28 17:49:09 +08:00
183 changed files with 23647 additions and 1429 deletions
+15 -1
View File
@@ -7,11 +7,13 @@ body:
attributes:
value: |
请说明你希望添加的功能。
站点适配请求请先按 [站点适配采集说明](https://github.com/jxxghp/MoviePilot/blob/v2/docs/site-adapter-capture.md) 生成脱敏 ZIP,并在下方附加。Issue 及附件是公开内容,提交前必须解压预览四个文件。不要上传 Cookie、Authorization、通行密钥、会话字段或任何原始数据。
- type: input
id: version
attributes:
label: 当前程序版本
description: 目前使用的程序版本
description: 目前使用的程序版本;仅提供站点采集文件且未安装 MoviePilot 时填写“不适用”
validations:
required: true
- type: dropdown
@@ -22,6 +24,9 @@ body:
options:
- Docker
- Windows
- macOS
- Linux
- 仅提供站点采集文件
validations:
required: true
- type: dropdown
@@ -32,6 +37,7 @@ body:
options:
- 主程序
- 插件
- 站点适配
- 其他
validations:
required: true
@@ -43,6 +49,14 @@ body:
placeholder: "功能改进"
validations:
required: true
- type: textarea
id: site-adapter-capture
attributes:
label: 站点适配采集文件
description: 站点适配请求必须把采集器生成并人工预览确认过的脱敏 ZIP 拖到这里;Issue 附件公开,严禁附加 Cookie、原始 HTML、HAR 或浏览器网络归档。其他类型请填写“不适用”。
placeholder: "将 moviepilot-site-capture-*.zip 拖到这里;非站点适配填写:不适用"
validations:
required: true
- type: textarea
id: references
attributes:
+481 -68
View File
@@ -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 }}
# 仓库设置中添加的 SecretSettings -> Secrets and variables -> Actions。
# 该 key 只传给 PR-Agent 运行时,不写入仓库。
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
# 仓库设置中添加的 Secret。OpenAI 兼容服务通常需要填写以 "/v1" 结尾的 API 根地址。
OPENAI.API_BASE: ${{ secrets.OPENAI_API_BASE }}
# 模型、输出语言和大 diff 处理策略。
config.model: "gpt-5.5"
config.fallback_models: '["gpt-5.4"]'
config.reasoning_effort: "xhigh"
config.ai_timeout: "900"
config.response_language: "zh-CN"
config.large_patch_policy: "clip"
config.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"
+1
View File
@@ -37,6 +37,7 @@ coverage.json
htmlcov/
.vscode
venv
moviepilot-site-capture-*.zip
# Pylint
pylint-report.json
+1
View File
@@ -59,6 +59,7 @@ curl -fsSL https://raw.githubusercontent.com/jxxghp/MoviePilot/v2/scripts/bootst
- 文档规则入口:[docs/rules/README.md](docs/rules/README.md)
- 开发环境与本地源码运行:[docs/development-setup.md](docs/development-setup.md)
- 测试说明:[docs/testing.md](docs/testing.md)
- 新站点适配采集与 Feature Request 提交:[docs/site-adapter-capture.md](docs/site-adapter-capture.md)
- REST API 文档:https://api.movie-pilot.org
- 插件开发说明:https://wiki.movie-pilot.org/zh/plugindev
+1
View File
@@ -58,6 +58,7 @@ Before contributing, read the repository rules and local environment guide, keep
- Rule index: [docs/rules/README.md](docs/rules/README.md)
- Development setup and local source run: [docs/development-setup.md](docs/development-setup.md)
- Testing guide: [docs/testing.md](docs/testing.md)
- New site adapter capture and Feature Request submission: [docs/site-adapter-capture.md](docs/site-adapter-capture.md)
- REST API documentation: https://api.movie-pilot.org
- Plugin development guide: https://wiki.movie-pilot.org/zh/plugindev
+40 -1
View File
@@ -51,7 +51,9 @@ from app.agent.middleware.tool_selection import ToolSelectorMiddleware
from app.agent.middleware.usage import UsageMiddleware
from app.agent.prompt import prompt_manager
from app.agent.runtime import agent_runtime_manager
from app.agent.mcp import agent_mcp_manager
from app.agent.tools.factory import MoviePilotToolFactory
from app.agent.tools.impl.mcp import create_external_mcp_tools
from app.chain import ChainBase
from app.core.config import settings
from app.core.event import eventmanager
@@ -1041,6 +1043,7 @@ class MoviePilotAgent:
settings.LLM_MAX_ITERATIONS,
self._public_runtime_config_signature(runtime_config),
agent_runtime_manager.current_signature(),
agent_mcp_manager.config_signature(),
)
def _get_cached_agent(
@@ -1097,6 +1100,39 @@ class MoviePilotAgent:
allow_message_tools=False,
)
async def _initialize_mcp_tools(self) -> List:
"""
初始化外部 MCP 工具列表。
"""
return await create_external_mcp_tools(
session_id=self.session_id,
user_id=self.user_id,
channel=self.channel,
source=self.source,
username=self.username,
stream_handler=self.stream_handler,
agent_context=self._tool_context,
)
async def _initialize_subagent_mcp_tools(self) -> List:
"""
初始化子代理可用的外部 MCP 工具列表。
"""
return await create_external_mcp_tools(
session_id=self.session_id,
user_id=self.user_id,
channel=self.channel,
source=self.source,
username=self.username,
stream_handler=None,
agent_context={
"user_reply_sent": False,
"reply_mode": None,
"should_dispatch_reply": False,
"is_admin": bool(self._tool_context.get("is_admin")),
},
)
async def _create_agent(self, streaming: bool = False):
"""
创建 LangGraph Agent(使用 create_agent + SummarizationMiddleware
@@ -1126,6 +1162,7 @@ class MoviePilotAgent:
# 工具列表
tools = self._initialize_tools()
tools.extend(await self._initialize_mcp_tools())
skills_middleware = SkillsMiddleware(
sources=[str(agent_runtime_manager.skills_dir)],
bundled_skills_dir=str(settings.ROOT_PATH / "skills"),
@@ -1142,9 +1179,11 @@ class MoviePilotAgent:
activity_log_tools = list(
getattr(activity_log_middleware, "tools", []) or []
)
subagent_tools = self._initialize_subagent_tools()
subagent_tools.extend(await self._initialize_subagent_mcp_tools())
subagent_middlewares, subagent_task_tools = create_subagent_middlewares(
model=non_streaming_model,
tools=self._initialize_subagent_tools(),
tools=subagent_tools,
stream_handler=self.stream_handler,
)
max_tools = settings.LLM_MAX_TOOLS
+26 -16
View File
@@ -952,6 +952,7 @@ class LLMHelper:
base_url: str | None = None,
base_url_preset: str | None = None,
user_agent: str | None = None,
temperature: Optional[float] = None,
use_proxy: bool | None = None,
):
"""
@@ -967,6 +968,7 @@ class LLMHelper:
:param base_url: API Base URL。未显式传入时使用当前配置项 LLM_BASE_URL。
:param base_url_preset: Base URL 预设。未显式传入时使用当前配置项 LLM_BASE_URL_PRESET。
:param user_agent: OpenAI兼容接口请求 User-Agent。未显式传入时使用配置项 LLM_USER_AGENT。
:param temperature: LLM 温度参数。未显式传入时使用配置项 LLM_TEMPERATURE。
:param use_proxy: 是否为本次 LLM 调用使用系统代理。未显式传入时使用配置项 LLM_USE_PROXY。
:return: LLM实例
"""
@@ -978,6 +980,7 @@ class LLMHelper:
base_url_preset if base_url_preset is not None else settings.LLM_BASE_URL_PRESET
)
user_agent_value = user_agent if user_agent is not None else settings.LLM_USER_AGENT
temperature_value = temperature if temperature is not None else settings.LLM_TEMPERATURE
normalized_thinking_level = cls._resolve_thinking_level(
thinking_level=thinking_level,
)
@@ -1034,7 +1037,7 @@ class LLMHelper:
model=model_name,
api_key=runtime["api_key"],
retries=3,
temperature=settings.LLM_TEMPERATURE,
temperature=temperature_value,
streaming=streaming,
client_args=_build_google_client_args(llm_proxy),
**thinking_kwargs,
@@ -1048,7 +1051,7 @@ class LLMHelper:
api_key=runtime["api_key"],
api_base=runtime["base_url"],
max_retries=3,
temperature=settings.LLM_TEMPERATURE,
temperature=temperature_value,
streaming=streaming,
stream_usage=True,
http_client=_build_httpx_client(llm_proxy),
@@ -1063,7 +1066,7 @@ class LLMHelper:
api_key=runtime["api_key"],
base_url=runtime["base_url"],
max_retries=3,
temperature=settings.LLM_TEMPERATURE,
temperature=temperature_value,
streaming=streaming,
stream_usage=True,
anthropic_proxy=llm_proxy,
@@ -1084,7 +1087,7 @@ class LLMHelper:
api_key=runtime["api_key"],
max_retries=3,
base_url=runtime.get("base_url"),
temperature=settings.LLM_TEMPERATURE,
temperature=temperature_value,
streaming=streaming,
stream_usage=True,
openai_proxy=llm_proxy,
@@ -1178,25 +1181,32 @@ class LLMHelper:
base_url: str | None = None,
base_url_preset: str | None = None,
user_agent: str | None = None,
temperature: Optional[float] = None,
use_proxy: bool | None = None,
) -> dict:
"""
使用当前已保存配置执行一次最小 LLM 调用。
使用当前配置或显式传入的临时配置执行一次最小 LLM 调用。
:param temperature: LLM 温度参数。未显式传入时沿用已保存配置。
"""
provider_name = provider if provider is not None else settings.LLM_PROVIDER
model_name = model if model is not None else settings.LLM_MODEL
start = time.perf_counter()
llm = await LLMHelper.get_llm(
streaming=False,
provider=provider_name,
model=model_name,
thinking_level=thinking_level,
api_key=api_key,
base_url=base_url,
base_url_preset=base_url_preset,
user_agent=user_agent,
use_proxy=use_proxy,
)
llm_kwargs = {
"streaming": False,
"provider": provider_name,
"model": model_name,
"thinking_level": thinking_level,
"api_key": api_key,
"base_url": base_url,
"base_url_preset": base_url_preset,
"user_agent": user_agent,
"use_proxy": use_proxy,
}
if temperature is not None:
llm_kwargs["temperature"] = temperature
llm = await LLMHelper.get_llm(**llm_kwargs)
try:
response = await asyncio.wait_for(llm.ainvoke(prompt), timeout=timeout)
except TimeoutError as err:
+600
View File
@@ -0,0 +1,600 @@
"""Agent 外部 MCP 客户端与配置管理。"""
from __future__ import annotations
import asyncio
import hashlib
import json
import os
import re
import uuid
from dataclasses import dataclass
from typing import Any, Optional
from urllib.parse import urljoin
from app.db.systemconfig_oper import SystemConfigOper
from app.log import logger
from app.schemas.agent import (
AgentMcpServerConfig,
AgentMcpServerTestResult,
AgentMcpServerToolInfo,
)
from app.schemas.types import SystemConfigKey
from app.utils.http import AsyncRequestUtils
MCP_PROTOCOL_VERSION = "2025-11-25"
MCP_CLIENT_NAME = "MoviePilot Agent"
DEFAULT_MCP_TIMEOUT = 30
MCP_TOOL_NAME_PATTERN = re.compile(r"[^a-zA-Z0-9_]+")
@dataclass(frozen=True)
class AgentMcpToolSpec:
"""已发现的外部 MCP 工具定义。"""
server: AgentMcpServerConfig
name: str
agent_tool_name: str
description: str
input_schema: dict[str, Any]
def _normalize_identifier(value: str, fallback: str = "mcp") -> str:
"""把服务器或工具名称转换为 Agent 工具可用的标识片段。"""
normalized = MCP_TOOL_NAME_PATTERN.sub("_", str(value or "").strip())
normalized = re.sub(r"_+", "_", normalized).strip("_").lower()
if not normalized:
normalized = fallback
if normalized[0].isdigit():
normalized = f"{fallback}_{normalized}"
return normalized[:64]
def _normalize_timeout(value: Any) -> int:
"""规范化 MCP 连接和调用超时时间。"""
try:
timeout = int(value or DEFAULT_MCP_TIMEOUT)
except (TypeError, ValueError):
timeout = DEFAULT_MCP_TIMEOUT
return min(max(timeout, 1), 600)
def _normalize_string_dict(value: Any) -> dict[str, str]:
"""规范化请求头和环境变量字典,移除空键。"""
if not isinstance(value, dict):
return {}
normalized: dict[str, str] = {}
for key, item in value.items():
normalized_key = str(key or "").strip()
if not normalized_key:
continue
normalized[normalized_key] = str(item or "")
return normalized
def _normalize_input_schema(value: Any) -> dict[str, Any]:
"""规范化 MCP 工具参数 Schema,保证至少是 object schema。"""
if not isinstance(value, dict):
return {"type": "object", "properties": {}, "required": []}
schema = dict(value)
schema.setdefault("type", "object")
schema.setdefault("properties", {})
schema.setdefault("required", [])
return schema
def _build_agent_tool_name(server: AgentMcpServerConfig, tool_name: str) -> str:
"""构造注入 Agent 的外部 MCP 工具名。"""
prefix = server.tool_prefix or f"mcp_{server.name or server.id}"
normalized_prefix = _normalize_identifier(prefix, fallback="mcp")
normalized_tool_name = _normalize_identifier(tool_name, fallback="tool")
if normalized_tool_name.startswith(f"{normalized_prefix}_"):
return normalized_tool_name
return f"{normalized_prefix}_{normalized_tool_name}"[:128]
def _jsonrpc_message(method: str, params: Optional[dict[str, Any]] = None, *, request_id: Optional[str] = None) -> dict:
"""构造 JSON-RPC 2.0 消息。"""
payload = {"jsonrpc": "2.0", "method": method}
if request_id is not None:
payload["id"] = request_id
if params is not None:
payload["params"] = params
return payload
def _raise_for_jsonrpc_error(payload: Any) -> None:
"""检查 JSON-RPC 响应错误并转换为运行时异常。"""
if isinstance(payload, dict) and payload.get("error"):
error = payload["error"]
if isinstance(error, dict):
message = error.get("message") or error
else:
message = error
raise RuntimeError(f"MCP JSON-RPC 错误: {message}")
def _extract_jsonrpc_result(payload: Any, request_id: str) -> Any:
"""从 JSON-RPC 响应中提取 result 字段。"""
if not isinstance(payload, dict):
raise RuntimeError("MCP 响应不是有效 JSON 对象")
if payload.get("id") != request_id:
raise RuntimeError("MCP 响应 ID 与请求不匹配")
_raise_for_jsonrpc_error(payload)
return payload.get("result")
async def _iter_sse_events(response) -> Any:
"""按 SSE 事件格式迭代响应流。"""
event_name = "message"
data_lines: list[str] = []
async for raw_line in response.aiter_lines():
line = raw_line.rstrip("\r")
if not line:
if data_lines:
yield {"event": event_name, "data": "\n".join(data_lines)}
event_name = "message"
data_lines = []
continue
if line.startswith(":"):
continue
field, _, value = line.partition(":")
if value.startswith(" "):
value = value[1:]
if field == "event":
event_name = value or "message"
elif field == "data":
data_lines.append(value)
if data_lines:
yield {"event": event_name, "data": "\n".join(data_lines)}
def _parse_sse_text_response(text: str, request_id: str) -> Any:
"""从非流式 SSE 文本响应中提取匹配请求的 JSON-RPC 结果。"""
event_name = "message"
data_lines: list[str] = []
for raw_line in str(text or "").splitlines():
line = raw_line.rstrip("\r")
if not line:
if data_lines:
payload = _load_sse_json_payload(event_name, "\n".join(data_lines))
if isinstance(payload, dict) and payload.get("id") == request_id:
return _extract_jsonrpc_result(payload, request_id)
event_name = "message"
data_lines = []
continue
if line.startswith(":"):
continue
field, _, value = line.partition(":")
if value.startswith(" "):
value = value[1:]
if field == "event":
event_name = value or "message"
elif field == "data":
data_lines.append(value)
if data_lines:
payload = _load_sse_json_payload(event_name, "\n".join(data_lines))
if isinstance(payload, dict) and payload.get("id") == request_id:
return _extract_jsonrpc_result(payload, request_id)
raise RuntimeError("MCP SSE 响应中未找到匹配请求")
def _load_sse_json_payload(event_name: str, data: str) -> Optional[dict]:
"""解析 SSE data 中的 JSON-RPC 消息。"""
if event_name not in {"message", "messages"}:
return None
try:
payload = json.loads(data)
except (TypeError, ValueError):
return None
return payload if isinstance(payload, dict) else None
class _StdioMcpSession:
"""stdio MCP 会话,按一次操作生命周期启动外部进程。"""
def __init__(self, server: AgentMcpServerConfig) -> None:
self.server = server
self.process: Optional[asyncio.subprocess.Process] = None
self.stderr_task: Optional[asyncio.Task] = None
async def __aenter__(self) -> "_StdioMcpSession":
"""启动 stdio MCP 子进程。"""
if not self.server.command:
raise RuntimeError("stdio MCP 服务器缺少启动命令")
env = os.environ.copy()
env.update(self.server.env or {})
self.process = await asyncio.create_subprocess_exec(
self.server.command,
*(self.server.args or []),
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
)
self.stderr_task = asyncio.create_task(self._drain_stderr())
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
"""结束 stdio MCP 子进程。"""
if self.stderr_task:
self.stderr_task.cancel()
if not self.process:
return
if self.process.returncode is None:
self.process.terminate()
try:
await asyncio.wait_for(self.process.wait(), timeout=2)
except asyncio.TimeoutError:
self.process.kill()
await self.process.wait()
async def _drain_stderr(self) -> None:
"""持续读取子进程 stderr,避免缓冲区阻塞。"""
if not self.process or not self.process.stderr:
return
try:
while True:
line = await self.process.stderr.readline()
if not line:
break
logger.debug(f"MCP stdio[{self.server.name}] stderr: {line.decode(errors='replace').strip()}")
except asyncio.CancelledError:
return
async def notify(self, method: str, params: Optional[dict[str, Any]] = None) -> None:
"""发送不需要响应的 JSON-RPC 通知。"""
await self._write_json(_jsonrpc_message(method, params))
async def request(self, method: str, params: Optional[dict[str, Any]] = None) -> Any:
"""发送 JSON-RPC 请求并等待响应。"""
request_id = uuid.uuid4().hex
await self._write_json(_jsonrpc_message(method, params, request_id=request_id))
while True:
payload = await self._read_json()
if payload.get("id") == request_id:
return _extract_jsonrpc_result(payload, request_id)
async def _write_json(self, payload: dict) -> None:
"""写入一行 JSON-RPC 消息。"""
if not self.process or not self.process.stdin:
raise RuntimeError("stdio MCP 进程未启动")
data = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
self.process.stdin.write(data.encode("utf-8"))
await self.process.stdin.drain()
async def _read_json(self) -> dict:
"""从 stdout 读取一行 JSON-RPC 消息。"""
if not self.process or not self.process.stdout:
raise RuntimeError("stdio MCP 进程未启动")
timeout = _normalize_timeout(self.server.timeout)
while True:
line = await asyncio.wait_for(self.process.stdout.readline(), timeout=timeout)
if not line:
raise RuntimeError("stdio MCP 进程已退出")
try:
payload = json.loads(line.decode("utf-8"))
except ValueError:
logger.debug(f"忽略非 JSON MCP stdout 行: {line!r}")
continue
if isinstance(payload, dict):
return payload
class _HttpMcpSession:
"""Streamable HTTP MCP 会话。"""
def __init__(self, server: AgentMcpServerConfig) -> None:
self.server = server
self.session_id: Optional[str] = None
async def __aenter__(self) -> "_HttpMcpSession":
"""进入 HTTP MCP 会话。"""
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
"""退出 HTTP MCP 会话。"""
return None
async def notify(self, method: str, params: Optional[dict[str, Any]] = None) -> None:
"""发送不需要响应的 JSON-RPC 通知。"""
await self._post(_jsonrpc_message(method, params), expect_response=False)
async def request(self, method: str, params: Optional[dict[str, Any]] = None) -> Any:
"""发送 JSON-RPC 请求并等待响应。"""
request_id = uuid.uuid4().hex
return await self._post(
_jsonrpc_message(method, params, request_id=request_id),
expect_response=True,
request_id=request_id,
)
async def _post(
self,
payload: dict,
*,
expect_response: bool,
request_id: Optional[str] = None,
) -> Any:
"""向 Streamable HTTP MCP 服务发送一条 JSON-RPC 消息。"""
if not self.server.url:
raise RuntimeError("HTTP MCP 服务器缺少 URL")
headers = {
"Accept": "application/json, text/event-stream",
"Content-Type": "application/json",
**(self.server.headers or {}),
}
if self.session_id:
headers["Mcp-Session-Id"] = self.session_id
response = await AsyncRequestUtils(
headers=headers,
timeout=_normalize_timeout(self.server.timeout),
content_type="application/json",
accept_type="application/json, text/event-stream",
http2=False,
).post_res(self.server.url, json=payload, raise_exception=True)
try:
if not response:
raise RuntimeError("HTTP MCP 请求无响应")
response.raise_for_status()
session_id = response.headers.get("Mcp-Session-Id")
if session_id:
self.session_id = session_id
if not expect_response:
return None
content_type = response.headers.get("content-type", "").lower()
if "text/event-stream" in content_type:
return _parse_sse_text_response(response.text, request_id or "")
data = response.json()
return _extract_jsonrpc_result(data, request_id or "")
finally:
if response is not None:
await response.aclose()
class _SseMcpSession:
"""旧版 HTTP+SSE MCP 会话。"""
def __init__(self, server: AgentMcpServerConfig) -> None:
self.server = server
self.response = None
self.endpoint: Optional[str] = None
self._stream_manager = None
self._event_iterator = None
async def __aenter__(self) -> "_SseMcpSession":
"""打开 SSE 流并读取服务端回传的 POST endpoint。"""
if not self.server.url:
raise RuntimeError("SSE MCP 服务器缺少 URL")
self._stream_manager = AsyncRequestUtils(
headers={"Accept": "text/event-stream", **(self.server.headers or {})},
timeout=_normalize_timeout(self.server.timeout),
accept_type="text/event-stream",
http2=False,
).get_stream(self.server.url, raise_exception=True)
self.response = await self._stream_manager.__aenter__()
if not self.response:
raise RuntimeError("SSE MCP 连接无响应")
self.response.raise_for_status()
self._event_iterator = _iter_sse_events(self.response).__aiter__()
self.endpoint = await self._read_endpoint()
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
"""关闭 SSE 流。"""
if self._stream_manager:
await self._stream_manager.__aexit__(exc_type, exc, tb)
async def notify(self, method: str, params: Optional[dict[str, Any]] = None) -> None:
"""发送不需要响应的 JSON-RPC 通知。"""
await self._post(_jsonrpc_message(method, params))
async def request(self, method: str, params: Optional[dict[str, Any]] = None) -> Any:
"""发送 JSON-RPC 请求并等待 SSE 流上的响应。"""
request_id = uuid.uuid4().hex
await self._post(_jsonrpc_message(method, params, request_id=request_id))
timeout = _normalize_timeout(self.server.timeout)
while True:
event = await asyncio.wait_for(self._event_iterator.__anext__(), timeout=timeout)
payload = _load_sse_json_payload(event.get("event", ""), event.get("data", ""))
if isinstance(payload, dict) and payload.get("id") == request_id:
return _extract_jsonrpc_result(payload, request_id)
async def _read_endpoint(self) -> str:
"""读取 SSE endpoint 事件中的 POST 地址。"""
timeout = _normalize_timeout(self.server.timeout)
while True:
event = await asyncio.wait_for(self._event_iterator.__anext__(), timeout=timeout)
if event.get("event") != "endpoint":
continue
endpoint = str(event.get("data") or "").strip()
if not endpoint:
continue
return urljoin(self.server.url, endpoint)
async def _post(self, payload: dict) -> None:
"""向 SSE 握手返回的 endpoint 发送 JSON-RPC 消息。"""
if not self.endpoint:
raise RuntimeError("SSE MCP endpoint 未初始化")
response = await AsyncRequestUtils(
headers={
"Accept": "application/json",
"Content-Type": "application/json",
**(self.server.headers or {}),
},
timeout=_normalize_timeout(self.server.timeout),
content_type="application/json",
accept_type="application/json",
http2=False,
).post_res(self.endpoint, json=payload, raise_exception=True)
try:
if not response:
raise RuntimeError("SSE MCP POST 请求无响应")
response.raise_for_status()
finally:
if response is not None:
await response.aclose()
async def _open_mcp_session(server: AgentMcpServerConfig):
"""根据配置创建对应的 MCP 传输会话。"""
transport = "http" if server.transport == "streamable_http" else server.transport
if transport == "stdio":
return _StdioMcpSession(server)
if transport == "sse":
return _SseMcpSession(server)
if transport == "http":
return _HttpMcpSession(server)
raise RuntimeError(f"不支持的 MCP 传输协议: {server.transport}")
class AgentMcpManager:
"""管理 Agent 外部 MCP 服务器配置、工具发现和工具调用。"""
def get_servers(self) -> list[AgentMcpServerConfig]:
"""读取已保存的外部 MCP 服务器配置。"""
raw_servers = SystemConfigOper().get(SystemConfigKey.AIAgentMcpServers) or []
if not isinstance(raw_servers, list):
return []
servers: list[AgentMcpServerConfig] = []
for raw_server in raw_servers:
try:
servers.append(self.normalize_server(raw_server))
except Exception as err:
logger.warning(f"忽略无效的 Agent MCP 配置: {err}")
return servers
async def save_servers(self, servers: list[AgentMcpServerConfig]) -> bool:
"""保存外部 MCP 服务器配置。"""
normalized_servers = [self.normalize_server(server).model_dump() for server in servers]
return await SystemConfigOper().async_set(
SystemConfigKey.AIAgentMcpServers,
normalized_servers or None,
)
def normalize_server(self, value: Any) -> AgentMcpServerConfig:
"""规范化单个 MCP 服务器配置。"""
if isinstance(value, AgentMcpServerConfig):
raw_server = value.model_dump()
elif isinstance(value, dict):
raw_server = dict(value)
else:
raise ValueError("MCP 服务器配置必须是对象")
raw_server["id"] = str(raw_server.get("id") or uuid.uuid4().hex[:12]).strip()
raw_server["name"] = str(raw_server.get("name") or raw_server["id"]).strip()
raw_server["transport"] = str(raw_server.get("transport") or "stdio").strip()
raw_server["description"] = str(raw_server.get("description") or "").strip() or None
raw_server["command"] = str(raw_server.get("command") or "").strip() or None
raw_server["args"] = [str(item) for item in raw_server.get("args") or []]
raw_server["env"] = _normalize_string_dict(raw_server.get("env"))
raw_server["url"] = str(raw_server.get("url") or "").strip() or None
raw_server["headers"] = _normalize_string_dict(raw_server.get("headers"))
raw_server["timeout"] = _normalize_timeout(raw_server.get("timeout"))
raw_server["tool_prefix"] = str(raw_server.get("tool_prefix") or "").strip() or None
raw_server["require_admin"] = bool(raw_server.get("require_admin", True))
return AgentMcpServerConfig.model_validate(raw_server)
def config_signature(self) -> str:
"""生成外部 MCP 配置签名,用于 Agent 图缓存失效。"""
payload = [server.model_dump() for server in self.get_servers()]
raw_text = json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str)
return hashlib.sha256(raw_text.encode("utf-8")).hexdigest()
async def initialize_session(self, session) -> None:
"""完成 MCP initialize 和 initialized 通知流程。"""
await session.request(
"initialize",
{
"protocolVersion": MCP_PROTOCOL_VERSION,
"capabilities": {},
"clientInfo": {
"name": MCP_CLIENT_NAME,
"version": "1.0.0",
},
},
)
await session.notify("notifications/initialized")
async def list_server_tools(self, server: AgentMcpServerConfig) -> list[AgentMcpToolSpec]:
"""连接单个 MCP 服务器并读取工具列表。"""
normalized_server = self.normalize_server(server)
session_manager = await _open_mcp_session(normalized_server)
async with session_manager as session:
await self.initialize_session(session)
result = await session.request("tools/list")
tools_payload = result.get("tools", []) if isinstance(result, dict) else []
tool_specs: list[AgentMcpToolSpec] = []
for item in tools_payload:
if not isinstance(item, dict) or not item.get("name"):
continue
tool_name = str(item["name"])
tool_specs.append(
AgentMcpToolSpec(
server=normalized_server,
name=tool_name,
agent_tool_name=_build_agent_tool_name(normalized_server, tool_name),
description=str(item.get("description") or ""),
input_schema=_normalize_input_schema(item.get("inputSchema")),
)
)
return tool_specs
async def list_enabled_tool_specs(self) -> list[AgentMcpToolSpec]:
"""读取所有启用 MCP 服务器暴露的工具定义。"""
tool_specs: list[AgentMcpToolSpec] = []
seen_names: set[str] = set()
for server in self.get_servers():
if not server.enabled:
continue
try:
for spec in await self.list_server_tools(server):
if spec.agent_tool_name in seen_names:
logger.warning(f"跳过重复的 MCP Agent 工具名: {spec.agent_tool_name}")
continue
tool_specs.append(spec)
seen_names.add(spec.agent_tool_name)
except Exception as err:
logger.warning(f"读取 MCP 服务器 {server.name} 工具失败: {err}")
return tool_specs
async def call_server_tool(
self,
server: AgentMcpServerConfig,
tool_name: str,
arguments: Optional[dict[str, Any]] = None,
) -> Any:
"""调用单个 MCP 服务器上的指定工具。"""
normalized_server = self.normalize_server(server)
session_manager = await _open_mcp_session(normalized_server)
async with session_manager as session:
await self.initialize_session(session)
return await session.request(
"tools/call",
{
"name": tool_name,
"arguments": arguments or {},
},
)
async def test_server(self, server: AgentMcpServerConfig) -> AgentMcpServerTestResult:
"""测试 MCP 服务器连接并返回工具列表。"""
tool_specs = await self.list_server_tools(server)
tools = [
AgentMcpServerToolInfo(
name=spec.name,
agent_tool_name=spec.agent_tool_name,
description=spec.description,
input_schema=spec.input_schema,
)
for spec in tool_specs
]
return AgentMcpServerTestResult(
success=True,
message=f"连接成功,发现 {len(tools)} 个工具",
tools=tools,
tool_count=len(tools),
)
agent_mcp_manager = AgentMcpManager()
-2
View File
@@ -315,8 +315,6 @@ class PromptManager:
"项目根目录": settings.ROOT_PATH,
"配置目录": settings.CONFIG_PATH,
"临时目录": settings.TEMP_PATH,
"日志目录": settings.LOG_PATH,
"主日志文件": settings.LOG_PATH / "moviepilot.log",
}
return [f" - {label}: `{path}`" for label, path in paths.items()]
+4 -5
View File
@@ -425,7 +425,6 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
"""
roots = [
settings.CONFIG_PATH / "agent",
settings.LOG_PATH,
]
resolved_roots = []
for root in roots:
@@ -461,7 +460,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
allowed_text = "".join(str(root) for root in allowed_roots)
return (
resolved_path,
f"抱歉,普通用户只能{operation}Agent配置目录和日志目录内的文件或目录:{allowed_text}",
f"抱歉,普通用户只能{operation}Agent配置目录内的文件或目录:{allowed_text}",
)
async def _check_local_storage_access(
@@ -483,7 +482,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
return None, None
return (
None,
f"抱歉,普通用户只能{operation}本地配置目录、Agent记忆目录和日志目录,不能访问远程存储。",
f"抱歉,普通用户只能{operation}本地Agent配置目录,不能访问远程存储。",
)
return await self._check_local_file_access(path=path, operation=operation)
@@ -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": "自定义识别词",
+11 -3
View File
@@ -15,7 +15,7 @@ from app.core.config import settings
from app.core.context import Context
from app.core.metainfo import MetaInfo
from app.db.site_oper import SiteOper
from app.helper.directory import DirectoryHelper
from app.helper.directory import DirectoryHelper, validate_download_save_path
from app.log import logger
from app.schemas import FileURI, TorrentInfo
from app.utils.crypto import HashUtils
@@ -183,8 +183,8 @@ class AddDownloadTasksTool(MoviePilotTool):
@staticmethod
def _resolve_direct_download_dir(save_path: Optional[str]) -> Optional[Path]:
"""解析直接下载使用的目录,优先使用 save_path,其次使用默认下载目录"""
if save_path:
return Path(save_path)
if save_path is not None:
return Path(validate_download_save_path(save_path))
download_dirs = DirectoryHelper().get_download_dirs()
if not download_dirs:
@@ -225,6 +225,8 @@ class AddDownloadTasksTool(MoviePilotTool):
merged_labels: Optional[str],
) -> tuple[Optional[str], Optional[str]]:
"""同步提交带上下文的下载任务,避免站点下载与下载器调用阻塞事件循环。"""
if save_path is not None:
save_path = validate_download_save_path(save_path)
return DownloadChain().download_single(
context=context,
downloader=downloader,
@@ -245,6 +247,12 @@ class AddDownloadTasksTool(MoviePilotTool):
if not torrent_inputs:
return "错误:torrent_url 不能为空。"
if save_path is not None:
try:
save_path = validate_download_save_path(save_path)
except ValueError as err:
return f"参数错误:save_path {str(err)}"
merged_labels = self._merge_labels_with_system_tag(labels)
success_count = 0
failed_messages = []
+1 -1
View File
@@ -28,7 +28,7 @@ class EditFileTool(MoviePilotTool):
description: str = (
"Edit a local text file by replacing specific old text with new text. "
"Non-admin users can only edit files inside the MoviePilot Agent config "
"and log directories."
"directory."
)
args_schema: Type[BaseModel] = EditFileInput
+98
View File
@@ -0,0 +1,98 @@
"""外部 MCP 工具适配器。"""
import json
from typing import Any, Optional
from pydantic import PrivateAttr
from app.agent.mcp import AgentMcpToolSpec, agent_mcp_manager
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
class McpExternalTool(MoviePilotTool):
"""将外部 MCP 工具包装为 MoviePilot Agent 工具。"""
name: str = "mcp_external_tool"
tags: list[str] = [
ToolTag.Read,
ToolTag.Admin,
]
description: str = "Call an external MCP tool configured for MoviePilot Agent."
args_schema: dict[str, Any] = {"type": "object", "properties": {}, "required": []}
require_admin: bool = True
_spec: AgentMcpToolSpec = PrivateAttr()
def __init__(self, spec: AgentMcpToolSpec, session_id: str, user_id: str) -> None:
super().__init__(
session_id=session_id,
user_id=user_id,
name=spec.agent_tool_name,
description=spec.description
or f"Call external MCP tool {spec.name} on {spec.server.name}.",
args_schema=spec.input_schema,
require_admin=spec.server.require_admin,
)
self._spec = spec
def get_tool_message(self, **kwargs) -> Optional[str]:
"""根据 MCP 工具信息生成友好的提示消息。"""
return f"调用 MCP 工具: {self._spec.server.name}/{self._spec.name}"
async def run(self, **kwargs) -> str:
"""
调用外部 MCP 工具。
:param kwargs: 传递给外部 MCP 工具的参数
:return: MCP 工具返回内容
"""
result = await agent_mcp_manager.call_server_tool(
server=self._spec.server,
tool_name=self._spec.name,
arguments=kwargs,
)
return self._format_mcp_result(result)
@staticmethod
def _format_mcp_result(result: Any) -> str:
"""将 MCP tools/call 返回结构转换为 Agent 可读文本。"""
if isinstance(result, dict):
content = result.get("content")
if isinstance(content, list):
parts = []
for item in content:
if not isinstance(item, dict):
continue
if item.get("type") == "text" and item.get("text") is not None:
parts.append(str(item["text"]))
elif item:
parts.append(json.dumps(item, ensure_ascii=False, default=str))
if parts:
return "\n".join(parts)
if result.get("isError"):
return json.dumps(result, ensure_ascii=False, indent=2, default=str)
if isinstance(result, str):
return result
return json.dumps(result, ensure_ascii=False, indent=2, default=str)
async def create_external_mcp_tools(
*,
session_id: str,
user_id: str,
channel: Optional[str] = None,
source: Optional[str] = None,
username: Optional[str] = None,
stream_handler=None,
agent_context: Optional[dict] = None,
) -> list[McpExternalTool]:
"""创建当前已启用的外部 MCP Agent 工具列表。"""
tools = []
for spec in await agent_mcp_manager.list_enabled_tool_specs():
tool = McpExternalTool(spec=spec, session_id=session_id, user_id=user_id)
tool.set_message_attr(channel=channel, source=source, username=username)
tool.set_stream_handler(stream_handler=stream_handler)
tool.set_agent_context(agent_context=agent_context)
tools.append(tool)
return tools
@@ -7,6 +7,7 @@ from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.core.metainfo import clear_rust_parse_options_cache
from app.db.systemconfig_oper import SystemConfigOper
from app.log import logger
from app.schemas.types import SystemConfigKey
@@ -85,6 +86,7 @@ class UpdateCustomIdentifiersTool(MoviePilotTool):
SystemConfigKey.CustomIdentifiers, value
)
if success:
clear_rust_parse_options_cache()
return json.dumps(
{
"success": True,
@@ -8,6 +8,7 @@ from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.download import DownloadChain
from app.helper.directory import validate_download_save_path
from app.log import logger
@@ -150,6 +151,18 @@ class UpdateDownloadTasksTool(MoviePilotTool):
],
}
if save_path is not None:
try:
save_path = validate_download_save_path(save_path)
except ValueError:
return {
"hash": hash_value,
"downloader": resolved_downloader,
"results": [
cls._build_result("save_path", False, "保存目录不在允许的下载目录范围内")
],
}
results = []
if tags:
tag_result = download_chain.set_torrents_tag(
+1 -1
View File
@@ -26,7 +26,7 @@ class WriteFileTool(MoviePilotTool):
]
description: str = (
"Write full content to a local text file. Non-admin users can only write "
"inside the MoviePilot Agent config and log directories."
"inside the MoviePilot Agent config directory."
)
args_schema: Type[BaseModel] = WriteFileInput
+108 -5
View File
@@ -20,6 +20,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app import schemas
from app.agent import MoviePilotAgent, ReplyMode, StreamingHandler, agent_manager
from app.agent.llm.capability import AgentCapabilityManager
from app.agent.mcp import agent_mcp_manager
from app.chain.message import MessageChain
from app.chain.site import site_interaction_manager
from app.chain.skills import skills_interaction_manager
@@ -34,6 +35,7 @@ from app.db.models.agentchat import AgentChat
from app.db.user_oper import UserOper, get_current_active_user
from app.helper.agent import attach_web_agent_edit_queue, detach_web_agent_edit_queue
from app.helper.interaction import agent_interaction_manager, media_interaction_manager
from app.helper.locale import LocaleHelper
from app.log import logger
from app.schemas.types import EventType, MessageChannel
@@ -55,6 +57,78 @@ _WEB_AGENT_NOTICE_LISTENER_REGISTERED = False
_WEB_AGENT_BACKGROUND_TASKS: set[asyncio.Task] = set()
def _ensure_superuser(user: User) -> None:
"""校验当前用户是否为超级管理员。"""
if not getattr(user, "is_superuser", False):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
@router.get("/mcp/servers", summary="查询 Agent MCP 服务器配置", response_model=schemas.Response)
async def list_agent_mcp_servers(
current_user: User = Depends(get_current_active_user),
) -> schemas.Response:
"""
查询 Agent 外部 MCP 服务器配置。
"""
_ensure_superuser(current_user)
servers = agent_mcp_manager.get_servers()
enabled_count = len([server for server in servers if server.enabled])
return schemas.Response(
success=True,
data={
"servers": [server.model_dump() for server in servers],
"enabled_count": enabled_count,
"total_count": len(servers),
},
)
@router.post("/mcp/servers", summary="保存 Agent MCP 服务器配置", response_model=schemas.Response)
async def save_agent_mcp_servers(
request: schemas.AgentMcpServersSaveRequest,
current_user: User = Depends(get_current_active_user),
) -> schemas.Response:
"""
保存 Agent 外部 MCP 服务器配置。
"""
_ensure_superuser(current_user)
success = await agent_mcp_manager.save_servers(request.servers)
return schemas.Response(
success=success,
message="保存MCP配置成功" if success else "保存MCP配置失败",
)
@router.post("/mcp/servers/test", summary="测试 Agent MCP 服务器", response_model=schemas.Response)
async def test_agent_mcp_server(
request: schemas.AgentMcpServerTestRequest,
current_user: User = Depends(get_current_active_user),
) -> schemas.Response:
"""
测试 Agent 外部 MCP 服务器连接并读取工具列表。
"""
_ensure_superuser(current_user)
try:
result = await agent_mcp_manager.test_server(request.server)
return schemas.Response(
success=result.success,
message=result.message,
data=result.model_dump(),
)
except Exception as err:
logger.warning(f"测试 Agent MCP 服务器失败: {err}")
return schemas.Response(
success=False,
message=f"测试MCP服务器失败: {str(err)}",
data={
"success": False,
"message": str(err),
"tools": [],
"tool_count": 0,
},
)
class _WebAgentStreamingHandler(StreamingHandler):
"""
Web 前端专用流式处理器,将工具提示和文本统一回调给 SSE。
@@ -326,15 +400,25 @@ def _save_web_agent_display_snapshot(
logger.debug(f"保存WebAgent展示历史失败: {e}")
def _build_web_agent_sse(event_type: str, data: Optional[dict] = None) -> str:
def _build_web_agent_sse(
event_type: str,
data: Optional[dict] = None,
locale: Optional[str] = None,
) -> str:
"""
构建 Web Agent SSE 消息。
:param event_type: 前端事件类型
:param data: 事件数据
:param locale: 当前请求语言
:return: 符合 SSE 格式的字符串
"""
payload = {"type": event_type, **(data or {})}
message = payload.get("message")
if event_type == "error" and isinstance(message, str):
payload["message_i18n"] = LocaleHelper.translate_text(
message, locale=locale
)
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
@@ -1597,6 +1681,7 @@ async def web_agent_stream(
:return: SSE 流式响应
"""
prompt = payload.text.strip()
locale = LocaleHelper.get_locale_from_request(request)
display_prompt = (payload.display_text or payload.text).strip()
is_traditional_message = (
_is_web_agent_traditional_message(prompt)
@@ -1610,6 +1695,7 @@ async def web_agent_stream(
_build_web_agent_sse(
"error",
{"message": denied_message},
locale=locale,
)
]),
media_type="text/event-stream",
@@ -1621,6 +1707,7 @@ async def web_agent_stream(
_build_web_agent_sse(
"error",
{"message": unknown_command_message},
locale=locale,
)
]),
media_type="text/event-stream",
@@ -1649,7 +1736,11 @@ async def web_agent_stream(
"""
生成传统消息链路的 WebAgent SSE 事件。
"""
yield _build_web_agent_sse("start", {"session_id": session_id})
yield _build_web_agent_sse(
"start",
{"session_id": session_id},
locale=locale,
)
events = await _collect_web_agent_traditional_events(
text=prompt,
current_user=current_user,
@@ -1660,7 +1751,11 @@ async def web_agent_stream(
display_messages.append(assistant_message)
for event in events:
event_payload = copy.deepcopy(event)
yield _build_web_agent_sse(event_payload.pop("type"), event_payload)
yield _build_web_agent_sse(
event_payload.pop("type"),
event_payload,
locale=locale,
)
if await request.is_disconnected():
break
await run_in_threadpool(
@@ -1670,7 +1765,7 @@ async def web_agent_stream(
messages=display_messages,
client_session_id=payload.session_id or session_id,
)
yield _build_web_agent_sse("done", {})
yield _build_web_agent_sse("done", {}, locale=locale)
return StreamingResponse(
traditional_event_generator(),
@@ -1688,6 +1783,7 @@ async def web_agent_stream(
_build_web_agent_sse(
"error",
{"message": "智能助手未启用,请先在系统设置中开启。"},
locale=locale,
)
]),
media_type="text/event-stream",
@@ -1703,6 +1799,7 @@ async def web_agent_stream(
_build_web_agent_sse(
"error",
{"message": "语音识别失败,请稍后重试。"},
locale=locale,
)
]),
media_type="text/event-stream",
@@ -1713,6 +1810,7 @@ async def web_agent_stream(
_build_web_agent_sse(
"error",
{"message": "请输入要发送给智能助手的内容或选择附件。"},
locale=locale,
)
]),
media_type="text/event-stream",
@@ -1825,6 +1923,7 @@ async def web_agent_stream(
yield _build_web_agent_sse(
"start",
{"session_id": session_id},
locale=locale,
)
disconnected = False
while not global_vars.is_system_stopped:
@@ -1832,7 +1931,11 @@ async def web_agent_stream(
disconnected = True
break
event = await event_queue.get()
yield _build_web_agent_sse(event.pop("type"), event)
yield _build_web_agent_sse(
event.pop("type"),
event,
locale=locale,
)
if task.done() and event_queue.empty():
break
except asyncio.CancelledError:
+4
View File
@@ -39,6 +39,9 @@ def _anthropic_error_response(
def _check_auth(api_key: Optional[str]) -> Optional[JSONResponse]:
"""
Anthropic 兼容接口以 API_TOKEN 认证受信客户端,认证通过即按管理员级 Agent 集成处理。
"""
if not api_key or api_key != settings.API_TOKEN:
return _anthropic_error_response(
"invalid x-api-key",
@@ -122,6 +125,7 @@ async def messages(
session_seed = anthropic_version or "anthropic"
session_id = build_session_id(f"{session_seed}:{uuid.uuid4().hex}", SESSION_PREFIX)
# 兼容接口的 API_TOKEN 客户端按管理员级 MoviePilot Agent 集成处理。
agent = _CollectingMoviePilotAgent(
session_id=session_id,
user_id=session_id,
+69 -12
View File
@@ -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()
+50
View File
@@ -6,11 +6,61 @@ from app import schemas
from app.chain.douban import DoubanChain
from app.core.context import MediaInfo
from app.core.security import verify_token
from app.db.models.user import User
from app.db.user_oper import get_current_active_superuser_async
from app.modules.douban.douban_cache import DoubanCache
from app.schemas import MediaType
router = APIRouter()
@router.get(
"/cache", summary="查询豆瓣识别缓存", response_model=schemas.Response
)
async def douban_recognition_cache(
_: User = Depends(get_current_active_superuser_async),
) -> schemas.Response:
"""查询可管理的豆瓣识别缓存。"""
cache_items = DoubanCache().list_items()
recognized_count = sum(1 for item in cache_items if item["douban_id"])
return schemas.Response(
success=True,
data={
"count": len(cache_items),
"recognized": recognized_count,
"unrecognized": len(cache_items) - recognized_count,
"data": cache_items,
},
)
@router.delete(
"/cache/{cache_key:path}",
summary="删除指定豆瓣识别缓存",
response_model=schemas.Response,
)
async def delete_douban_recognition_cache(
cache_key: str,
_: User = Depends(get_current_active_superuser_async),
) -> schemas.Response:
"""按缓存键删除单条豆瓣识别缓存。"""
deleted_item = DoubanCache().delete(cache_key)
if not deleted_item:
return schemas.Response(success=False, message="豆瓣识别缓存不存在")
return schemas.Response(success=True, message="豆瓣识别缓存删除成功")
@router.delete(
"/cache", summary="清空豆瓣识别缓存", response_model=schemas.Response
)
async def clear_douban_recognition_cache(
_: User = Depends(get_current_active_superuser_async),
) -> schemas.Response:
"""清空全部豆瓣识别缓存。"""
DoubanCache().clear()
return schemas.Response(success=True, message="豆瓣识别缓存清理完成")
@router.get(
"/person/{person_id}", summary="人物详情", response_model=schemas.MediaPerson
)
+31
View File
@@ -9,14 +9,41 @@ from app.core.context import MediaInfo, Context, SubtitleInfo, TorrentInfo
from app.core.metainfo import MetaInfo
from app.core.security import verify_token
from app.db.models.user import User
from app.db.site_oper import SiteOper
from app.db.systemconfig_oper import SystemConfigOper
from app.db.user_oper import get_current_active_user
from app.helper.directory import DirectoryHelper
from app.schemas.types import SystemConfigKey
from app.utils.security import SecurityUtils
router = APIRouter()
def _prepare_subtitle_download(subtitle: SubtitleInfo) -> tuple[bool, str]:
"""
校验字幕下载签名,并用服务端站点配置覆盖请求凭据。
"""
if subtitle.site is None:
return False, "字幕站点信息为空"
clean_url = SecurityUtils.verify_signed_url(
subtitle.enclosure,
purpose=SecurityUtils.subtitle_download_purpose(subtitle.site),
)
if not clean_url:
return False, "字幕下载链接签名无效"
site = SiteOper().get(subtitle.site)
if not site:
return False, "字幕站点信息不存在"
subtitle.enclosure = clean_url
subtitle.site_cookie = site.cookie
subtitle.site_ua = site.ua
subtitle.site_proxy = bool(site.proxy)
return True, ""
@router.get("/", summary="正在下载", response_model=List[schemas.DownloaderTorrent])
def current(
name: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)
@@ -127,6 +154,10 @@ def download_subtitle(
"""
subtitle_info = SubtitleInfo()
subtitle_info.from_dict(subtitle_in.model_dump())
valid, message = _prepare_subtitle_download(subtitle_info)
if not valid:
return schemas.Response(success=False, message=message)
success, message, saved_files = DownloadChain().download_subtitle(
subtitle=subtitle_info,
tmdbid=tmdbid,
+5 -4
View File
@@ -22,8 +22,9 @@ from app.db.models import User
from app.db.models.downloadhistory import DownloadHistory, DownloadFiles
from app.db.models.transferhistory import TransferHistory
from app.db.user_oper import (
get_current_active_superuser_async,
get_current_active_manage_user,
get_current_active_superuser,
get_current_active_superuser_async,
)
from app.helper.progress import ProgressHelper
from app.schemas.types import EventType
@@ -223,7 +224,7 @@ def delete_transfer_history(
deletesrc: Optional[bool] = False,
deletedest: Optional[bool] = False,
db: Session = Depends(get_db),
_: User = Depends(get_current_active_superuser),
_: User = Depends(get_current_active_manage_user),
) -> Any:
"""
删除整理记录
@@ -264,7 +265,7 @@ def delete_transfer_history(
def ai_redo_transfer_history(
history_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_active_superuser),
_: User = Depends(get_current_active_manage_user),
) -> Any:
"""
手动触发单条历史记录的 AI 重新整理并返回进度键
@@ -293,7 +294,7 @@ def ai_redo_transfer_history(
def batch_ai_redo_transfer_history(
payload: schemas.BatchTransferHistoryRedoRequest,
db: Session = Depends(get_db),
_: User = Depends(get_current_active_superuser),
_: User = Depends(get_current_active_manage_user),
) -> Any:
"""
手动触发多条历史记录的 AI 批量重新整理并返回进度键
+28 -17
View File
@@ -36,6 +36,7 @@ class LlmTestRequest(BaseModel):
base_url: Optional[str] = None
base_url_preset: Optional[str] = None
user_agent: Optional[str] = None
temperature: Optional[float] = None
use_proxy: Optional[bool] = None
@@ -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),
)
+1
View File
@@ -19,6 +19,7 @@ router = APIRouter()
# MCP 协议版本
MCP_PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18", "2024-11-05"]
MCP_PROTOCOL_VERSION = MCP_PROTOCOL_VERSIONS[0] # 默认使用最新版本
# MCP 经 API_TOKEN / X-API-KEY 认证后是管理员级集成入口;隐藏工具只收敛暴露面,不构成权限边界。
MCP_HIDDEN_TOOLS = {
"execute_command",
"search_web",
+2 -1
View File
@@ -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)}")
+5
View File
@@ -231,6 +231,9 @@ def _error_response(
def _check_auth(
credentials: Optional[HTTPAuthorizationCredentials],
) -> Optional[JSONResponse]:
"""
OpenAI 兼容接口以 API_TOKEN 认证受信客户端认证通过即按管理员级 Agent 集成处理
"""
if not credentials or credentials.scheme.lower() != "bearer":
return _error_response(
"Invalid bearer token.",
@@ -317,6 +320,7 @@ async def chat_completions(
session_id = build_session_id(session_key, SESSION_PREFIX)
username = str(payload.user or "openai-client")
# 兼容接口的 API_TOKEN 客户端按管理员级 MoviePilot Agent 集成处理。
agent = _CollectingMoviePilotAgent(
session_id=session_id,
user_id=session_key,
@@ -409,6 +413,7 @@ async def responses(
session_key = str(payload.user or uuid.uuid4())
session_id = build_session_id(session_key, SESSION_PREFIX)
# 兼容接口的 API_TOKEN 客户端按管理员级 MoviePilot Agent 集成处理。
agent = _CollectingMoviePilotAgent(
session_id=session_id,
user_id=session_key,
+84 -27
View File
@@ -1,3 +1,4 @@
import asyncio
import mimetypes
import shutil
from typing import Annotated, Any, List, Optional
@@ -39,6 +40,67 @@ PROTECTED_ROUTES = {"/api/v1/openapi.json", "/docs", "/docs/oauth2-redirect", "/
PLUGIN_PREFIX = f"{settings.API_V1_STR}/plugin"
router = APIRouter()
_plugin_release_refresh_tasks: set[asyncio.Task] = set()
async def _get_market_plugin_from_repo(
plugin_manager: PluginManager,
plugin_id: str,
repo_url: str,
force: bool,
) -> Optional[schemas.Plugin]:
"""
只读取指定插件仓库的市场元数据避免单插件详情触发全部市场刷新
"""
market_plugins = await plugin_manager.async_get_plugins_from_market(
repo_url, settings.VERSION_FLAG, force
)
market_plugin = next(
(
plugin
for plugin in market_plugins or []
if plugin.id == plugin_id
),
None,
)
if market_plugin or not settings.VERSION_FLAG:
return market_plugin
compatible_plugins = await plugin_manager.async_get_plugins_from_market(
repo_url, None, force
)
return next(
(
plugin
for plugin in compatible_plugins or []
if plugin.id == plugin_id
),
None,
)
async def _refresh_plugin_release_versions(plugin_id: str, repo_url: str) -> None:
"""
后台强制刷新 Release 缓存接口响应路径优先返回已有缓存
"""
try:
async with async_fresh(True):
await PluginHelper().async_get_plugin_release_versions(plugin_id, repo_url)
except Exception as e:
logger.warning(f"后台刷新插件 {plugin_id} Release 列表失败:{e}")
def _schedule_plugin_release_refresh(plugin_id: str, repo_url: str) -> None:
"""
保留后台任务引用避免任务被回收同时让 helper 负责同仓库强刷合并
"""
task = asyncio.create_task(_refresh_plugin_release_versions(plugin_id, repo_url))
_plugin_release_refresh_tasks.add(task)
def _discard_task(completed_task: asyncio.Task) -> None:
_plugin_release_refresh_tasks.discard(completed_task)
task.add_done_callback(_discard_task)
def register_plugin_api(plugin_id: Optional[str] = None):
@@ -239,6 +301,15 @@ async def _get_plugin_history_detail(
if local_repo_plugin:
return _merge_plugin_market_metadata(installed_plugin, local_repo_plugin)
if installed_plugin.repo_url:
market_plugin = await _get_market_plugin_from_repo(
plugin_manager, plugin_id, installed_plugin.repo_url, force
)
if not market_plugin:
logger.debug(f"插件 {plugin_id} 未从来源仓库获取到更新说明,返回本地插件信息")
return installed_plugin
return _merge_plugin_market_metadata(installed_plugin, market_plugin)
market_plugin = next(
(
plugin
@@ -359,30 +430,9 @@ async def plugin_releases(
}
plugin_manager = PluginManager()
market_plugins = await plugin_manager.async_get_plugins_from_market(
repo_url, settings.VERSION_FLAG, force
market_plugin = await _get_market_plugin_from_repo(
plugin_manager, plugin_id, repo_url, force
)
market_plugin = next(
(
plugin
for plugin in market_plugins or []
if plugin.id == plugin_id
),
None,
)
if not market_plugin and settings.VERSION_FLAG:
compatible_plugins = await plugin_manager.async_get_plugins_from_market(
repo_url, None, force
)
market_plugin = next(
(
plugin
for plugin in compatible_plugins or []
if plugin.id == plugin_id
),
None,
)
latest_version = market_plugin.plugin_version if market_plugin else None
current_version = plugin_manager.get_local_plugin_version(plugin_id)
if not getattr(market_plugin, "release", False):
@@ -393,8 +443,15 @@ async def plugin_releases(
"items": [],
}
async with async_fresh(force):
release_items = await PluginHelper().async_get_plugin_release_versions(plugin_id, repo_url)
plugin_helper = PluginHelper()
has_release_cache = (
await plugin_helper.async_has_plugin_release_cache(repo_url)
if force
else False
)
release_items = await plugin_helper.async_get_plugin_release_versions(plugin_id, repo_url)
if force and has_release_cache:
_schedule_plugin_release_refresh(plugin_id, repo_url)
items = []
for item in release_items:
version = item.get("version")
@@ -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)
+79 -9
View File
@@ -12,9 +12,11 @@ from app.core.config import settings
from app.core.event import eventmanager
from app.core.metainfo import MetaInfo
from app.core.security import verify_resource_token, verify_token
from app.helper.locale import LocaleHelper
from app.log import logger
from app.schemas import MediaRecognizeConvertEventData
from app.schemas.types import MediaType, ChainEventType
from app.utils.security import SecurityUtils
router = APIRouter()
@@ -38,11 +40,65 @@ def _parse_media_type(mtype: Optional[str]) -> Optional[MediaType]:
return MediaType.from_agent(mtype) or MediaType(mtype)
def _sse_event(data: dict) -> str:
def _sse_event(data: dict, locale: Optional[str] = None) -> str:
"""
转换为SSE事件
"""
return f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
payload = data
message = payload.get("message")
text = payload.get("text")
if isinstance(message, str) or isinstance(text, str):
payload = data.copy()
if isinstance(message, str):
payload["message_i18n"] = LocaleHelper.translate_text(
message, locale=locale
)
if isinstance(text, str):
payload["text_i18n"] = LocaleHelper.translate_text(text, locale=locale)
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
def _serialize_signed_subtitle_result(subtitle: Any) -> dict:
"""
序列化字幕结果并签名下载链接签名用途绑定站点 ID
"""
data = subtitle.to_dict() if hasattr(subtitle, "to_dict") else dict(subtitle)
enclosure = data.get("enclosure")
if enclosure:
data["enclosure"] = SecurityUtils.sign_url(
enclosure,
purpose=SecurityUtils.subtitle_download_purpose(data.get("site")),
)
return data
def _serialize_signed_subtitle_results(subtitles: List[Any]) -> List[dict]:
"""
批量序列化字幕结果确保返回给客户端的下载链接均已签名
"""
return [_serialize_signed_subtitle_result(subtitle) for subtitle in subtitles]
def _sign_subtitle_search_event(event: dict) -> dict:
"""
签名字幕搜索流事件中的下载链接
"""
signed_event = dict(event)
if "items" in signed_event:
signed_event["items"] = _serialize_signed_subtitle_results(
signed_event.get("items") or []
)
return signed_event
async def _iter_signed_subtitle_search_events(
event_source: AsyncIterator[dict],
) -> AsyncIterator[dict]:
"""
输出仅包含签名字幕下载链接的搜索流事件
"""
async for event in event_source:
yield _sign_subtitle_search_event(event)
def _merge_append_event(pending_event: Optional[dict], event: dict) -> dict:
@@ -123,6 +179,7 @@ async def _stream_search_events(request: Request, event_source: AsyncIterator[di
"""
输出搜索SSE事件
"""
locale = LocaleHelper.get_locale_from_request(request)
try:
has_sent_final_replace = False
async for event in _iter_batched_search_events(event_source):
@@ -138,10 +195,13 @@ async def _stream_search_events(request: Request, event_source: AsyncIterator[di
and event.get("items")
):
event = {key: value for key, value in event.items() if key != "items"}
yield _sse_event(event)
yield _sse_event(event, locale=locale)
except Exception as err:
logger.error(f"渐进式搜索出错:{err}", exc_info=True)
yield _sse_event({"type": "error", "success": False, "message": str(err)})
yield _sse_event(
{"type": "error", "success": False, "message": str(err)},
locale=locale,
)
@router.get("/last", summary="查询搜索结果", response_model=List[schemas.Context])
@@ -168,7 +228,9 @@ async def search_latest_context(_: schemas.TokenPayload = Depends(verify_token))
success=True,
data={
"params": params,
"results": [result.to_dict() for result in results],
"results": _serialize_signed_subtitle_results(results)
if params.get("result_type") == "subtitle"
else [result.to_dict() for result in results],
},
)
@@ -625,7 +687,11 @@ async def search_subtitle_by_title_stream(
title=keyword, page=page, sites=_parse_site_list(sites), cache_local=True
)
return StreamingResponse(
_stream_search_events(request, event_source), media_type="text/event-stream"
_stream_search_events(
request,
_iter_signed_subtitle_search_events(event_source),
),
media_type="text/event-stream",
)
@@ -645,7 +711,7 @@ async def search_subtitle_by_title(
if not subtitles:
return schemas.Response(success=False, message="未搜索到任何字幕")
return schemas.Response(
success=True, data=[subtitle.to_dict() for subtitle in subtitles]
success=True, data=_serialize_signed_subtitle_results(subtitles)
)
@@ -798,7 +864,11 @@ async def search_subtitle_by_id_stream(
yield event
return StreamingResponse(
_stream_search_events(request, event_source()), media_type="text/event-stream"
_stream_search_events(
request,
_iter_signed_subtitle_search_events(event_source()),
),
media_type="text/event-stream",
)
@@ -832,7 +902,7 @@ async def search_subtitle_by_id(
if not subtitles:
return schemas.Response(success=False, message="未搜索到任何字幕")
return schemas.Response(
success=True, data=[subtitle.to_dict() for subtitle in subtitles]
success=True, data=_serialize_signed_subtitle_results(subtitles)
)
+14 -12
View File
@@ -22,6 +22,8 @@ from app.db.models.siteuserdata import SiteUserData
from app.db.site_oper import SiteOper
from app.db.systemconfig_oper import SystemConfigOper
from app.db.user_oper import (
get_current_active_manage_user,
get_current_active_manage_user_async,
get_current_active_superuser,
get_current_active_superuser_async,
)
@@ -37,7 +39,7 @@ router = APIRouter()
@router.get("/", summary="所有站点", response_model=List[schemas.Site])
async def read_sites(
db: AsyncSession = Depends(get_async_db),
_: User = Depends(get_current_active_superuser),
_: User = Depends(get_current_active_manage_user_async),
) -> List[dict]:
"""
获取站点列表
@@ -50,7 +52,7 @@ async def add_site(
*,
db: AsyncSession = Depends(get_async_db),
site_in: schemas.Site,
_: User = Depends(get_current_active_superuser),
_: User = Depends(get_current_active_manage_user_async),
) -> Any:
"""
新增站点
@@ -89,7 +91,7 @@ async def update_site(
*,
db: AsyncSession = Depends(get_async_db),
site_in: schemas.Site,
_: User = Depends(get_current_active_superuser),
_: User = Depends(get_current_active_manage_user_async),
) -> Any:
"""
更新站点信息
@@ -150,7 +152,7 @@ def reset(
async def update_sites_priority(
priorities: List[dict],
db: AsyncSession = Depends(get_async_db),
_: User = Depends(get_current_active_superuser_async),
_: User = Depends(get_current_active_manage_user_async),
) -> Any:
"""
批量更新站点优先级
@@ -203,7 +205,7 @@ def update_cookie_by_body(
site_id: int,
site_cookie_update: schemas.SiteCookieUpdate,
db: Session = Depends(get_db),
_: User = Depends(get_current_active_superuser),
_: User = Depends(get_current_active_manage_user),
) -> Any:
"""
使用请求体中的用户密码更新站点Cookie
@@ -226,7 +228,7 @@ def update_cookie(
password: str,
code: Optional[str] = None,
db: Session = Depends(get_db),
_: User = Depends(get_current_active_superuser),
_: User = Depends(get_current_active_manage_user),
) -> Any:
"""
使用用户密码更新站点Cookie
@@ -246,7 +248,7 @@ def update_cookie(
def refresh_userdata(
site_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_active_superuser),
_: User = Depends(get_current_active_manage_user),
) -> Any:
"""
刷新站点用户数据
@@ -273,7 +275,7 @@ def refresh_userdata(
)
async def read_userdata_latest(
db: AsyncSession = Depends(get_async_db),
_: User = Depends(get_current_active_superuser_async),
_: User = Depends(get_current_active_manage_user_async),
) -> Any:
"""
查询所有站点最新用户数据
@@ -291,7 +293,7 @@ async def read_userdata(
site_id: int,
workdate: Optional[str] = None,
db: AsyncSession = Depends(get_async_db),
_: User = Depends(get_current_active_superuser_async),
_: User = Depends(get_current_active_manage_user_async),
) -> Any:
"""
查询站点用户数据
@@ -395,7 +397,7 @@ async def site_resource(
cat: Optional[str] = None,
page: Optional[int] = 0,
db: AsyncSession = Depends(get_async_db),
_: User = Depends(get_current_active_superuser_async),
_: User = Depends(get_current_active_manage_user_async),
) -> Any:
"""
浏览站点资源
@@ -543,7 +545,7 @@ async def support_sites(_: User = Depends(get_current_active_superuser_async)):
async def read_site(
site_id: int,
db: AsyncSession = Depends(get_async_db),
_: User = Depends(get_current_active_superuser_async),
_: User = Depends(get_current_active_manage_user_async),
) -> Any:
"""
通过ID获取站点信息
@@ -561,7 +563,7 @@ async def read_site(
async def delete_site(
site_id: int,
db: AsyncSession = Depends(get_async_db),
_: User = Depends(get_current_active_superuser_async),
_: User = Depends(get_current_active_manage_user_async),
) -> Any:
"""
删除站点
+7 -6
View File
@@ -15,6 +15,7 @@ from app.core.config import settings
from app.core.security import verify_token
from app.db.models import User
from app.db.user_oper import (
get_current_active_manage_user,
get_current_active_superuser,
get_current_active_superuser_async,
)
@@ -91,7 +92,7 @@ def list_files(
fileitem: schemas.FileItem,
sort: Optional[str] = "updated_at",
keyword: Optional[str] = None,
_: User = Depends(get_current_active_superuser),
_: User = Depends(get_current_active_manage_user),
) -> Any:
"""
查询当前目录下所有目录和文件
@@ -117,7 +118,7 @@ def list_files(
def mkdir(
fileitem: schemas.FileItem,
name: str,
_: User = Depends(get_current_active_superuser),
_: User = Depends(get_current_active_manage_user),
) -> Any:
"""
创建目录
@@ -135,7 +136,7 @@ def mkdir(
@router.post("/delete", summary="删除文件或目录", response_model=schemas.Response)
def delete(
fileitem: schemas.FileItem, _: User = Depends(get_current_active_superuser)
fileitem: schemas.FileItem, _: User = Depends(get_current_active_manage_user)
) -> Any:
"""
删除文件或目录
@@ -150,7 +151,7 @@ def delete(
@router.post("/download", summary="下载文件")
def download(
fileitem: schemas.FileItem, _: User = Depends(get_current_active_superuser)
fileitem: schemas.FileItem, _: User = Depends(get_current_active_manage_user)
) -> Any:
"""
下载文件或目录
@@ -166,7 +167,7 @@ def download(
@router.post("/image", summary="预览图片")
def image(
fileitem: schemas.FileItem, _: User = Depends(get_current_active_superuser)
fileitem: schemas.FileItem, _: User = Depends(get_current_active_manage_user)
) -> Any:
"""
下载文件或目录
@@ -185,7 +186,7 @@ def rename(
fileitem: schemas.FileItem,
new_name: str,
recursive: Optional[bool] = False,
_: User = Depends(get_current_active_superuser),
_: User = Depends(get_current_active_manage_user),
) -> Any:
"""
重命名文件或目录
+154 -47
View File
@@ -17,7 +17,7 @@ from app.db.models.subscribe import Subscribe
from app.db.models.subscribehistory import SubscribeHistory
from app.db.models.user import User
from app.db.systemconfig_oper import SystemConfigOper
from app.db.user_oper import get_current_active_user_async
from app.db.user_oper import get_current_active_user, get_current_active_user_async
from app.helper.server import MoviePilotServerHelper
from app.log import logger
from app.scheduler import Scheduler
@@ -51,14 +51,69 @@ def build_subscribe_event_payload(subscribe: Subscribe) -> dict:
return {column.name: values.get(column.name) for column in subscribe.__table__.columns}
def can_access_subscribe(
subscribe: Subscribe | SubscribeHistory | None, current_user: User
) -> bool:
"""
判断当前用户是否可访问订阅及其历史记录
超级用户拥有全局订阅管理能力普通用户只能访问 username 精确匹配自己的订阅
username 表示无法归属的 legacy 订阅只能由超级用户管理
"""
if not subscribe:
return False
if current_user.is_superuser:
return True
username = subscribe.username
return bool(username) and username == current_user.name
async def get_accessible_subscribe(
db: AsyncSession, subscribe_id: int, current_user: User
) -> Subscribe | None:
"""
按订阅 ID 读取当前用户可访问的订阅行
"""
subscribe = await Subscribe.async_get(db, subscribe_id)
if can_access_subscribe(subscribe, current_user):
return subscribe
return None
def get_accessible_subscribe_sync(
db: Session, subscribe_id: int, current_user: User
) -> Subscribe | None:
"""
同步读取当前用户可访问的订阅行
"""
subscribe = Subscribe.get(db, subscribe_id)
if can_access_subscribe(subscribe, current_user):
return subscribe
return None
def select_accessible_subscribe(
subscribes: List[Subscribe], current_user: User
) -> Subscribe | None:
"""
从候选订阅中选择当前用户可访问的第一条记录
"""
for subscribe in subscribes or []:
if can_access_subscribe(subscribe, current_user):
return subscribe
return None
@router.get("/", summary="查询所有订阅", response_model=List[schemas.Subscribe])
async def read_subscribes(
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token),
current_user: User = Depends(get_current_active_user_async),
) -> Any:
"""
查询所有订阅
"""
if not current_user.is_superuser:
return await Subscribe.async_list_by_username(db, current_user.name)
return await Subscribe.async_list(db)
@@ -69,7 +124,7 @@ async def list_subscribes(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
"""
查询所有订阅 API_TOKEN认证?token=xxx
"""
return await read_subscribes()
return await Subscribe.async_list()
@router.post("/", summary="新增订阅", response_model=schemas.Response)
@@ -106,7 +161,11 @@ async def create_subscribe(
# completed_episode 是响应派生字段,禁止写入持久层
subscribe_dict.pop("completed_episode", None)
sid, message = await SubscribeChain().async_add(
mtype=mtype, title=title, exist_ok=True, **subscribe_dict
mtype=mtype,
title=title,
exist_ok=True,
owner_scope=not current_user.is_superuser,
**subscribe_dict,
)
return schemas.Response(success=bool(sid), message=message, data={"id": sid})
@@ -116,17 +175,18 @@ async def update_subscribe(
*,
subscribe_in: schemas.Subscribe,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token),
current_user: User = Depends(get_current_active_user_async),
) -> Any:
"""
更新订阅信息
"""
subscribe = await Subscribe.async_get(db, subscribe_in.id)
subscribe = await get_accessible_subscribe(db, subscribe_in.id, current_user)
if not subscribe:
return schemas.Response(success=False, message="订阅不存在")
# 避免更新缺失集数
old_subscribe_dict = subscribe.to_dict()
subscribe_dict = subscribe_in.model_dump()
subscribe_dict["username"] = subscribe.username
if subscribe_in.episode_priority is None:
subscribe_dict.pop("episode_priority", None)
# completed_episode 是响应派生字段,禁止写入持久层
@@ -165,12 +225,12 @@ async def update_subscribe_status(
subid: int,
state: str,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token),
current_user: User = Depends(get_current_active_user_async),
) -> Any:
"""
更新订阅状态
"""
subscribe = await Subscribe.async_get(db, subid)
subscribe = await get_accessible_subscribe(db, subid, current_user)
if not subscribe:
return schemas.Response(success=False, message="订阅不存在")
valid_states = ["R", "P", "S"]
@@ -199,7 +259,7 @@ async def subscribe_mediaid(
season: Optional[int] = None,
title: Optional[str] = None,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token),
current_user: User = Depends(get_current_active_user_async),
) -> Any:
"""
根据 TMDBID/豆瓣ID/BangumiId 查询订阅 tmdb:/douban:
@@ -209,23 +269,27 @@ async def subscribe_mediaid(
tmdbid = mediaid[5:]
if not tmdbid or not str(tmdbid).isdigit():
return Subscribe()
result = await Subscribe.async_exists(db, tmdbid=int(tmdbid), season=season)
subscribes = await Subscribe.async_get_by_tmdbid(db, int(tmdbid), season)
result = select_accessible_subscribe(subscribes, current_user)
elif mediaid.startswith("douban:"):
doubanid = mediaid[7:]
if not doubanid:
return Subscribe()
result = await Subscribe.async_get_by_doubanid(db, doubanid)
subscribes = await Subscribe.async_list_by_doubanid(db, doubanid)
result = select_accessible_subscribe(subscribes, current_user)
if not result and title:
title_check = True
elif mediaid.startswith("bangumi:"):
bangumiid = mediaid[8:]
if not bangumiid or not str(bangumiid).isdigit():
return Subscribe()
result = await Subscribe.async_get_by_bangumiid(db, int(bangumiid))
subscribes = await Subscribe.async_list_by_bangumiid(db, int(bangumiid))
result = select_accessible_subscribe(subscribes, current_user)
if not result and title:
title_check = True
else:
result = await Subscribe.async_get_by_mediaid(db, mediaid)
subscribes = await Subscribe.async_list_by_mediaid(db, mediaid)
result = select_accessible_subscribe(subscribes, current_user)
if not result and title:
title_check = True
# 使用名称检查订阅
@@ -233,18 +297,23 @@ async def subscribe_mediaid(
meta = MetaInfo(title)
if season is not None:
meta.begin_season = season
result = await Subscribe.async_get_by_title(
subscribes = await Subscribe.async_list_by_title(
db, title=meta.name, season=meta.begin_season
)
result = select_accessible_subscribe(subscribes, current_user)
return result if result else Subscribe()
@router.get("/refresh", summary="刷新订阅", response_model=schemas.Response)
def refresh_subscribes(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def refresh_subscribes(
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
刷新所有订阅
"""
if not current_user.is_superuser:
return schemas.Response(success=False, message="订阅不存在")
Scheduler().start("subscribe_refresh")
return schemas.Response(success=True)
@@ -253,12 +322,12 @@ def refresh_subscribes(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
async def reset_subscribes(
subid: int,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token),
current_user: User = Depends(get_current_active_user_async),
) -> Any:
"""
重置订阅
"""
subscribe = await Subscribe.async_get(db, subid)
subscribe = await get_accessible_subscribe(db, subid, current_user)
if subscribe:
# 在更新之前获取旧数据
old_subscribe_dict = subscribe.to_dict()
@@ -292,26 +361,43 @@ async def reset_subscribes(
@router.get("/check", summary="刷新订阅 TMDB 信息", response_model=schemas.Response)
def check_subscribes(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def check_subscribes(
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
刷新订阅 TMDB 信息
"""
if not current_user.is_superuser:
return schemas.Response(success=False, message="订阅不存在")
Scheduler().start("subscribe_tmdb")
return schemas.Response(success=True)
@router.get("/search", summary="搜索所有订阅", response_model=schemas.Response)
async def search_subscribes(
background_tasks: BackgroundTasks, _: schemas.TokenPayload = Depends(verify_token)
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_async_db),
current_user: User = Depends(get_current_active_user_async),
) -> Any:
"""
搜索所有订阅
"""
background_tasks.add_task(
Scheduler().start,
job_id="subscribe_search",
**{"sid": None, "state": "R", "manual": True},
)
if current_user.is_superuser:
background_tasks.add_task(
Scheduler().start,
job_id="subscribe_search",
**{"sid": None, "state": "R", "manual": True},
)
else:
subscribes = await Subscribe.async_list_by_username(
db, current_user.name, state="R"
)
for subscribe in subscribes:
background_tasks.add_task(
Scheduler().start,
job_id="subscribe_search",
**{"sid": subscribe.id, "state": None, "manual": True},
)
return schemas.Response(success=True)
@@ -321,11 +407,15 @@ async def search_subscribes(
async def search_subscribe(
subscribe_id: int,
background_tasks: BackgroundTasks,
_: schemas.TokenPayload = Depends(verify_token),
db: AsyncSession = Depends(get_async_db),
current_user: User = Depends(get_current_active_user_async),
) -> Any:
"""
根据订阅编号搜索订阅
"""
subscribe = await get_accessible_subscribe(db, subscribe_id, current_user)
if not subscribe:
return schemas.Response(success=False, message="订阅不存在")
background_tasks.add_task(
Scheduler().start,
job_id="subscribe_search",
@@ -339,7 +429,7 @@ async def delete_subscribe_by_mediaid(
mediaid: str,
season: Optional[int] = None,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token),
current_user: User = Depends(get_current_active_user_async),
) -> Any:
"""
根据TMDBID或豆瓣ID删除订阅 tmdb:/douban:
@@ -355,15 +445,17 @@ async def delete_subscribe_by_mediaid(
doubanid = mediaid[7:]
if not doubanid:
return schemas.Response(success=False)
subscribe = await Subscribe.async_get_by_doubanid(db, doubanid)
if subscribe:
delete_subscribes.append(subscribe)
subscribes = await Subscribe.async_list_by_doubanid(db, doubanid)
delete_subscribes.extend(subscribes)
else:
subscribe = await Subscribe.async_get_by_mediaid(db, mediaid)
if subscribe:
delete_subscribes.append(subscribe)
subscribes = await Subscribe.async_list_by_mediaid(db, mediaid)
delete_subscribes.extend(subscribes)
delete_events = []
for subscribe in delete_subscribes:
for subscribe in [
subscribe
for subscribe in delete_subscribes
if can_access_subscribe(subscribe, current_user)
]:
subscribe_info = build_subscribe_event_payload(subscribe)
subscribe_id = subscribe_info.get("id")
if not subscribe_id:
@@ -464,14 +556,19 @@ async def subscribe_history(
page: Optional[int] = 1,
count: Optional[int] = 30,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token),
current_user: User = Depends(get_current_active_user_async),
) -> Any:
"""
查询电影/电视剧订阅历史
"""
histories = await SubscribeHistory.async_list_by_type(
db, mtype=mtype, page=page, count=count
)
if current_user.is_superuser:
histories = await SubscribeHistory.async_list_by_type(
db, mtype=mtype, page=page, count=count
)
else:
histories = await SubscribeHistory.async_list_by_type_and_username(
db, mtype=mtype, username=current_user.name, page=page, count=count
)
result = []
for history in histories:
history_item = schemas.Subscribe.model_validate(history, from_attributes=True)
@@ -488,12 +585,14 @@ async def subscribe_history(
async def delete_subscribe_history(
history_id: int,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token),
current_user: User = Depends(get_current_active_user_async),
) -> Any:
"""
删除订阅历史
"""
await SubscribeHistory.async_delete(db, history_id)
history = await SubscribeHistory.async_get(db, history_id)
if can_access_subscribe(history, current_user):
await SubscribeHistory.async_delete(db, history_id)
return schemas.Response(success=True)
@@ -565,11 +664,13 @@ async def popular_subscribes(
async def user_subscribes(
username: str,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token),
current_user: User = Depends(get_current_active_user_async),
) -> Any:
"""
查询用户订阅
"""
if not current_user.is_superuser and username != current_user.name:
return []
return await Subscribe.async_list_by_username(db, username)
@@ -581,12 +682,12 @@ async def user_subscribes(
def subscribe_files(
subscribe_id: int,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token),
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
订阅相关文件信息
"""
subscribe = Subscribe.get(db, subscribe_id)
subscribe = get_accessible_subscribe_sync(db, subscribe_id, current_user)
if subscribe:
return SubscribeChain().subscribe_files_info(subscribe)
return schemas.SubscrbieInfo()
@@ -594,11 +695,16 @@ def subscribe_files(
@router.post("/share", summary="分享订阅", response_model=schemas.Response)
async def subscribe_share(
sub: schemas.SubscribeShare, _: schemas.TokenPayload = Depends(verify_token)
sub: schemas.SubscribeShare,
db: AsyncSession = Depends(get_async_db),
current_user: User = Depends(get_current_active_user_async),
) -> Any:
"""
分享订阅
"""
subscribe = await get_accessible_subscribe(db, sub.subscribe_id, current_user)
if not subscribe:
return schemas.Response(success=False, message="订阅不存在")
state, errmsg = await MoviePilotServerHelper.async_sub_share(
subscribe_id=sub.subscribe_id,
share_title=sub.share_title,
@@ -728,26 +834,27 @@ async def subscribe_share_statistics(
async def read_subscribe(
subscribe_id: int,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token),
current_user: User = Depends(get_current_active_user_async),
) -> Any:
"""
根据订阅编号查询订阅信息
"""
if not subscribe_id:
return Subscribe()
return await Subscribe.async_get(db, subscribe_id)
subscribe = await get_accessible_subscribe(db, subscribe_id, current_user)
return subscribe if subscribe else Subscribe()
@router.delete("/{subscribe_id}", summary="删除订阅", response_model=schemas.Response)
async def delete_subscribe(
subscribe_id: int,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token),
current_user: User = Depends(get_current_active_user_async),
) -> Any:
"""
删除订阅信息
"""
subscribe = await Subscribe.async_get(db, subscribe_id)
subscribe = await get_accessible_subscribe(db, subscribe_id, current_user)
if subscribe:
# 在删除之前获取订阅信息
subscribe_info = build_subscribe_event_payload(subscribe)
+18 -9
View File
@@ -35,10 +35,11 @@ from app.db.user_oper import (
get_current_active_user_async,
)
from app.helper.image import ImageHelper
from app.helper.locale import LocaleHelper
from app.helper.message import MessageHelper
from app.helper.server import MoviePilotServerHelper
from app.helper.progress import ProgressHelper
from app.helper.rule import RuleHelper
from app.helper.server import MoviePilotServerHelper
from app.helper.system import SystemHelper
from app.log import logger
from app.scheduler import Scheduler
@@ -797,13 +798,14 @@ async def get_progress(
实时获取处理进度返回格式为SSE
"""
progress = ProgressHelper(process_type)
locale = LocaleHelper.get_current_locale()
async def event_generator():
try:
while not global_vars.is_system_stopped:
if await request.is_disconnected():
break
detail = progress.get()
detail = progress.get(locale=locale)
yield f"data: {json.dumps(detail)}\n\n"
await asyncio.sleep(0.5)
except asyncio.CancelledError:
@@ -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})
+50
View File
@@ -5,11 +5,61 @@ from fastapi import APIRouter, Depends
from app import schemas
from app.chain.tmdb import TmdbChain
from app.core.security import verify_token
from app.db.models.user import User
from app.db.user_oper import get_current_active_superuser_async
from app.modules.themoviedb.tmdb_cache import TmdbCache
from app.schemas.types import MediaType
router = APIRouter()
@router.get(
"/cache", summary="查询 TheMovieDb 识别缓存", response_model=schemas.Response
)
async def tmdb_recognition_cache(
_: User = Depends(get_current_active_superuser_async),
) -> schemas.Response:
"""查询可管理的 TheMovieDb 识别缓存。"""
cache_items = TmdbCache().list_items()
recognized_count = sum(1 for item in cache_items if item["tmdb_id"])
return schemas.Response(
success=True,
data={
"count": len(cache_items),
"recognized": recognized_count,
"unrecognized": len(cache_items) - recognized_count,
"data": cache_items,
},
)
@router.delete(
"/cache/{cache_key:path}",
summary="删除指定 TheMovieDb 识别缓存",
response_model=schemas.Response,
)
async def delete_tmdb_recognition_cache(
cache_key: str,
_: User = Depends(get_current_active_superuser_async),
) -> schemas.Response:
"""按缓存键删除单条 TheMovieDb 识别缓存。"""
deleted_item = TmdbCache().delete(cache_key)
if not deleted_item:
return schemas.Response(success=False, message="TheMovieDb 识别缓存不存在")
return schemas.Response(success=True, message="TheMovieDb 识别缓存删除成功")
@router.delete(
"/cache", summary="清空 TheMovieDb 识别缓存", response_model=schemas.Response
)
async def clear_tmdb_recognition_cache(
_: User = Depends(get_current_active_superuser_async),
) -> schemas.Response:
"""清空全部 TheMovieDb 识别缓存。"""
TmdbCache().clear()
return schemas.Response(success=True, message="TheMovieDb 识别缓存清理完成")
@router.get(
"/seasons/{tmdbid}", summary="TMDB所有季", response_model=List[schemas.TmdbSeason]
)
+11 -13
View File
@@ -6,14 +6,16 @@ from sqlalchemy.orm import Session
from app import schemas
from app.chain.media import MediaChain
from app.chain.storage import StorageChain
from app.chain.transfer import TransferChain
from app.core.config import settings, global_vars
from app.core.security import verify_token, verify_apitoken
from app.db import get_db
from app.db.models import User
from app.db.models.transferhistory import TransferHistory
from app.db.user_oper import get_current_active_superuser
from app.db.user_oper import (
get_current_active_manage_user,
get_current_active_superuser,
)
from app.helper.directory import DirectoryHelper
from app.log import logger
from app.schemas import (
@@ -183,7 +185,7 @@ def _get_manual_transfer_target_key(
def match_manual_transfer_target_path(
transer_item: ManualTransferItem,
db: Session = Depends(get_db),
_: User = Depends(get_current_active_superuser),
_: User = Depends(get_current_active_manage_user),
) -> Any:
"""
根据源文件匹配手动整理目的路径
@@ -243,7 +245,7 @@ def manual_transfer(
transer_item: ManualTransferItem,
background: Optional[bool] = False,
db: Session = Depends(get_db),
_: User = Depends(get_current_active_superuser),
_: User = Depends(get_current_active_manage_user),
) -> Any:
"""
手动转移文件或历史记录支持自定义剧集识别格式
@@ -256,6 +258,7 @@ def manual_transfer(
downloader = None
download_hash = None
src_fileitems: List[FileItem] = []
cleanup_dest_fileitem: Optional[FileItem] = None
target_path = Path(transer_item.target_path) if transer_item.target_path else None
if transer_item.logid:
# 查询历史记录
@@ -274,15 +277,8 @@ def manual_transfer(
else:
# 源路径
src_fileitems = [FileItem(**history.src_fileitem)]
# 目的路径
if history.dest_fileitem and not transer_item.preview:
# 删除旧的已整理文件
dest_fileitem = FileItem(**history.dest_fileitem)
state = StorageChain().delete_media_file(dest_fileitem)
if not state:
return schemas.Response(
success=False, message=f"{dest_fileitem.path} 删除失败"
)
cleanup_dest_fileitem = FileItem(**history.dest_fileitem)
# 从历史数据获取信息
if transer_item.from_history:
@@ -427,6 +423,7 @@ def manual_transfer(
download_hash=download_hash,
preview=transer_item.preview,
sync_extra_files=False,
cleanup_dest_fileitem=cleanup_dest_fileitem,
)
if transer_item.preview:
if isinstance(errormsg, dict):
@@ -508,6 +505,7 @@ def manual_transfer(
download_hash=download_hash,
preview=transer_item.preview,
sync_extra_files=True,
cleanup_dest_fileitem=cleanup_dest_fileitem,
)
# 失败
if not state:
@@ -533,7 +531,7 @@ def manual_transfer(
)
def recommend_episode_format(
recommend_item: EpisodeFormatRecommendItem,
_: User = Depends(get_current_active_superuser),
_: User = Depends(get_current_active_manage_user),
) -> Any:
"""
根据目录样本推荐集数定位模板
+21 -18
View File
@@ -10,11 +10,14 @@ from app import schemas
from app.chain.workflow import WorkflowChain
from app.core.config import global_vars
from app.core.plugin import PluginManager
from app.core.security import verify_token
from app.workflow import WorkFlowManager
from app.db import get_async_db, get_db
from app.db.models import Workflow
from app.db.models import Workflow, User
from app.db.systemconfig_oper import SystemConfigOper
from app.db.user_oper import (
get_current_active_manage_user,
get_current_active_manage_user_async,
)
from app.db.workflow_oper import WorkflowOper
from app.helper.server import MoviePilotServerHelper
from app.scheduler import Scheduler
@@ -30,7 +33,7 @@ WORKFLOW_TRIGGER_MANUAL = "manual"
@router.get("/", summary="所有工作流", response_model=List[schemas.Workflow])
async def list_workflows(
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token),
_: User = Depends(get_current_active_manage_user_async),
) -> Any:
"""
获取工作流列表
@@ -42,7 +45,7 @@ async def list_workflows(
async def create_workflow(
workflow: schemas.Workflow,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token),
_: User = Depends(get_current_active_manage_user_async),
) -> Any:
"""
创建工作流
@@ -62,7 +65,7 @@ async def create_workflow(
@router.get("/plugin/actions", summary="查询插件动作", response_model=List[dict])
def list_plugin_actions(
plugin_id: str = None, _: schemas.TokenPayload = Depends(verify_token)
plugin_id: str = None, _: User = Depends(get_current_active_manage_user)
) -> Any:
"""
获取所有动作
@@ -71,7 +74,7 @@ def list_plugin_actions(
@router.get("/actions", summary="所有动作", response_model=List[dict])
async def list_actions(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
async def list_actions(_: User = Depends(get_current_active_manage_user_async)) -> Any:
"""
获取所有动作
"""
@@ -79,7 +82,7 @@ async def list_actions(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
@router.get("/event_types", summary="获取所有事件类型", response_model=List[dict])
async def get_event_types(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
async def get_event_types(_: User = Depends(get_current_active_manage_user_async)) -> Any:
"""
获取所有事件类型
"""
@@ -94,7 +97,7 @@ async def get_event_types(_: schemas.TokenPayload = Depends(verify_token)) -> An
@router.post("/share", summary="分享工作流", response_model=schemas.Response)
async def workflow_share(
workflow: schemas.WorkflowShare, _: schemas.TokenPayload = Depends(verify_token)
workflow: schemas.WorkflowShare, _: User = Depends(get_current_active_manage_user_async)
) -> Any:
"""
分享工作流
@@ -115,7 +118,7 @@ async def workflow_share(
@router.delete("/share/{share_id}", summary="删除分享", response_model=schemas.Response)
async def workflow_share_delete(
share_id: int, _: schemas.TokenPayload = Depends(verify_token)
share_id: int, _: User = Depends(get_current_active_manage_user_async)
) -> Any:
"""
删除分享
@@ -128,7 +131,7 @@ async def workflow_share_delete(
async def workflow_fork(
workflow: schemas.WorkflowShare,
db: AsyncSession = Depends(get_async_db),
_: schemas.User = Depends(verify_token),
_: User = Depends(get_current_active_manage_user_async),
) -> Any:
"""
复用工作流
@@ -194,7 +197,7 @@ async def workflow_shares(
name: Optional[str] = None,
page: Optional[int] = 1,
count: Optional[int] = 30,
_: schemas.TokenPayload = Depends(verify_token),
_: User = Depends(get_current_active_manage_user_async),
) -> Any:
"""
查询分享的工作流
@@ -208,7 +211,7 @@ async def workflow_shares(
def run_workflow(
workflow_id: int,
from_begin: Optional[bool] = True,
_: schemas.TokenPayload = Depends(verify_token),
_: User = Depends(get_current_active_manage_user),
) -> Any:
"""
执行工作流
@@ -225,7 +228,7 @@ def run_workflow(
def start_workflow(
workflow_id: int,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token),
_: User = Depends(get_current_active_manage_user),
) -> Any:
"""
启用工作流
@@ -259,7 +262,7 @@ def start_workflow(
def pause_workflow(
workflow_id: int,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token),
_: User = Depends(get_current_active_manage_user),
) -> Any:
"""
停用工作流
@@ -287,7 +290,7 @@ def pause_workflow(
async def reset_workflow(
workflow_id: int,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token),
_: User = Depends(get_current_active_manage_user_async),
) -> Any:
"""
重置工作流
@@ -308,7 +311,7 @@ async def reset_workflow(
async def get_workflow(
workflow_id: int,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token),
_: User = Depends(get_current_active_manage_user_async),
) -> Any:
"""
获取工作流详情
@@ -320,7 +323,7 @@ async def get_workflow(
def update_workflow(
workflow: schemas.Workflow,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token),
_: User = Depends(get_current_active_manage_user),
) -> Any:
"""
更新工作流
@@ -350,7 +353,7 @@ def update_workflow(
def delete_workflow(
workflow_id: int,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token),
_: User = Depends(get_current_active_manage_user),
) -> Any:
"""
删除工作流
+21 -2
View File
@@ -1,10 +1,11 @@
import gzip
import hmac
import json
from typing import Annotated, Callable, Any, Dict, Optional
import aiofiles
from anyio import Path as AsyncPath
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Request, Response
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Path, Request, Response
from fastapi.responses import PlainTextResponse
from fastapi.routing import APIRoute
@@ -44,6 +45,24 @@ async def verify_server_enabled():
return True
async def verify_update_auth(
x_cookiecloud_auth: Annotated[
Optional[str], Header(alias="X-CookieCloud-Auth")
] = None,
):
"""
校验CookieCloud上传接口的可选共享认证头
"""
expected_header = (settings.COOKIECLOUD_AUTH_HEADER or "").strip()
if not expected_header:
return True
provided_header = (x_cookiecloud_auth or "").strip()
if not hmac.compare_digest(provided_header, expected_header):
raise HTTPException(status_code=403, detail="CookieCloud认证失败")
return True
cookie_router = APIRouter(
route_class=GzipRoute,
tags=["servcookie"],
@@ -61,7 +80,7 @@ async def post_root():
return "Hello MoviePilot! COOKIECLOUD API ROOT = /cookiecloud"
@cookie_router.post("/update")
@cookie_router.post("/update", dependencies=[Depends(verify_update_auth)])
async def update_cookie(req: schemas.CookieData):
"""
上传Cookie数据
+314 -19
View File
@@ -1,11 +1,13 @@
import base64
import copy
import hashlib
import json
import re
import shutil
import time
from pathlib import Path
from typing import List, Optional, Tuple, Set, Dict, Union
from urllib.parse import parse_qs, urlparse
from app import schemas
from app.chain import ChainBase
@@ -16,9 +18,10 @@ from app.core.context import MediaInfo, SubtitleInfo, TorrentInfo, Context
from app.core.event import eventmanager, Event
from app.core.meta import MetaBase
from app.core.metainfo import MetaInfo
from app.db.downloadfailure_oper import DownloadFailureOper
from app.db.downloadhistory_oper import DownloadHistoryOper
from app.db.mediaserver_oper import MediaServerOper
from app.helper.directory import DirectoryHelper
from app.helper.directory import DirectoryHelper, validate_download_save_path
from app.helper.thread import ThreadHelper
from app.helper.torrent import TorrentHelper
from app.log import logger
@@ -31,6 +34,21 @@ from app.utils.string import StringUtils
from app.utils.system import SystemUtils
DOWNLOAD_FAILURE_RESOURCE_TTL_SECONDS = 24 * 60 * 60
DOWNLOAD_FAILURE_TRANSIENT_TTL_SECONDS = 60 * 60
DOWNLOAD_FAILURE_RESOURCE_ERROR_KEYWORDS = (
"无法读取种子文件",
"下载种子内容为空",
"无法获取下载地址",
"种子下载失败",
"torrent not found",
"not found",
"404",
"deleted",
"invalid torrent",
)
class DownloadChain(ChainBase):
"""
下载处理链
@@ -107,19 +125,27 @@ class DownloadChain(ChainBase):
def _resolve_media_download_dir(
media_info: MediaInfo,
save_path: Optional[str] = None,
) -> Union[str, Path]:
) -> Tuple[Optional[str], Optional[Path], str]:
"""
根据媒体信息解析下载目录
"""
storage = 'local'
if save_path:
return storage, Path(save_path)
if save_path is not None:
try:
validated_save_path = validate_download_save_path(save_path)
except ValueError as err:
logger.warn(str(err))
return None, None, str(err)
if re.match(r"^[A-Za-z]:/", validated_save_path):
return storage, Path(validated_save_path), ""
file_uri = FileURI.from_uri(validated_save_path)
return file_uri.storage or storage, Path(file_uri.path), ""
dir_info = DirectoryHelper().get_dir(media_info, include_unsorted=True)
storage = dir_info.storage if dir_info else storage
if not dir_info:
logger.error(f"未找到下载目录:{media_info.type.value} {media_info.title_year}")
return None
return None, None, "未找到下载目录"
if not dir_info.media_type and dir_info.download_type_folder:
download_dir = Path(dir_info.download_path) / media_info.type.value
@@ -129,7 +155,7 @@ class DownloadChain(ChainBase):
if not dir_info.media_category and dir_info.download_category_folder and media_info.category:
download_dir = download_dir / media_info.category
return storage, download_dir
return storage, download_dir, ""
@staticmethod
def _upload_subtitle_file(
@@ -293,12 +319,12 @@ class DownloadChain(ChainBase):
if not mediainfo:
return False, "无法识别媒体信息", []
storage, target_dir = self._resolve_media_download_dir(
storage, target_dir, error_msg = self._resolve_media_download_dir(
media_info=mediainfo,
save_path=save_path,
)
if not target_dir:
return False, "未找到下载目录", []
return False, error_msg or "未找到下载目录", []
request = RequestUtils(
cookies=subtitle.site_cookie,
@@ -357,6 +383,183 @@ class DownloadChain(ChainBase):
except Exception as err:
logger.error(f"提交下载成功后处理后台任务失败:{str(err)}")
@staticmethod
def _is_subscribe_source(source: Optional[str]) -> bool:
"""
判断下载来源是否为订阅任务
"""
return bool(source and str(source).startswith("Subscribe|"))
@staticmethod
def _format_failure_episodes(meta: Optional[MetaBase]) -> Optional[str]:
"""
从识别元数据中格式化用于失败记录的集数
"""
if not meta:
return None
if getattr(meta, "episode", None):
return meta.episode
episode_list = getattr(meta, "episode_list", None)
if episode_list:
return StringUtils.format_ep(list(episode_list))
return None
@staticmethod
def _torrent_resource_key(torrent: Optional[TorrentInfo]) -> str:
"""
生成不保存敏感下载链接的种子资源键
"""
if not torrent:
return ""
for attr_name in ("torrent_id", "info_hash"):
value = getattr(torrent, attr_name, None)
if value:
return str(value)
for attr_name in ("page_url", "enclosure"):
url = getattr(torrent, attr_name, None)
if not url:
continue
match = re.search(r"\[(.*?)](.*)", str(url))
if match:
url = match.group(2)
parsed = urlparse(str(url))
params = parse_qs(parsed.query)
for param_name in ("id", "torrentid", "torrent_id", "tid", "hash"):
values = params.get(param_name)
if values:
return f"{parsed.netloc}:{param_name}={values[0]}"
if parsed.netloc and parsed.path:
return f"{parsed.netloc}{parsed.path}"
title = getattr(torrent, "title", "") or ""
size = getattr(torrent, "size", "") or ""
return f"title={title}|size={size}"
@classmethod
def _build_download_failure_fingerprint(cls, context: Context) -> Optional[str]:
"""
根据媒体和种子资源信息生成失败冷却指纹
"""
media = getattr(context, "media_info", None)
torrent = getattr(context, "torrent_info", None)
if not media or not torrent:
return None
media_type = getattr(getattr(media, "type", None), "value", getattr(media, "type", None))
media_key = (
getattr(media, "tmdb_id", None)
or getattr(media, "douban_id", None)
or getattr(media, "imdb_id", None)
or getattr(media, "tvdb_id", None)
or f"{getattr(media, 'title', '')}:{getattr(media, 'year', '')}"
)
meta = getattr(context, "meta_info", None)
site = getattr(torrent, "site", None) or getattr(torrent, "site_name", None)
payload = {
"media_type": str(media_type or ""),
"media_key": str(media_key or ""),
"season": str(getattr(meta, "season", None) or getattr(media, "season", None) or ""),
"episodes": cls._format_failure_episodes(meta) or "",
"site": str(site or ""),
"resource": cls._torrent_resource_key(torrent),
}
if not payload["media_type"] or not payload["media_key"] or not payload["resource"]:
return None
raw_text = json.dumps(payload, ensure_ascii=False, sort_keys=True)
return hashlib.sha256(raw_text.encode("utf-8")).hexdigest()
@staticmethod
def _download_failure_ttl(error_msg: Optional[str]) -> int:
"""
按失败原因确定资源冷却时间
"""
error_text = str(error_msg or "").lower()
if any(keyword in error_text for keyword in DOWNLOAD_FAILURE_RESOURCE_ERROR_KEYWORDS):
return DOWNLOAD_FAILURE_RESOURCE_TTL_SECONDS
return DOWNLOAD_FAILURE_TRANSIENT_TTL_SECONDS
def _record_download_failure(
self,
context: Context,
error_msg: Optional[str],
downloader: Optional[str] = None,
source: Optional[str] = None,
episodes: Optional[Set[int]] = None,
) -> Optional[str]:
"""
记录资源级下载失败并返回本次失败指纹
"""
fingerprint = self._build_download_failure_fingerprint(context)
if not fingerprint:
return None
now_timestamp = time.time()
now_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(now_timestamp))
next_retry_at = time.strftime(
"%Y-%m-%d %H:%M:%S",
time.localtime(now_timestamp + self._download_failure_ttl(error_msg)),
)
media = context.media_info
meta = context.meta_info
torrent = context.torrent_info
site = getattr(torrent, "site", None)
try:
DownloadFailureOper().record_failure(
fingerprint=fingerprint,
now_time=now_time,
next_retry_at=next_retry_at,
type=getattr(getattr(media, "type", None), "value", getattr(media, "type", None)),
title=getattr(media, "title", None),
year=getattr(media, "year", None),
tmdbid=getattr(media, "tmdb_id", None),
doubanid=getattr(media, "douban_id", None),
seasons=getattr(meta, "season", None),
episodes=StringUtils.format_ep(list(episodes)) if episodes else self._format_failure_episodes(meta),
site=site if isinstance(site, int) else None,
site_name=getattr(torrent, "site_name", None),
torrent_id=self._torrent_resource_key(torrent),
torrent_name=getattr(torrent, "title", None),
torrent_size=getattr(torrent, "size", None),
downloader=downloader,
source=str(source)[:1000] if source else None,
error_message=str(error_msg or "")[:1000],
)
except Exception as err:
logger.error(f"记录下载失败冷却失败:{str(err)}")
return fingerprint
def _active_download_failure_fingerprints(
self,
contexts: List[Context],
source: Optional[str],
) -> Set[str]:
"""
查询当前订阅候选中仍处于冷却期的失败指纹
"""
if not self._is_subscribe_source(source):
return set()
fingerprints = [
fingerprint
for fingerprint in [
self._build_download_failure_fingerprint(context)
for context in contexts or []
]
if fingerprint
]
if not fingerprints:
return set()
now_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
try:
return set(
DownloadFailureOper()
.get_active_by_fingerprints(fingerprints=fingerprints, now_time=now_time)
.keys()
)
except Exception as err:
logger.error(f"查询下载失败冷却失败:{str(err)}")
return set()
def download_torrent(self, torrent: TorrentInfo,
channel: MessageChannel = None,
source: Optional[str] = None,
@@ -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 NoneTrue 时返回 (hash, error_msg)
:param custom_words: 下载来源如订阅的完整自定义识别词文本随下载记录存档供整理时原样复现识别
:return: return_detail=False 时返回下载任务 hash Nonereturn_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
View File
@@ -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
View File
@@ -29,7 +29,12 @@ from app.db.models import TransferHistory
from app.db.transferhistory_oper import TransferHistoryOper
from app.db.user_oper import UserOper
from app.helper.directory import DirectoryHelper
from app.helper.interaction import agent_interaction_manager, media_interaction_manager, PendingMediaInteraction
from app.helper.interaction import (
agent_interaction_manager,
media_interaction_manager,
plugin_input_interaction_manager,
PendingMediaInteraction,
)
from app.helper.torrent import TorrentHelper
from app.log import logger
from app.schemas import CommingMessage, DownloadDirectory, FileURI, NotExistMediaInfo, Notification
@@ -136,9 +141,9 @@ class MessageChain(ChainBase):
logger.debug(f"未识别到消息内容::{body}{form}{args}")
return
# 获取原消息ID信息
original_message_id = info.message_id
original_chat_id = info.chat_id
reply_to_message_id = info.reply_to_message_id
# 处理消息
self.handle_message(
@@ -149,6 +154,7 @@ class MessageChain(ChainBase):
text=text,
original_message_id=original_message_id,
original_chat_id=original_chat_id,
reply_to_message_id=reply_to_message_id,
images=images,
audio_refs=audio_refs,
files=files,
@@ -160,12 +166,13 @@ class MessageChain(ChainBase):
source: str,
userid: Union[str, int],
username: str,
text: str,
text: Optional[str],
original_message_id: Optional[Union[str, int]] = None,
original_chat_id: Optional[str] = None,
images: Optional[List[CommingMessage.MessageImage]] = None,
audio_refs: Optional[List[str]] = None,
files: Optional[List[CommingMessage.MessageAttachment]] = None,
reply_to_message_id: Optional[Union[str, int]] = None,
) -> None:
"""
识别消息内容执行操作
@@ -201,6 +208,21 @@ class MessageChain(ChainBase):
)
return
if self._handle_plugin_input_interaction(
channel=channel,
source=source,
userid=userid,
username=username,
text=text,
original_chat_id=original_chat_id,
reply_to_message_id=reply_to_message_id,
images=images,
audio_refs=audio_refs,
files=files,
has_audio_input=has_audio_input,
):
return
is_agent_message = self._is_agent_message(
userid=userid,
text=text,
@@ -236,6 +258,7 @@ class MessageChain(ChainBase):
text=text,
original_message_id=original_message_id,
original_chat_id=original_chat_id,
reply_to_message_id=reply_to_message_id,
images=images,
audio_refs=audio_refs,
files=files,
@@ -259,7 +282,7 @@ class MessageChain(ChainBase):
source: str,
userid: Union[str, int],
username: str,
text: str,
text: Optional[str],
original_message_id: Optional[Union[str, int]] = None,
original_chat_id: Optional[str] = None,
images: Optional[List[CommingMessage.MessageImage]] = None,
@@ -267,6 +290,7 @@ class MessageChain(ChainBase):
files: Optional[List[CommingMessage.MessageAttachment]] = None,
has_audio_input: bool = False,
processing_status: Optional[_ProcessingStatus] = None,
reply_to_message_id: Optional[Union[str, int]] = None,
) -> bool:
"""执行实际消息路由,便于统一包裹处理中状态。"""
@@ -290,6 +314,21 @@ class MessageChain(ChainBase):
)
return False
if self._handle_plugin_input_interaction(
channel=channel,
source=source,
userid=userid,
username=username,
text=text,
original_chat_id=original_chat_id,
reply_to_message_id=reply_to_message_id,
images=images,
audio_refs=audio_refs,
files=files,
has_audio_input=has_audio_input,
):
return False
no_ai_requested, no_ai_text = self._strip_no_ai_prefix(text)
if no_ai_requested:
text = no_ai_text
@@ -411,10 +450,128 @@ class MessageChain(ChainBase):
"userid": userid,
"channel": channel,
"source": source,
"chat_id": original_chat_id,
"reply_to_message_id": reply_to_message_id,
},
)
return False
def _handle_plugin_input_interaction(
self,
channel: MessageChannel,
source: str,
userid: Union[str, int],
username: str,
text: str,
original_chat_id: Optional[Union[str, int]] = None,
images: Optional[List[CommingMessage.MessageImage]] = None,
audio_refs: Optional[List[str]] = None,
files: Optional[List[CommingMessage.MessageAttachment]] = None,
has_audio_input: bool = False,
reply_to_message_id: Optional[Union[str, int]] = None,
) -> bool:
"""
将插件输入会话中的下一条普通文本派发给指定插件
"""
if not text or not text.strip() or images or audio_refs or files or has_audio_input:
return False
if text.startswith("CALLBACK:"):
return False
is_cancel_text = text.strip().lower() in {"取消", "退出", "q", "quit", "exit"}
request, status = plugin_input_interaction_manager.consume_by_user(
userid,
channel,
source,
original_chat_id,
reply_to_message_id=reply_to_message_id,
bypass_reply_check=is_cancel_text,
)
if not request:
return False
if status == "expired":
self.eventmanager.send_event(
EventType.MessageAction,
{
"plugin_id": request.plugin_id,
"__mp_target_plugin_id": request.plugin_id,
"text": f"plugin_input_expired|{request.request_id}",
"userid": userid,
"channel": channel,
"source": source,
"username": username,
"chat_id": original_chat_id,
"reply_to_message_id": reply_to_message_id,
"prompt_id": request.prompt_id,
"input_session_id": request.request_id,
"expired": True,
"payload": request.payload,
},
)
self.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title="插件输入已超时,请重新发起操作。",
save_history=False,
)
)
return not text.strip().startswith("/")
if is_cancel_text:
self.eventmanager.send_event(
EventType.MessageAction,
{
"plugin_id": request.plugin_id,
"__mp_target_plugin_id": request.plugin_id,
"text": f"plugin_input_cancel|{request.request_id}",
"userid": userid,
"channel": channel,
"source": source,
"username": username,
"chat_id": original_chat_id,
"reply_to_message_id": reply_to_message_id,
"prompt_id": request.prompt_id,
"input_session_id": request.request_id,
"cancelled": True,
"payload": request.payload,
},
)
self.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title="已取消插件输入",
save_history=False,
)
)
return True
self.eventmanager.send_event(
EventType.MessageAction,
{
"plugin_id": request.plugin_id,
"__mp_target_plugin_id": request.plugin_id,
"text": f"plugin_input|{request.request_id}",
"input_text": text,
"userid": userid,
"channel": channel,
"source": source,
"username": username,
"chat_id": original_chat_id,
"reply_to_message_id": reply_to_message_id,
"prompt_id": request.prompt_id,
"input_session_id": request.request_id,
"payload": request.payload,
},
)
return True
@classmethod
def _strip_no_ai_prefix(cls, text: str) -> Tuple[bool, str]:
"""
+49 -5
View File
@@ -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
View File
@@ -530,7 +530,7 @@ class SearchChain(ChainBase):
def search_by_title(self, title: str, page: Optional[int] = 0,
sites: List[int] = None, cache_local: Optional[bool] = False) -> List[Context]:
"""
根据标题搜索资源不识别不过滤直接返回站点内容
根据标题搜索资源不识别媒体信息按默认搜索过滤规则返回站点内容
:param title: 标题为空时返回所有站点首页内容
:param page: 页码
:param sites: 站点ID列表
@@ -552,6 +552,10 @@ class SearchChain(ChainBase):
if not torrents:
logger.warn(f'{title} 未搜索到资源')
return []
torrents = self.__filter_title_search_torrents(torrents=torrents)
if not torrents:
logger.warn(f'{title} 没有符合过滤规则的资源')
return []
# 组装上下文
contexts = [
Context(
@@ -791,7 +795,7 @@ class SearchChain(ChainBase):
async def async_search_by_title(self, title: str, page: Optional[int] = 0,
sites: List[int] = None, cache_local: Optional[bool] = False) -> List[Context]:
"""
根据标题异步搜索资源不识别不过滤直接返回站点内容
根据标题异步搜索资源不识别媒体信息按默认搜索过滤规则返回站点内容
:param title: 标题为空时返回所有站点首页内容
:param page: 页码
:param sites: 站点ID列表
@@ -813,6 +817,10 @@ class SearchChain(ChainBase):
if not torrents:
logger.warn(f'{title} 未搜索到资源')
return []
torrents = await run_in_threadpool(self.__filter_title_search_torrents, torrents=torrents)
if not torrents:
logger.warn(f'{title} 没有符合过滤规则的资源')
return []
# 组装上下文
contexts = [
Context(
@@ -830,7 +838,7 @@ class SearchChain(ChainBase):
sites: List[int] = None,
cache_local: Optional[bool] = False) -> AsyncIterator[dict]:
"""
根据标题渐进式搜索资源不识别不过滤站点完成顺序返回结果
根据标题渐进式搜索资源不识别媒体信息默认搜索过滤规则返回结果
"""
if cache_local:
self.cancel_ai_recommend()
@@ -845,8 +853,14 @@ class SearchChain(ChainBase):
logger.info(f'开始渐进式浏览资源,站点:{sites} ...')
contexts: List[Context] = []
rule_groups: List[str] = SystemConfigOper().get(SystemConfigKey.SearchFilterRuleGroups) or []
async for event in self.__async_search_all_sites_stream(keyword=title, sites=sites, page=page):
result = event.pop("items", []) or []
result = await run_in_threadpool(
self.__filter_title_search_torrents,
torrents=result,
rule_groups=rule_groups,
)
batch_contexts = [
Context(
meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description),
@@ -876,6 +890,29 @@ class SearchChain(ChainBase):
"total_items": len(contexts)
}
def __filter_title_search_torrents(self,
torrents: List[TorrentInfo],
rule_groups: Optional[List[str]] = None) -> List[TorrentInfo]:
"""
对标题搜索结果应用默认搜索过滤规则不执行媒体识别和标题精确匹配
"""
if not torrents:
return []
if rule_groups is None:
rule_groups = SystemConfigOper().get(SystemConfigKey.SearchFilterRuleGroups) or []
if not rule_groups:
return torrents
logger.info(f'开始过滤标题搜索结果,使用规则组:{rule_groups} ...')
filtered_torrents = self.filter_torrents(
rule_groups=rule_groups,
torrent_list=torrents,
mediainfo=None,
) or []
logger.info(f'标题搜索过滤完成,剩余 {len(filtered_torrents)} 个资源')
return filtered_torrents
async def async_search_by_id_stream(self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
mtype: MediaType = None, area: Optional[str] = "title",
season: Optional[int] = None, sites: List[int] = None,
+70 -10
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+273 -6
View File
@@ -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
View File
@@ -1240,14 +1240,44 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
history_exists: bool = True,
):
"""
当同一种子的任务都已结束时回写下载器已整理标签
当同一种子的任务都已结束且种子已完成下载回写下载器已整理标签
"""
if (
history_exists
and download_hash
and self.jobview.is_torrent_done(download_hash)
not history_exists
or not download_hash
or not self.jobview.is_torrent_done(download_hash)
):
self.transfer_completed(hashs=download_hash, downloader=downloader)
return
# 作业视图只包含已登记的整理任务;多集种子部分文件先下载完成时,
# 剩余文件尚未产生任务,此时打已整理标签会使下载器轮询永久跳过
# 剩余文件(#6009),因此必须确认种子已整体下载完成。
if not self.__is_torrent_download_completed(download_hash, downloader):
logger.debug(
f"种子 {download_hash} 尚未下载完成或状态未知,暂不设置已整理标签"
)
return
if not self.jobview.is_torrent_done(download_hash):
logger.debug(
f"种子 {download_hash} 存在新登记的整理任务,暂不设置已整理标签"
)
return
self.transfer_completed(hashs=download_hash, downloader=downloader)
def __is_torrent_download_completed(
self, download_hash: str, downloader: Optional[str]
) -> bool:
"""
检查种子在下载器中是否已完成下载查询不到或查询失败时视为未完成
留待下载器定时轮询兜底避免误打已整理标签
"""
try:
torrents = self.list_torrents(hashs=download_hash, downloader=downloader)
if not torrents:
return False
return all((torrent.progress or 0) >= 100 for torrent in torrents)
except Exception as e:
logger.error(f"检查种子 {download_hash} 下载进度失败:{e}")
return False
def __send_metadata_scrape_event(
self, task: TransferTask, transferinfo: TransferInfo
@@ -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
View File
@@ -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
+22
View File
@@ -1214,8 +1214,19 @@ def cached(region: Optional[str] = None, maxsize: Optional[int] = 1024, ttl: Opt
"""
await cache_backend.clear(region=cache_region)
async def cache_exists(*args, **kwargs) -> bool:
"""
判断当前参数对应的有效缓存是否存在
"""
cache_key = __get_cache_key(args, kwargs)
cached_value = await cache_backend.get(cache_key, region=cache_region)
return should_cache(cached_value) and await async_is_valid_cache_value(
cache_key, cached_value, cache_region
)
async_wrapper.cache_region = cache_region
async_wrapper.cache_clear = cache_clear
async_wrapper.cache_exists = cache_exists
return async_wrapper
else:
# 同步函数使用同步缓存后端
@@ -1246,8 +1257,19 @@ def cached(region: Optional[str] = None, maxsize: Optional[int] = 1024, ttl: Opt
"""
cache_backend.clear(region=cache_region)
def cache_exists(*args, **kwargs) -> bool:
"""
判断当前参数对应的有效缓存是否存在
"""
cache_key = __get_cache_key(args, kwargs)
cached_value = cache_backend.get(cache_key, region=cache_region)
return should_cache(cached_value) and is_valid_cache_value(
cache_key, cached_value, cache_region
)
wrapper.cache_region = cache_region
wrapper.cache_clear = cache_clear
wrapper.cache_exists = cache_exists
return wrapper
return decorator
+2
View File
@@ -377,6 +377,8 @@ class ConfigModel(BaseModel):
COOKIECLOUD_KEY: Optional[str] = None
# CookieCloud端对端加密密码
COOKIECLOUD_PASSWORD: Optional[str] = None
# CookieCloud本地上传接口的X-CookieCloud-Auth期望值,留空表示不校验
COOKIECLOUD_AUTH_HEADER: Optional[str] = None
# CookieCloud同步间隔(分钟)
COOKIECLOUD_INTERVAL: Optional[int] = 60 * 24
# CookieCloud同步黑名单,多个域名,分割
+7
View File
@@ -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,
}
+36
View File
@@ -439,11 +439,19 @@ class EventManager(metaclass=Singleton):
if not handlers:
logger.debug(f"No handlers found for broadcast event: {event}")
return
target_plugin_id = None
if event.event_type == EventType.MessageAction and isinstance(event.event_data, dict):
target_plugin_id = event.event_data.get("__mp_target_plugin_id")
# 为每个处理器提供独立的事件实例,防止某个处理器对 event_data 的修改影响其他处理器
for handler_id, handler in handlers.items():
if target_plugin_id and not self.__should_dispatch_to_target_plugin(
handler, handler_id, str(target_plugin_id)
):
continue
# 仅浅拷贝顶层字典,避免不必要的深拷贝开销;这样可以隔离键级别的替换/赋值
if isinstance(event.event_data, dict):
event_data_copy = event.event_data.copy()
event_data_copy.pop("__mp_target_plugin_id", None)
else:
event_data_copy = event.event_data
isolated_event = Event(event_type=event.event_type,
@@ -459,6 +467,34 @@ class EventManager(metaclass=Singleton):
# 对于同步函数,在线程池中运行
self.__executor.submit(self.__safe_invoke_handler, handler, isolated_event)
@classmethod
def __should_dispatch_to_target_plugin(
cls,
handler: Callable,
handler_identifier: str,
target_plugin_id: str,
) -> bool:
"""
限定插件输入事件只投递给目标插件避免自由文本被其他插件观察到
"""
class_name, method_name = cls.__parse_handler_names(handler)
if class_name != target_plugin_id:
return False
identifier_parts = (handler_identifier or "").split(".")
if len(identifier_parts) < 2:
logger.debug(
"Target plugin dispatch skipped because handler identifier is invalid: "
f"target={target_plugin_id}, handler={handler_identifier}"
)
return False
if identifier_parts[-2:] != [class_name, method_name]:
logger.debug(
"Target plugin dispatch skipped because handler identifier does not match handler: "
f"target={target_plugin_id}, handler={handler_identifier}, parsed={class_name}.{method_name}"
)
return False
return True
def __safe_invoke_handler(self, handler: Callable, event: Event):
"""
调用处理器处理链式或广播事件
+9 -2
View File
@@ -17,7 +17,7 @@ class CustomizationMatcher(metaclass=Singleton):
self._customization_re_cache = {}
@staticmethod
def _normalize_customization(customization):
def normalize_customization(customization):
"""
规范化自定义占位符配置兼容历史字符串与列表两种保存格式
"""
@@ -27,6 +27,13 @@ class CustomizationMatcher(metaclass=Singleton):
return []
return list(filter(None, customization))
@staticmethod
def _normalize_customization(customization):
"""
兼容旧调用统一转到公开的自定义占位符规范化入口
"""
return CustomizationMatcher.normalize_customization(customization)
def match(self, title=None):
"""
:param title: 资源标题或文件名
@@ -35,7 +42,7 @@ class CustomizationMatcher(metaclass=Singleton):
if not title:
return ""
# 自定义占位符需要跟随系统配置实时生效,避免单例缓存导致保存后仍沿用旧规则。
customization = self._normalize_customization(
customization = self.normalize_customization(
self.systemconfig.get(SystemConfigKey.Customization)
)
if not customization:
+37
View File
@@ -24,6 +24,13 @@ SUBTITLE_EPISODE_ALL_RE = re.compile(
r"([0-9一二三四五六七八九十百零]+)\s*集\s*全|[全共]\s*([0-9一二三四五六七八九十百零]+)\s*[集话話期幕]",
re.IGNORECASE,
)
# 结尾分支显式区分有无右方括号,避免可选括号回溯后绕过数字后缀边界
SUBTITLE_EPISODE_RANGE_FIN_RE = re.compile(
r"(?<!\d)\[?\s*(\d{1,4})\s*-\s*(\d{1,4})\s*"
r"(?:(?:Fin|End)(?![a-z0-9])|完结(?![\u4e00-\u9fff]))"
r"(?:\s*\](?!\d)|(?!\s*(?:\]\d|\d))\s*)",
re.IGNORECASE,
)
VIDEO_BIT_RE = re.compile(
r"(?<![A-Za-z0-9])(?P<bit>8|10|12|16)[\s._-]*bits?(?![A-Za-z0-9])",
re.IGNORECASE,
@@ -292,6 +299,36 @@ class MetaBase(object):
self.type = MediaType.TV
self._subtitle_flag = True
return
# 01-26Fin 等数字范围+完结标记
self.__init_episode_range_fin(title_text)
else:
# 副标题无中文季集标记时,仍识别 01-26Fin 等数字范围+完结标记
self.__init_episode_range_fin(title_text)
def __init_episode_range_fin(self, title_text: str):
"""
识别 01-26Fin / [01-38 END] "数字范围+完结标记"格式的集数信息
"""
episode_range_str = SUBTITLE_EPISODE_RANGE_FIN_RE.search(title_text)
if not episode_range_str:
return
try:
begin_episode = int(episode_range_str.group(1))
end_episode = int(episode_range_str.group(2))
except Exception as err:
logger.debug(f'识别集失败:{str(err)} - {traceback.format_exc()}')
return
if begin_episode < 1 or begin_episode > end_episode or end_episode >= 10000:
return
# 两个数字都落在常见年份区间时视为年份范围而非集数(如 2019-2020完结)
if begin_episode >= 1900 and end_episode <= 2155:
return
if self.begin_episode is None:
self.begin_episode = begin_episode
self.end_episode = end_episode
self.total_episode = end_episode
self.type = MediaType.TV
self._subtitle_flag = True
@property
def season(self) -> str:
+1 -1
View File
@@ -56,7 +56,7 @@ class MetaVideo(MetaBase):
_name_se_words = ['', '', '', '', '', '', '']
_name_movie_words = ['剧场版', '劇場版', '电影版', '電影版']
_name_nostring_re = r"^PTS|^JADE|^AOD|^CHC|^[A-Z]{1,4}TV[\-0-9UVHDK]*" \
r"|\d{1,2}th|\d{1,2}bit|IMAX|^3D|\s+3D|XXX|\s+DC$" \
r"|\d{1,2}th|\d{1,2}bit|IMAX|^3D|\s+3D|\s+DC$" \
r"|[第\s共]+[0-9一二三四五六七八九十\-\s]+季" \
r"|[第\s共]+[0-9一二三四五六七八九十百零\-\s]+[集话話]" \
r"|连载|日剧|美剧|电视剧|动画片|动漫|欧美|西德|日韩|超高清|高清|无水印|下载|蓝光|翡翠台|梦幻天堂·龙网|★?\d*月?新番" \
+13 -9
View File
@@ -89,6 +89,18 @@ class ReleaseGroupsMatcher(metaclass=Singleton):
self.systemconfig = SystemConfigOper()
self.__groups_re_cache = {}
def get_release_groups(self) -> str:
"""
返回内置与用户自定义制作组组成的匹配规则
"""
custom_release_groups = self.systemconfig.get(SystemConfigKey.CustomReleaseGroups)
if isinstance(custom_release_groups, list):
custom_release_groups = list(filter(None, custom_release_groups))
if custom_release_groups:
custom_release_groups_str = '|'.join(custom_release_groups)
return f"{self.__release_groups}|{custom_release_groups_str}"
return self.__release_groups
def __get_groups_re(self, groups: str):
"""
发布组规则通常很长按规则文本缓存编译结果避免每个标题都重复编译
@@ -108,15 +120,7 @@ class ReleaseGroupsMatcher(metaclass=Singleton):
if not title:
return ""
if not groups:
# 自定义组
custom_release_groups = self.systemconfig.get(SystemConfigKey.CustomReleaseGroups)
if isinstance(custom_release_groups, list):
custom_release_groups = list(filter(None, custom_release_groups))
if custom_release_groups:
custom_release_groups_str = '|'.join(custom_release_groups)
groups = f"{self.__release_groups}|{custom_release_groups_str}"
else:
groups = self.__release_groups
groups = self.get_release_groups()
title = f"{title} "
groups_re = self.__get_groups_re(groups)
unique_groups = []
+6
View File
@@ -297,6 +297,12 @@ class StreamingPlatforms(metaclass=Singleton):
if alias:
self._lookup_cache[alias.upper()] = canonical_name
def get_lookup_cache(self) -> dict:
"""
返回流媒体平台查询表副本供批量解析配置复用
"""
return dict(self._lookup_cache)
def get_streaming_platform_name(self, platform_code: str) -> Optional[str]:
"""
根据流媒体平台简称或全称获取标准名称
+31 -10
View File
@@ -1,3 +1,4 @@
import hashlib
from pathlib import Path
from functools import lru_cache
from typing import Tuple, List, Optional
@@ -40,6 +41,7 @@ _EMBY_TMDB_RE_LIST = (
re.compile(r'\{tmdbid[=\-](\d+)\}'),
re.compile(r'\{tmdb[=\-](\d+)\}'),
)
_RUST_PARSE_OPTIONS_CACHE_KEY = "_cache_key"
def _empty_metainfo() -> dict:
@@ -72,6 +74,28 @@ def _apply_range_total(metainfo: dict, begin_key: str, end_key: str, total_key:
metainfo[total_key] = 1
def _rust_parse_options_cache_key(options: dict) -> str:
"""
生成 Rust Meta 配置缓存键避免扩展层每次重新展开大配置
"""
digest = hashlib.blake2b(digest_size=16)
def update(value) -> None:
digest.update(repr(value).encode("utf-8"))
digest.update(b"\0")
streaming_platforms = options.get("streaming_platforms") or {}
update(tuple(options.get("custom_words") or []))
update(tuple(options.get("media_exts") or []))
update(options.get("release_groups") or "")
update(tuple(options.get("customization") or []))
update(tuple(sorted(
(str(key), str(value))
for key, value in streaming_platforms.items()
)))
return digest.hexdigest()
def _find_metainfo_python(title: str) -> Tuple[str, dict]:
"""
使用 Python 解析标题中的显式媒体标签作为 Rust 入口不可用时的兜底
@@ -209,24 +233,20 @@ def _rust_default_parse_options() -> dict:
from app.schemas.types import SystemConfigKey
systemconfig = SystemConfigOper()
custom_release_groups = systemconfig.get(SystemConfigKey.CustomReleaseGroups)
if isinstance(custom_release_groups, list):
custom_release_groups = list(filter(None, custom_release_groups))
release_matcher = ReleaseGroupsMatcher()
release_groups = release_matcher._ReleaseGroupsMatcher__release_groups
if custom_release_groups:
release_groups = f"{release_groups}|{'|'.join(custom_release_groups)}"
release_groups = ReleaseGroupsMatcher().get_release_groups()
customization = CustomizationMatcher._normalize_customization(
customization = CustomizationMatcher.normalize_customization(
systemconfig.get(SystemConfigKey.Customization)
)
return {
options = {
"custom_words": systemconfig.get(SystemConfigKey.CustomIdentifiers) or [],
"media_exts": settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIOEXT,
"release_groups": release_groups,
"customization": customization,
"streaming_platforms": StreamingPlatforms()._lookup_cache,
"streaming_platforms": StreamingPlatforms().get_lookup_cache(),
}
options[_RUST_PARSE_OPTIONS_CACHE_KEY] = _rust_parse_options_cache_key(options)
return options
@lru_cache(maxsize=256)
@@ -236,6 +256,7 @@ def _rust_custom_parse_options(custom_words: Tuple[str, ...]) -> dict:
"""
options = dict(_rust_default_parse_options())
options["custom_words"] = list(custom_words)
options[_RUST_PARSE_OPTIONS_CACHE_KEY] = _rust_parse_options_cache_key(options)
return options
+163 -42
View File
@@ -363,6 +363,20 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
logger.warn(f"检测到本地插件 {candidate.get('id')} 依赖文件变化,请重新安装本地插件以安装依赖")
continue
federated_change = self._get_federated_plugin_change(event_path)
if federated_change:
pid, candidate, remote_entry_ready = federated_change
# 运行目录由构建方直接写入;外部本地仓库只在入口完整时同步运行副本。
if candidate and remote_entry_ready:
if candidate.get("compatible") is False:
logger.info(
f"检测到本地插件 {pid} 联邦构建产物变化,"
f"但跳过同步:{candidate.get('skip_reason')}"
)
elif pid not in local_plugins_to_sync:
local_plugins_to_sync[pid] = (candidate, event_path, False)
continue
# 跳过非 .py 文件
if not event_path.name.endswith(".py"):
continue
@@ -385,13 +399,14 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
f"文件:{event_path},但跳过同步:{local_candidate.get('skip_reason')}"
)
continue
local_plugins_to_sync[local_candidate.get("id")] = (local_candidate, event_path)
local_plugins_to_sync[local_candidate.get("id")] = (local_candidate, event_path, True)
for pid, (candidate, event_path) in local_plugins_to_sync.items():
for pid, (candidate, event_path, should_reload) in local_plugins_to_sync.items():
package_version = candidate.get("package_version")
source_root = f"plugins.{package_version}" if package_version else "plugins"
logger.info(f"检测到本地插件 {pid} 文件变化,来源:{source_root},文件:{event_path}")
if self._sync_local_plugin_if_installed(pid, candidate):
change_name = "Python 文件" if should_reload else "联邦构建产物"
logger.info(f"检测到本地插件 {pid} {change_name}变化,来源:{source_root},文件:{event_path}")
if self._sync_local_plugin_if_installed(pid, candidate) and should_reload:
plugins_to_reload.add(pid)
# 触发重载
@@ -403,6 +418,71 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
except Exception as e:
logger.error(f"插件 {pid} 热重载失败: {e}", exc_info=True)
def _get_federated_plugin_change(
self,
event_path: Path,
) -> Optional[Tuple[str, Optional[dict], bool]]:
"""
识别运行态 Vue 插件声明目录内的构建产物变化
:return: 插件 ID本地仓库候选和联邦入口是否完整非联邦目录变化返回 None
"""
try:
event_path = event_path.resolve()
candidate = self._get_local_plugin_candidate_from_path(event_path)
if candidate:
pid = candidate.get("id")
plugin_dir = Path(candidate.get("path")).resolve()
else:
runtime_root = (settings.ROOT_PATH / "app" / "plugins").resolve()
if not event_path.is_relative_to(runtime_root):
return None
relative_parts = event_path.relative_to(runtime_root).parts
if not relative_parts:
return None
plugin_dir = runtime_root / relative_parts[0]
pid = next(
(
plugin_id
for plugin_id in self._running_plugins
if plugin_id.lower() == relative_parts[0].lower()
),
None,
)
if not pid:
return None
plugin = self._running_plugins.get(pid)
if not plugin:
return None
render_mode, dist_path = plugin.get_render_mode()
if render_mode != "vue" or not isinstance(dist_path, str) or not dist_path:
return None
relative_dist_path = Path(dist_path)
if relative_dist_path.is_absolute() or ".." in relative_dist_path.parts or "\\" in dist_path:
return None
plugin_dir = plugin_dir.resolve()
dist_dir = (plugin_dir / relative_dist_path).resolve()
if (
dist_dir == plugin_dir
or not dist_dir.is_relative_to(plugin_dir)
or not event_path.is_relative_to(dist_dir)
):
return None
remote_entry = dist_dir / "remoteEntry.js"
remote_entry_ready = (
remote_entry.is_file()
and remote_entry.resolve().is_relative_to(plugin_dir)
)
return pid, candidate, remote_entry_ready
except Exception as e:
logger.error(f"识别插件联邦构建产物变化时出错: {e}")
return None
@staticmethod
def _get_plugin_id_from_path(event_path: Path) -> Optional[str]:
"""
@@ -517,7 +597,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
source_dir,
dest_dir,
dirs_exist_ok=True,
ignore=shutil.ignore_patterns("__pycache__", "*.pyc", ".DS_Store")
ignore=shutil.ignore_patterns("__pycache__", "*.pyc", ".DS_Store", "node_modules")
)
PluginManager()._recent_local_sync[pid] = time.time()
logger.info(f"已同步本地插件 {pid}{source_dir} -> {dest_dir}")
@@ -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
View File
@@ -169,6 +169,15 @@ def set_or_refresh_resource_token_cookie(
# 根据剩余时长提前刷新令牌
if remaining_time < timedelta(seconds=(settings.RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS / 3)):
raise jwt.ExpiredSignatureError
expected_claims = {
"sub": str(payload.sub),
"username": payload.username,
"super_user": payload.super_user,
"level": payload.level,
"purpose": "resource",
}
if any(decoded_token.get(claim) != value for claim, value in expected_claims.items()):
raise jwt.InvalidTokenError("资源令牌身份或权限上下文不匹配")
except jwt.PyJWTError:
logger.debug(f"Token error occurred. refreshing token")
except Exception as e:
@@ -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
"""
+61
View File
@@ -0,0 +1,61 @@
from typing import Dict, List, Optional
from app.db import DbOper
from app.db.models.downloadfailure import DownloadFailure
class DownloadFailureOper(DbOper):
"""
下载失败冷却记录管理
"""
def get_active_by_fingerprints(
self,
fingerprints: List[str],
now_time: str,
) -> Dict[str, DownloadFailure]:
"""
批量按指纹查询仍在冷却期的失败记录
"""
failures = DownloadFailure.get_active_by_fingerprints(
self._db,
fingerprints=fingerprints,
now_time=now_time,
)
return {
failure.fingerprint: failure
for failure in failures
if failure and failure.fingerprint
}
def record_failure(
self,
fingerprint: str,
now_time: str,
next_retry_at: str,
**kwargs: object,
) -> DownloadFailure:
"""
新增或更新资源失败记录
"""
return DownloadFailure.record_failure(
self._db,
fingerprint=fingerprint,
now_time=now_time,
next_retry_at=next_retry_at,
**kwargs,
)
def delete_expired(
self,
before_time: str,
limit: Optional[int] = 500,
) -> int:
"""
删除已过期较久的失败记录
"""
return DownloadFailure.delete_expired(
self._db,
before_time=before_time,
limit=limit,
)
+1
View File
@@ -1,4 +1,5 @@
from .agentchat import AgentChat
from .downloadfailure import DownloadFailure
from .downloadhistory import DownloadHistory, DownloadFiles
from .mediaserver import MediaServerItem
from .message import Message
+137
View File
@@ -0,0 +1,137 @@
from typing import List, Optional
from sqlalchemy import Column, Float, Index, Integer, String
from sqlalchemy.orm import Session
from app.db import Base, db_query, db_update, get_id_column
class DownloadFailure(Base):
"""
下载失败冷却记录
"""
id = get_id_column()
# 资源失败指纹
fingerprint = Column(String, nullable=False)
# 类型 电影/电视剧
type = Column(String)
# 标题
title = Column(String)
# 年份
year = Column(String)
# TMDBID
tmdbid = Column(Integer)
# 豆瓣ID
doubanid = Column(String)
# Sxx
seasons = Column(String)
# Exx
episodes = Column(String)
# 站点ID
site = Column(Integer)
# 站点名称
site_name = Column(String)
# 种子资源键
torrent_id = Column(String)
# 种子名称
torrent_name = Column(String)
# 种子大小
torrent_size = Column(Float)
# 下载器
downloader = Column(String)
# 下载来源
source = Column(String)
# 失败原因
error_message = Column(String)
# 重试次数
retry_count = Column(Integer, default=0)
# 首次失败时间
first_failed_at = Column(String)
# 最近失败时间
last_failed_at = Column(String)
# 下次允许重试时间
next_retry_at = Column(String)
__table_args__ = (
Index("ux_downloadfailure_fingerprint", "fingerprint", unique=True),
Index("ix_downloadfailure_next_retry_at", "next_retry_at"),
Index("ix_downloadfailure_media_site", "type", "tmdbid", "doubanid", "site"),
)
@classmethod
@db_query
def get_active_by_fingerprints(
cls,
db: Session,
fingerprints: List[str],
now_time: str,
) -> List["DownloadFailure"]:
"""
按指纹批量查询仍处于冷却期的失败记录
"""
normalized = list(dict.fromkeys([fingerprint for fingerprint in fingerprints if fingerprint]))
if not normalized:
return []
return (
db.query(cls)
.filter(cls.fingerprint.in_(normalized), cls.next_retry_at > now_time)
.all()
)
@classmethod
@db_update
def record_failure(
cls,
db: Session,
fingerprint: str,
now_time: str,
next_retry_at: str,
**kwargs: object,
) -> "DownloadFailure":
"""
新增或更新资源失败记录
"""
failure = db.query(cls).filter(cls.fingerprint == fingerprint).first()
payload = {
**kwargs,
"fingerprint": fingerprint,
"last_failed_at": now_time,
"next_retry_at": next_retry_at,
}
if failure:
payload["retry_count"] = (failure.retry_count or 0) + 1
for key, value in payload.items():
setattr(failure, key, value)
return failure
failure = cls(
**payload,
retry_count=1,
first_failed_at=now_time,
)
db.add(failure)
return failure
@classmethod
@db_update
def delete_expired(
cls,
db: Session,
before_time: str,
limit: Optional[int] = 500,
) -> int:
"""
分批清理已过期较久的失败冷却记录
"""
ids = [
row[0]
for row in db.query(cls.id)
.filter(cls.next_retry_at < before_time)
.order_by(cls.id.asc())
.limit(limit)
.all()
]
if not ids:
return 0
return db.query(cls).filter(cls.id.in_(ids)).delete(synchronize_session=False)
+89
View File
@@ -130,6 +130,46 @@ class Subscribe(Base):
return None
return result.scalars().first()
@classmethod
@db_query
def exists_by_username(cls, db: Session, username: str, tmdbid: Optional[int] = None,
doubanid: Optional[str] = None, season: Optional[int] = None):
"""
按订阅 owner 查询同一媒体的订阅行
"""
if not username:
return None
if tmdbid:
query = db.query(cls).filter(cls.username == username, cls.tmdbid == tmdbid)
if season is not None:
query = query.filter(cls.season == season)
return query.first()
elif doubanid:
return db.query(cls).filter(cls.username == username, cls.doubanid == doubanid).first()
return None
@classmethod
@async_db_query
async def async_exists_by_username(cls, db: AsyncSession, username: str, tmdbid: Optional[int] = None,
doubanid: Optional[str] = None, season: Optional[int] = None):
"""
异步按订阅 owner 查询同一媒体的订阅行
"""
if not username:
return None
if tmdbid:
query = select(cls).filter(cls.username == username, cls.tmdbid == tmdbid)
if season is not None:
query = query.filter(cls.season == season)
result = await db.execute(query)
elif doubanid:
result = await db.execute(
select(cls).filter(cls.username == username, cls.doubanid == doubanid)
)
else:
return None
return result.scalars().first()
@classmethod
@db_query
def get_by_state(cls, db: Session, state: str):
@@ -174,6 +214,22 @@ class Subscribe(Base):
)
return result.scalars().first()
@classmethod
@async_db_query
async def async_list_by_title(cls, db: AsyncSession, title: str, season: Optional[int] = None):
"""
异步按标题查询候选订阅列表
"""
if season is not None:
result = await db.execute(
select(cls).filter(cls.name == title, cls.season == season)
)
else:
result = await db.execute(
select(cls).filter(cls.name == title)
)
return result.scalars().all()
@classmethod
@db_query
def get_by_tmdbid(cls, db: Session, tmdbid: int, season: Optional[int] = None):
@@ -209,6 +265,17 @@ class Subscribe(Base):
)
return result.scalars().first()
@classmethod
@async_db_query
async def async_list_by_doubanid(cls, db: AsyncSession, doubanid: str):
"""
异步按豆瓣 ID 查询候选订阅列表
"""
result = await db.execute(
select(cls).filter(cls.doubanid == doubanid)
)
return result.scalars().all()
@classmethod
@db_query
def get_by_bangumiid(cls, db: Session, bangumiid: int):
@@ -222,6 +289,17 @@ class Subscribe(Base):
)
return result.scalars().first()
@classmethod
@async_db_query
async def async_list_by_bangumiid(cls, db: AsyncSession, bangumiid: int):
"""
异步按 Bangumi ID 查询候选订阅列表
"""
result = await db.execute(
select(cls).filter(cls.bangumiid == bangumiid)
)
return result.scalars().all()
@classmethod
@db_query
def get_by_mediaid(cls, db: Session, mediaid: str):
@@ -235,6 +313,17 @@ class Subscribe(Base):
)
return result.scalars().first()
@classmethod
@async_db_query
async def async_list_by_mediaid(cls, db: AsyncSession, mediaid: str):
"""
异步按自定义媒体 ID 查询候选订阅列表
"""
result = await db.execute(
select(cls).filter(cls.mediaid == mediaid)
)
return result.scalars().all()
@classmethod
@db_query
def get_by(cls, db: Session, type: str, season: Optional[str] = None,
+25
View File
@@ -102,6 +102,31 @@ class SubscribeHistory(Base):
)
return result.scalars().all()
@classmethod
@async_db_query
async def async_list_by_type_and_username(
cls,
db: AsyncSession,
mtype: str,
username: str,
page: Optional[int] = 1,
count: Optional[int] = 30
):
"""
按订阅 owner 查询指定类型的历史分页
"""
if not username:
return []
result = await db.execute(
select(cls).filter(
cls.type == mtype,
cls.username == username
).order_by(
cls.date.desc()
).offset((page - 1) * count).limit(count)
)
return result.scalars().all()
@classmethod
@db_query
def exists(cls, db: Session, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
+45
View File
@@ -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
View File
@@ -29,10 +29,19 @@ class SubscribeOper(DbOper):
"""
新增订阅
"""
subscribe = Subscribe.exists(self._db,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=kwargs.get('season'))
owner_scope = bool(kwargs.pop("owner_scope", False))
username = kwargs.get("username") if owner_scope else None
if username:
subscribe = Subscribe.exists_by_username(self._db,
username=username,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=kwargs.get('season'))
else:
subscribe = Subscribe.exists(self._db,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=kwargs.get('season'))
kwargs.update({
"name": mediainfo.title,
"year": mediainfo.year,
@@ -55,10 +64,17 @@ class SubscribeOper(DbOper):
subscribe = Subscribe(**kwargs)
subscribe.create(self._db)
# 查询订阅
subscribe = Subscribe.exists(self._db,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=kwargs.get('season'))
if username:
subscribe = Subscribe.exists_by_username(self._db,
username=username,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=kwargs.get('season'))
else:
subscribe = Subscribe.exists(self._db,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=kwargs.get('season'))
return subscribe.id, "新增订阅成功"
else:
return subscribe.id, "订阅已存在"
@@ -67,10 +83,19 @@ class SubscribeOper(DbOper):
"""
异步新增订阅
"""
subscribe = await Subscribe.async_exists(self._db,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=kwargs.get('season'))
owner_scope = bool(kwargs.pop("owner_scope", False))
username = kwargs.get("username") if owner_scope else None
if username:
subscribe = await Subscribe.async_exists_by_username(self._db,
username=username,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=kwargs.get('season'))
else:
subscribe = await Subscribe.async_exists(self._db,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=kwargs.get('season'))
kwargs.update({
"name": mediainfo.title,
"year": mediainfo.year,
@@ -93,10 +118,17 @@ class SubscribeOper(DbOper):
subscribe = Subscribe(**kwargs)
await subscribe.async_create(self._db)
# 查询订阅
subscribe = await Subscribe.async_exists(self._db,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=kwargs.get('season'))
if username:
subscribe = await Subscribe.async_exists_by_username(self._db,
username=username,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=kwargs.get('season'))
else:
subscribe = await Subscribe.async_exists(self._db,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=kwargs.get('season'))
return subscribe.id, "新增订阅成功"
else:
return subscribe.id, "订阅已存在"
+30
View File
@@ -58,6 +58,36 @@ async def get_current_active_user_async(
return current_user
def _ensure_manage_user(current_user: User) -> User:
"""
校验用户具备全局管理权限
"""
permissions = current_user.permissions or {}
if not current_user.is_superuser and not bool(permissions.get("manage")):
raise HTTPException(
status_code=400, detail="用户权限不足"
)
return current_user
def get_current_active_manage_user(
current_user: User = Depends(get_current_active_user),
) -> User:
"""
获取当前拥有管理权限的激活用户
"""
return _ensure_manage_user(current_user)
async def get_current_active_manage_user_async(
current_user: User = Depends(get_current_active_user_async),
) -> User:
"""
异步获取当前拥有管理权限的激活用户
"""
return _ensure_manage_user(current_user)
def get_current_active_superuser(
current_user: User = Depends(get_current_user),
) -> User:
+44 -1
View File
@@ -1,10 +1,35 @@
from fastapi import FastAPI
from typing import Awaitable, Callable
from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.core.config import settings
from app.helper.locale import LocaleHelper
from app.startup.lifecycle import lifespan
async def localized_http_exception_handler(
_request: Request,
exc: HTTPException,
) -> JSONResponse:
"""
HTTPException 响应补充多语言错误详情
:param _request: 当前 HTTP 请求
:param exc: FastAPI HTTP 异常
:return: detail_i18n JSON 错误响应
"""
content = {"detail": exc.detail}
if isinstance(exc.detail, str):
content["detail_i18n"] = LocaleHelper.translate_text(exc.detail)
return JSONResponse(
status_code=exc.status_code,
content=content,
headers=exc.headers,
)
def create_app() -> FastAPI:
"""
创建并配置 FastAPI 应用实例
@@ -15,6 +40,8 @@ def create_app() -> FastAPI:
lifespan=lifespan
)
_app.add_exception_handler(HTTPException, localized_http_exception_handler)
# 配置 CORS 中间件
_app.add_middleware(
CORSMiddleware, # noqa
@@ -24,6 +51,22 @@ def create_app() -> FastAPI:
allow_headers=["*"],
)
@_app.middleware("http")
async def locale_context_middleware(
request: Request,
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
"""
为每个请求设置后端多语言上下文
"""
token = LocaleHelper.set_current_locale(
LocaleHelper.get_locale_from_request(request)
)
try:
return await call_next(request)
finally:
LocaleHelper.reset_current_locale(token)
return _app
+45 -4
View File
@@ -1,4 +1,5 @@
import base64
import time
from typing import Tuple, Optional
from lxml import etree
@@ -57,6 +58,36 @@ class CookieHelper:
]
}
@staticmethod
def get_page_content(page: BrowserPage, retries: int = 3, interval: float = 1.0) -> Optional[str]:
"""
获取页面源码页面跳转中如登录前后的重定向会导致 page.content() 抛出
"Unable to retrieve content because the page is navigating" 异常等待加载完成后重试
:param page: 浏览器页面
:param retries: 最大重试次数
:param interval: 重试间隔
:return: 页面源码
"""
for i in range(retries):
# 等待加载失败不代表源码不可读取,最后一次等待失败时仍尝试直接获取源码
try:
page.wait_for_load_state("domcontentloaded", timeout=10 * 1000)
except Exception as e:
if i < retries - 1:
logger.warning(f"等待页面加载完成失败:{str(e)}{interval}秒后重试 ({i + 1}/{retries - 1})")
time.sleep(interval)
continue
logger.warning(f"等待页面加载完成失败:{str(e)},尝试直接获取源码")
try:
return page.content()
except Exception as e:
if i >= retries - 1:
logger.error(f"获取页面源码失败:{str(e)}")
return None
logger.warning(f"获取页面源码失败:{str(e)}{interval}秒后重试 ({i + 1}/{retries - 1})")
time.sleep(interval)
return None
@staticmethod
def parse_cookies(cookies: list) -> str:
"""
@@ -93,11 +124,13 @@ class CookieHelper:
:return: Cookie和UA
"""
# 登录页面代码
html_text = page.content()
html_text = self.get_page_content(page)
if not html_text:
return None, None, "获取源码失败"
# 查找用户名输入框
html = etree.HTML(html_text)
if html is None:
return None, None, "解析网页源码失败"
try:
username_xpath = None
for xpath in self._SITE_LOGIN_XPATH.get("username"):
@@ -189,7 +222,12 @@ class CookieHelper:
if "verify" in page.url:
if not otp_code:
return None, None, "需要二次验证码"
html = etree.HTML(page.content())
html_text = self.get_page_content(page)
if not html_text:
return None, None, "获取网页源码失败"
html = etree.HTML(html_text)
if html is None:
return None, None, "解析网页源码失败"
for xpath in self._SITE_LOGIN_XPATH.get("twostep"):
if html.xpath(xpath):
try:
@@ -205,14 +243,17 @@ class CookieHelper:
break
# 登录后的源码
html_text = page.content()
html_text = self.get_page_content(page)
if not html_text:
return None, None, "获取网页源码失败"
if SiteUtils.is_logged_in(html_text):
return self.parse_cookies(page.context.cookies()), \
page.evaluate("() => window.navigator.userAgent"), ""
else:
# 读取错误信息
# 从登录后的页面读取错误信息
html = etree.HTML(html_text)
if html is None:
return None, None, "登录失败"
error_xpath = None
for xpath in self._SITE_LOGIN_XPATH.get("error"):
if html.xpath(xpath):
+119 -2
View File
@@ -1,15 +1,17 @@
import re
from pathlib import Path
from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath
from typing import List, Optional, Tuple
from app import schemas
from app.core.context import MediaInfo
from app.db.systemconfig_oper import SystemConfigOper
from app.log import logger
from app.schemas.types import SystemConfigKey
from app.schemas.types import StorageSchema, SystemConfigKey
from app.utils.system import SystemUtils
JINJA2_VAR_PATTERN = re.compile(r"\{\{.*?}}", re.DOTALL)
WINDOWS_DRIVE_PATTERN = re.compile(r"^[A-Za-z]:[\\/]")
WINDOWS_DRIVE_PREFIX_PATTERN = re.compile(r"^[A-Za-z]:")
class DirectoryHelper:
@@ -169,3 +171,118 @@ class DirectoryHelper:
# 媒体根路径
media_root = rename_path.parents[rename_format_level - 1]
return media_root
def _split_file_uri(value: str) -> Tuple[str, str]:
"""
拆分 FileURI 字符串保留原始路径用于安全校验
"""
for storage in StorageSchema:
protocol = f"{storage.value}:"
if value.startswith(protocol):
return storage.value, value[len(protocol):]
return "local", value
def _normalize_safe_posix_path(raw_path: str) -> PurePosixPath:
"""
规范化保存目录路径并拒绝跨目录或跨平台歧义写法
"""
if not raw_path:
raise ValueError("保存路径不能为空")
if "\\" in raw_path:
raise ValueError("保存路径不能包含反斜杠")
if raw_path.startswith("//"):
raise ValueError("保存路径不能使用 UNC 路径")
if WINDOWS_DRIVE_PATTERN.match(raw_path):
raise ValueError("保存路径不能使用 Windows 盘符路径")
if not raw_path.startswith("/"):
raise ValueError("保存路径必须是绝对路径")
path = PurePosixPath(raw_path)
parts = [part for part in path.parts if part != "/"]
if ".." in parts:
raise ValueError("保存路径不能包含上级目录")
if parts and re.fullmatch(r"[A-Za-z]:", parts[0]):
raise ValueError("保存路径不能使用 Windows 盘符路径")
return path
def _normalize_safe_windows_path(raw_path: str) -> PureWindowsPath:
"""
规范化已配置的 Windows 盘符路径UNC 与反斜杠写法不参与下载目录 allowlist
"""
if not raw_path:
raise ValueError("保存路径不能为空")
if "\\" in raw_path:
raise ValueError("保存路径不能包含反斜杠")
if raw_path.startswith("//"):
raise ValueError("保存路径不能使用 UNC 路径")
if not WINDOWS_DRIVE_PATTERN.match(raw_path):
raise ValueError("保存路径必须是 Windows 绝对路径")
path = PureWindowsPath(raw_path)
if ".." in path.parts:
raise ValueError("保存路径不能包含上级目录")
return path
def _normalize_download_path(raw_path: str, storage: str) -> Tuple[str, PurePath]:
"""
按存储类型解析下载路径本地允许 POSIX 或已配置的 Windows drive远端保持 FileURI POSIX 语义
"""
path_value = str(raw_path or "").strip()
if storage == "local" and WINDOWS_DRIVE_PREFIX_PATTERN.match(path_value):
return "windows", _normalize_safe_windows_path(path_value)
return "posix", _normalize_safe_posix_path(path_value)
def _download_path_uri(storage: str, path: PurePath) -> str:
"""
生成可传给下载器的 save_path保持 /download/paths 暴露的本地和远端路径风格
"""
path_value = path.as_posix()
if storage == "local":
return path_value
return schemas.FileURI(storage=storage, path=path_value).uri
def _normalize_download_root(dir_info: schemas.TransferDirectoryConf) -> Optional[Tuple[str, str, PurePath]]:
"""
读取下载目录配置中的根路径无效配置不参与用户 save_path allowlist
"""
if not dir_info.download_path:
return None
storage = dir_info.storage or "local"
try:
path_style, root_path = _normalize_download_path(dir_info.download_path, storage)
return storage, path_style, root_path
except ValueError as err:
logger.warn(f"跳过无效下载目录配置:{str(err)}")
return None
def validate_download_save_path(save_path: str) -> str:
"""
校验用户传入的下载保存目录/download/paths 暴露的下载目录配置是允许写入的公共合同
:param save_path: 下载保存目录支持本地 /path 或远端 <storage>:/path
:return: 可直接传给下载接口的规范化保存目录
"""
value = str(save_path or "").strip()
storage, raw_path = _split_file_uri(value)
target_style, target_path = _normalize_download_path(raw_path, storage)
for dir_info in DirectoryHelper().get_download_dirs():
root = _normalize_download_root(dir_info)
if not root:
continue
root_storage, root_style, root_path = root
if storage != root_storage:
continue
if target_style != root_style:
continue
if target_path == root_path or target_path.is_relative_to(root_path):
return _download_path_uri(storage, target_path)
raise ValueError("保存路径不在允许的下载目录范围内")
+341
View File
@@ -398,6 +398,347 @@ class MediaInteractionManager:
media_interaction_manager = MediaInteractionManager()
@dataclass
class PendingPluginInputInteraction:
"""
记录插件临时接管用户下一条文本输入的会话
"""
request_id: str
user_id: str
plugin_id: str
channel: Optional[MessageChannel]
source: Optional[str]
username: Optional[str]
chat_id: Optional[str] = None
prompt_id: Optional[str] = None
payload: Optional[Any] = None
timeout_seconds: int = 120
created_at: datetime = field(default_factory=datetime.now)
# Optional reply binding for channels that can report reply_to_message_id.
prompt_message_id: Optional[str] = None
@property
def expires_at(self) -> datetime:
return self.created_at + timedelta(seconds=max(1, self.timeout_seconds))
class PluginInputInteractionManager:
"""
管理插件输入会话
会话按用户和渠道绑定同一用户在同一渠道只保留一个待输入会话
"""
EXPIRED_GRACE_SECONDS = 300
def __init__(self):
self._by_id: Dict[str, PendingPluginInputInteraction] = {}
self._by_user_channel: Dict[Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]], str] = {}
self._expired_by_user_channel: Dict[
Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]],
PendingPluginInputInteraction,
] = {}
self._lock = Lock()
@staticmethod
def _user_channel_source_key(
user_id: Union[str, int],
channel: Optional[MessageChannel],
source: Optional[str] = None,
chat_id: Optional[Union[str, int]] = None,
) -> Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]]:
return str(user_id), channel, source, str(chat_id) if chat_id not in (None, "") else None
@classmethod
def _keys_overlap(
cls,
left: Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]],
right: Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]],
) -> bool:
left_user, left_channel, left_source, left_chat_id = left
right_user, right_channel, right_source, right_chat_id = right
if left_user != right_user:
return False
if left_chat_id and right_chat_id and left_chat_id != right_chat_id:
return False
if (left_channel is None and left_source is None) or (right_channel is None and right_source is None):
return left_channel == right_channel and left_source == right_source
channel_overlap = left_channel == right_channel or left_channel is None or right_channel is None
source_overlap = left_source == right_source or left_source is None or right_source is None
return channel_overlap and source_overlap
def _cleanup_locked(self) -> None:
now = datetime.now()
expired_tombstones = [
key
for key, request in self._expired_by_user_channel.items()
if request.expires_at + timedelta(seconds=self.EXPIRED_GRACE_SECONDS) < now
]
for key in expired_tombstones:
self._expired_by_user_channel.pop(key, None)
expired = [
request_id
for request_id, request in self._by_id.items()
if request.expires_at < now
]
for request_id in expired:
request = self._by_id.pop(request_id, None)
if request:
key = self._user_channel_source_key(
request.user_id,
request.channel,
request.source,
request.chat_id,
)
self._by_user_channel.pop(key, None)
self._expired_by_user_channel[key] = request
def create_or_replace(
self,
user_id: Union[str, int],
plugin_id: str,
channel: Optional[MessageChannel],
source: Optional[str],
username: Optional[str],
chat_id: Optional[Union[str, int]] = None,
prompt_id: Optional[str] = None,
timeout_seconds: int = 120,
payload: Optional[Any] = None,
*,
prompt_message_id: Optional[Union[str, int]] = None,
) -> PendingPluginInputInteraction:
with self._lock:
self._cleanup_locked()
key = self._user_channel_source_key(user_id, channel, source, chat_id)
old_request_ids = [
request_id
for stored_key, request_id in self._by_user_channel.items()
if self._keys_overlap(stored_key, key)
]
for old_request_id in old_request_ids:
self._by_id.pop(old_request_id, None)
self._by_user_channel = {
stored_key: request_id
for stored_key, request_id in self._by_user_channel.items()
if request_id not in old_request_ids
}
self._expired_by_user_channel = {
stored_key: request
for stored_key, request in self._expired_by_user_channel.items()
if not self._keys_overlap(stored_key, key)
}
normalized_chat_id = str(chat_id) if chat_id not in (None, "") else None
normalized_prompt_message_id = (
str(prompt_message_id)
if channel == MessageChannel.Telegram and normalized_chat_id and prompt_message_id not in (None, "")
else None
)
request = PendingPluginInputInteraction(
request_id=uuid.uuid4().hex[:12],
user_id=str(user_id),
plugin_id=plugin_id,
channel=channel,
source=source,
username=username,
chat_id=normalized_chat_id,
prompt_id=prompt_id,
prompt_message_id=normalized_prompt_message_id,
timeout_seconds=timeout_seconds,
payload=payload,
)
self._by_id[request.request_id] = request
self._by_user_channel[key] = request.request_id
return request
def get_by_user(
self,
user_id: Union[str, int],
channel: Optional[MessageChannel] = None,
source: Optional[str] = None,
chat_id: Optional[Union[str, int]] = None,
) -> Optional[PendingPluginInputInteraction]:
with self._lock:
self._cleanup_locked()
request_id = self._find_request_id_locked(user_id, channel, source, chat_id)
if request_id:
return self._by_id.get(request_id)
return None
def pop_by_user(
self,
user_id: Union[str, int],
channel: Optional[MessageChannel] = None,
source: Optional[str] = None,
chat_id: Optional[Union[str, int]] = None,
) -> Optional[PendingPluginInputInteraction]:
with self._lock:
self._cleanup_locked()
key, request_id = self._find_key_and_request_id_locked(user_id, channel, source, chat_id)
if request_id:
self._by_user_channel.pop(key, None)
return self._by_id.pop(request_id, None)
expired_key, request = self._find_expired_key_and_request_locked(user_id, channel, source, chat_id)
if expired_key:
self._expired_by_user_channel.pop(expired_key, None)
return request
def consume_by_user(
self,
user_id: Union[str, int],
channel: Optional[MessageChannel] = None,
source: Optional[str] = None,
chat_id: Optional[Union[str, int]] = None,
*,
reply_to_message_id: Optional[Union[str, int]] = None,
bypass_reply_check: bool = False,
) -> Tuple[Optional[PendingPluginInputInteraction], Optional[str]]:
with self._lock:
key, request_id = self._find_key_and_request_id_locked(user_id, channel, source, chat_id)
if request_id:
request = self._by_id.get(request_id)
if not request:
self._by_user_channel.pop(key, None)
elif request.expires_at < datetime.now():
self._by_user_channel.pop(key, None)
self._by_id.pop(request_id, None)
if request.prompt_message_id:
return None, None
return request, "expired"
elif not self._reply_matches_prompt(
request,
chat_id,
reply_to_message_id,
ignore_reply_to_message_id=bypass_reply_check,
):
return None, None
else:
self._by_user_channel.pop(key, None)
self._by_id.pop(request_id, None)
return request, "active"
self._cleanup_locked()
key, request = self._find_expired_key_and_request_locked(user_id, channel, source, chat_id)
if request:
self._expired_by_user_channel.pop(key, None)
if request.prompt_message_id:
return None, None
return request, "expired"
self._cleanup_locked()
return None, None
@staticmethod
def _reply_matches_prompt(
request: PendingPluginInputInteraction,
chat_id: Optional[Union[str, int]],
reply_to_message_id: Optional[Union[str, int]],
*,
ignore_reply_to_message_id: bool = False,
) -> bool:
if not request.prompt_message_id:
return True
if not request.chat_id or chat_id in (None, ""):
return False
if str(chat_id) != str(request.chat_id):
return False
if ignore_reply_to_message_id:
return True
if reply_to_message_id in (None, ""):
return False
return str(reply_to_message_id) == str(request.prompt_message_id)
def _find_request_id_locked(
self,
user_id: Union[str, int],
channel: Optional[MessageChannel],
source: Optional[str],
chat_id: Optional[Union[str, int]] = None,
) -> Optional[str]:
_, request_id = self._find_key_and_request_id_locked(user_id, channel, source, chat_id)
return request_id
def _find_key_and_request_id_locked(
self,
user_id: Union[str, int],
channel: Optional[MessageChannel],
source: Optional[str],
chat_id: Optional[Union[str, int]] = None,
) -> Tuple[Optional[Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]]], Optional[str]]:
for key in self._candidate_keys(user_id, channel, source, chat_id):
request_id = self._by_user_channel.get(key)
if request_id:
return key, request_id
return None, None
def _find_expired_key_and_request_locked(
self,
user_id: Union[str, int],
channel: Optional[MessageChannel],
source: Optional[str],
chat_id: Optional[Union[str, int]] = None,
) -> Tuple[Optional[Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]]],
Optional[PendingPluginInputInteraction]]:
now = datetime.now()
for key in self._candidate_keys(user_id, channel, source, chat_id):
request = self._expired_by_user_channel.get(key)
if not request:
continue
if request.expires_at + timedelta(seconds=self.EXPIRED_GRACE_SECONDS) < now:
self._expired_by_user_channel.pop(key, None)
continue
return key, request
return None, None
def _candidate_keys(
self,
user_id: Union[str, int],
channel: Optional[MessageChannel],
source: Optional[str],
chat_id: Optional[Union[str, int]] = None,
) -> List[Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]]]:
chat_key = str(chat_id) if chat_id not in (None, "") else None
candidates = [
self._user_channel_source_key(user_id, channel, source, chat_key),
]
if source is not None:
candidates.append(self._user_channel_source_key(user_id, channel, None, chat_key))
if channel is not None and source is not None:
candidates.append(self._user_channel_source_key(user_id, None, source, chat_key))
if channel is None and source is None:
wildcard_key = self._user_channel_source_key(user_id, None, None, chat_key)
candidates.append(wildcard_key)
if chat_key is not None:
candidates.append(self._user_channel_source_key(user_id, channel, source, None))
if source is not None:
candidates.append(self._user_channel_source_key(user_id, channel, None, None))
if channel is not None and source is not None:
candidates.append(self._user_channel_source_key(user_id, None, source, None))
if channel is None and source is None:
candidates.append(self._user_channel_source_key(user_id, None, None, None))
return candidates
def remove(self, request_id: str) -> None:
with self._lock:
request = self._by_id.pop(request_id, None)
if request:
self._by_user_channel.pop(
self._user_channel_source_key(request.user_id, request.channel, request.source, request.chat_id),
None,
)
def clear(self) -> None:
with self._lock:
self._by_id.clear()
self._by_user_channel.clear()
self._expired_by_user_channel.clear()
plugin_input_interaction_manager = PluginInputInteractionManager()
@dataclass(frozen=True)
class AgentInteractionOption:
"""
+302
View File
@@ -0,0 +1,302 @@
import json
import re
from contextvars import ContextVar, Token
from functools import lru_cache
from pathlib import Path
from typing import Any, Optional
class LocaleHelper:
"""
后端多语言文本辅助器
该类只为需要返回给前端展示的文本生成并行多语言字段旧有中文字段仍由调用方保留
"""
DEFAULT_LOCALE = "zh-CN"
SUPPORTED_LOCALES = ("zh-CN", "zh-TW", "en-US")
HEADER_NAMES = ("x-moviepilot-locale", "x-locale")
_PATTERN_FIELD = re.compile(r"\{([A-Za-z_][A-Za-z0-9_]*)\}")
_CURRENT_LOCALE: ContextVar[str] = ContextVar("moviepilot_locale", default=DEFAULT_LOCALE)
_LOCALES_DIR = Path(__file__).resolve().parents[1] / "locales"
_LOCALE_ALIASES = {
"zh": "zh-CN",
"zh-cn": "zh-CN",
"zh-hans": "zh-CN",
"zh-hans-cn": "zh-CN",
"zh-tw": "zh-TW",
"zh-hant": "zh-TW",
"zh-hant-tw": "zh-TW",
"en": "en-US",
"en-us": "en-US",
}
@classmethod
def normalize_locale(cls, locale: Optional[str]) -> str:
"""
规范化语言标识无法识别时返回默认简体中文
:param locale: 原始语言标识 zh-CNzh_CNen-US
:return: 项目支持的语言标识
"""
return cls._match_locale(locale) or cls.DEFAULT_LOCALE
@classmethod
def get_locale_from_request(cls, request: Any) -> str:
"""
从请求参数或请求头解析前端期望语言
:param request: FastAPI Request 或带 headers 属性的兼容对象
:return: 项目支持的语言标识
"""
query_params = getattr(request, "query_params", {}) or {}
query_locale = query_params.get("locale") if hasattr(query_params, "get") else None
if query_locale:
return cls.normalize_locale(query_locale)
headers = getattr(request, "headers", {}) or {}
for header_name in cls.HEADER_NAMES:
value = headers.get(header_name)
if value:
return cls.normalize_locale(value)
accept_language = headers.get("accept-language")
if not accept_language:
return cls.DEFAULT_LOCALE
choices = []
for index, item in enumerate(accept_language.split(",")):
parts = [part.strip() for part in item.split(";") if part.strip()]
if not parts:
continue
quality = 1.0
for part in parts[1:]:
if part.startswith("q="):
try:
quality = float(part[2:])
except ValueError:
quality = 0.0
choices.append((-quality, index, parts[0]))
for _, _, candidate in sorted(choices):
locale = cls._match_locale(candidate)
if locale:
return locale
return cls.DEFAULT_LOCALE
@classmethod
def get_current_locale(cls) -> str:
"""
获取当前请求上下文中的语言标识
:return: 项目支持的语言标识
"""
return cls._CURRENT_LOCALE.get()
@classmethod
def set_current_locale(cls, locale: Optional[str]) -> Token[str]:
"""
设置当前请求上下文中的语言标识
:param locale: 原始语言标识
:return: 用于恢复上下文的令牌
"""
return cls._CURRENT_LOCALE.set(cls.normalize_locale(locale))
@classmethod
def reset_current_locale(cls, token: Token[str]) -> None:
"""
恢复当前请求上下文中的语言标识
:param token: set_current_locale 返回的上下文令牌
"""
cls._CURRENT_LOCALE.reset(token)
@classmethod
def translate(
cls,
key: str,
locale: Optional[str] = None,
default: Optional[str] = None,
**kwargs: Any,
) -> str:
"""
根据翻译键获取多语言文本
:param key: 点分隔翻译键
:param locale: 目标语言未传入或无法识别时使用默认语言
:param default: 翻译缺失时返回的默认文本
:param kwargs: 字符串格式化参数
:return: 翻译后的文本
"""
normalized_locale = cls.normalize_locale(locale) if locale else cls.get_current_locale()
template = cls._lookup(cls._load_catalog(normalized_locale), key)
if template is None and normalized_locale != cls.DEFAULT_LOCALE:
template = cls._lookup(cls._load_catalog(cls.DEFAULT_LOCALE), key)
if template is None:
template = default or key
return cls._format(template, kwargs)
@classmethod
def translate_text(cls, text: Optional[str], locale: Optional[str] = None) -> str:
"""
翻译存量接口返回的中文文本
:param text: 原始中文文本
:param locale: 目标语言未传入或无法识别时使用默认语言
:return: 翻译后的文本缺失翻译时返回原文
"""
if not text:
return ""
normalized_locale = cls.normalize_locale(locale) if locale else cls.get_current_locale()
translated = cls._lookup_message(cls._load_catalog(normalized_locale), text)
if translated is None and cls._contains_chinese(text):
translated = cls._lookup_pattern(normalized_locale, text)
if translated is None and normalized_locale != cls.DEFAULT_LOCALE:
translated = cls._lookup_message(cls._load_catalog(cls.DEFAULT_LOCALE), text)
if (
translated is None
and normalized_locale != cls.DEFAULT_LOCALE
and cls._contains_chinese(text)
):
translated = cls._lookup_pattern(cls.DEFAULT_LOCALE, text)
return translated or text
@classmethod
def _match_locale(cls, locale: Optional[str]) -> Optional[str]:
"""
将原始语言标识匹配为项目支持的语言
"""
if not locale:
return None
normalized = locale.strip().replace("_", "-").lower()
if not normalized:
return None
return cls._LOCALE_ALIASES.get(normalized)
@staticmethod
@lru_cache(maxsize=16)
def _load_catalog(locale: str) -> dict[str, Any]:
"""
加载指定语言的翻译表
"""
catalog_path = LocaleHelper._LOCALES_DIR / f"{locale}.json"
try:
with catalog_path.open("r", encoding="utf-8") as file:
return json.load(file)
except (FileNotFoundError, json.JSONDecodeError):
return {}
@staticmethod
def _lookup(catalog: dict[str, Any], key: str) -> Optional[str]:
"""
按点分隔键从结构化翻译表中查找文本
"""
current: Any = catalog
for part in key.split("."):
if not isinstance(current, dict) or part not in current:
return None
current = current[part]
return current if isinstance(current, str) else None
@staticmethod
def _lookup_message(catalog: dict[str, Any], text: str) -> Optional[str]:
"""
从精确消息表中查找存量中文文本
"""
messages = catalog.get("messages")
if not isinstance(messages, dict):
return None
translated = messages.get(text)
return translated if isinstance(translated, str) else None
@classmethod
def _lookup_pattern(cls, locale: str, text: str) -> Optional[str]:
"""
使用动态模板匹配存量中文文本
"""
for pattern, target in cls._load_pattern_matchers(locale):
matched = pattern.fullmatch(text)
if matched:
return cls._format(
target,
cls._build_pattern_values(locale, matched.groupdict()),
)
return None
@classmethod
def _build_pattern_values(cls, locale: str, values: dict[str, str]) -> dict[str, str]:
"""
为动态模板补充可选的占位值翻译
"""
pattern_values = dict(values)
catalog = cls._load_catalog(locale)
default_catalog = (
cls._load_catalog(cls.DEFAULT_LOCALE)
if locale != cls.DEFAULT_LOCALE
else catalog
)
for name, value in values.items():
translated = cls._lookup_message(catalog, value)
if translated is None and locale != cls.DEFAULT_LOCALE:
translated = cls._lookup_message(default_catalog, value)
pattern_values[f"{name}_i18n"] = translated or value
return pattern_values
@staticmethod
@lru_cache(maxsize=16)
def _load_pattern_matchers(locale: str) -> list[tuple[re.Pattern[str], str]]:
"""
加载并缓存指定语言的动态文本匹配器
"""
catalog = LocaleHelper._load_catalog(locale)
patterns = catalog.get("message_patterns")
if not isinstance(patterns, list):
return []
matchers = []
for item in patterns:
if not isinstance(item, dict):
continue
source = item.get("source")
target = item.get("target")
if not isinstance(source, str) or not isinstance(target, str):
continue
pattern = LocaleHelper._compile_pattern(source)
if pattern is None:
continue
matchers.append((pattern, target))
return matchers
@classmethod
def _compile_pattern(cls, source: str) -> Optional[re.Pattern[str]]:
"""
将带命名占位符的中文模板编译为正则
"""
field_names = cls._PATTERN_FIELD.findall(source)
if not field_names:
return None
pattern = cls._PATTERN_FIELD.sub(
lambda match: f"(?P<{match.group(1)}>.+?)",
re.escape(source).replace(r"\{", "{").replace(r"\}", "}"),
)
return re.compile(pattern)
@staticmethod
def _contains_chinese(text: str) -> bool:
"""
判断文本是否包含中文字符
"""
return any("\u4e00" <= char <= "\u9fff" for char in text)
@staticmethod
def _format(template: str, kwargs: dict[str, Any]) -> str:
"""
格式化翻译模板参数缺失时保留模板原文
"""
if not kwargs:
return template
try:
return template.format(**kwargs)
except (KeyError, AttributeError, IndexError):
return template
+140 -57
View File
@@ -5,13 +5,14 @@ import io
import json
import shutil
import site
import stat
import sys
import tempfile
import threading
import time
import traceback
import zipfile
from pathlib import Path
from pathlib import Path, PurePosixPath, PureWindowsPath
from typing import Dict, List, Optional, Tuple, Set, Callable, Awaitable
from urllib.parse import parse_qs, quote, unquote, urlsplit
@@ -757,7 +758,7 @@ class PluginHelper(metaclass=WeakSingleton):
source_dir,
dest_dir,
dirs_exist_ok=True,
ignore=shutil.ignore_patterns("__pycache__", "*.pyc", ".DS_Store")
ignore=shutil.ignore_patterns("__pycache__", "*.pyc", ".DS_Store", "node_modules")
)
return True, ""
except Exception as e:
@@ -1723,6 +1724,95 @@ class PluginHelper(metaclass=WeakSingleton):
self.refresh_persistent_plugin_backup(pid)
return True, ""
@staticmethod
def __validate_release_zip_name(name: str) -> None:
"""
校验 release zip 成员名在 POSIX Windows 语义下都只能表示相对路径
"""
if not name:
raise ValueError("非法 Release 压缩包成员:成员名为空")
if "\x00" in name:
raise ValueError(f"非法 Release 压缩包成员:{name}")
if "\\" in name:
raise ValueError(f"非法 Release 压缩包成员:{name}")
posix_path = PurePosixPath(name)
windows_path = PureWindowsPath(name)
if (
name.startswith("//")
or posix_path.is_absolute()
or windows_path.is_absolute()
or windows_path.drive
):
raise ValueError(f"非法 Release 压缩包成员:{name}")
parts = [part for part in posix_path.parts if part not in ("", ".")]
if not parts:
raise ValueError(f"非法 Release 压缩包成员:{name}")
if ".." in parts:
raise ValueError(f"非法 Release 压缩包成员:{name}")
@staticmethod
def __validate_release_zip_type(info: zipfile.ZipInfo) -> None:
"""
release zip 只接受普通文件和目录避免归档内的符号链接或设备文件影响安装边界
"""
mode = info.external_attr >> 16
file_type = stat.S_IFMT(mode)
if not file_type:
return
if stat.S_ISREG(mode) or stat.S_ISDIR(mode):
return
raise ValueError(f"非法 Release 压缩包成员:{info.filename}")
@staticmethod
def __get_release_zip_base_prefix(infos: List[zipfile.ZipInfo]) -> str:
"""
识别 release zip 的单一顶层目录用于保持插件包根目录剥离行为
"""
names = [info.filename for info in infos]
names_with_slash = [name for name in names if "/" in name]
if names_with_slash and len(names_with_slash) == len(names):
first_seg = names_with_slash[0].split("/", 1)[0]
if first_seg and all(name.startswith(first_seg + "/") for name in names):
return first_seg + "/"
return ""
@classmethod
def __iter_release_zip_targets(
cls, zf: zipfile.ZipFile, dest_base: Path
) -> List[Tuple[zipfile.ZipInfo, Path, bool]]:
"""
release zip 成员解析为安装目标路径并保证目标路径不会逃逸插件目录
"""
infos = zf.infolist()
for info in infos:
cls.__validate_release_zip_type(info)
cls.__validate_release_zip_name(info.filename)
base_prefix = cls.__get_release_zip_base_prefix(infos)
dest_root = dest_base.resolve()
targets = []
for info in infos:
raw_name = info.filename
rel_name = raw_name[len(base_prefix):] if base_prefix else raw_name
if not rel_name:
if base_prefix and raw_name == base_prefix:
continue
raise ValueError(f"非法 Release 压缩包成员:{raw_name}")
cls.__validate_release_zip_name(rel_name)
rel_parts = [part for part in PurePosixPath(rel_name).parts if part not in ("", ".")]
if not rel_parts:
raise ValueError(f"非法 Release 压缩包成员:{raw_name}")
dest_path = (dest_root / Path(*rel_parts)).resolve()
try:
dest_path.relative_to(dest_root)
except ValueError as exc:
raise ValueError(f"非法 Release 压缩包成员:{raw_name}") from exc
targets.append((info, dest_path, info.is_dir()))
return targets
def __install_from_release(self, pid: str, user_repo: str, release_tag: str) -> Tuple[bool, str]:
"""
通过 GitHub Release 资产文件安装插件
@@ -1766,29 +1856,18 @@ class PluginHelper(metaclass=WeakSingleton):
try:
with zipfile.ZipFile(io.BytesIO(res.content)) as zf:
namelist = zf.namelist()
if not namelist:
infos = zf.infolist()
if not infos:
return False, "压缩包内容为空"
# 若所有条目均在同一顶层目录下(如 pid/),则剥离这一层,避免出现双层目录
names_with_slash = [n for n in namelist if '/' in n]
base_prefix = ''
if names_with_slash and len(names_with_slash) == len(namelist):
first_seg = names_with_slash[0].split('/')[0]
if all(n.startswith(first_seg + '/') for n in namelist):
base_prefix = first_seg + '/'
dest_base = Path(settings.ROOT_PATH) / "app" / "plugins" / pid.lower()
targets = self.__iter_release_zip_targets(zf, dest_base)
wrote_any = False
for name in namelist:
rel_path = name[len(base_prefix):]
if not rel_path:
for info, dest_path, is_dir in targets:
if is_dir:
dest_path.mkdir(parents=True, exist_ok=True)
continue
if rel_path.endswith('/'):
(dest_base / rel_path.rstrip('/')).mkdir(parents=True, exist_ok=True)
continue
dest_path = dest_base / rel_path
dest_path.parent.mkdir(parents=True, exist_ok=True)
with zf.open(name, 'r') as src, open(dest_path, 'wb') as dst:
with zf.open(info, 'r') as src, open(dest_path, 'wb') as dst:
dst.write(src.read())
wrote_any = True
if not wrote_any:
@@ -2139,35 +2218,48 @@ class PluginHelper(metaclass=WeakSingleton):
normal_task_key = (loop, normalized_repo_url, False)
force_task_key = (loop, normalized_repo_url, True)
with self._release_task_lock:
force_task = self._release_tasks.get(force_task_key)
if force_task and not force_task.done():
task_key = force_task_key
task = force_task
elif is_fresh():
pending_normal_task = self._release_tasks.get(normal_task_key)
if pending_normal_task and pending_normal_task.done():
pending_normal_task = None
task_key = force_task_key
task = loop.create_task(
self._async_refresh_plugin_repo_releases(normalized_repo_url, pending_normal_task)
)
self._release_tasks[task_key] = task
task.add_done_callback(
lambda completed_task: self._remove_release_task(task_key, completed_task)
)
if is_fresh():
force_task = self._release_tasks.get(force_task_key)
if force_task and not force_task.done():
task_key = force_task_key
task = force_task
else:
pending_normal_task = self._release_tasks.get(normal_task_key)
if pending_normal_task and pending_normal_task.done():
pending_normal_task = None
task_key = force_task_key
task = loop.create_task(
self._async_refresh_plugin_repo_releases(normalized_repo_url, pending_normal_task)
)
self._release_tasks[task_key] = task
task.add_done_callback(
lambda completed_task: self._remove_release_task(task_key, completed_task)
)
else:
task_key = normal_task_key
task = self._release_tasks.get(task_key)
if task is None or task.done():
pending_normal_task = self._release_tasks.get(normal_task_key)
if pending_normal_task is None or pending_normal_task.done():
task = loop.create_task(self._async_get_plugin_repo_releases(normalized_repo_url))
self._release_tasks[task_key] = task
task.add_done_callback(
lambda completed_task: self._remove_release_task(task_key, completed_task)
)
else:
task = pending_normal_task
payload = await asyncio.shield(task)
return self.__parse_plugin_release_response(pid, payload)
async def async_has_plugin_release_cache(self, repo_url: str) -> bool:
"""
判断指定仓库的 Release 列表缓存是否已经存在
"""
if not repo_url:
return False
return await self._async_get_plugin_repo_releases.cache_exists(
self, repo_url.rstrip("/")
)
async def _async_refresh_plugin_repo_releases(
self,
repo_url: str,
@@ -2783,28 +2875,19 @@ class PluginHelper(metaclass=WeakSingleton):
try:
with zipfile.ZipFile(io.BytesIO(res.content)) as zf:
namelist = zf.namelist()
if not namelist:
infos = zf.infolist()
if not infos:
return False, "压缩包内容为空"
names_with_slash = [n for n in namelist if '/' in n]
base_prefix = ''
if names_with_slash and len(names_with_slash) == len(namelist):
first_seg = names_with_slash[0].split('/')[0]
if all(n.startswith(first_seg + '/') for n in namelist):
base_prefix = first_seg + '/'
dest_base = AsyncPath(settings.ROOT_PATH) / "app" / "plugins" / pid.lower()
dest_base = Path(settings.ROOT_PATH) / "app" / "plugins" / pid.lower()
targets = self.__iter_release_zip_targets(zf, dest_base)
wrote_any = False
for name in namelist:
rel_path = name[len(base_prefix):]
if not rel_path:
for info, dest_path, is_dir in targets:
async_dest_path = AsyncPath(dest_path)
if is_dir:
await async_dest_path.mkdir(parents=True, exist_ok=True)
continue
if rel_path.endswith('/'):
await (dest_base / rel_path.rstrip('/')).mkdir(parents=True, exist_ok=True)
continue
dest_path = dest_base / rel_path
await dest_path.parent.mkdir(parents=True, exist_ok=True)
with zf.open(name, 'r') as src:
await async_dest_path.parent.mkdir(parents=True, exist_ok=True)
with zf.open(info, 'r') as src:
data = src.read()
async with aiofiles.open(dest_path, 'wb') as dst:
await dst.write(data)
+60 -19
View File
@@ -1,7 +1,8 @@
from enum import Enum
from typing import Union, Optional
from typing import Optional, Union
from app.core.cache import TTLCache
from app.helper.locale import LocaleHelper
from app.schemas.types import ProgressKey
@@ -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
+25
View File
@@ -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 WNSEdge/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
+198
View File
@@ -0,0 +1,198 @@
{
"system": {
"modules": {
"BangumiModule": {
"name": "Bangumi"
},
"DiscordModule": {
"name": "Discord"
},
"DoubanModule": {
"name": "豆瓣"
},
"EmbyModule": {
"name": "Emby"
},
"FanartModule": {
"name": "Fanart"
},
"FeishuModule": {
"name": "飞书"
},
"FileManagerModule": {
"name": "文件整理"
},
"FilterModule": {
"name": "过滤器"
},
"IndexerModule": {
"name": "站点索引"
},
"JellyfinModule": {
"name": "Jellyfin"
},
"PlexModule": {
"name": "Plex"
},
"PostgreSQLModule": {
"name": "PostgreSQL"
},
"QbittorrentModule": {
"name": "Qbittorrent"
},
"QQBotModule": {
"name": "QQ"
},
"RedisModule": {
"name": "Redis缓存"
},
"RtorrentModule": {
"name": "Rtorrent"
},
"SlackModule": {
"name": "Slack"
},
"SubtitleModule": {
"name": "站点字幕"
},
"SynologyChatModule": {
"name": "Synology Chat"
},
"TelegramModule": {
"name": "Telegram"
},
"TheMovieDbModule": {
"name": "TheMovieDb"
},
"TheTvDbModule": {
"name": "TheTvDb"
},
"TransmissionModule": {
"name": "Transmission"
},
"TrimeMediaModule": {
"name": "飞牛影视"
},
"UgreenModule": {
"name": "绿联影视"
},
"VoceChatModule": {
"name": "VoceChat"
},
"WebPushModule": {
"name": "WebPush"
},
"WechatModule": {
"name": "企业微信"
},
"WechatClawBotModule": {
"name": "微信 ClawBot"
},
"ZSpaceModule": {
"name": "极影视"
}
},
"module_test": {
"unsupported": "模块不支持测试"
}
},
"messages": {
"模块不支持测试": "模块不支持测试",
"网络请求失败": "网络请求失败",
"豆瓣网络连接失败": "豆瓣网络连接失败",
"Bangumi网络连接失败": "Bangumi网络连接失败",
"fanart网络连接失败": "fanart网络连接失败",
"保存MCP配置成功": "保存MCP配置成功",
"保存MCP配置失败": "保存MCP配置失败",
"未配置站点或未通过用户认证": "未配置站点或未通过用户认证",
"Redis连接失败,请检查配置": "Redis连接失败,请检查配置",
"TheMovieDb 识别缓存不存在": "TheMovieDb 识别缓存不存在",
"TheMovieDb 识别缓存删除成功": "TheMovieDb 识别缓存删除成功",
"TheMovieDb 识别缓存清理完成": "TheMovieDb 识别缓存清理完成",
"豆瓣识别缓存不存在": "豆瓣识别缓存不存在",
"豆瓣识别缓存删除成功": "豆瓣识别缓存删除成功",
"豆瓣识别缓存清理完成": "豆瓣识别缓存清理完成"
},
"message_patterns": [
{
"source": "无法连接Qbittorrent下载器:{name}",
"target": "无法连接Qbittorrent下载器:{name}"
},
{
"source": "无法连接Transmission下载器:{name}",
"target": "无法连接Transmission下载器:{name}"
},
{
"source": "无法连接rTorrent下载器:{name}",
"target": "无法连接rTorrent下载器:{name}"
},
{
"source": "无法连接Emby服务器:{name}",
"target": "无法连接Emby服务器:{name}"
},
{
"source": "无法连接Jellyfin服务器:{name}",
"target": "无法连接Jellyfin服务器:{name}"
},
{
"source": "无法连接Plex服务器:{name}",
"target": "无法连接Plex服务器:{name}"
},
{
"source": "飞牛影视配置不完整:{name}",
"target": "飞牛影视配置不完整:{name}"
},
{
"source": "无法连接飞牛影视:{name}",
"target": "无法连接飞牛影视:{name}"
},
{
"source": "绿联影视配置不完整:{name}",
"target": "绿联影视配置不完整:{name}"
},
{
"source": "无法连接绿联影视:{name}",
"target": "无法连接绿联影视:{name}"
},
{
"source": "无法连接极影视服务器:{name}",
"target": "无法连接极影视服务器:{name}"
},
{
"source": "Telegram {name} 未就绪",
"target": "Telegram {name} 未就绪"
},
{
"source": "飞书 {name} 未就绪",
"target": "飞书 {name} 未就绪"
},
{
"source": "Discord {name} Bot 未就绪",
"target": "Discord {name} Bot 未就绪"
},
{
"source": "Slack {name} 未就绪",
"target": "Slack {name} 未就绪"
},
{
"source": "无法连接Bangumi,错误码:{code}",
"target": "无法连接Bangumi,错误码:{code}"
},
{
"source": "无法连接fanart,错误码:{code}",
"target": "无法连接fanart,错误码:{code}"
},
{
"source": "无法连接 {domain},错误码:{code}",
"target": "无法连接 {domain},错误码:{code}"
},
{
"source": "{domain} 网络连接失败",
"target": "{domain} 网络连接失败"
},
{
"source": "测试MCP服务器失败: {reason}",
"target": "测试MCP服务器失败: {reason}"
}
]
}
File diff suppressed because it is too large Load Diff
+28 -1
View File
@@ -25,10 +25,11 @@ class DoubanCache(metaclass=WeakSingleton):
"type": MediaType
}
"""
# TMDB缓存过期
# 豆瓣缓存过期
_douban_cache_expire: bool = True
def __init__(self):
"""初始化豆瓣识别缓存并恢复本地持久化数据。"""
self.maxsize = settings.CONF.douban
self.ttl = settings.CONF.meta
self.region = "__douban_cache__"
@@ -46,6 +47,30 @@ class DoubanCache(metaclass=WeakSingleton):
"""
with lock:
self._cache.clear()
self.save(force=True)
def list_items(self) -> list[dict]:
"""返回可供管理界面展示的豆瓣识别缓存列表。"""
with lock:
cache_items = []
for key, value in self._cache.items():
if not isinstance(value, dict):
continue
media_type = value.get("type")
if not isinstance(media_type, MediaType):
try:
media_type = MediaType(media_type)
except (TypeError, ValueError):
media_type = None
cache_items.append({
"key": key,
"douban_id": value.get("id") or 0,
"title": value.get("title") or "",
"year": value.get("year") or "",
"media_type": media_type.to_agent() if media_type else "unknown",
"poster_path": value.get("poster_path") or "",
})
return sorted(cache_items, key=lambda item: item["key"])
@staticmethod
def __get_key(meta: MetaBase) -> str:
@@ -73,6 +98,7 @@ class DoubanCache(metaclass=WeakSingleton):
redis_data = self._cache.get(key)
if redis_data:
self._cache.delete(key)
self.save(force=True)
return redis_data
return {}
@@ -169,4 +195,5 @@ class DoubanCache(metaclass=WeakSingleton):
pickle.dump(new_meta_data, f, pickle.HIGHEST_PROTOCOL) # noqa
def __del__(self):
"""实例释放前保存非 Redis 缓存。"""
self.save()
+13
View File
@@ -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]:
"""
媒体库项目详情
+28
View File
@@ -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]:
"""
+9 -42
View File
@@ -479,57 +479,24 @@ class Alist(StorageBase, metaclass=WeakSingleton):
"""
return self.get_folder(Path(fileitem.path).parent)
def __is_empty_dir(self, fileitem: schemas.FileItem) -> bool:
"""
判断目录是否为空
:param fileitem: 文件项
:return: 是否为空目录
"""
if fileitem.type != "dir":
return False
# 获取目录内容
items = self.list(fileitem)
return len(items) == 0
def delete(self, fileitem: schemas.FileItem) -> bool:
"""
删除文件或目录空目录用专用API
删除文件或目录
:param fileitem: 文件项
:return: 是否删除成功
"""
# 如果是空目录,优先用 remove_empty_directory
if fileitem.type == "dir" and self.__is_empty_dir(fileitem):
resp = RequestUtils(headers=self.__get_header_with_token()).post_res(
self.__get_api_url("/api/fs/remove_empty_directory"),
json={
"src_dir": fileitem.path,
},
)
if resp is None:
logger.warn(
f"【OpenList】请求删除空目录 {fileitem.path} 失败,无法连接alist服务"
)
return False
if resp.status_code != 200:
logger.warn(
f"【OpenList】请求删除空目录 {fileitem.path} 失败,状态码:{resp.status_code}"
)
return False
result = resp.json()
if result["code"] != 200:
logger.warn(
f"【OpenList】删除空目录 {fileitem.path} 失败,错误信息:{result['message']}"
)
return False
return True
# 其它情况(文件或非空目录)
path = Path(fileitem.path)
name = fileitem.name or path.name
if not name:
logger.warn(f"【OpenList】删除路径 {fileitem.path} 无效")
return False
resp = RequestUtils(headers=self.__get_header_with_token()).post_res(
self.__get_api_url("/api/fs/remove"),
json={
"dir": Path(fileitem.path).parent.as_posix(),
"names": [fileitem.name],
"dir": path.parent.as_posix(),
"names": [name],
},
)
if resp is None:
+4
View File
@@ -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,
+26 -4
View File
@@ -61,6 +61,10 @@ def _parse_publish_time(publish_time: str) -> Tuple[float, ...]:
class FilterModule(_ModuleBase):
"""
过滤器模块负责按内置和自定义规则筛选种子资源
"""
CONFIG_WATCH = {
SystemConfigKey.CustomFilterRules.value,
SystemConfigKey.CustomIdentifiers.value,
@@ -73,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],
+1 -1
View File
@@ -34,7 +34,7 @@ BUILTIN_RULE_SET: Dict[str, dict] = {
r"[中国國繁简](/|\s|\\|\|)?[繁简英粤]|[英简繁](/|\s|\\|\|)?[中繁简]"
r"|繁體|简体|[中国國][字配]|国语|國語|中文|中字|简日|繁日|简繁|繁体"
r"|([\s,.-\[])(chs|cht)(|[\s,.-\]])"
r"|(?<![a-z0-9])(gb|big5)(?![a-z0-9])"
r"|(?<![a-z0-9])(?<!\d\s)(gb|big5)(?![a-z0-9])"
],
"exclude": [],
"tmdb": {
+13
View File
@@ -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]:
"""
媒体库项目详情
+65 -1
View File
@@ -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]:
"""
+13
View File
@@ -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]:
"""
媒体库项目详情
+17
View File
@@ -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]:
"""
+26 -6
View File
@@ -37,9 +37,14 @@ _QBITTORRENT_PAUSED_STATES = {
"stoppeddl",
"stoppedup",
}
_TORRENT_FILES_RETRY_TIMES = 5
_TORRENT_FILES_RETRY_INTERVAL = 1
class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
"""
qBittorrent 下载器模块负责下载任务添加文件选择和任务管理
"""
def init_module(self) -> None:
"""
@@ -50,6 +55,9 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
@staticmethod
def get_name() -> str:
"""
获取模块名称
"""
return "Qbittorrent"
@staticmethod
@@ -73,7 +81,10 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
"""
return 1
def stop(self):
def stop(self) -> None:
"""
停止模块
"""
pass
def test(self) -> Optional[Tuple[bool, str]]:
@@ -90,6 +101,9 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
return True, ""
def init_setting(self) -> Tuple[str, Union[str, bool]]:
"""
返回控制模块启用状态的配置项
"""
pass
def scheduler_job(self) -> None:
@@ -181,9 +195,6 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
ignore_category_check=False
)
# 获取种子内容布局: `Original: 原始, Subfolder: 创建子文件夹, NoSubfolder: 不创建子文件夹`
torrent_layout = server.get_content_layout()
if not state:
# 查询所有下载器的种子
torrents, error = server.get_torrents()
@@ -196,7 +207,8 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
if torrent.get("name") == getattr(torrent_from_file, 'name', '') \
and torrent.get("total_size") == getattr(torrent_from_file, 'total_size', 0):
torrent_hash = torrent.get("hash")
torrent_tags = [str(tag).strip() for tag in torrent.get("tags").split(',')]
server.delete_torrents_tag(torrent_hash, tag)
torrent_tags = [str(tag).strip() for tag in (torrent.get("tags") or "").split(',')]
logger.warn(f"下载器中已存在该种子任务:{torrent_hash} - {torrent.get('name')}")
# 给种子打上标签
if "已整理" in torrent_tags:
@@ -204,6 +216,8 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
if settings.TORRENT_TAG and settings.TORRENT_TAG not in torrent_tags:
logger.info(f"给种子 {torrent_hash} 打上标签:{settings.TORRENT_TAG}")
server.set_torrents_tag(ids=torrent_hash, tags=[settings.TORRENT_TAG])
# 获取种子内容布局: `Original: 原始, Subfolder: 创建子文件夹, NoSubfolder: 不创建子文件夹`
torrent_layout = server.get_content_layout()
return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, f"下载任务已存在"
finally:
torrents.clear()
@@ -219,9 +233,15 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
if not torrent_hash:
return None, None, None, f"下载任务添加成功,但获取Qbittorrent任务信息失败:{content}"
else:
# 获取种子内容布局: `Original: 原始, Subfolder: 创建子文件夹, NoSubfolder: 不创建子文件夹`
torrent_layout = server.get_content_layout()
if is_paused:
# 种子文件
torrent_files = server.get_files(torrent_hash)
torrent_files = server.get_files(
torrent_hash,
retry=_TORRENT_FILES_RETRY_TIMES,
interval=_TORRENT_FILES_RETRY_INTERVAL,
)
if not torrent_files:
return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, "获取种子文件失败,下载任务可能在暂停状态"
+51 -11
View File
@@ -259,9 +259,34 @@ class Qbittorrent:
"""
if not self.qbc:
return None
# completed会包含移动状态 改为获取seeding状态 包含活动上传, 正在做种, 及强制做种
torrents, error = self.get_torrents(status="seeding", ids=ids, tags=tags)
return None if error else torrents or []
torrents, error = self.get_torrents(status="completed", ids=ids, tags=tags)
if error:
return None
ret_torrents = []
for torrent in torrents or []:
state = str(torrent.get("state") or "").strip().lower()
progress = torrent.get("progress") or 0
amount_left = torrent.get("amount_left") or 0
if (
progress >= 1
and amount_left <= 0
and state not in {
"allocating",
"checkingdl",
"checkingup",
"downloading",
"error",
"forceddl",
"missingfiles",
"metadl",
"moving",
"queueddl",
"stalleddl",
"unknown",
}
):
ret_torrents.append(torrent)
return ret_torrents
def get_downloading_torrents(self, ids: Union[str, list] = None,
tags: Union[str, list] = None) -> Optional[List[TorrentDictionary]]:
@@ -278,14 +303,16 @@ class Qbittorrent:
def delete_torrents_tag(self, ids: Union[str, list], tag: Union[str, list]) -> bool:
"""
删除Tag
从指定种子移除标签并删除全局标签定义
:param ids: 种子Hash列表
:param tag: 标签内容
:return: 是否删除成功
"""
if not self.qbc:
return False
try:
self.qbc.torrents_delete_tags(torrent_hashes=ids, tags=tag)
self.qbc.torrents_remove_tags(torrent_hashes=ids, tags=tag)
self.qbc.torrents_delete_tags(tags=tag)
return True
except Exception as err:
logger.error(f"删除种子Tag出错:{str(err)}")
@@ -488,17 +515,30 @@ class Qbittorrent:
logger.error(f"删除种子出错:{str(err)}")
return False
def get_files(self, tid: str) -> Optional[TorrentFilesList]:
def get_files(self, tid: str, retry: int = 1, interval: float = 0) -> Optional[TorrentFilesList]:
"""
获取种子文件清单
:param tid: 种子Hash
:param retry: 最多尝试次数
:param interval: 重试间隔单位秒
:return: 种子文件清单
"""
if not self.qbc:
return None
try:
return self.qbc.torrents_files(torrent_hash=tid)
except Exception as err:
logger.error(f"获取种子文件列表出错:{str(err)}")
return None
last_error = None
retry_times = max(retry, 1)
for index in range(retry_times):
try:
torrent_files = self.qbc.torrents_files(torrent_hash=tid)
if torrent_files:
return torrent_files
except Exception as err:
last_error = err
if index < retry_times - 1 and interval:
time.sleep(interval)
if last_error:
logger.error(f"获取种子文件列表出错:{str(last_error)}")
return None
def set_files(self, **kwargs) -> bool:
"""
+29 -1
View File
@@ -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,
)
+24
View File
@@ -0,0 +1,24 @@
def ensure_urllib3_header_param_compat() -> None:
"""
pyTelegramBotAPI imports urllib3.fields.format_header_param at import time.
Some urllib3-future builds only expose newer formatter names.
RFC 2231 formatting is kept as the last fallback because it encodes
non-ASCII values differently from urllib3's old default.
"""
try:
from urllib3 import fields
except ImportError:
return
if hasattr(fields, "format_header_param"):
return
for fallback_name in (
"format_header_param_html5",
"format_multipart_header_param",
"format_header_param_rfc2231",
):
fallback = getattr(fields, fallback_name, None)
if fallback is not None:
fields.format_header_param = fallback
return
+161 -38
View File
@@ -8,32 +8,41 @@ from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Union
from urllib.parse import urljoin, quote
from telebot import TeleBot, apihelper
from telebot.types import (
from app.modules.telegram.compat import ensure_urllib3_header_param_compat
# Must run before importing pyTelegramBotAPI.
ensure_urllib3_header_param_compat()
from telebot import TeleBot, apihelper # noqa: E402
from telebot.types import ( # noqa: E402
BotCommand,
InlineKeyboardMarkup,
InlineKeyboardButton,
InputMediaPhoto,
)
from telegramify_markdown import standardize, telegramify # noqa
try:
from telegramify_markdown import entities_to_markdownv2 # noqa
from telebot.types import ForceReply # noqa: E402
except ImportError:
ForceReply = None
from telegramify_markdown import standardize, telegramify # noqa: E402
try:
from telegramify_markdown import entities_to_markdownv2 # noqa: E402
except ImportError:
entities_to_markdownv2 = None
try:
from telegramify_markdown.content import ContentTypes, File, Photo, Text
from telegramify_markdown.content import ContentTypes, File, Photo, Text # noqa: E402
except ImportError:
from telegramify_markdown.type import ContentTypes, File, Photo, Text
from telegramify_markdown.type import ContentTypes, File, Photo, Text # noqa: E402
from app.core.config import settings
from app.core.context import MediaInfo, Context
from app.core.metainfo import MetaInfo
from app.helper.image import ImageHelper
from app.helper.thread import ThreadHelper
from app.log import logger
from app.utils.common import retry
from app.utils.http import RequestUtils
from app.utils.string import StringUtils
from app.core.config import settings # noqa: E402
from app.core.context import MediaInfo, Context # noqa: E402
from app.core.metainfo import MetaInfo # noqa: E402
from app.helper.image import ImageHelper # noqa: E402
from app.helper.thread import ThreadHelper # noqa: E402
from app.log import logger # noqa: E402
from app.utils.common import retry # noqa: E402
from app.utils.http import RequestUtils # noqa: E402
from app.utils.string import StringUtils # noqa: E402
TELEGRAM_PARSE_MODE_MARKDOWN = "MarkdownV2"
@@ -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)
+30
View File
@@ -27,6 +27,7 @@ class TmdbCache(metaclass=WeakSingleton):
_tmdb_cache_expire: bool = True
def __init__(self):
"""初始化 TMDB 识别缓存并恢复本地持久化数据。"""
self.maxsize = settings.CONF.douban
self.ttl = settings.CONF.meta
self.region = "__tmdb_cache__"
@@ -44,6 +45,33 @@ class TmdbCache(metaclass=WeakSingleton):
"""
with lock:
self._cache.clear()
self.save(force=True)
def list_items(self) -> list[dict]:
"""
返回可供管理界面展示的 TMDB 识别缓存列表
"""
with lock:
cache_items = []
for key, value in self._cache.items():
if not isinstance(value, dict):
continue
media_type = value.get("type")
if not isinstance(media_type, MediaType):
try:
media_type = MediaType(media_type)
except (TypeError, ValueError):
media_type = None
cache_items.append({
"key": key,
"tmdb_id": value.get("id") or 0,
"title": value.get("title") or "",
"year": value.get("year") or "",
"media_type": media_type.to_agent() if media_type else "unknown",
"poster_path": value.get("poster_path") or "",
"backdrop_path": value.get("backdrop_path") or "",
})
return sorted(cache_items, key=lambda item: item["key"])
@staticmethod
def __get_key(meta: MetaBase) -> str:
@@ -71,6 +99,7 @@ class TmdbCache(metaclass=WeakSingleton):
redis_data = self._cache.get(key)
if redis_data:
self._cache.delete(key)
self.save(force=True)
return redis_data
return {}
@@ -156,4 +185,5 @@ class TmdbCache(metaclass=WeakSingleton):
pickle.dump(new_meta_data, f, pickle.HIGHEST_PROTOCOL) # type: ignore
def __del__(self):
"""实例释放前保存非 Redis 缓存。"""
self.save()
+15
View File
@@ -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]:
+26
View File
@@ -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]]:
"""
搜索影片演员
+15
View File
@@ -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],
+15
View File
@@ -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