Compare commits

...

18 Commits

Author SHA1 Message Date
jxxghp
8b5524a321 更新 version.py 2026-07-17 14:50:58 +08:00
jxxghp
b972b46747 Merge remote-tracking branch 'origin/v2' into v2 2026-07-17 09:47:01 +08:00
jxxghp
0598fbdd75 fix(media): add support for custom words in media recognition 2026-07-17 09:46:55 +08:00
freeman
572299a45e feat(llm): 新增 Amazon Bedrock 提供商,支持 AK/SK 与 Bedrock API Key 双认证 (#6130) 2026-07-16 19:27:06 +08:00
kuke2733
229824a417 feat(subscribe): merge mediaserver library entries into files info (#6131) 2026-07-16 17:36:56 +08:00
DDSRem
a0ee99aacc chore: bump moviepilot-rust to 0.2.3 (#6128) 2026-07-16 06:31:31 +08:00
InfinityPacer
92918ce380 ci(pr-agent): use shared review runner (#6127) 2026-07-16 06:24:52 +08:00
InfinityPacer
a4335fe753 fix(lifecycle): harden application shutdown (#6125) 2026-07-16 06:24:31 +08:00
jxxghp
107ba37834 更新 version.py 2026-07-15 20:22:27 +08:00
jxxghp
c27678ce06 fix(ugreen): send client id during login 2026-07-15 17:38:31 +08:00
InfinityPacer
7725342a80 fix(scheduler): refresh plugin jobs after reload (#6124) 2026-07-15 17:29:31 +08:00
InfinityPacer
893269f8c1 fix(modules): serialize configuration reload lifecycle (#6122) 2026-07-15 17:28:49 +08:00
InfinityPacer
00d46f3aab docs: clarify docstring punctuation style (#6121) 2026-07-15 16:01:47 +08:00
jxxghp
077241b6ed Merge remote-tracking branch 'origin/v2' into v2 2026-07-15 10:46:28 +08:00
jxxghp
b24a07e388 fix: enhance response data structure in filtering rules with media info 2026-07-15 10:46:21 +08:00
InfinityPacer
f814c271cc refactor(runtime): tighten resource cleanup and test isolation (#6116) 2026-07-14 16:03:29 +08:00
InfinityPacer
e015c67689 chore(db): add driver error diagnostics (#6115) 2026-07-14 12:31:46 +08:00
qqcomeup
98b16bda8d 优化 Docker 启动完成日志 (#6112) 2026-07-14 12:31:07 +08:00
69 changed files with 3033 additions and 767 deletions

View File

@@ -2,8 +2,7 @@ name: PR-Agent
on:
pull_request_target:
# PR-Agent 通过 base repo 上下文读取 PR diff 并发布 Review,不 checkout 或执行 PR 分支代码。
# pull_request_target 允许 fork PR 使用仓库 secrets因此 workflow 只运行固定 digest 的 PR-Agent 容器。
# Fork 审查需要目标仓库凭据;该 job 仅通过 GitHub API 读取 PR 内容,不 checkout 或执行 PR 分支代码。
types:
- opened
- reopened
@@ -11,17 +10,13 @@ on:
- review_requested
- synchronize
issue_comment:
# 手动命令只在 PR 评论中有意义;编辑后的命令也可重新触发。
types:
- created
- edited
permissions:
# 读取仓库内容和 PR diff。
contents: read
# 更新 PR 描述、发布 PR Review 或修改 PR 相关元数据。
pull-requests: write
# PR 评论在 GitHub API 中属于 issue comments手动问答需要该权限。
issues: write
jobs:
@@ -29,21 +24,11 @@ jobs:
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 == '/describe' ||
startsWith(github.event.comment.body, '/describe ') ||
github.event.comment.body == '/review' ||
startsWith(github.event.comment.body, '/review ') ||
github.event.comment.body == '/ask' ||
startsWith(github.event.comment.body, '/ask ')
)
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR"]'), github.event.comment.author_association)
)
)
concurrency:
@@ -53,484 +38,15 @@ jobs:
timeout-minutes: 20
steps:
- 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
- name: Run PR Review
uses: docker://ghcr.io/infinitypacer/pr-review-runner:latest
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: '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.
- 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
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
- 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: |
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.
- 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
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'
PRR_AUTO_REVIEW_SCOPE: all
PRR_ALLOWED_ASSOCIATIONS: '["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR"]'
PRR_DISABLED_COMMANDS: '["/improve"]'
PRR_SKIP_LABEL: skip pr-agent
PRR_SKIP_TITLE_PATTERN: '^(?:\[Auto\]|Auto)'
config.response_language: zh-CN

View File

@@ -1058,6 +1058,29 @@ class LLMHelper:
http_async_client=_build_httpx_client(llm_proxy, async_client=True),
**thinking_kwargs,
)
elif runtime["runtime"] == "bedrock":
from langchain_aws import ChatBedrockConverse
from app.agent.llm.provider import LLMProviderManager
aws_region = runtime.get("aws_region") or "us-east-1"
aws_auth = runtime.get("aws_auth") or {}
# Bearer 认证需要跳过 SigV4 签名并注入 Authorization 头SigV4 认证
# 直接以 AK/SK 签名;两种方式统一由 provider 管理器构造 boto3 客户端。
bedrock_client = LLMProviderManager().create_bedrock_client(
"bedrock-runtime",
region=aws_region,
credentials=aws_auth,
base_url=runtime.get("base_url"),
use_proxy=use_proxy,
read_timeout=settings.LLM_TOOL_TIMEOUT,
)
model = ChatBedrockConverse(
model_id=model_name,
client=bedrock_client,
temperature=temperature_value,
disable_streaming=not streaming,
)
elif runtime["runtime"] in {"anthropic_compatible", "copilot_anthropic"}:
from langchain_anthropic import ChatAnthropic
@@ -1107,7 +1130,11 @@ class LLMHelper:
# 优先使用 provider / models.dev 目录中的上下文上限,减少用户手填成本。
model_profile = getattr(model, "profile", None)
if model_profile:
logger.debug(f"使用LLM模型: {model.model}Profile: {model.profile}")
# ChatBedrockConverse 等模型类没有 model 属性,模型名存放在 model_id。
logged_model_name = getattr(model, "model", None) or getattr(
model, "model_id", model_name
)
logger.debug(f"使用LLM模型: {logged_model_name}Profile: {model_profile}")
else:
model_record = runtime.get("model_record") or {}
model_metadata = runtime.get("model_metadata") or {}

View File

@@ -7,13 +7,14 @@ import base64
import copy
import hashlib
import json
import re
import secrets
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from urllib.parse import urlencode
from urllib.parse import urlencode, urlsplit
import aiofiles
import httpx
@@ -106,6 +107,90 @@ class LLMProviderManager(metaclass=Singleton):
_MODELS_DEV_BUNDLED_PATH = Path(__file__).with_name("models.json")
_MODELS_DEV_CACHE_TTL = 7 * 24 * 60 * 60
_AUTH_SESSION_DONE_RETENTION = 300
_BEDROCK_DEFAULT_REGION = "us-east-1"
_BEDROCK_API_KEY_PREFIX = "bedrock-api-key-"
_BEDROCK_GPT_OSS_BASE_REGIONS = (
"ap-northeast-1",
"ap-south-1",
"ap-southeast-2",
"eu-central-1",
"eu-north-1",
"eu-west-1",
"eu-west-2",
"sa-east-1",
"us-east-1",
"us-east-2",
"us-west-2",
)
_BEDROCK_GPT_OSS_SAFEGUARD_REGIONS = (
"ap-northeast-1",
"ap-south-1",
"ap-southeast-2",
"eu-west-1",
"eu-west-2",
"sa-east-1",
"us-east-1",
"us-east-2",
"us-west-2",
)
_BEDROCK_ON_DEMAND_MODEL_REGIONS = {
"openai.gpt-oss-120b-1:0": _BEDROCK_GPT_OSS_BASE_REGIONS,
"openai.gpt-oss-20b-1:0": _BEDROCK_GPT_OSS_BASE_REGIONS,
"openai.gpt-oss-safeguard-120b": _BEDROCK_GPT_OSS_SAFEGUARD_REGIONS,
"openai.gpt-oss-safeguard-20b": _BEDROCK_GPT_OSS_SAFEGUARD_REGIONS,
"amazon.nova-lite-v1:0": (
"ap-northeast-1",
"ap-southeast-2",
"eu-west-2",
"us-east-1",
"us-gov-west-1",
),
"amazon.nova-micro-v1:0": (
"ap-southeast-2",
"eu-west-2",
"us-east-1",
"us-gov-west-1",
),
"amazon.nova-pro-v1:0": (
"ap-southeast-2",
"eu-west-2",
"us-east-1",
"us-gov-west-1",
),
"anthropic.claude-3-5-haiku-20241022-v1:0": (
"us-west-2",
),
"anthropic.claude-3-5-sonnet-20240620-v1:0": (
"ap-northeast-1",
"ap-northeast-2",
"ap-southeast-1",
"eu-central-1",
"eu-central-2",
"us-east-1",
"us-gov-west-1",
"us-west-2",
),
"anthropic.claude-3-5-sonnet-20241022-v2:0": (
"ap-southeast-2",
"us-west-2",
),
"anthropic.claude-3-7-sonnet-20250219-v1:0": (
"eu-west-2",
"us-gov-west-1",
),
"anthropic.claude-3-haiku-20240307-v1:0": (
"ap-northeast-1",
"ap-northeast-2",
"ap-south-1",
"ap-southeast-2",
"eu-central-1",
"eu-west-1",
"eu-west-3",
"us-east-1",
"us-gov-west-1",
"us-west-2",
),
}
_CHATGPT_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
_CHATGPT_ISSUER = "https://auth.openai.com"
_CHATGPT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
@@ -367,6 +452,50 @@ class LLMProviderManager(metaclass=Singleton):
api_key_hint="填写 Anthropic API Key。",
description="Anthropic Claude 官方端点。",
),
ProviderSpec(
id="amazon-bedrock",
name="Amazon Bedrock",
runtime="bedrock",
models_dev_provider_id="amazon-bedrock",
default_base_url="https://bedrock-runtime.us-east-1.amazonaws.com",
base_url_presets=(
url_preset(
id="bedrock-us-east-1",
label="美东弗吉尼亚北部us-east-1",
value="https://bedrock-runtime.us-east-1.amazonaws.com",
),
url_preset(
id="bedrock-us-west-2",
label="美西俄勒冈us-west-2",
value="https://bedrock-runtime.us-west-2.amazonaws.com",
),
url_preset(
id="bedrock-eu-central-1",
label="欧洲法兰克福eu-central-1",
value="https://bedrock-runtime.eu-central-1.amazonaws.com",
),
url_preset(
id="bedrock-ap-northeast-1",
label="亚太东京ap-northeast-1",
value="https://bedrock-runtime.ap-northeast-1.amazonaws.com",
),
url_preset(
id="bedrock-ap-southeast-1",
label="亚太新加坡ap-southeast-1",
value="https://bedrock-runtime.ap-southeast-1.amazonaws.com",
),
),
base_url_editable=True,
api_key_label="Bedrock API Key / AK:SK",
api_key_hint=(
"支持两种认证方式:填写 Amazon Bedrock API Keybedrock-api-key- 开头,"
"Bearer 认证);或填写 Access Key ID:Secret Access Key可选追加 :Session Token"
"SigV4 认证。Base URL 决定 AWS Region。"
),
model_list_strategy="bedrock",
description="Amazon Bedrock 托管模型服务,支持 Bedrock API Key 与 AK/SK 双认证。",
sort_order=35,
),
ProviderSpec(
id="deepseek",
name="DeepSeek",
@@ -1743,6 +1872,112 @@ class LLMProviderManager(metaclass=Singleton):
return normalized[:-3]
return normalized
@classmethod
def _extract_bedrock_region(cls, base_url: Optional[str]) -> str:
"""
从 Bedrock 运行时端点 URL 中提取 AWS Region
兼容标准端点、FIPS 端点与 PrivateLinkVPCE端点等主机名形态
从中识别 Region 段。
:param base_url: 形如 https://bedrock-runtime.us-east-1.amazonaws.com 的端点地址
:return: 提取到的 Region无法识别时回退 us-east-1
"""
hostname = urlsplit((base_url or "").strip().lower()).hostname or ""
match = re.search(
r"(?:^|\.)(?:bedrock(?:-runtime)?(?:-fips)?)"
r"\.([a-z0-9-]+-\d+)(?:\.|$)",
hostname,
)
if match:
return match.group(1)
return cls._BEDROCK_DEFAULT_REGION
# Inference Profile 的地理前缀与可用 Region 的对应关系,用于降级目录按
# 当前 Region 过滤掉不可调用的 Profile 条目。
_BEDROCK_GEO_PREFIXES: dict[str, tuple[str, ...]] = {
"us": ("us-east-", "us-west-"),
"eu": ("eu-",),
"apac": ("ap-",),
"au": ("ap-southeast-2", "ap-southeast-4"),
"jp": ("ap-northeast-1", "ap-northeast-3"),
"ca": ("ca-",),
}
_BEDROCK_NON_COMMERCIAL_REGION_PREFIXES = (
"cn-",
"eu-isoe-",
"us-gov-",
"us-iso-",
"us-isob-",
"us-isof-",
)
@classmethod
def _bedrock_model_matches_region(cls, model_id: str, region: str) -> bool:
"""
判断目录中的模型 ID 在指定 Region 是否可调用
models.dev 目录同时收录裸模型 ID直连调用与带地理前缀的
Inference Profile IDus./eu./apac./global. 等)。带前缀的条目只在
对应地理分区和 AWS 分区的 Region 可用global Profile 仅允许商业
AWS 分区。裸 ID 仅在明确记录的 ON_DEMAND Region 可用,未知条目
按不可直连处理。
:param model_id: 目录中的模型 ID
:param region: 当前 Base URL 对应的 AWS Region
:return: 该模型在当前 Region 可调用时返回 True
"""
prefix = model_id.split(".", 1)[0]
if prefix == "global":
return not region.startswith(cls._BEDROCK_NON_COMMERCIAL_REGION_PREFIXES)
region_prefixes = cls._BEDROCK_GEO_PREFIXES.get(prefix)
if region_prefixes is not None:
return (
not region.startswith(cls._BEDROCK_NON_COMMERCIAL_REGION_PREFIXES)
and region.startswith(region_prefixes)
)
on_demand_regions = cls._BEDROCK_ON_DEMAND_MODEL_REGIONS.get(model_id)
return on_demand_regions is not None and region in on_demand_regions
@classmethod
def _parse_bedrock_credentials(cls, api_key: Optional[str]) -> dict[str, Any]:
"""
解析 Bedrock 凭证字符串,识别 Bearer 与 SigV4 两种认证方式
- Bedrock API Keybedrock-api-key- 开头的长期 Key或控制台生成的短期
Token走 Bearer 认证;
- `AccessKeyId:SecretAccessKey` 或 `AccessKeyId:SecretAccessKey:SessionToken`
走 SigV4 认证AWS Access Key ID 均以 "AKIA"/"ASIA" 开头。
:param api_key: 用户在 API Key 输入框填写的凭证内容
:return: 含 auth_scheme 及对应凭证字段的字典
"""
normalized = str(api_key or "").strip()
if not normalized:
raise LLMProviderAuthError(
"Amazon Bedrock 需要填写 Bedrock API Key 或 Access Key ID:Secret Access Key"
)
if not normalized.startswith(cls._BEDROCK_API_KEY_PREFIX):
parts = [part.strip() for part in normalized.split(":")]
if len(parts) in {2, 3} and all(parts):
credentials = {
"auth_scheme": "sigv4",
"access_key_id": parts[0],
"secret_access_key": parts[1],
}
if len(parts) == 3:
credentials["session_token"] = parts[2]
return credentials
if ":" in normalized:
raise LLMProviderAuthError(
"Amazon Bedrock AK/SK 凭证格式不正确,"
"请按 AccessKeyId:SecretAccessKey 或 "
"AccessKeyId:SecretAccessKey:SessionToken 填写"
)
return {"auth_scheme": "bearer", "bearer_token": normalized}
async def _list_models_from_google(
self,
api_key: str,
@@ -1857,6 +2092,235 @@ class LLMProviderManager(metaclass=Singleton):
)
return sorted(results, key=lambda item: item["name"].lower())
def _build_bedrock_boto3_config(
self,
use_proxy: Optional[bool] = None,
) -> Any:
"""
构造 Bedrock boto3 客户端配置,统一超时、重试与代理策略
:param use_proxy: 是否使用系统代理None 时读取 LLM_USE_PROXY 配置
:return: botocore Config 实例
"""
from botocore.config import Config
should_use_proxy = settings.LLM_USE_PROXY if use_proxy is None else use_proxy
proxies = None
if should_use_proxy and settings.PROXY_HOST:
proxies = {"http": settings.PROXY_HOST, "https": settings.PROXY_HOST}
return Config(
connect_timeout=10,
read_timeout=60,
retries={"max_attempts": 3, "mode": "standard"},
proxies=proxies,
)
@staticmethod
def _bedrock_endpoint_url(
service_name: str, base_url: Optional[str]
) -> Optional[str]:
"""
解析应传给 boto3 客户端的自定义端点 URL
标准公有端点交由 boto3 按 Region 自行推导;用户填写 PrivateLink、
FIPS 等非标准端点时才显式透传,保证所选网络路径实际生效。
:param service_name: boto3 服务名bedrock 或 bedrock-runtime
:param base_url: 用户配置的 Base URL
:return: 需要显式指定端点时返回 URL否则返回 None
"""
normalized = (base_url or "").strip().rstrip("/")
if not normalized:
return None
if re.fullmatch(
rf"https://{service_name}\.[a-z0-9-]+\.amazonaws\.com",
normalized,
):
return None
return normalized
def create_bedrock_client(
self,
service_name: str,
region: str,
credentials: dict[str, Any],
base_url: Optional[str] = None,
use_proxy: Optional[bool] = None,
read_timeout: Optional[int] = None,
) -> Any:
"""
按解析后的凭证创建 Bedrock boto3 客户端Bearer 方式注入 Authorization 头
:param service_name: boto3 服务名bedrock 或 bedrock-runtime
:param region: AWS Region
:param credentials: `_parse_bedrock_credentials` 的解析结果
:param base_url: 用户配置的 Base URL非标准端点PrivateLink/FIPS 等)时透传给 boto3
:param use_proxy: 是否使用系统代理
:param read_timeout: 读取超时秒数None 时使用默认值
:return: boto3 客户端实例
"""
import boto3
from botocore import UNSIGNED
config = self._build_bedrock_boto3_config(use_proxy)
if read_timeout:
config = config.merge(type(config)(read_timeout=read_timeout))
endpoint_kwargs: dict[str, Any] = {}
endpoint_url = self._bedrock_endpoint_url(service_name, base_url)
if endpoint_url:
endpoint_kwargs["endpoint_url"] = endpoint_url
if credentials["auth_scheme"] == "sigv4":
return boto3.client(
service_name,
region_name=region,
aws_access_key_id=credentials["access_key_id"],
aws_secret_access_key=credentials["secret_access_key"],
aws_session_token=credentials.get("session_token"),
config=config,
**endpoint_kwargs,
)
# Bearer 认证:以 UNSIGNED 跳过 SigV4 签名,再把 API Key 注入 Authorization 头。
bearer_token = credentials["bearer_token"]
config = config.merge(type(config)(signature_version=UNSIGNED))
client = boto3.client(
service_name,
region_name=region,
aws_access_key_id="unsigned",
aws_secret_access_key="unsigned",
config=config,
**endpoint_kwargs,
)
def _inject_bearer(request: Any, **_kwargs: Any) -> None:
request.headers["Authorization"] = f"Bearer {bearer_token}"
client.meta.events.register(
f"request-created.{service_name}",
_inject_bearer,
)
return client
async def _list_models_from_bedrock_fallback(
self,
region: str,
use_proxy: Optional[bool] = None,
) -> list[dict[str, Any]]:
"""
从 models.dev 目录筛选当前 Region 可调用的 Bedrock 模型
:param region: 当前 Base URL 对应的 AWS Region
:param use_proxy: 是否使用系统代理
:return: 过滤后的标准化模型记录列表
"""
models = await self._list_models_from_models_dev_only(
provider_id="amazon-bedrock",
use_proxy=use_proxy,
)
return [
model
for model in models
if self._bedrock_model_matches_region(model["id"], region)
]
async def _list_models_from_bedrock(
self,
api_key: str,
base_url: Optional[str],
use_proxy: Optional[bool] = None,
) -> list[dict[str, Any]]:
"""
从 Bedrock 控制面拉取模型目录,聚合跨区 Inference Profile 与直连模型
Bedrock 多数新模型仅允许通过 Inference Profileus./eu./apac./global. 前缀)
调用,因此优先列出 Profile再补充支持 ON_DEMAND 直连的基础模型。
:param api_key: 用户填写的凭证内容Bedrock API Key 或 AK/SK
:param base_url: Bedrock 运行时端点,决定 Region
:param use_proxy: 是否使用系统代理
:return: 标准化后的模型记录列表
"""
credentials = self._parse_bedrock_credentials(api_key)
region = self._extract_bedrock_region(base_url)
# runtime VPCE 无法安全推导对应的控制面 VPCEFIPS 端点也不能绕回
# 公有非 FIPS 控制面,因此直接使用本地目录。
if self._bedrock_endpoint_url("bedrock-runtime", base_url):
return await self._list_models_from_bedrock_fallback(region, use_proxy)
client = self.create_bedrock_client(
"bedrock",
region=region,
credentials=credentials,
use_proxy=use_proxy,
)
def _fetch() -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
profiles: list[dict[str, Any]] = []
paginator = client.get_paginator("list_inference_profiles")
for page in paginator.paginate(typeEquals="SYSTEM_DEFINED"):
profiles.extend(page.get("inferenceProfileSummaries") or [])
foundation = client.list_foundation_models(
byOutputModality="TEXT",
byInferenceType="ON_DEMAND",
).get("modelSummaries") or []
return profiles, foundation
try:
profile_summaries, foundation_summaries = await asyncio.to_thread(_fetch)
except Exception as err:
# 部分 Bedrock API Key 的授权范围仅覆盖 bedrock-runtime 推理接口,
# 控制面查询被拒时降级到 models.dev 目录,保证仍能选择模型。
logger.warning(
f"获取 Amazon Bedrock 控制面模型列表失败,降级 models.dev 目录: {err}"
)
return await self._list_models_from_bedrock_fallback(region, use_proxy)
finally:
await asyncio.to_thread(client.close)
results: list[dict[str, Any]] = []
seen_ids: set[str] = set()
def _append_record(model_id: str, display_name: Optional[str]) -> None:
if not model_id or model_id in seen_ids:
return
seen_ids.add(model_id)
# Inference Profile 带区域前缀models.dev 目录按基础模型 ID 收录,
# 去掉首个前缀段再查一次元数据。
metadata = self._cached_models_dev_model("amazon-bedrock", model_id)
if not metadata and "." in model_id:
metadata = self._cached_models_dev_model(
"amazon-bedrock",
model_id.split(".", 1)[1],
)
results.append(
self._normalize_model_record(
model_id=model_id,
display_name=display_name or (metadata or {}).get("name") or model_id,
metadata=metadata or {},
source="provider",
)
)
for profile in profile_summaries:
if (profile.get("status") or "ACTIVE") != "ACTIVE":
continue
_append_record(
str(profile.get("inferenceProfileId") or "").strip(),
profile.get("inferenceProfileName"),
)
# 控制面已按当前 Region 和 ON_DEMAND 筛选,不能复用仅面向
# models.dev 降级目录的静态白名单,否则 AWS 新增模型会被遗漏。
for summary in foundation_summaries:
lifecycle = (summary.get("modelLifecycle") or {}).get("status") or "ACTIVE"
if lifecycle != "ACTIVE":
continue
_append_record(
str(summary.get("modelId") or "").strip(),
summary.get("modelName"),
)
return sorted(results, key=lambda item: item["name"].lower())
@staticmethod
def _copilot_headers(
token: Optional[str] = None, include_auth: bool = True
@@ -2064,6 +2528,13 @@ class LLMProviderManager(metaclass=Singleton):
use_proxy=use_proxy,
)
if resolved_model_list_strategy == "bedrock":
return await self._list_models_from_bedrock(
api_key=runtime["api_key"],
base_url=runtime.get("base_url"),
use_proxy=use_proxy,
)
if resolved_model_list_strategy == "anthropic_compatible":
return await self._list_models_from_models_dev_only(
provider_id=provider_id,
@@ -2731,6 +3202,22 @@ class LLMProviderManager(metaclass=Singleton):
)
return result
if resolved_runtime == "bedrock":
effective_base_url = normalized_base_url or self._default_base_url_for_provider(
spec
)
credentials = self._parse_bedrock_credentials(normalized_api_key)
result.update(
{
"api_key": normalized_api_key,
"base_url": effective_base_url,
"aws_region": self._extract_bedrock_region(effective_base_url),
"aws_auth": credentials,
"auth_mode": "api_key",
}
)
return result
if resolved_runtime == "anthropic_compatible":
effective_base_url = normalized_base_url or self._default_base_url_for_provider(
spec

View File

@@ -26,13 +26,17 @@ router = APIRouter()
async def recognize(
title: str,
subtitle: Optional[str] = None,
custom_words: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_token),
) -> Any:
"""
根据标题、副标题识别媒体信息
:param custom_words: 临时识别词(每行一条规则),传入时仅在本次识别中生效,不会保存到系统配置
"""
# 识别媒体信息
metainfo = MetaInfo(title, subtitle)
# 识别媒体信息,传入临时识别词时优先于系统配置的识别词生效
metainfo = MetaInfo(
title, subtitle, custom_words=custom_words.split("\n") if custom_words else None
)
mediainfo = await MediaChain().async_recognize_by_meta(metainfo)
if mediainfo:
return Context(meta_info=metainfo, media_info=mediainfo).to_dict()
@@ -48,12 +52,13 @@ async def recognize2(
_: Annotated[str, Depends(verify_apitoken)],
title: str,
subtitle: Optional[str] = None,
custom_words: Optional[str] = None,
) -> Any:
"""
根据标题、副标题识别媒体信息 API_TOKEN认证?token=xxx
"""
# 识别媒体信息
return await recognize(title, subtitle)
return await recognize(title, subtitle, custom_words)
@router.get(

View File

@@ -1123,33 +1123,64 @@ def ruletest(
"""
过滤规则测试,规则类型 1-订阅2-洗版3-搜索
"""
metainfo = MetaInfo(title=title, subtitle=subtitle)
torrent = schemas.TorrentInfo(
title=title,
description=subtitle,
)
# 查询规则组详情
rulegroup = RuleHelper().get_rule_group(rulegroup_name)
result_data = {
"title": title,
"subtitle": subtitle,
"rulegroup_name": rulegroup_name,
"rulegroup": rulegroup.model_dump() if rulegroup else None,
"meta_info": metainfo.to_dict(),
"media_info": None,
"torrent_info": torrent.model_dump(),
"priority": None,
"matched": False,
}
if not rulegroup:
return schemas.Response(
success=False, message=f"过滤规则组 {rulegroup_name} 不存在!"
success=False,
message=f"过滤规则组 {rulegroup_name} 不存在!",
data=result_data,
)
# 根据标题查询媒体信息
media_info = MediaChain().recognize_by_meta(
MetaInfo(title=title, subtitle=subtitle),
metainfo,
obtain_images=False,
)
result_data["media_info"] = media_info.to_dict() if media_info else None
if not media_info:
return schemas.Response(success=False, message="未识别到媒体信息!")
return schemas.Response(
success=False,
message="未识别到媒体信息!",
data=result_data,
)
# 过滤
result = SearchChain().filter_torrents(
rule_groups=[rulegroup.name], torrent_list=[torrent], mediainfo=media_info
)
if not result:
return schemas.Response(success=False, message="不符合过滤规则!")
return schemas.Response(
success=False,
message="不符合过滤规则!",
data=result_data,
)
result_data.update(
{
"matched": True,
"priority": 100 - result[0].pri_order + 1,
"torrent_info": result[0].model_dump(),
}
)
return schemas.Response(
success=True, data={"priority": 100 - result[0].pri_order + 1}
success=True,
data=result_data,
)
@@ -1308,12 +1339,7 @@ def restart_system(_: User = Depends(get_current_active_superuser)):
"""
if not SystemHelper.can_restart():
return schemas.Response(success=False, message="当前运行环境不支持重启操作!")
# 标识停止事件
global_vars.stop_system()
# 执行重启
ret, msg = SystemHelper.restart()
if not ret:
global_vars.resume_system()
return schemas.Response(success=ret, message=msg)
@@ -1331,11 +1357,7 @@ def upgrade_system(
if not SystemHelper.can_restart():
return schemas.Response(success=False, message="当前运行环境不支持升级操作!")
# 标识停止事件
global_vars.stop_system()
ret, msg = SystemHelper.upgrade(mode=mode or "release")
if not ret:
global_vars.resume_system()
return schemas.Response(success=ret, message=msg)

View File

@@ -1,6 +1,6 @@
import threading
from datetime import datetime
from typing import Callable, List, Union, Optional, Generator, Any
from typing import Callable, Dict, List, Union, Optional, Generator, Any
from app.chain import ChainBase
from app.core.config import global_vars
@@ -210,6 +210,24 @@ class MediaServerChain(ChainBase):
"""
return self.run_module("mediaserver_play_url", server=server, item_id=item_id)
def get_season_episode_ids(self, server: str, item_id: Union[str, int],
season: int) -> Dict[int, str]:
"""
获取指定季的集号到媒体服务器条目 ID 映射
:param server: 媒体服务器名称
:param item_id: 剧集在媒体服务器中的条目 ID
:param season: 季号
:return: 集号到条目 ID 的映射,无数据时返回空字典
"""
result = self.run_module(
"mediaserver_season_episode_ids",
server=server,
item_id=item_id,
season=season,
)
return result or {}
def get_image_cookies(
self, server: Optional[str], image_url: str
) -> Optional[str | dict]:

View File

@@ -11,6 +11,7 @@ from app import schemas
from app.chain import ChainBase
from app.chain.download import DownloadChain
from app.chain.media import MediaChain
from app.chain.mediaserver import MediaServerChain
from app.chain.search import SearchChain
from app.chain.tmdb import TmdbChain
from app.chain.torrents import TorrentsChain
@@ -34,6 +35,7 @@ from app.helper.interaction import (
supports_markdown,
update_or_post_message,
)
from app.helper.mediaserver import MediaServerHelper
from app.helper.server import MoviePilotServerHelper
from app.helper.torrent import TorrentHelper
from app.log import logger
@@ -3642,6 +3644,84 @@ class SubscribeChain(ChainBase):
else:
episodes[0].library.append(file_info)
# 合并所有媒体服务器已存在条目(逐台查询,不只取第一个命中)
mediaserver_chain = MediaServerChain()
server_names = list(MediaServerHelper().get_services().keys())
def _has_server_entry(library_list: List[schemas.SubscribeLibraryFileInfo],
server_name: Optional[str],
server_type: Optional[str]) -> bool:
for info in library_list or []:
if info.server and server_name and info.server == server_name:
return True
if info.server_type and server_type and info.server_type == server_type \
and info.server == server_name \
and (not info.file_path or str(info.file_path).startswith(("http://", "https://"))):
return True
return False
for server_name in server_names:
exists_media = self.media_exists(mediainfo=mediainfo, server=server_name)
# 仅合并真实媒体服务器结果,跳过本地 FileManager 兜底(已由 media_files 覆盖)
if not exists_media or not (exists_media.server or exists_media.server_type):
continue
resolved_server = exists_media.server or server_name
server_storage = exists_media.server_type or resolved_server
server_itemid = str(exists_media.itemid) if exists_media.itemid is not None else None
series_detail_url = None
if resolved_server and exists_media.itemid is not None:
series_detail_url = mediaserver_chain.get_play_url(
server=resolved_server,
item_id=exists_media.itemid,
)
if subscribe.type == MediaType.TV.value:
season_number = subscribe.season if subscribe.season is not None else 1
exist_episodes = (exists_media.seasons or {}).get(season_number) or []
episode_item_ids: Dict[int, str] = {}
if resolved_server and exists_media.itemid is not None:
episode_item_ids = mediaserver_chain.get_season_episode_ids(
server=resolved_server,
item_id=exists_media.itemid,
season=season_number,
)
for episode_number in exist_episodes:
episode_info = episodes.get(episode_number)
if not episode_info:
continue
if _has_server_entry(episode_info.library, resolved_server, exists_media.server_type):
continue
episode_itemid = episode_item_ids.get(episode_number) or server_itemid
detail_url = series_detail_url
if resolved_server and episode_item_ids.get(episode_number):
detail_url = mediaserver_chain.get_play_url(
server=resolved_server,
item_id=episode_itemid,
) or series_detail_url
episode_info.library.append(
schemas.SubscribeLibraryFileInfo(
storage=server_storage,
file_path=detail_url,
server=resolved_server,
server_type=exists_media.server_type,
itemid=str(episode_itemid) if episode_itemid is not None else None,
)
)
else:
episode_info = episodes.get(0)
if episode_info and not _has_server_entry(
episode_info.library, resolved_server, exists_media.server_type):
episode_info.library.append(
schemas.SubscribeLibraryFileInfo(
storage=server_storage,
file_path=series_detail_url,
server=resolved_server,
server_type=exists_media.server_type,
itemid=server_itemid,
)
)
# 更新订阅信息
subscribe_info.subscribe = Subscribe(**subscribe.to_dict())
subscribe_info.episodes = episodes

View File

@@ -38,8 +38,6 @@ class SystemChain(ChainBase):
"""
重启系统
"""
from app.core.config import global_vars
if channel and userid:
self.post_message(Notification(
channel=channel,
@@ -54,8 +52,6 @@ class SystemChain(ChainBase):
}, self._restart_file)
# 主动备份一次插件
self.backup_plugins()
# 设置停止标志,通知所有模块准备停止
global_vars.stop_system()
# 重启
SystemHelper.restart()

View File

@@ -1211,12 +1211,6 @@ class GlobalVar(object):
"""
self.STOP_EVENT.set()
def resume_system(self):
"""
恢复系统运行标记。
"""
self.STOP_EVENT.clear()
@property
def is_system_stopped(self):
"""

View File

@@ -58,12 +58,11 @@ class ModuleManager(metaclass=Singleton):
"""
logger.info("正在停止所有模块...")
for module_id, module in self._running_modules.items():
if hasattr(module, "stop"):
try:
module.stop()
logger.debug(f"Moudle Stoped{module_id}")
except Exception as err:
logger.error(f"Stop Moudle Error{module_id}{str(err)} - {traceback.format_exc()}", exc_info=True)
try:
module.stop()
logger.debug(f"Moudle Stoped{module_id}")
except Exception as err:
logger.error(f"Stop Moudle Error{module_id}{str(err)} - {traceback.format_exc()}", exc_info=True)
logger.info("所有模块停止完成")
def reload(self):

View File

@@ -1,12 +1,60 @@
import asyncio
from typing import Any, Generator, List, Optional, Self, Tuple, AsyncGenerator, Union
from sqlalchemy import NullPool, QueuePool, and_, create_engine, inspect, text, select, delete, Column, Integer, \
from sqlalchemy import NullPool, QueuePool, and_, create_engine, event, inspect, text, select, delete, Column, Integer, \
Sequence, Identity
from sqlalchemy.engine import Engine as SQLAlchemyEngine, ExceptionContext
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import Session, as_declarative, declared_attr, scoped_session, sessionmaker
from app.core.config import settings
from app.log import logger
def _database_error_metadata(error: BaseException) -> Optional[dict[str, Any]]:
"""提取 SQLite 与 PostgreSQL 驱动提供的稳定错误分类字段。"""
metadata = {"error_type": type(error).__name__}
# DBAPI 驱动字段并不共享统一类型,动态读取可同时兼容 sqlite3、psycopg2 与 asyncpg。
sqlite_errorcode = getattr(error, "sqlite_errorcode", None)
sqlite_errorname = getattr(error, "sqlite_errorname", None)
if sqlite_errorcode is not None or sqlite_errorname:
if sqlite_errorcode is not None:
metadata["error_code"] = sqlite_errorcode
if sqlite_errorname:
metadata["error_name"] = sqlite_errorname
return metadata
sqlstate = getattr(error, "sqlstate", None) or getattr(error, "pgcode", None)
if not sqlstate:
sqlstate = getattr(getattr(error, "diag", None), "sqlstate", None)
if sqlstate:
metadata["sqlstate"] = sqlstate
return metadata
return None
def _log_database_error(exception_context: ExceptionContext) -> None:
"""记录非敏感驱动错误码,并保持 SQLAlchemy 原有异常传播。"""
metadata = _database_error_metadata(exception_context.original_exception)
if not metadata:
return
dialect = exception_context.dialect
fields = {
"database": dialect.name,
"driver": dialect.driver,
**metadata,
}
logger.error(
"数据库驱动异常:" + ", ".join(f"{key}={value}" for key, value in fields.items())
)
def _register_database_error_logging(engine: SQLAlchemyEngine) -> None:
"""为主程序 Engine 注册统一的底层驱动错误诊断。"""
event.listen(engine, "handle_error", _log_database_error)
def get_id_column():
@@ -71,6 +119,7 @@ def _get_sqlite_engine(is_async: bool = False):
# 创建数据库引擎
engine = create_engine(**_db_kwargs)
_register_database_error_logging(engine)
# 设置WAL模式
_journal_mode = "WAL" if settings.DB_WAL_ENABLE else "DELETE"
@@ -91,6 +140,7 @@ def _get_sqlite_engine(is_async: bool = False):
}
# 创建异步数据库引擎
async_engine = create_async_engine(**_db_kwargs)
_register_database_error_logging(async_engine.sync_engine)
# 设置WAL模式
_journal_mode = "WAL" if settings.DB_WAL_ENABLE else "DELETE"
@@ -146,6 +196,7 @@ def _get_postgresql_engine(is_async: bool = False):
# 创建数据库引擎
engine = create_engine(**_db_kwargs)
_register_database_error_logging(engine)
print(f"PostgreSQL database connected to {settings.DB_POSTGRESQL_TARGET}/{settings.DB_POSTGRESQL_DATABASE}")
return engine
@@ -163,6 +214,7 @@ def _get_postgresql_engine(is_async: bool = False):
}
# 创建异步数据库引擎
async_engine = create_async_engine(**_db_kwargs)
_register_database_error_logging(async_engine.sync_engine)
print(f"Async PostgreSQL database connected to {settings.DB_POSTGRESQL_TARGET}/{settings.DB_POSTGRESQL_DATABASE}")
return async_engine

View File

@@ -18,8 +18,10 @@ from app.log import logger
from app.utils.mixins import ConfigReloadMixin
from app.utils.singleton import Singleton
# 定义一个全局线程池执行器
_executor = concurrent.futures.ThreadPoolExecutor()
# DoH 关闭时需要释放线程池;保持惰性创建可避免未启用 DoH 时占用进程级资源
_executor: Optional[concurrent.futures.ThreadPoolExecutor] = None
_executor_lock = Lock()
_doh_enabled = False
# 定义默认的DoH配置
_doh_timeout = 5
@@ -29,11 +31,21 @@ _doh_lock = Lock()
_orig_getaddrinfo = socket.getaddrinfo
def _get_executor_locked() -> concurrent.futures.ThreadPoolExecutor:
"""在持有执行器锁时按需获取 DoH 查询线程池"""
global _executor
if _executor is None:
_executor = concurrent.futures.ThreadPoolExecutor()
return _executor
def enable_doh(enable: bool) -> None:
"""
对 socket.getaddrinfo 进行补丁
"""
global _doh_enabled
def _patched_getaddrinfo(host: str, *args, **kwargs):
"""
socket.getaddrinfo的补丁版本。
@@ -47,9 +59,15 @@ def enable_doh(enable: bool) -> None:
logger.info(f"已解析 [{host}] 为 [{ip}] (缓存)")
return _orig_getaddrinfo(ip, *args, **kwargs)
# 使用DoH解析主机
futures = []
for resolver in settings.DOH_RESOLVERS.split(","):
futures.append(_executor.submit(_doh_query, resolver, host))
with _executor_lock:
if not _doh_enabled:
return _orig_getaddrinfo(host, *args, **kwargs)
executor = _get_executor_locked()
# 一次解析的任务必须在同一临界区提交完,避免关闭过程中部分任务落入新线程池
futures = [
executor.submit(_doh_query, resolver, host)
for resolver in settings.DOH_RESOLVERS.split(",")
]
for future in concurrent.futures.as_completed(futures):
ip = future.result()
if ip is not None:
@@ -60,11 +78,9 @@ def enable_doh(enable: bool) -> None:
break
return _orig_getaddrinfo(host, *args, **kwargs)
if enable:
# 替换 socket.getaddrinfo 方法
socket.getaddrinfo = _patched_getaddrinfo
else:
socket.getaddrinfo = _orig_getaddrinfo
with _executor_lock:
_doh_enabled = enable
socket.getaddrinfo = _patched_getaddrinfo if enable else _orig_getaddrinfo
class DohHelper(ConfigReloadMixin, metaclass=Singleton):
@@ -77,14 +93,31 @@ class DohHelper(ConfigReloadMixin, metaclass=Singleton):
enable_doh(settings.DOH_ENABLE)
def on_config_changed(self) -> None:
if not settings.DOH_ENABLE:
self.shutdown()
return
with _doh_lock:
# DOH配置有变动的情况下清空缓存
_doh_cache.clear()
enable_doh(settings.DOH_ENABLE)
enable_doh(True)
def get_reload_name(self) -> str:
return 'DoH'
def shutdown(self) -> None:
"""恢复系统 DNS 并释放 DoH 查询线程池"""
global _executor, _doh_enabled
with _executor_lock:
_doh_enabled = False
socket.getaddrinfo = _orig_getaddrinfo
executor = _executor
_executor = None
with _doh_lock:
_doh_cache.clear()
if executor:
executor.shutdown(wait=True)
def _doh_query(resolver: str, host: str) -> Optional[str]:
"""
使用给定的DoH解析器查询给定主机的IP地址。

View File

@@ -605,6 +605,7 @@ class MessageQueueManager(metaclass=SingletonClass):
self.check_interval = check_interval
self._running = True
self._stop_event = threading.Event()
self.thread = threading.Thread(target=self._monitor_loop, daemon=True)
self.thread.start()
@@ -752,13 +753,15 @@ class MessageQueueManager(metaclass=SingletonClass):
logger.info(f"队列剩余消息:{self.queue.qsize()}")
except queue.Empty:
break
time.sleep(self.check_interval)
if self._stop_event.wait(self.check_interval):
break
def stop(self) -> None:
"""
停止队列管理器
"""
self._running = False
self._stop_event.set()
logger.info("正在停止消息队列...")
self.thread.join()
logger.info("消息队列已停止")
@@ -841,7 +844,8 @@ def stop_message():
"""
停止消息服务
"""
# 停止消息队列
MessageQueueManager().stop()
# 关闭消息演染器
TemplateHelper().close()
# 只关闭已启动的服务,避免清理路径反向创建后台线程和缓存
if queue_manager := MessageQueueManager.get_existing_instance():
queue_manager.stop()
if template_helper := TemplateHelper.get_existing_instance():
template_helper.close()

View File

@@ -124,7 +124,7 @@ class NonBlockingFileHandler:
"""
_instance = None
_lock = threading.Lock()
_rotating_handlers = {}
_stop_sentinel = object()
def __new__(cls):
if cls._instance is None:
@@ -138,6 +138,9 @@ class NonBlockingFileHandler:
return
self._initialized = True
self._state_lock = threading.RLock()
self._handlers_lock = threading.Lock()
self._rotating_handlers = {}
self._write_queue = queue.Queue(maxsize=log_settings.ASYNC_FILE_QUEUE_SIZE)
self._executor = ThreadPoolExecutor(max_workers=log_settings.ASYNC_FILE_WORKERS,
thread_name_prefix="LogWriter")
@@ -151,27 +154,28 @@ class NonBlockingFileHandler:
"""
获取或创建RotatingFileHandler实例
"""
if file_path not in self._rotating_handlers:
# 确保目录存在
file_path.parent.mkdir(parents=True, exist_ok=True)
with self._handlers_lock:
if file_path not in self._rotating_handlers:
# 确保目录存在
file_path.parent.mkdir(parents=True, exist_ok=True)
# 创建RotatingFileHandler
handler = RotatingFileHandler(
filename=str(file_path),
maxBytes=log_settings.LOG_MAX_FILE_SIZE_BYTES,
backupCount=log_settings.LOG_BACKUP_COUNT,
encoding='utf-8'
)
# 创建RotatingFileHandler
handler = RotatingFileHandler(
filename=str(file_path),
maxBytes=log_settings.LOG_MAX_FILE_SIZE_BYTES,
backupCount=log_settings.LOG_BACKUP_COUNT,
encoding='utf-8'
)
# 设置格式化器
formatter = logging.Formatter(log_settings.LOG_FILE_FORMAT)
handler.setFormatter(formatter)
# 设置格式化器
formatter = logging.Formatter(log_settings.LOG_FILE_FORMAT)
handler.setFormatter(formatter)
self._rotating_handlers[file_path] = handler
self._rotating_handlers[file_path] = handler
return self._rotating_handlers[file_path]
return self._rotating_handlers[file_path]
def write_log(self, level: str, message: str, file_path: Path):
def write_log(self, level: str, message: str, file_path: Path) -> None:
"""
写入日志 - 自动检测协程环境并使用合适的方式
"""
@@ -181,8 +185,11 @@ class NonBlockingFileHandler:
if self._is_in_event_loop():
# 在协程环境中,使用非阻塞方式
self._write_non_blocking(entry)
else:
# 不在协程环境中,直接同步写入
return
with self._state_lock:
if not self._running:
return
# 不在协程环境中,持锁同步写入,避免关闭文件处理器时仍有写操作进行
self._write_sync(entry)
@staticmethod
@@ -196,15 +203,19 @@ class NonBlockingFileHandler:
except RuntimeError:
return False
def _write_non_blocking(self, entry: LogEntry):
def _write_non_blocking(self, entry: LogEntry) -> bool:
"""
非阻塞写入(用于协程环境)
"""
try:
self._write_queue.put_nowait(entry)
except queue.Full:
# 队列满时,使用线程池处理
self._executor.submit(self._write_sync, entry)
with self._state_lock:
if not self._running:
return False
try:
self._write_queue.put_nowait(entry)
except queue.Full:
# 队列满时,使用线程池处理
self._executor.submit(self._write_sync, entry)
return True
@staticmethod
def _write_sync(entry: LogEntry):
@@ -215,8 +226,7 @@ class NonBlockingFileHandler:
# 获取RotatingFileHandler实例
handler = NonBlockingFileHandler()._get_rotating_handler(entry.file_path)
# 使用RotatingFileHandler的emit方法只传递原始消息
handler.emit(logging.LogRecord(
handler.handle(logging.LogRecord(
name='',
level=getattr(logging, entry.level.upper(), logging.INFO),
pathname='',
@@ -235,22 +245,28 @@ class NonBlockingFileHandler:
"""
后台批量写入线程
"""
while self._running:
while True:
try:
# 收集一批日志条目
batch = []
should_stop = False
end_time = time.time() + log_settings.WRITE_TIMEOUT
while len(batch) < log_settings.BATCH_WRITE_SIZE and time.time() < end_time:
try:
remaining_time = max(0, end_time - time.time())
entry = self._write_queue.get(timeout=remaining_time)
if entry is self._stop_sentinel:
should_stop = True
break
batch.append(entry)
except queue.Empty:
break
if batch:
self._write_batch(batch)
if should_stop:
break
except Exception as e:
print(f"批量写入线程错误: {e}")
@@ -275,8 +291,7 @@ class NonBlockingFileHandler:
# 批量写入
for entry in entries:
# 使用RotatingFileHandler的emit方法只传递原始消息
handler.emit(logging.LogRecord(
handler.handle(logging.LogRecord(
name='',
level=getattr(logging, entry.level.upper(), logging.INFO),
pathname='',
@@ -294,15 +309,23 @@ class NonBlockingFileHandler:
def shutdown(self):
"""
关闭文件处理器
排空异步日志并关闭文件处理器
"""
self._running = False
if hasattr(self, '_write_thread'):
self._write_thread.join(timeout=5)
with self._state_lock:
if not self._running:
return
self._running = False
if hasattr(self, '_write_thread') and self._write_thread.is_alive():
# 状态锁保证停止标记之后不会再有生产者入队
self._write_queue.put(self._stop_sentinel)
if hasattr(self, '_write_thread') and self._write_thread.is_alive():
self._write_thread.join()
if self._executor:
self._executor.shutdown(wait=True)
# 清理缓存
for handler in self._rotating_handlers.values():
handler.flush()
handler.close()
self._rotating_handlers.clear()

View File

@@ -30,16 +30,31 @@ elif SystemUtils.is_frozen():
sys.stderr = open(os.devnull, 'w')
from app.factory import app
from app.core.config import settings
from app.core.config import global_vars, settings
from app.db.init import init_db, update_db
# 设置进程名
setproctitle.setproctitle(settings.PROJECT_NAME)
class MoviePilotServer(uvicorn.Server):
"""在 Uvicorn 开始优雅退出前发布应用协作停止标志"""
def handle_exit(self, sig, frame) -> None:
global_vars.stop_system()
super().handle_exit(sig, frame)
# uvicorn服务
Server = uvicorn.Server(Config(app, host=settings.HOST, port=settings.PORT,
reload=settings.DEV, workers=multiprocessing.cpu_count() * 2 + 1,
timeout_graceful_shutdown=60))
Server = MoviePilotServer(Config(app, host=settings.HOST, port=settings.PORT,
reload=settings.DEV, workers=multiprocessing.cpu_count() * 2 + 1,
timeout_graceful_shutdown=60))
def request_shutdown() -> None:
"""发布协作停止标志并请求 Uvicorn 退出"""
global_vars.stop_system()
Server.should_exit = True
def start_tray():
@@ -64,8 +79,8 @@ def start_tray():
"""
退出程序
"""
request_shutdown()
TrayIcon.stop()
Server.should_exit = True
import pystray
@@ -93,10 +108,11 @@ def signal_handler(signum, frame):
信号处理函数,用于优雅停止服务
"""
print(f"收到信号 {signum},开始优雅停止服务...")
Server.should_exit = True
request_shutdown()
if __name__ == '__main__':
def run_application() -> None:
"""初始化进程并启动 API 服务"""
# 注册信号处理器
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)
@@ -109,3 +125,7 @@ if __name__ == '__main__':
update_db()
# 启动API服务
Server.run()
if __name__ == '__main__':
run_application()

View File

@@ -1,8 +1,10 @@
import threading
from abc import abstractmethod, ABCMeta
from typing import Generic, Tuple, Union, TypeVar, Type, Dict, Optional, Callable
from pathlib import Path
from app.helper.service import ServiceConfigHelper
from app.log import logger
from app.schemas import Notification, NotificationConf, MediaServerConf, DownloaderConf
from app.schemas.types import ModuleType, DownloaderType, MediaServerType, MessageChannel, StorageSchema, \
OtherModulesType, SystemConfigKey
@@ -15,8 +17,21 @@ class _ModuleBase(ConfigReloadMixin, metaclass=ABCMeta):
输入参数与输出参数一致的,或没有输出的,可以被多个模块重复实现
"""
def on_config_changed(self):
self.init_module()
def __init__(self) -> None:
"""初始化模块生命周期锁"""
super().__init__()
self._reload_lock = threading.RLock()
def on_config_changed(self) -> None:
"""串行停止旧资源并按最新配置重新初始化模块"""
with self._reload_lock:
try:
self.stop()
except Exception as err:
logger.error(
f"停止 {self.get_reload_name()} 旧资源失败,继续按最新配置初始化:{err}"
)
self.init_module()
def get_reload_name(self):
return self.get_name()

View File

@@ -58,7 +58,6 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]):
if not Discord:
logger.error("Discord 依赖未就绪(需要安装 discord.py==2.6.4),模块未启动")
return
self.stop()
super().init_service(
service_name=Discord.__name__.lower(), service_type=Discord
)
@@ -89,12 +88,13 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]):
"""
return 4
def stop(self):
"""
停止模块
"""
def stop(self) -> None:
"""停止模块"""
for client in self.get_instances().values():
client.stop()
try:
client.stop()
except Exception as err:
logger.error(f"停止Discord模块实例失败{err}")
def test(self) -> Optional[Tuple[bool, str]]:
"""

View File

@@ -1,4 +1,4 @@
from typing import Any, Generator, List, Optional, Tuple, Union
from typing import Any, Dict, Generator, List, Optional, Tuple, Union
from app import schemas
from app.core.context import MediaInfo
@@ -300,6 +300,21 @@ class EmbyModule(_ModuleBase, _MediaServerBase[Emby]):
return None
return server_obj.get_play_url(item_id)
def mediaserver_season_episode_ids(self, server: str, item_id: Union[str, int],
season: int) -> Optional[Dict[int, str]]:
"""
获取指定季的集号到条目 ID 映射
:param server: Emby 媒体服务器名称
:param item_id: 剧集在 Emby 中的条目 ID
:param season: 季号
:return: 集号到条目 ID 的映射,服务器不可用或无数据时返回 None
"""
server_obj: Emby = self.get_instance(server)
if not server_obj:
return None
return server_obj.get_season_episode_ids(str(item_id), season)
def mediaserver_latest(self, server: Optional[str] = None, count: Optional[int] = 20,
username: Optional[str] = None) -> List[schemas.MediaServerPlayItem]:
"""

View File

@@ -474,6 +474,37 @@ class Emby:
return None, None
return None, {}
def get_season_episode_ids(self, item_id: str, season: int) -> Dict[int, str]:
"""
获取指定季的集号到媒体服务器条目 ID 映射
:param item_id: 剧集在 Emby 中的 ID
:param season: 季号
:return: {集号: episode_item_id}
"""
if not item_id or not self._host or not self._apikey:
return {}
try:
url = f"{self._host}emby/Shows/{item_id}/Episodes"
params = {
"Season": season,
"IsMissing": "false",
"api_key": self._apikey
}
res_json = RequestUtils().get_res(url, params)
if not res_json:
return {}
episode_ids: Dict[int, str] = {}
for res_item in res_json.json().get("Items") or []:
episode_index = res_item.get("IndexNumber")
episode_id = res_item.get("Id")
if episode_index is None or not episode_id:
continue
episode_ids[int(episode_index)] = str(episode_id)
return episode_ids
except Exception as e:
logger.error(f"获取 Emby 季集条目 ID 出错:{str(e)}")
return {}
def get_remote_image_by_id(self, item_id: str, image_type: str) -> Optional[str]:
"""
根据ItemId从Emby查询TMDB的图片地址

View File

@@ -10,7 +10,6 @@ from app.schemas.types import ModuleType
class FeishuModule(_ModuleBase, _MessageBase[Feishu]):
def init_module(self) -> None:
self.stop()
super().init_service(service_name=Feishu.__name__.lower(), service_type=Feishu)
self._channel = MessageChannel.Feishu
@@ -30,13 +29,13 @@ class FeishuModule(_ModuleBase, _MessageBase[Feishu]):
def get_priority() -> int:
return 2
def stop(self):
def stop(self) -> None:
"""停止模块"""
for client in self.get_instances().values():
if hasattr(client, "stop"):
try:
client.stop()
except Exception as err:
logger.error(f"停止飞书模块实例失败:{err}")
try:
client.stop()
except Exception as err:
logger.error(f"停止飞书模块实例失败:{err}")
def test(self) -> Optional[Tuple[bool, str]]:
if not self.get_instances():

View File

@@ -606,8 +606,12 @@ class FileManagerModule(_ModuleBase):
"""
判断媒体文件是否存在于文件系统(网盘或本地文件),只支持标准媒体库结构
:param mediainfo: 识别的媒体信息
:param server: 指定媒体服务器名称时跳过本地文件系统检查
:return: 如不存在返回None存在时返回信息包括每季已存在所有集{type: movie/tv, seasons: {season: [episodes]}}
"""
if kwargs.get("server"):
return None
if not settings.LOCAL_EXISTS_SEARCH:
return None

View File

@@ -92,13 +92,6 @@ class FilterModule(_ModuleBase):
self.rule_set = deepcopy(self.builtin_rule_set)
self.__init_custom_rules()
def on_config_changed(self) -> None:
"""
自定义过滤或 Meta 识别配置变更后重建规则集并刷新 Rust Meta 配置缓存。
"""
clear_rust_parse_options_cache()
self.init_module()
def __init_custom_rules(self):
"""
加载用户自定义规则,如跟内置规则冲突,以用户自定义规则为准
@@ -137,10 +130,8 @@ class FilterModule(_ModuleBase):
return 4
def stop(self) -> None:
"""
停止过滤器模块。
"""
pass
"""停止模块"""
clear_rust_parse_options_cache()
def test(self) -> None:
"""

View File

@@ -1,4 +1,4 @@
from typing import Any, Generator, List, Optional, Tuple, Union
from typing import Any, Dict, Generator, List, Optional, Tuple, Union
from app import schemas
from app.core.context import MediaInfo
@@ -299,6 +299,21 @@ class JellyfinModule(_ModuleBase, _MediaServerBase[Jellyfin]):
return None
return server_obj.get_play_url(item_id)
def mediaserver_season_episode_ids(self, server: str, item_id: Union[str, int],
season: int) -> Optional[Dict[int, str]]:
"""
获取指定季的集号到条目 ID 映射
:param server: Jellyfin 媒体服务器名称
:param item_id: 剧集在 Jellyfin 中的条目 ID
:param season: 季号
:return: 集号到条目 ID 的映射,服务器不可用或无数据时返回 None
"""
server_obj: Jellyfin = self.get_instance(server)
if not server_obj:
return None
return server_obj.get_season_episode_ids(str(item_id), season)
def mediaserver_latest(self, server: Optional[str] = None, count: Optional[int] = 20,
username: Optional[str] = None) -> List[schemas.MediaServerPlayItem]:
"""

View File

@@ -523,6 +523,38 @@ class Jellyfin:
return None, None
return None, {}
def get_season_episode_ids(self, item_id: str, season: int) -> Dict[int, str]:
"""
获取指定季的集号到媒体服务器条目 ID 映射
:param item_id: 剧集在 Jellyfin 中的 ID
:param season: 季号
:return: {集号: episode_item_id}
"""
if not item_id or not self._host or not self._apikey or not self.user:
return {}
try:
url = f"{self._host}Shows/{item_id}/Episodes"
params = {
"season": season,
"userId": self.user,
"isMissing": "false",
"api_key": self._apikey
}
res_json = RequestUtils().get_res(url, params)
if not res_json:
return {}
episode_ids: Dict[int, str] = {}
for res_item in res_json.json().get("Items") or []:
episode_index = res_item.get("IndexNumber")
episode_id = res_item.get("Id")
if episode_index is None or not episode_id:
continue
episode_ids[int(episode_index)] = str(episode_id)
return episode_ids
except Exception as e:
logger.error(f"获取 Jellyfin 季集条目 ID 出错:{str(e)}")
return {}
def get_remote_image_by_id(self, item_id: str, image_type: str) -> Optional[str]:
"""
根据ItemId从Jellyfin查询TMDB图片地址

View File

@@ -1,4 +1,4 @@
from typing import Optional, Tuple, Union, Any, List, Generator
from typing import Optional, Tuple, Union, Any, List, Generator, Dict
from app import schemas
from app.core.context import MediaInfo
@@ -44,13 +44,14 @@ class PlexModule(_ModuleBase, _MediaServerBase[Plex]):
"""
return 3
def stop(self):
"""
停止模块服务
"""
def stop(self) -> None:
"""停止模块"""
for server in self.get_instances().values():
if server:
server.close()
try:
if server:
server.close()
except Exception as err:
logger.error(f"停止Plex模块实例失败{err}")
def test(self) -> Optional[Tuple[bool, str]]:
"""
@@ -348,3 +349,18 @@ class PlexModule(_ModuleBase, _MediaServerBase[Plex]):
if not server_obj:
return None
return server_obj.get_play_url(item_id)
def mediaserver_season_episode_ids(self, server: str, item_id: Union[str, int],
season: int) -> Optional[Dict[int, str]]:
"""
获取指定季的集号到条目 ID 映射
:param server: Plex 媒体服务器名称
:param item_id: 剧集在 Plex 中的条目 ID / key
:param season: 季号
:return: 集号到条目 ID 的映射,服务器不可用或无数据时返回 None
"""
server_obj: Plex = self.get_instance(server)
if not server_obj:
return None
return server_obj.get_season_episode_ids(str(item_id), season)

View File

@@ -293,6 +293,31 @@ class Plex:
season_episodes[episode.seasonNumber].append(episode.index)
return videos.key, season_episodes
def get_season_episode_ids(self, item_id: str, season: int) -> Dict[int, str]:
"""
获取指定季的集号到媒体服务器条目 ID 映射
:param item_id: 剧集在 Plex 中的 ID / key
:param season: 季号
:return: {集号: episode_item_key}
"""
if not self._plex or not item_id:
return {}
try:
videos = self.__fetch_item(item_id)
if not videos:
return {}
episode_ids: Dict[int, str] = {}
for episode in videos.episodes():
if episode.seasonNumber != int(season):
continue
if episode.index is None or not episode.key:
continue
episode_ids[int(episode.index)] = str(episode.key)
return episode_ids
except Exception as e:
logger.error(f"获取 Plex 季集条目 ID 出错:{str(e)}")
return {}
def __search_show(self,
title: Optional[str] = None,
original_title: Optional[str] = None,

View File

@@ -46,7 +46,6 @@ class QQBotModule(_ModuleBase, _MessageBase[QQBot]):
)
def init_module(self) -> None:
self.stop()
super().init_service(service_name=QQBot.__name__.lower(), service_type=QQBot)
self._channel = MessageChannel.QQ
@@ -67,9 +66,12 @@ class QQBotModule(_ModuleBase, _MessageBase[QQBot]):
return 10
def stop(self) -> None:
"""停止模块"""
for client in self.get_instances().values():
if hasattr(client, "stop"):
try:
client.stop()
except Exception as err:
logger.error(f"停止QQ Bot模块实例失败{err}")
def test(self) -> Optional[Tuple[bool, str]]:
if not self.get_instances():

View File

@@ -69,12 +69,13 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
"""
return 3
def stop(self):
"""
停止模块
"""
def stop(self) -> None:
"""停止模块"""
for client in self.get_instances().values():
client.stop()
try:
client.stop()
except Exception as err:
logger.error(f"停止Slack模块实例失败{err}")
def test(self) -> Optional[Tuple[bool, str]]:
"""

View File

@@ -62,12 +62,13 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
"""
return 0
def stop(self):
"""
停止模块
"""
def stop(self) -> None:
"""停止模块"""
for client in self.get_instances().values():
client.stop()
try:
client.stop()
except Exception as err:
logger.error(f"停止Telegram模块实例失败{err}")
def test(self) -> Optional[Tuple[bool, str]]:
"""

View File

@@ -279,8 +279,6 @@ class Telegram:
@staticmethod
def _telegramify_item_text(item: Text) -> str:
"""将 telegramify 文本片段转换为 Telegram MarkdownV2 字符串。"""
if hasattr(item, "content"):
return item.content
if entities_to_markdownv2:
return entities_to_markdownv2(item.text, item.entities)
return standardize(item.text)
@@ -290,8 +288,6 @@ class Telegram:
"""将 telegramify 文本或媒体片段转换为 Telegram MarkdownV2 caption。"""
if isinstance(item, Text):
return Telegram._telegramify_item_text(item)
if hasattr(item, "caption"):
return item.caption
if entities_to_markdownv2:
return entities_to_markdownv2(item.caption_text, item.caption_entities)
return standardize(item.caption_text)
@@ -1540,14 +1536,19 @@ class Telegram:
# 清理菜单命令
self._bot.delete_my_commands()
def stop(self):
def stop(self) -> None:
"""
停止Telegram消息接收服务
"""
# 停止所有typing任务
for chat_id in list(self._typing_tasks.keys()):
self._stop_typing_task(chat_id)
if self._bot:
self._bot.stop_polling()
if not self._bot:
return
self._bot.stop_bot()
if self._polling_thread:
self._polling_thread.join()
logger.info("Telegram消息接收服务已停止")
self._polling_thread = None
self._bot = None
logger.info("Telegram消息接收服务已停止")

View File

@@ -43,12 +43,6 @@ class TheMovieDbModule(_ModuleBase):
self.category = CategoryHelper()
self.scraper = TmdbScraper()
def on_config_changed(self):
# 停止模块
self.stop()
# 初始化模块
self.init_module()
@staticmethod
def get_name() -> str:
return "TheMovieDb"
@@ -74,9 +68,13 @@ class TheMovieDbModule(_ModuleBase):
"""
return 1
def stop(self):
self.cache.save()
self.tmdb.close()
def stop(self) -> None:
"""停止模块"""
# 缓存持久化失败不能阻断 HTTP 客户端关闭
try:
self.cache.save()
finally:
self.tmdb.close()
def test(self) -> Tuple[bool, str]:
"""

View File

@@ -114,7 +114,6 @@ class TheTvDbModule(_ModuleBase):
return 4
def stop(self):
logger.info("TheTvDbModule 停止。正在清除 TVDB 会话。")
with self.__auth_lock:
self.tvdb = None

View File

@@ -61,10 +61,14 @@ class TrimeMediaModule(_ModuleBase, _MediaServerBase[TrimeMedia]):
logger.info(f"飞牛影视 {name} 连接断开,尝试重连 ...")
server.reconnect()
def stop(self):
def stop(self) -> None:
"""停止模块"""
for server in self.get_instances().values():
if server.is_authenticated():
server.disconnect()
try:
if server.is_authenticated():
server.disconnect()
except Exception as err:
logger.error(f"停止飞牛影视模块实例失败:{err}")
def test(self) -> Optional[Tuple[bool, str]]:
"""

View File

@@ -60,10 +60,14 @@ class UgreenModule(_ModuleBase, _MediaServerBase[Ugreen]):
logger.info(f"绿联影视 {name} 连接断开,尝试重连 ...")
server.reconnect()
def stop(self):
def stop(self) -> None:
"""停止模块"""
for server in self.get_instances().values():
if server.is_authenticated():
server.disconnect()
try:
if server.is_authenticated():
server.disconnect()
except Exception as err:
logger.error(f"停止绿联影视模块实例失败:{err}")
def test(self) -> Optional[Tuple[bool, str]]:
"""

View File

@@ -161,13 +161,14 @@ class Api:
def _common_headers(self) -> dict[str, str]:
"""
获取绿联 Web 端通用请求头。
获取绿联 Web 端通用请求头,兼容新版登录客户端标识
"""
return {
"Accept": "application/json, text/plain, */*",
"Client-Id": self._client_id,
"Client-Version": self._client_version,
"UG-Agent": self._ug_agent,
"UG-Client-Id": self._client_id,
"X-Specify-Language": self._language,
}

View File

@@ -24,7 +24,6 @@ class WechatModule(_ModuleBase, _MessageBase[WeChat]):
"""
初始化模块
"""
self.stop()
super().init_service(service_name=WeChat.__name__.lower(),
service_type=self._create_client)
self._channel = MessageChannel.Wechat
@@ -54,13 +53,14 @@ class WechatModule(_ModuleBase, _MessageBase[WeChat]):
"""
return 1
def stop(self):
def stop(self) -> None:
"""停止模块"""
for client in self.get_instances().values():
if hasattr(client, "stop"):
try:
try:
if hasattr(client, "stop"):
client.stop()
except Exception as err:
logger.error(f"停止微信模块实例失败:{err}")
except Exception as err:
logger.error(f"停止微信模块实例失败:{err}")
@staticmethod
def _is_bot_mode(config: dict) -> bool:

View File

@@ -23,7 +23,6 @@ class WechatClawBotModule(_ModuleBase, _MessageBase[WechatClawBot]):
def init_module(self) -> None:
"""初始化模块。"""
self.stop()
super().init_service(
service_name=WechatClawBot.__name__.lower(), service_type=WechatClawBot
)
@@ -49,14 +48,13 @@ class WechatClawBotModule(_ModuleBase, _MessageBase[WechatClawBot]):
"""获取模块优先级。"""
return 2
def stop(self):
"""停止模块"""
def stop(self) -> None:
"""停止模块"""
for client in self.get_instances().values():
if hasattr(client, "stop"):
try:
client.stop()
except Exception as err:
logger.error(f"停止微信 ClawBot 模块实例失败:{err}")
try:
client.stop()
except Exception as err:
logger.error(f"停止微信 ClawBot 模块实例失败:{err}")
def test(self) -> Optional[Tuple[bool, str]]:
"""测试模块连接性。"""

View File

@@ -25,7 +25,7 @@ from app.chain.subscribe import SubscribeChain
from app.chain.transfer import TransferChain
from app.chain.workflow import WorkflowChain
from app.core.config import settings, global_vars
from app.core.event import eventmanager
from app.core.event import Event, eventmanager
from app.core.plugin import PluginManager
from app.db import SessionFactory
from app.db.models.downloadhistory import DownloadHistory, DownloadFiles
@@ -987,6 +987,14 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
for pid in PluginManager().get_running_plugin_ids():
self.update_plugin_job(pid)
@eventmanager.register(EventType.PluginReload)
def on_plugin_reload(self, event: Event) -> None:
"""插件重载后按当前实例重新注册全部定时服务"""
plugin_id = event.event_data.get("plugin_id")
if not plugin_id:
return
self.update_plugin_job(plugin_id)
def init_workflow_jobs(self):
"""
初始化工作流定时服务

View File

@@ -217,6 +217,12 @@ class SubscribeLibraryFileInfo(BaseModel):
storage: Optional[str] = "local"
# 文件路径
file_path: Optional[str] = None
# 媒体服务器名称
server: Optional[str] = None
# 媒体服务器类型emby、jellyfin、plex 等
server_type: Optional[str] = None
# 媒体服务器条目 ID
itemid: Optional[str] = None
class SubscribeEpisodeInfo(BaseModel):

View File

@@ -1,5 +1,7 @@
import asyncio
import inspect
from contextlib import asynccontextmanager
from typing import Callable
from fastapi import FastAPI
@@ -20,6 +22,7 @@ from app.chain.system import SystemChain
from app.core.config import global_vars, settings
from app.helper.server import MoviePilotServerHelper
from app.helper.system import SystemHelper
from app.log import logger, LoggerManager
from app.startup.command_initializer import init_command, stop_command, restart_command
from app.startup.modules_initializer import init_modules, stop_modules
from app.startup.monitor_initializer import stop_monitor, init_monitor
@@ -55,6 +58,16 @@ async def init_extra():
await MoviePilotServerHelper.async_report_usage()
async def run_shutdown_step(name: str, callback: Callable[[], object]) -> None:
"""隔离单个关闭阶段的异常,确保后续资源仍有机会释放"""
try:
result = callback()
if inspect.isawaitable(result):
await result
except Exception as err:
logger.error(f"关闭{name}失败:{err}")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""
@@ -89,6 +102,7 @@ async def lifespan(app: FastAPI):
yield
finally:
print("Shutting down...")
global_vars.stop_system()
# 取消同步插件任务
try:
sync_plugins_task.cancel()
@@ -97,20 +111,21 @@ async def lifespan(app: FastAPI):
pass
except Exception as e:
print(str(e))
if not settings.MOVIEPILOT_SAFE_MODE:
# 备份插件
SystemChain().backup_plugins()
# 停止工作流
stop_workflow()
# 停止命令
stop_command()
# 停止监控器
stop_monitor()
# 停止定时器
stop_scheduler()
# 停止插件
stop_plugins()
# 停止模块
await stop_modules()
# 关闭共享的异步 HTTP 连接池,释放底层连接资源
await aclose_shared_async_transports()
try:
if not settings.MOVIEPILOT_SAFE_MODE:
await run_shutdown_step(
"插件备份", lambda: SystemChain().backup_plugins()
)
await run_shutdown_step("工作流", stop_workflow)
await run_shutdown_step("命令服务", stop_command)
await run_shutdown_step("监控器", stop_monitor)
await run_shutdown_step("定时器", stop_scheduler)
await run_shutdown_step("插件", stop_plugins)
await run_shutdown_step("模块服务", stop_modules)
await run_shutdown_step(
"共享异步 HTTP 连接池",
aclose_shared_async_transports,
)
finally:
# 日志最后关闭,确保其他组件的收尾信息已写入文件
LoggerManager.shutdown()

View File

@@ -1,4 +1,6 @@
import inspect
import sys
from typing import Callable
from app.helper.redis import RedisHelper, AsyncRedisHelper
@@ -129,27 +131,27 @@ async def stop_modules():
"""
服务关闭
"""
# 停止AI智能体
await stop_agent()
# 停止模块
ModuleManager().stop()
# 停止事件消费
EventManager().stop()
# 停止虚拟显示
DisplayHelper().stop()
# 停止线程池
ThreadHelper().shutdown()
# 停止消息服务
stop_message()
# 关闭Redis缓存连接
RedisHelper().close()
await AsyncRedisHelper().close()
# 停止数据库连接
await close_database()
# 停止前端服务
stop_frontend()
# 清理临时文件
clear_temp()
async def run_step(name: str, callback: Callable[[], object]) -> None:
"""单个模块资源关闭失败时继续执行后续阶段"""
try:
result = callback()
if inspect.isawaitable(result):
await result
except Exception as err:
logger.error(f"关闭{name}失败:{err}")
await run_step("AI智能体", stop_agent)
await run_step("模块", lambda: ModuleManager().stop())
await run_step("事件消费", lambda: EventManager().stop())
await run_step("虚拟显示", lambda: DisplayHelper().stop())
await run_step("DoH服务", lambda: DohHelper().shutdown())
await run_step("线程池", lambda: ThreadHelper().shutdown())
await run_step("消息服务", stop_message)
await run_step("Redis缓存连接", lambda: RedisHelper().close())
await run_step("异步Redis缓存连接", lambda: AsyncRedisHelper().close())
await run_step("数据库连接", close_database)
await run_step("前端服务", stop_frontend)
await run_step("临时文件", clear_temp)
def init_modules():

View File

@@ -9,6 +9,8 @@ fixture 一并识别autouse 自动作用于每个用例,无需逐用例改
"""
from __future__ import annotations
import ipaddress
import pytest
# 本地回环/通配地址放行其余主机一律视为真实出站getaddrinfo 的 host 可能为 str 或 bytes
@@ -20,21 +22,45 @@ def block_real_network(monkeypatch):
"""防御纵深:拦截对非本地主机的真实出站,强制测试零真实网络。
补在各用例自身 mock 之上:某用例万一漏 mock 外部依赖TMDB / LLM 目录 / 下载器 /
媒体服务器 / 任意外链),其真实 DNS 解析会在此被拦并报错,而非静默发请求。本地回环放行
sqlite 等。asyncio 默认解析器经线程池调用 ``socket.getaddrinfo``,故拦此一处即覆盖
同步与异步出站。``monkeypatch`` 在用例结束后自动还原,不影响其他用例与进程退出。
媒体服务器 / 任意外链),其 DNS 解析或 socket 连接会被拦截。本地回环放行sqlite 等)。
所有拦截记录会在用例收尾再次断言,避免业务代码捕获网络异常后让漏 mock 的用例静默通过。
``monkeypatch`` 在用例结束后自动还原,不影响其他用例与进程退出。
"""
import socket
_real_getaddrinfo = socket.getaddrinfo
_real_connect = socket.socket.connect
attempts = []
def _is_allowed_host(host) -> bool:
normalized = host.decode() if isinstance(host, (bytes, bytearray)) else host
if normalized is None or normalized in _ALLOWED_NETWORK_HOSTS:
return True
try:
address = ipaddress.ip_address(str(normalized).split("%", 1)[0])
return address.is_loopback or address.is_unspecified
except ValueError:
return False
def _blocked(operation: str, host):
attempts.append((operation, host))
raise RuntimeError(
f"测试禁止真实出站网络:尝试通过 {operation} 访问 {host!r};请 mock 对应外部依赖"
)
def _guarded_getaddrinfo(host, *args, **kwargs):
normalized = host.decode() if isinstance(host, (bytes, bytearray)) else host
if normalized is not None and normalized not in _ALLOWED_NETWORK_HOSTS:
raise RuntimeError(
f"测试禁止真实出站网络:尝试解析 {normalized!r};请 mock 对应外部依赖"
)
if not _is_allowed_host(host):
_blocked("DNS", host)
return _real_getaddrinfo(host, *args, **kwargs)
def _guarded_connect(sock, address):
if isinstance(address, tuple) and address and not _is_allowed_host(address[0]):
_blocked("socket", address[0])
return _real_connect(sock, address)
monkeypatch.setattr(socket, "getaddrinfo", _guarded_getaddrinfo)
monkeypatch.setattr(socket.socket, "connect", _guarded_connect)
yield
if attempts:
details = ", ".join(f"{operation}:{host}" for operation, host in attempts)
pytest.fail(f"测试期间发生真实出站网络尝试:{details}")

View File

@@ -76,6 +76,14 @@ _REQUESTS_RETRY_IDEMPOTENT_METHODS = ("GET", "HEAD", "OPTIONS")
_pending_eviction_tasks: set[asyncio.Task] = set()
def _discard_pending_eviction_task(task: asyncio.Task) -> None:
"""从跨线程共享集合移除已完成的 transport 关闭任务"""
with _shared_async_transports_lock:
_pending_eviction_tasks.discard(task)
if not task.cancelled() and (error := task.exception()):
logger.debug(f"LRU 淘汰共享 transport 时关闭失败: {error!r}")
def _get_shared_async_transport(
proxy: Optional[str],
verify: Union[bool, str],
@@ -140,8 +148,9 @@ def _get_shared_async_transport(
try:
task = loop.create_task(evicted_transport.aclose())
# 强引用避免 task 仅被 loop 弱持有而触发 "Task was destroyed but pending"
_pending_eviction_tasks.add(task)
task.add_done_callback(_pending_eviction_tasks.discard)
with _shared_async_transports_lock:
_pending_eviction_tasks.add(task)
task.add_done_callback(_discard_pending_eviction_task)
except Exception as e: # pragma: no cover - 防御性
logger.debug(f"LRU 淘汰共享 transport 时调度关闭失败: {e!r}")
@@ -160,17 +169,27 @@ async def aclose_shared_async_transports() -> None:
# 弹出而非 get+clear避免外层 dict 残留空 OrderedDict 占位
with _shared_async_transports_lock:
per_loop = _shared_async_transports.pop(loop, None)
if not per_loop:
pending_evictions = [
task
for task in _pending_eviction_tasks
if task.get_loop() is loop
]
transports = list(per_loop.values()) if per_loop else []
if per_loop:
per_loop.clear()
if not transports and not pending_evictions:
return
transports = list(per_loop.values())
per_loop.clear()
# 并行关闭:每个 transport 的 TLS close_notify 各占一个 RTT
# 顺序等待会线性放大 shutdown 耗时return_exceptions 让单点失败
# 不影响其他 transport 的释放
results = await asyncio.gather(
*(t.aclose() for t in transports), return_exceptions=True
*pending_evictions,
*(t.aclose() for t in transports),
return_exceptions=True,
)
for result in results:
with _shared_async_transports_lock:
_pending_eviction_tasks.difference_update(pending_evictions)
for result in results[len(pending_evictions):]:
if isinstance(result, BaseException):
logger.debug(f"关闭共享 AsyncHTTPTransport 失败: {result!r}")

View File

@@ -10,6 +10,11 @@ class Singleton(abc.ABCMeta, type):
_instances: dict = {}
def get_existing_instance(cls, *args, **kwargs):
"""按相同参数返回已创建实例,不触发初始化"""
key = (cls, args, frozenset(kwargs.items()))
return cls._instances.get(key)
def __call__(cls, *args, **kwargs):
key = (cls, args, frozenset(kwargs.items()))
if key not in cls._instances:
@@ -31,6 +36,10 @@ class SingletonClass(abc.ABCMeta, type):
_instances: dict = {}
def get_existing_instance(cls):
"""返回已创建实例,不触发初始化"""
return cls._instances.get(cls)
def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
cls._instances[cls] = super().__call__(*args, **kwargs)

View File

@@ -20,6 +20,8 @@ function WARN() {
echo -e "${WARN} ${1}"
}
ENTRYPOINT_START_TIME="$(date +%s)"
function normalize_env_value() {
printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]'
}
@@ -57,6 +59,42 @@ function run_package_command() {
fi
}
function wait_backend_ready() {
local entrypoint_start_time="${1:-$(date +%s)}"
local backend_start_time="${2:-$(date +%s)}"
local python_pid="${3:-}"
local backend_port="${PORT:-3001}"
local web_port="${NGINX_PORT:-3000}"
local timeout="${MOVIEPILOT_BACKEND_READY_TIMEOUT:-300}"
local ready_url="http://127.0.0.1:${backend_port}/api/v1/system/global?token=moviepilot"
local deadline
if ! [[ "${timeout}" =~ ^[0-9]+$ ]] || [ "$((10#${timeout}))" -le 0 ]; then
WARN "→ MOVIEPILOT_BACKEND_READY_TIMEOUT=${timeout} 无效,使用默认 300 秒。"
timeout=300
else
timeout=$((10#${timeout}))
fi
deadline=$(( $(date +%s) + timeout ))
while [ "$(date +%s)" -lt "${deadline}" ]; do
if [ -n "${python_pid}" ] && ! kill -0 "${python_pid}" >/dev/null 2>&1; then
WARN "→ 后端服务启动完成探测已停止:后端进程已退出。"
return 1
fi
if curl -fsS --max-time 2 "${ready_url}" >/dev/null 2>&1; then
local now
now="$(date +%s)"
INFO "→ MoviePilot Web 已可访问,启动总耗时 $(( now - entrypoint_start_time )) 秒,后端就绪耗时 $(( now - backend_start_time )) 秒,后端端口 ${backend_port},前端端口 ${web_port}"
return 0
fi
sleep 1
done
WARN "→ 后端服务启动完成探测超时,已等待 ${timeout} 秒,后端端口 ${backend_port},继续等待进程日志..."
return 1
}
# 环境变量补全
# 优先级: 系统环境变量 -> .env 文件 (即使为空字符串) -> 预设默认值
# 精准适配 Python 端 set_key (quote_mode="always", 单引号包裹, \' 转义)
@@ -480,12 +518,14 @@ umask "${UMASK}"
# 启动后端服务
INFO "→ 启动后端服务..."
BACKEND_START_TIME="$(date +%s)"
if [ "${START_NOGOSU:-false}" = "true" ]; then
"${VENV_PATH}/bin/python3" app/main.py > /dev/stdout 2> /dev/stderr &
else
gosu moviepilot:moviepilot "${VENV_PATH}/bin/python3" app/main.py > /dev/stdout 2> /dev/stderr &
fi
PYTHON_PID=$!
wait_backend_ready "${ENTRYPOINT_START_TIME}" "${BACKEND_START_TIME}" "${PYTHON_PID}" &
# 等待 Python 进程退出。
# 如果收到信号trap 会中断 wait并执行 graceful_exit。

View File

@@ -1,6 +1,6 @@
# PR-Agent 使用说明
本仓库通过 GitHub Actions 运行 PR-Agent帮助贡献者维护 PR 摘要、获取代码审查结果和提出 PR 相关问题。
本仓库通过 PR Review Runner 运行 PR-Agent帮助贡献者维护 PR 摘要、获取代码审查结果和提出 PR 相关问题。
## 自动执行
@@ -25,26 +25,24 @@ PR 带有 `skip pr-agent` 标签,或标题以 `[Auto]`、`Auto` 开头时,
/ask 这次改动有没有遗漏权限校验?
```
- `/describe`:更新 PR Body 内按语言显示`PR-Agent 摘要``PR-Agent Summary`,并保留贡献者原有的 PR 描述。
- `/describe`:更新 PR Body 内的 `PR-Agent 摘要`,并保留贡献者原有的 PR 描述。
- `/review`:发起一次代码审查。
- `/ask ...`:就当前 PR 提问,回复会发布在普通 PR 评论中。
本仓库禁用 `/improve` 及其等价别名。其他命令是否可用由 runner 所包含的 PR-Agent 能力决定。
手工命令仅允许以下 GitHub 身份关联的用户使用:`OWNER``MEMBER``COLLABORATOR``CONTRIBUTOR``FIRST_TIME_CONTRIBUTOR`
新建的合法命令评论会触发执行;编辑后仍为合法命令的评论也会触发。编辑普通讨论评论不会调用模型。
## 审查结果
`/describe` 的结果位于 PR Body 中按语言显示`PR-Agent 摘要``PR-Agent Summary` 区域,用于概览本次变更。
本仓库固定使用中文生成 PR-Agent 内容。`/describe` 的结果位于 PR Body 的 `PR-Agent 摘要` 区域,用于概览本次变更。
`/review` 和自动审查会通过原生 GitHub Review 发布,结果位于 Review 页签,标题固定为 `PR-Agent Code Review`审查摘要包含可点击的 `文件:行号` 链接;可定位到本次变更的具体问题会在对应代码行以行内评论呈现,无法行内定位的问题仍通过摘要中的链接呈现
`/review` 和自动审查会通过原生 GitHub Review 发布,结果位于 Review 页签,标题固定为 `PR-Agent Code Review`。可定位到本次变更的问题会在对应代码行以行内评论呈现,并使用 high、medium 或 low 风险标识
审查不会额外创建专用的摘要评论。未发现需要处理的问题时Review 会显示:
> 本次变更无需提出审查意见,暂无其他反馈。
Review 摘要会自然概括本次变更和整体审查结论,不会复制行内评论。未发现需要处理的问题时,摘要会概括变更并自然说明暂无其他反馈。审查不会额外创建专用的 issue comment 摘要。
## 安全边界
workflow 使用固定 digest 的 PR-Agent 容器镜像,不使用浮动标签。自动审查通过 `pull_request_target` 在目标仓库上下文中读取 PR 信息,但不会 checkout 或执行 PR 分支代码。
权限保持最小化:只授予读取仓库内容所需的 `contents: read`,以及更新 PR Body、发布 Review 和回复 PR 评论所需的写权限;不会向仓库推送代码或创建提交。
自动审查通过 `pull_request_target` 在目标仓库上下文中读取 PR 信息,并使用共享 runner 的 `latest` 镜像,但不会 checkout 或执行 PR 分支代码。权限保持最小化:只授予读取仓库内容所需的 `contents: read`,以及更新 PR Body、发布 Review 和回复 PR 评论所需的写权限;不会向仓库推送代码或创建提交。

View File

@@ -10,6 +10,8 @@ All **public classes**, **public methods**, and **public functions** in this pro
## Docstring Format
Short, label-style docstrings should follow the surrounding code style and must not gain a period mechanically. Complete sentences that explain non-obvious behavior should use normal Chinese punctuation.
### Single-line (for simple, obvious descriptions)
```python
@@ -28,7 +30,7 @@ def download(
download_dir: Path,
) -> Optional[str]:
"""
添加下载任务到下载器
添加下载任务到下载器
:param context: 当前媒体上下文,包含识别结果和种子选择信息
:param torrent: 要下载的种子信息
@@ -43,7 +45,7 @@ def download(
```python
class DownloadChain(ChainBase):
"""
下载处理链,负责协调搜索结果的种子选择、下载器调度和下载后处理
下载处理链,负责协调搜索结果的种子选择、下载器调度和下载后处理
"""
```

View File

@@ -7,9 +7,5 @@ timeout_method = thread
# 让本仓自身的新告警更醒目。本仓代码引发的告警一律不在此忽略,应在源码/用例处修复。
filterwarnings =
ignore:datetime.datetime.utcfromtimestamp\(\) is deprecated:DeprecationWarning
ignore:websockets.legacy is deprecated:DeprecationWarning
ignore:websockets.InvalidStatusCode is deprecated:DeprecationWarning
ignore:pkg_resources is deprecated as an API:DeprecationWarning
ignore:Deprecated call to .pkg_resources.declare_namespace:DeprecationWarning
ignore:'crypt' is deprecated:DeprecationWarning
ignore:'audioop' is deprecated:DeprecationWarning

View File

@@ -1,4 +1,4 @@
moviepilot-rust~=0.2.2
moviepilot-rust~=0.2.3
pydantic>=2.13.4,<3.0.0
pydantic-settings>=2.14.1,<3.0.0
SQLAlchemy~=2.0.50
@@ -80,6 +80,8 @@ langchain~=1.3.9
langchain-core~=1.4.7
langchain-community~=0.4.2
langchain-anthropic~=1.4.6
langchain-aws~=1.6.2
boto3~=1.42.42
langchain-openai~=1.3.2
langchain-google-genai~=4.2.5
langchain-deepseek~=1.1.0

View File

@@ -16,9 +16,11 @@ prepare_backend()
from app.testing.network_guard import block_real_network # noqa: E402,F401
def _report_session_cleanup_error(name: str, err: Exception) -> None:
"""测试收尾清理失败只记录诊断,不覆盖原始 pytest 退出状态"""
def _report_session_cleanup_error(session, name: str, err: Exception) -> None:
"""记录收尾错误;原测试绿色时将会话标记为失败"""
sys.stderr.write(f"\npytest session cleanup failed: {name}: {err!r}\n")
if session.exitstatus == 0:
session.exitstatus = 1
def pytest_sessionfinish(session, exitstatus):
@@ -28,21 +30,27 @@ def pytest_sessionfinish(session, exitstatus):
shutdown_blocking_executors(cancel_futures=True)
except Exception as err:
_report_session_cleanup_error("agent blocking executors", err)
_report_session_cleanup_error(session, "agent blocking executors", err)
try:
from app.helper.thread import ThreadHelper
from app.utils.singleton import Singleton
helper = Singleton._instances.get((ThreadHelper, (), frozenset()))
helper = ThreadHelper.get_existing_instance()
if helper:
helper.shutdown()
except Exception as err:
_report_session_cleanup_error("thread helper", err)
_report_session_cleanup_error(session, "thread helper", err)
try:
from app.helper.message import stop_message
stop_message()
except Exception as err:
_report_session_cleanup_error(session, "message service", err)
try:
from app.log import LoggerManager
LoggerManager.shutdown()
except Exception as err:
_report_session_cleanup_error("logger manager", err)
_report_session_cleanup_error(session, "logger manager", err)

View File

@@ -0,0 +1,106 @@
import asyncio
import pytest
from sqlalchemy import create_engine, text
from sqlalchemy.exc import OperationalError
import app.db as db_module
class _SqliteError(Exception):
"""模拟 sqlite3 异常暴露的扩展错误字段。"""
sqlite_errorcode = 266
sqlite_errorname = "SQLITE_IOERR_READ"
class _PsycopgError(Exception):
"""模拟 psycopg2 异常暴露的 SQLSTATE 字段。"""
pgcode = "40001"
class _AsyncpgError(Exception):
"""模拟 asyncpg 适配异常暴露的 SQLSTATE 字段。"""
sqlstate = "23505"
@pytest.mark.parametrize(
("error", "expected"),
[
(
_SqliteError("disk I/O error"),
{
"error_type": "_SqliteError",
"error_code": 266,
"error_name": "SQLITE_IOERR_READ",
},
),
(
_PsycopgError("serialization failure"),
{
"error_type": "_PsycopgError",
"sqlstate": "40001",
},
),
(
_AsyncpgError("duplicate key"),
{
"error_type": "_AsyncpgError",
"sqlstate": "23505",
},
),
],
)
def test_database_error_metadata_extracts_driver_codes(error, expected) -> None:
"""诊断元数据应兼容 SQLite、psycopg2 与 asyncpg 的稳定错误字段。"""
assert db_module._database_error_metadata(error) == expected
def test_database_error_listener_omits_statement_and_parameters(monkeypatch) -> None:
"""数据库错误日志不得包含 SQL、参数或驱动返回的原始消息。"""
messages = []
engine = create_engine("sqlite:///:memory:")
monkeypatch.setattr("app.db.logger.error", messages.append)
db_module._register_database_error_logging(engine)
with pytest.raises(OperationalError):
with engine.connect() as connection:
connection.execute(
text("SELECT * FROM missing_table WHERE token = :token"),
{"token": "private-token"},
)
assert len(messages) == 1
assert "database=sqlite" in messages[0]
assert "driver=pysqlite" in messages[0]
assert "error_code=1" in messages[0]
assert "error_name=SQLITE_ERROR" in messages[0]
assert "missing_table" not in messages[0]
assert "private-token" not in messages[0]
def test_async_database_engine_logs_driver_error_metadata(monkeypatch) -> None:
"""异步 Engine 应通过底层 sync engine 记录驱动错误码。"""
messages = []
monkeypatch.setattr("app.db.logger.error", messages.append)
async def query_missing_table() -> None:
async with db_module.AsyncEngine.connect() as connection:
await connection.execute(text("SELECT * FROM async_missing_table"))
with pytest.raises(OperationalError):
asyncio.run(query_missing_table())
assert len(messages) == 1
assert "database=sqlite" in messages[0]
assert "driver=aiosqlite" in messages[0]
assert "error_code=1" in messages[0]
assert "error_name=SQLITE_ERROR" in messages[0]
assert "async_missing_table" not in messages[0]
def test_database_error_metadata_ignores_unclassified_errors() -> None:
"""没有驱动错误码时不应制造无效诊断日志。"""
assert db_module._database_error_metadata(RuntimeError("plain failure")) is None

View File

@@ -78,6 +78,26 @@ def _run_permission_case(tmp_path: Path, body: str, env: dict[str, str] | None =
return chown_log.read_text(encoding="utf-8") if chown_log.exists() else ""
def _run_entrypoint_case(tmp_path: Path, body: str, env: dict[str, str] | None = None) -> str:
functions = _write_entrypoint_functions(tmp_path)
case_env = {
**os.environ,
"ENTRYPOINT_FUNCTIONS": str(functions),
}
if env:
case_env.update(env)
script = textwrap.dedent(
f"""\
set -euo pipefail
source "${{ENTRYPOINT_FUNCTIONS}}"
{body}
"""
)
result = subprocess.run(["bash", "-c", script], check=True, env=case_env, text=True, capture_output=True)
return result.stdout
def test_image_paths_are_not_chowned_by_default_regardless_of_owner(tmp_path: Path) -> None:
log = _run_permission_case(
tmp_path,
@@ -170,3 +190,56 @@ def test_runtime_writable_paths_are_still_corrected(tmp_path: Path) -> None:
assert not any(line.startswith("-R ") and ".cloakbrowser" in line for line in lines)
assert not any(f"{tmp_path}/app " in line for line in lines)
assert not any(f"{tmp_path}/public" in line for line in lines)
def test_backend_ready_log_uses_configured_ports(tmp_path: Path) -> None:
curl_log = tmp_path / "curl.log"
output = _run_entrypoint_case(
tmp_path,
"""
INFO() { printf '[INFO] %s\\n' "$1"; }
curl() {
printf '%s\\n' "$*" > "${CURL_LOG}"
return 0
}
PORT=4321 NGINX_PORT=8765 wait_backend_ready 1 2 "$$"
""",
env={"CURL_LOG": str(curl_log)},
)
assert curl_log.read_text(encoding="utf-8") == (
"-fsS --max-time 2 http://127.0.0.1:4321/api/v1/system/global?token=moviepilot\n"
)
assert "MoviePilot Web 已可访问" in output
assert "后端就绪耗时" in output
assert "后端端口 4321" in output
assert "前端端口 8765" in output
def test_backend_ready_timeout_falls_back_to_default_for_invalid_value(tmp_path: Path) -> None:
output = _run_entrypoint_case(
tmp_path,
"""
WARN() { printf '[WARN] %s\\n' "$1"; }
curl() { return 1; }
MOVIEPILOT_BACKEND_READY_TIMEOUT=invalid wait_backend_ready 1 2 999999 || true
""",
)
assert "MOVIEPILOT_BACKEND_READY_TIMEOUT=invalid 无效,使用默认 300 秒" in output
assert "后端服务启动完成探测已停止:后端进程已退出" in output
def test_backend_ready_timeout_accepts_leading_zero_decimal(tmp_path: Path) -> None:
output = _run_entrypoint_case(
tmp_path,
"""
INFO() { printf '[INFO] %s\\n' "$1"; }
WARN() { printf '[WARN] %s\\n' "$1"; }
curl() { return 0; }
MOVIEPILOT_BACKEND_READY_TIMEOUT=08 wait_backend_ready 1 2 "$$"
""",
)
assert "MOVIEPILOT_BACKEND_READY_TIMEOUT=08 无效" not in output
assert "MoviePilot Web 已可访问" in output

View File

@@ -3,6 +3,61 @@ import socket
from app.helper import doh
def test_doh_executor_is_lazy_and_shutdown_restores_socket(monkeypatch):
"""DoH 线程池按需创建,并在模块关闭时恢复系统 DNS"""
original_getaddrinfo = socket.getaddrinfo
helper = object.__new__(doh.DohHelper)
monkeypatch.setattr(doh.settings, "DOH_DOMAINS", "example.com")
monkeypatch.setattr(doh.settings, "DOH_RESOLVERS", "resolver.test")
monkeypatch.setattr(doh, "_doh_query", lambda resolver, host: "203.0.113.7")
monkeypatch.setattr(doh, "_orig_getaddrinfo", lambda host, *args, **kwargs: [])
try:
helper.shutdown()
assert doh._executor is None
doh.enable_doh(True)
socket.getaddrinfo("example.com", None)
executor = doh._executor
assert executor is not None
helper.shutdown()
assert doh._executor is None
assert socket.getaddrinfo is doh._orig_getaddrinfo
assert getattr(executor, "_shutdown", False)
finally:
helper.shutdown()
socket.getaddrinfo = original_getaddrinfo
def test_doh_config_reload_disables_and_closes_executor(monkeypatch):
"""热更新关闭 DoH 时恢复系统 DNS 并释放已创建的线程池"""
original_getaddrinfo = socket.getaddrinfo
helper = object.__new__(doh.DohHelper)
monkeypatch.setattr(doh.settings, "DOH_DOMAINS", "example.com")
monkeypatch.setattr(doh.settings, "DOH_RESOLVERS", "resolver.test")
monkeypatch.setattr(doh, "_doh_query", lambda resolver, host: "203.0.113.7")
monkeypatch.setattr(doh, "_orig_getaddrinfo", lambda host, *args, **kwargs: [])
try:
helper.shutdown()
doh.enable_doh(True)
socket.getaddrinfo("example.com", None)
executor = doh._executor
assert executor is not None
monkeypatch.setattr(doh.settings, "DOH_ENABLE", False)
helper.on_config_changed()
assert doh._executor is None
assert getattr(executor, "_shutdown", False)
assert socket.getaddrinfo is doh._orig_getaddrinfo
finally:
helper.shutdown()
socket.getaddrinfo = original_getaddrinfo
def test_enable_doh_reuses_cached_host_resolution(monkeypatch):
"""
同一 DoH 域名第二次解析应命中缓存,避免重复请求远端解析器。
@@ -33,6 +88,7 @@ def test_enable_doh_reuses_cached_host_resolution(monkeypatch):
socket.getaddrinfo("example.com", None)
socket.getaddrinfo("example.com", None)
finally:
object.__new__(doh.DohHelper).shutdown()
socket.getaddrinfo = original_getaddrinfo
with doh._doh_lock:
doh._doh_cache.clear()

View File

@@ -102,6 +102,7 @@ class EmbyDashboardLinksTest(unittest.TestCase):
with (
patch.object(client, "_Emby__get_emby_librarys") as librarys,
patch.object(client, "_Emby__get_local_image_by_id") as image_by_id,
patch.object(client, "get_items_count", return_value=0),
):
librarys.return_value = [
{

View File

@@ -0,0 +1,405 @@
import asyncio
import signal
import threading
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import FastAPI
from app.startup import lifecycle, modules_initializer
from app.utils import http as http_utils
def _assert_completed_once(mock: MagicMock) -> None:
if isinstance(mock, AsyncMock):
mock.assert_awaited_once_with()
else:
mock.assert_called_once_with()
def _patch_lifespan(monkeypatch, *, failing_step: str | None = None) -> dict:
"""隔离 lifespan 的外部依赖,并按名称注入一个关闭失败"""
monkeypatch.setattr(lifecycle.settings, "MOVIEPILOT_SAFE_MODE", False)
monkeypatch.setattr(lifecycle.global_vars, "set_loop", MagicMock())
monkeypatch.setattr(lifecycle.global_vars, "stop_system", MagicMock())
for name in (
"init_routers",
"init_modules",
"init_plugins",
"init_scheduler",
"init_monitor",
"init_command",
"init_workflow",
):
monkeypatch.setattr(lifecycle, name, MagicMock())
system_chain = MagicMock()
monkeypatch.setattr(lifecycle, "SystemChain", MagicMock(return_value=system_chain))
monkeypatch.setattr(lifecycle, "init_extra", AsyncMock())
shutdown_steps = {
"backup_plugins": system_chain.backup_plugins,
"stop_workflow": MagicMock(),
"stop_command": MagicMock(),
"stop_monitor": MagicMock(),
"stop_scheduler": MagicMock(),
"stop_plugins": MagicMock(),
"stop_modules": AsyncMock(),
"close_http": AsyncMock(),
}
for name in (
"stop_workflow",
"stop_command",
"stop_monitor",
"stop_scheduler",
"stop_plugins",
):
monkeypatch.setattr(lifecycle, name, shutdown_steps[name])
monkeypatch.setattr(lifecycle, "stop_modules", shutdown_steps["stop_modules"])
monkeypatch.setattr(
lifecycle,
"aclose_shared_async_transports",
shutdown_steps["close_http"],
)
if failing_step:
shutdown_steps[failing_step].side_effect = RuntimeError(
f"{failing_step} failed"
)
logger_shutdown = MagicMock()
monkeypatch.setattr(lifecycle.LoggerManager, "shutdown", logger_shutdown)
shutdown_steps["logger"] = logger_shutdown
return shutdown_steps
@pytest.mark.parametrize(
"failing_step",
[
"backup_plugins",
"stop_workflow",
"stop_command",
"stop_monitor",
"stop_scheduler",
"stop_plugins",
"stop_modules",
"close_http",
],
)
def test_lifespan_continues_after_each_shutdown_owner_failure(
monkeypatch,
failing_step,
):
"""任一关闭阶段失败都不能跳过后续资源所有者"""
shutdown_steps = _patch_lifespan(monkeypatch, failing_step=failing_step)
async def run_lifespan():
async with lifecycle.lifespan(FastAPI()):
pass
asyncio.run(run_lifespan())
lifecycle.global_vars.stop_system.assert_called_once_with()
for step in shutdown_steps.values():
_assert_completed_once(step)
def test_uvicorn_signal_publishes_stop_before_server_exit(monkeypatch):
"""Uvicorn 接管系统信号时必须先发布协作停止标志"""
from app import main
calls = []
monkeypatch.setattr(main.global_vars, "stop_system", lambda: calls.append("stop"))
monkeypatch.setattr(
main.uvicorn.Server,
"handle_exit",
lambda _self, _sig, _frame: calls.append("uvicorn"),
)
server = object.__new__(main.MoviePilotServer)
server.handle_exit(signal.SIGTERM, None)
assert calls == ["stop", "uvicorn"]
def test_application_preserves_stop_requested_before_startup(monkeypatch):
"""启动流程不能清除初始化前已经发布的退出请求"""
from app import main
stop_event = threading.Event()
stop_event.set()
monkeypatch.setattr(main.global_vars, "STOP_EVENT", stop_event)
calls = []
monkeypatch.setattr(
main.signal,
"signal",
lambda *_args: calls.append("signal"),
)
monkeypatch.setattr(main, "start_tray", lambda: calls.append("tray"))
monkeypatch.setattr(main, "init_db", lambda: calls.append("init_db"))
monkeypatch.setattr(main, "update_db", lambda: calls.append("update_db"))
monkeypatch.setattr(main.Server, "run", lambda: calls.append("server"))
main.run_application()
assert stop_event.is_set()
assert calls == [
"signal",
"signal",
"tray",
"init_db",
"update_db",
"server",
]
def test_uvicorn_preserves_stop_requested_before_serve(monkeypatch):
"""Uvicorn 启动不能清除数据库初始化阶段已经发布的停止请求"""
from app import main
stop_event = threading.Event()
monkeypatch.setattr(main.global_vars, "STOP_EVENT", stop_event)
main.global_vars.stop_system()
async def serve(_self, sockets=None):
assert main.global_vars.is_system_stopped
monkeypatch.setattr(main.uvicorn.Server, "serve", serve)
server = object.__new__(main.MoviePilotServer)
asyncio.run(server.serve())
@pytest.mark.parametrize("endpoint_name", ["restart_system", "upgrade_system"])
@pytest.mark.parametrize(
"initially_stopped",
[False, True],
ids=["running", "stopping"],
)
def test_restart_endpoint_failure_preserves_stop_state(
monkeypatch,
endpoint_name,
initially_stopped,
):
"""重启或升级失败不能发布或撤销停止请求"""
from app.api.endpoints import system
stop_event = threading.Event()
if initially_stopped:
stop_event.set()
monkeypatch.setattr(system.global_vars, "STOP_EVENT", stop_event)
monkeypatch.setattr(system.SystemHelper, "can_restart", MagicMock(return_value=True))
monkeypatch.setattr(
system.SystemHelper,
"restart" if endpoint_name == "restart_system" else "upgrade",
MagicMock(return_value=(False, "restart failed")),
)
if endpoint_name == "restart_system":
response = system.restart_system(None)
else:
response = system.upgrade_system(None, None)
assert not response.success
assert stop_event.is_set() is initially_stopped
def test_command_restart_failure_does_not_publish_stop_request(monkeypatch):
"""命令重启失败时进程仍在运行,不能提前发布停止请求"""
from app.chain.system import SystemChain
from app.core.config import global_vars
stop_event = threading.Event()
monkeypatch.setattr(global_vars, "STOP_EVENT", stop_event)
monkeypatch.setattr(SystemChain, "backup_plugins", MagicMock())
restart = MagicMock(return_value=(False, "restart failed"))
monkeypatch.setattr("app.chain.system.SystemHelper.restart", restart)
chain = object.__new__(SystemChain)
chain.restart(channel=None, userid=None)
restart.assert_called_once_with()
assert not stop_event.is_set()
def test_stop_modules_continues_after_internal_owner_failures(monkeypatch):
"""模块关闭编排中的多个失败不能阻断其余清理"""
stop_agent = AsyncMock(side_effect=RuntimeError("agent failed"))
monkeypatch.setattr(modules_initializer, "stop_agent", stop_agent)
dependencies = _patch_module_shutdown_dependencies(monkeypatch)
dependencies["module"].side_effect = RuntimeError("module failed")
asyncio.run(modules_initializer.stop_modules())
stop_agent.assert_awaited_once_with()
for dependency in dependencies.values():
_assert_completed_once(dependency)
def _patch_module_shutdown_dependencies(monkeypatch) -> dict:
"""替换 stop_modules 的资源所有者,避免测试启动真实后台服务"""
dependencies = {}
for name, method_name in (
("ModuleManager", "stop"),
("EventManager", "stop"),
("DisplayHelper", "stop"),
("DohHelper", "shutdown"),
("ThreadHelper", "shutdown"),
("RedisHelper", "close"),
):
instance = MagicMock()
setattr(instance, method_name, MagicMock())
monkeypatch.setattr(
modules_initializer,
name,
MagicMock(return_value=instance),
)
key = name.removesuffix("Helper").removesuffix("Manager").lower()
dependencies[key] = getattr(instance, method_name)
for name in ("stop_message", "stop_frontend", "clear_temp"):
dependency = MagicMock()
monkeypatch.setattr(modules_initializer, name, dependency)
dependencies[name] = dependency
async_redis = MagicMock()
async_redis.close = AsyncMock()
monkeypatch.setattr(
modules_initializer,
"AsyncRedisHelper",
MagicMock(return_value=async_redis),
)
dependencies["async_redis"] = async_redis.close
close_database = AsyncMock()
monkeypatch.setattr(modules_initializer, "close_database", close_database)
dependencies["close_database"] = close_database
return dependencies
def test_shared_http_close_waits_for_real_lru_eviction(monkeypatch):
"""最终 HTTP 关闭必须等待真实 LRU 淘汰任务并消费其异常"""
class FakeTransport:
created = []
def __init__(self, **_kwargs):
self.close_started = asyncio.Event()
self.release_close = asyncio.Event()
self.closed = False
self.fail_on_close = not self.created
if not self.fail_on_close:
self.release_close.set()
self.created.append(self)
async def aclose(self):
self.close_started.set()
await self.release_close.wait()
self.closed = True
if self.fail_on_close:
raise RuntimeError("eviction close failed")
monkeypatch.setattr(http_utils, "_MAX_SHARED_TRANSPORTS_PER_LOOP", 1)
monkeypatch.setattr(http_utils.httpx, "AsyncHTTPTransport", FakeTransport)
debug = MagicMock()
monkeypatch.setattr(http_utils.logger, "debug", debug)
async def run_test():
transport_kwargs = {
"proxy": None,
"verify": True,
"http2": False,
"max_keepalive_connections": 1,
"max_connections": 1,
}
evicted_transport = http_utils._get_shared_async_transport(
**transport_kwargs,
keepalive_expiry=1,
)
active_transport = http_utils._get_shared_async_transport(
**transport_kwargs,
keepalive_expiry=2,
)
await asyncio.wait_for(evicted_transport.close_started.wait(), timeout=1)
loop = asyncio.get_running_loop()
with http_utils._shared_async_transports_lock:
eviction_tasks = [
task
for task in http_utils._pending_eviction_tasks
if task.get_loop() is loop
]
assert len(eviction_tasks) == 1
close_task = asyncio.create_task(http_utils.aclose_shared_async_transports())
await asyncio.sleep(0)
try:
assert not close_task.done()
evicted_transport.release_close.set()
await close_task
await asyncio.sleep(0)
assert eviction_tasks[0].done()
assert evicted_transport.closed
assert active_transport.closed
with http_utils._shared_async_transports_lock:
assert not any(
task.get_loop() is loop
for task in http_utils._pending_eviction_tasks
)
finally:
evicted_transport.release_close.set()
active_transport.release_close.set()
await asyncio.gather(close_task, return_exceptions=True)
await http_utils.aclose_shared_async_transports()
asyncio.run(run_test())
debug.assert_any_call(
"LRU 淘汰共享 transport 时关闭失败: "
"RuntimeError('eviction close failed')"
)
def test_shared_http_close_ignores_eviction_from_other_loop():
"""当前事件循环关闭不能等待其他循环持有的淘汰任务"""
ready = threading.Event()
release = threading.Event()
failures = []
state = {}
def run_foreign_loop():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
async def delayed_close():
while not release.is_set():
await asyncio.sleep(0.01)
task = loop.create_task(delayed_close())
state["task"] = task
with http_utils._shared_async_transports_lock:
http_utils._pending_eviction_tasks.add(task)
task.add_done_callback(http_utils._discard_pending_eviction_task)
ready.set()
try:
loop.run_until_complete(task)
loop.run_until_complete(asyncio.sleep(0))
except BaseException as err:
failures.append(err)
finally:
with http_utils._shared_async_transports_lock:
http_utils._pending_eviction_tasks.discard(task)
loop.close()
thread = threading.Thread(target=run_foreign_loop)
thread.start()
try:
assert ready.wait(timeout=2)
asyncio.run(http_utils.aclose_shared_async_transports())
assert thread.is_alive()
assert not state["task"].done()
finally:
release.set()
thread.join(timeout=2)
assert not thread.is_alive()
assert not failures

View File

@@ -0,0 +1,392 @@
"""Amazon Bedrock provider 的凭证解析、Region 提取与运行时解析测试"""
import asyncio
import time
from unittest.mock import MagicMock, patch
import pytest
from app.agent.llm.provider import (
LLMProviderAuthError,
LLMProviderManager,
)
@pytest.fixture(autouse=True)
def _reset_manager_singleton():
"""每个用例前后清理 LLMProviderManager 单例,避免缓存互相污染"""
LLMProviderManager._instances.clear()
yield
LLMProviderManager._instances.clear()
def test_bedrock_provider_registered():
manager = LLMProviderManager()
spec = manager.get_provider("amazon-bedrock")
assert spec.runtime == "bedrock"
assert spec.model_list_strategy == "bedrock"
assert spec.base_url_editable is True
assert spec.default_base_url == "https://bedrock-runtime.us-east-1.amazonaws.com"
preset_ids = {preset.id for preset in spec.base_url_presets}
assert "bedrock-us-east-1" in preset_ids
assert "bedrock-ap-northeast-1" in preset_ids
def test_parse_bedrock_credentials_bearer_api_key():
credentials = LLMProviderManager._parse_bedrock_credentials(
"bedrock-api-key-abcdef123456"
)
assert credentials["auth_scheme"] == "bearer"
assert credentials["bearer_token"] == "bedrock-api-key-abcdef123456"
def test_parse_bedrock_credentials_sigv4_ak_sk():
credentials = LLMProviderManager._parse_bedrock_credentials(
"AKIAIOSFODNN7EXAMPLE:wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
)
assert credentials["auth_scheme"] == "sigv4"
assert credentials["access_key_id"] == "AKIAIOSFODNN7EXAMPLE"
assert credentials["secret_access_key"] == "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
assert "session_token" not in credentials
def test_parse_bedrock_credentials_sigv4_with_session_token():
credentials = LLMProviderManager._parse_bedrock_credentials(
"ASIAIOSFODNN7EXAMPLE:secret/key:session-token-value"
)
assert credentials["auth_scheme"] == "sigv4"
assert credentials["session_token"] == "session-token-value"
def test_parse_bedrock_credentials_empty_rejected():
with pytest.raises(LLMProviderAuthError):
LLMProviderManager._parse_bedrock_credentials("")
def test_parse_bedrock_credentials_malformed_colon_rejected():
with pytest.raises(LLMProviderAuthError):
LLMProviderManager._parse_bedrock_credentials("AKIA123:")
def test_extract_bedrock_region_from_base_url():
"""应从标准、FIPS 与 PrivateLink Bedrock 端点提取 Region"""
extract = LLMProviderManager._extract_bedrock_region
assert extract("https://bedrock-runtime.us-east-1.amazonaws.com") == "us-east-1"
assert extract("https://bedrock-runtime.ap-northeast-1.amazonaws.com/") == "ap-northeast-1"
assert extract("https://bedrock-runtime.mx-central-1.amazonaws.com") == "mx-central-1"
assert extract("https://bedrock.eu-central-1.amazonaws.com") == "eu-central-1"
# FIPS 与 PrivateLinkVPCE端点同样能识别 Region
assert extract("https://bedrock-runtime-fips.us-east-1.amazonaws.com") == "us-east-1"
assert (
extract("https://vpce-0abc123-xyz.bedrock-runtime.us-west-2.vpce.amazonaws.com")
== "us-west-2"
)
# 无法识别时回退默认 Region
assert extract("https://example.com/us-west-2") == "us-east-1"
assert extract("https://example.com?region=.us-west-2.") == "us-east-1"
assert extract("https://example.com") == "us-east-1"
assert extract(None) == "us-east-1"
assert extract("") == "us-east-1"
def test_bedrock_endpoint_url_passthrough():
"""自定义 Bedrock 端点应透传,标准端点交由 boto3 推导"""
resolve = LLMProviderManager._bedrock_endpoint_url
# 标准公有端点交由 boto3 推导,不显式透传
assert resolve("bedrock-runtime", "https://bedrock-runtime.us-east-1.amazonaws.com") is None
assert resolve("bedrock", "https://bedrock.eu-central-1.amazonaws.com") is None
assert resolve("bedrock-runtime", None) is None
assert resolve("bedrock-runtime", "") is None
# FIPS / PrivateLink 等非标准端点需要显式生效
assert (
resolve("bedrock-runtime", "https://bedrock-runtime-fips.us-east-1.amazonaws.com")
== "https://bedrock-runtime-fips.us-east-1.amazonaws.com"
)
assert (
resolve(
"bedrock-runtime",
"https://vpce-0abc123-xyz.bedrock-runtime.us-west-2.vpce.amazonaws.com/",
)
== "https://vpce-0abc123-xyz.bedrock-runtime.us-west-2.vpce.amazonaws.com"
)
# runtime 端点填给控制面服务名时不匹配标准形态,同样透传
assert (
resolve("bedrock", "https://bedrock-runtime.us-east-1.amazonaws.com")
== "https://bedrock-runtime.us-east-1.amazonaws.com"
)
def test_create_bedrock_client_uses_custom_endpoint():
"""创建 Bedrock 客户端时应把 PrivateLink 地址传给 boto3"""
manager = LLMProviderManager()
endpoint_url = (
"https://vpce-0abc123-xyz.bedrock-runtime.us-west-2.vpce.amazonaws.com"
)
client = MagicMock()
with patch("boto3.client", return_value=client) as create_client:
result = manager.create_bedrock_client(
service_name="bedrock-runtime",
region="us-west-2",
credentials={
"auth_scheme": "sigv4",
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
"secret_access_key": "secret",
},
base_url=endpoint_url,
use_proxy=False,
)
assert result is client
assert create_client.call_args.kwargs["endpoint_url"] == endpoint_url
def test_resolve_runtime_bedrock_bearer():
manager = LLMProviderManager()
runtime = asyncio.run(
manager.resolve_runtime(
provider_id="amazon-bedrock",
model="global.anthropic.claude-haiku-4-5-20251001-v1:0",
api_key="bedrock-api-key-abc123",
base_url="https://bedrock-runtime.ap-northeast-1.amazonaws.com",
)
)
assert runtime["runtime"] == "bedrock"
assert runtime["aws_region"] == "ap-northeast-1"
assert runtime["aws_auth"]["auth_scheme"] == "bearer"
def test_resolve_runtime_bedrock_sigv4_default_region():
manager = LLMProviderManager()
runtime = asyncio.run(
manager.resolve_runtime(
provider_id="amazon-bedrock",
model="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
api_key="AKIAIOSFODNN7EXAMPLE:wJalrXUtnFEMI/K7MDENG",
)
)
assert runtime["runtime"] == "bedrock"
assert runtime["aws_region"] == "us-east-1"
assert runtime["aws_auth"]["auth_scheme"] == "sigv4"
assert runtime["aws_auth"]["access_key_id"] == "AKIAIOSFODNN7EXAMPLE"
def test_resolve_runtime_bedrock_missing_credentials_rejected():
manager = LLMProviderManager()
with pytest.raises(LLMProviderAuthError):
asyncio.run(
manager.resolve_runtime(
provider_id="amazon-bedrock",
model="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
api_key=None,
)
)
def test_bedrock_model_matches_region():
"""目录模型应按 Profile 分区及裸模型 ON_DEMAND Region 过滤"""
matches = LLMProviderManager._bedrock_model_matches_region
# 已知裸模型 ID 仅在其支持 ON_DEMAND 的 Region 保留
assert matches("anthropic.claude-3-5-sonnet-20241022-v2:0", "us-west-2")
assert matches("anthropic.claude-3-5-sonnet-20241022-v2:0", "ap-southeast-2")
assert not matches("anthropic.claude-3-5-sonnet-20241022-v2:0", "ap-northeast-1")
assert not matches("anthropic.claude-sonnet-4-5-20250929-v1:0", "us-west-2")
assert not matches("amazon.nova-premier-v1:0", "ap-northeast-1")
assert not matches("meta.llama4-maverick-17b-instruct-v1:0", "ap-northeast-1")
# 已确认支持 ON_DEMAND 的裸模型与 global Profile 维持可用
assert matches("amazon.nova-lite-v1:0", "ap-northeast-1")
assert matches("openai.gpt-oss-20b-1:0", "ap-northeast-1")
assert not matches("openai.gpt-oss-20b-1:0", "ap-southeast-1")
assert matches("global.anthropic.claude-sonnet-4-5-20250929-v1:0", "ap-northeast-1")
assert not matches(
"global.anthropic.claude-sonnet-4-5-20250929-v1:0",
"us-gov-west-1",
)
# 地理前缀只在对应分区 Region 可调用
assert matches("us.anthropic.claude-haiku-4-5-20251001-v1:0", "us-west-2")
assert not matches("us.anthropic.claude-haiku-4-5-20251001-v1:0", "ap-northeast-1")
assert not matches("us.anthropic.claude-haiku-4-5-20251001-v1:0", "us-gov-west-1")
assert matches("apac.amazon.nova-micro-v1:0", "ap-southeast-1")
assert not matches("apac.amazon.nova-micro-v1:0", "eu-central-1")
assert matches("eu.anthropic.claude-haiku-4-5-20251001-v1:0", "eu-central-1")
assert not matches("eu.anthropic.claude-haiku-4-5-20251001-v1:0", "us-east-1")
assert not matches("eu.anthropic.claude-haiku-4-5-20251001-v1:0", "eu-isoe-west-1")
def test_bedrock_au_profile_matches_melbourne_region():
"""AU Inference Profile 应允许从悉尼和墨尔本 Region 调用"""
matches = LLMProviderManager._bedrock_model_matches_region
assert matches("au.amazon.nova-lite-v1:0", "ap-southeast-2")
assert matches("au.amazon.nova-lite-v1:0", "ap-southeast-4")
def test_list_models_bedrock_custom_endpoint_skips_control_plane():
"""自定义 runtime 端点刷新模型时应直接使用离线目录"""
manager = LLMProviderManager()
manager._models_dev_data = {
"amazon-bedrock": {
"id": "amazon-bedrock",
"name": "Amazon Bedrock",
"models": {
"global.anthropic.claude-sonnet-4-5-20250929-v1:0": {
"name": "Claude Sonnet 4.5 (Global)",
"limit": {"context": 200000, "output": 64000},
},
},
}
}
manager._models_dev_loaded_at = time.time()
with patch.object(
LLMProviderManager,
"create_bedrock_client",
side_effect=AssertionError("不应访问控制面"),
):
models = asyncio.run(
manager._list_models_from_bedrock(
api_key="bedrock-api-key-runtime-only",
base_url="https://bedrock-runtime-fips.us-east-1.amazonaws.com",
use_proxy=False,
)
)
assert [model["id"] for model in models] == [
"global.anthropic.claude-sonnet-4-5-20250929-v1:0"
]
def test_list_models_bedrock_keeps_control_plane_on_demand_models():
"""控制面返回的 ON_DEMAND 基础模型不应被静态降级规则遗漏"""
manager = LLMProviderManager()
client = MagicMock()
client.get_paginator.return_value.paginate.return_value = [
{
"inferenceProfileSummaries": [
{
"inferenceProfileId": (
"global.anthropic.claude-sonnet-4-5-20250929-v1:0"
),
"inferenceProfileName": "Claude Sonnet 4.5 (Global)",
"status": "ACTIVE",
}
]
}
]
client.list_foundation_models.return_value = {
"modelSummaries": [
{
"modelId": "openai.gpt-oss-20b-1:0",
"modelName": "GPT OSS 20B",
"modelLifecycle": {"status": "ACTIVE"},
},
{
"modelId": "amazon.nova-lite-v1:0",
"modelName": "Nova Lite",
"modelLifecycle": {"status": "ACTIVE"},
},
]
}
with patch.object(
LLMProviderManager, "create_bedrock_client", return_value=client
):
models = asyncio.run(
manager._list_models_from_bedrock(
api_key="bedrock-api-key-runtime-only",
base_url="https://bedrock-runtime.ap-northeast-1.amazonaws.com",
use_proxy=False,
)
)
assert {model["id"] for model in models} == {
"amazon.nova-lite-v1:0",
"global.anthropic.claude-sonnet-4-5-20250929-v1:0",
"openai.gpt-oss-20b-1:0",
}
client.close.assert_called_once()
def test_list_models_bedrock_falls_back_to_models_dev_on_control_plane_denial():
"""控制面被拒(如 API Key 仅授权 bedrock-runtime时降级 models.dev 目录"""
manager = LLMProviderManager()
# 预填 models.dev 内存缓存,降级路径不触发真实网络请求
manager._models_dev_data = {
"amazon-bedrock": {
"id": "amazon-bedrock",
"name": "Amazon Bedrock",
"models": {
"anthropic.claude-3-5-sonnet-20241022-v2:0": {
"name": "Claude Sonnet 3.5 v2",
"limit": {"context": 200000, "output": 8192},
},
"amazon.nova-lite-v1:0": {
"name": "Nova Lite",
"limit": {"context": 300000, "output": 5000},
},
"openai.gpt-oss-20b-1:0": {
"name": "GPT OSS 20B",
"limit": {"context": 131072, "output": 16384},
},
"apac.amazon.nova-lite-v1:0": {
"name": "Nova Lite (APAC)",
"limit": {"context": 300000, "output": 5000},
},
"meta.llama4-maverick-17b-instruct-v1:0": {
"name": "Llama 4 Maverick",
"limit": {"context": 1000000, "output": 8192},
},
"anthropic.claude-sonnet-4-5-20250929-v1:0": {
"name": "Claude Sonnet 4.5",
"limit": {"context": 200000, "output": 64000},
},
"global.anthropic.claude-sonnet-4-5-20250929-v1:0": {
"name": "Claude Sonnet 4.5 (Global)",
"limit": {"context": 200000, "output": 64000},
},
"us.anthropic.claude-haiku-4-5-20251001-v1:0": {
"name": "Claude Haiku 4.5 (US)",
"limit": {"context": 200000, "output": 64000},
},
},
}
}
manager._models_dev_loaded_at = time.time()
denied_client = MagicMock()
denied_client.get_paginator.side_effect = Exception(
"AccessDeniedException: not authorized to perform bedrock:ListInferenceProfiles"
)
with patch.object(
LLMProviderManager, "create_bedrock_client", return_value=denied_client
):
models = asyncio.run(
manager._list_models_from_bedrock(
api_key="bedrock-api-key-runtime-only",
base_url="https://bedrock-runtime.ap-northeast-1.amazonaws.com",
use_proxy=False,
)
)
# 降级后仅保留东京 Region 可调用的裸模型与 Profile
model_ids = {m["id"] for m in models}
assert "anthropic.claude-3-5-sonnet-20241022-v2:0" not in model_ids
assert "amazon.nova-lite-v1:0" in model_ids
assert "openai.gpt-oss-20b-1:0" in model_ids
assert "apac.amazon.nova-lite-v1:0" in model_ids
assert "meta.llama4-maverick-17b-instruct-v1:0" not in model_ids
assert "global.anthropic.claude-sonnet-4-5-20250929-v1:0" in model_ids
assert "us.anthropic.claude-haiku-4-5-20251001-v1:0" not in model_ids
assert "anthropic.claude-sonnet-4-5-20250929-v1:0" not in model_ids
assert all(m["source"] == "models.dev" for m in models)
denied_client.close.assert_called_once()

149
tests/test_log_shutdown.py Normal file
View File

@@ -0,0 +1,149 @@
import threading
import time
from unittest.mock import MagicMock
from app.log import LogEntry, NonBlockingFileHandler, log_settings
def test_non_blocking_file_handler_shutdown_wakes_writer_and_closes_handlers(tmp_path):
"""日志关闭应立即唤醒空闲写线程,并关闭所有已打开的文件处理器"""
original_instance = NonBlockingFileHandler._instance
NonBlockingFileHandler._instance = None
handler = NonBlockingFileHandler()
handler._rotating_handlers = {}
log_handler = handler._get_rotating_handler(tmp_path / "shutdown.log")
try:
started_at = time.monotonic()
handler.shutdown()
elapsed = time.monotonic() - started_at
assert elapsed < 1
assert not handler._write_thread.is_alive()
assert log_handler.stream is None
assert handler._write_non_blocking(
LogEntry("info", "late-message", tmp_path / "shutdown.log")
) is False
assert handler._write_queue.empty()
finally:
if handler._write_thread.is_alive():
handler._running = False
handler._write_thread.join(timeout=5)
if log_handler.stream is not None:
log_handler.close()
NonBlockingFileHandler._instance = original_instance
def test_non_blocking_file_handler_shutdown_drains_queued_batches(monkeypatch, tmp_path):
"""停止标记之前已进入队列的日志应跨批次全部写完"""
original_instance = NonBlockingFileHandler._instance
NonBlockingFileHandler._instance = None
monkeypatch.setattr(log_settings, "BATCH_WRITE_SIZE", 2)
handler = NonBlockingFileHandler()
handler._rotating_handlers = {}
written = []
monkeypatch.setattr(
handler,
"_write_batch",
lambda batch: written.extend(entry.message for entry in batch),
)
try:
for index in range(5):
handler._write_non_blocking(
LogEntry("info", f"message-{index}", tmp_path / "drain.log")
)
handler.shutdown()
assert written == [f"message-{index}" for index in range(5)]
assert not handler._write_thread.is_alive()
finally:
if handler._write_thread.is_alive():
handler._running = False
handler._write_queue.put(handler._stop_sentinel)
handler._write_thread.join(timeout=5)
NonBlockingFileHandler._instance = original_instance
def test_non_blocking_file_handler_creates_one_handler_for_concurrent_first_write(monkeypatch, tmp_path):
"""同一路径首次并发写入时只创建并关闭一个文件处理器"""
original_instance = NonBlockingFileHandler._instance
NonBlockingFileHandler._instance = None
handler = NonBlockingFileHandler()
handler._rotating_handlers = {}
first_created = threading.Event()
second_started = threading.Event()
release_first = threading.Event()
created_handlers = []
results = []
class ProbeHandler:
def __init__(self, **kwargs):
self.closed = False
created_handlers.append(self)
if len(created_handlers) == 1:
first_created.set()
release_first.wait(timeout=2)
@staticmethod
def setFormatter(formatter):
pass
@staticmethod
def flush():
pass
def close(self):
self.closed = True
monkeypatch.setattr("app.log.RotatingFileHandler", ProbeHandler)
file_path = tmp_path / "concurrent.log"
def get_handler(started=None):
if started:
started.set()
results.append(handler._get_rotating_handler(file_path))
first = threading.Thread(target=get_handler)
second = threading.Thread(target=get_handler, args=(second_started,))
try:
first.start()
assert first_created.wait(timeout=1)
second.start()
assert second_started.wait(timeout=1)
time.sleep(0.05)
release_first.set()
first.join(timeout=2)
second.join(timeout=2)
assert len(created_handlers) == 1
assert results[0] is results[1]
handler.shutdown()
assert created_handlers[0].closed is True
finally:
release_first.set()
first.join(timeout=2)
second.join(timeout=2)
handler.shutdown()
NonBlockingFileHandler._instance = original_instance
def test_non_blocking_file_handler_uses_handler_lock(monkeypatch, tmp_path):
"""日志写入通过 Handler 入口串行化 emit 与 rollover"""
original_instance = NonBlockingFileHandler._instance
NonBlockingFileHandler._instance = None
handler = NonBlockingFileHandler()
handler._rotating_handlers = {}
log_handler = MagicMock()
monkeypatch.setattr(handler, "_get_rotating_handler", MagicMock(return_value=log_handler))
try:
handler._write_sync(LogEntry("info", "message", tmp_path / "locked.log"))
log_handler.handle.assert_called_once()
log_handler.emit.assert_not_called()
finally:
handler.shutdown()
NonBlockingFileHandler._instance = original_instance

View File

@@ -20,6 +20,16 @@ def clear_media_interactions():
plugin_input_interaction_manager.clear()
@pytest.fixture(autouse=True)
def mock_default_media_search():
"""未显式验证搜索结果的消息路由用例不访问真实媒体元数据服务"""
with patch(
"app.chain.media.MediaChain.search",
side_effect=lambda title: (_build_meta(title), []),
):
yield
def _build_meta(name: str) -> MetaBase:
"""构造媒体识别元数据。"""
meta = MetaBase(name)

View File

@@ -0,0 +1,30 @@
import time
from app.helper.message import MessageQueueManager, TemplateHelper, stop_message
from app.utils.singleton import SingletonClass
def test_message_queue_stop_wakes_idle_monitor(monkeypatch):
"""消息队列停止时应唤醒空闲监控线程,不等待完整检查周期"""
monkeypatch.setattr(MessageQueueManager, "init_config", lambda self: None)
manager = object.__new__(MessageQueueManager)
manager.__init__(check_interval=10)
started_at = time.monotonic()
manager.stop()
elapsed = time.monotonic() - started_at
assert elapsed < 1
assert not manager.thread.is_alive()
def test_stop_message_does_not_initialize_absent_services(monkeypatch):
"""消息服务未初始化时,关闭入口不应为了清理而创建后台资源"""
monkeypatch.setattr(SingletonClass, "_instances", {})
assert MessageQueueManager.get_existing_instance() is None
assert TemplateHelper.get_existing_instance() is None
stop_message()
assert MessageQueueManager not in SingletonClass._instances
assert TemplateHelper not in SingletonClass._instances

View File

@@ -0,0 +1,187 @@
import threading
from unittest.mock import Mock, patch
import pytest
from app.modules import _MessageBase
from app.modules.discord import DiscordModule
from app.modules.feishu import FeishuModule
from app.modules.filter import FilterModule
from app.modules.plex import PlexModule
from app.modules.qqbot import QQBotModule
from app.modules.slack import SlackModule
from app.modules.telegram import TelegramModule
from app.modules.telegram.telegram import Telegram
from app.modules.themoviedb import TheMovieDbModule
from app.modules.trimemedia import TrimeMediaModule
from app.modules.ugreen import UgreenModule
from app.modules.wechat import WechatModule
from app.modules.wechatclawbot import WechatClawBotModule
def test_config_reload_stops_before_initializing_latest_generation():
"""同一模块的重载必须串行,并依次停止和初始化 generation。"""
module = TelegramModule()
call_order = []
reload_started = threading.Event()
reload_finished = threading.Event()
def reload_module():
reload_started.set()
module.on_config_changed()
reload_finished.set()
with patch.object(
module, "stop", side_effect=lambda: call_order.append("stop")
), patch.object(
_MessageBase,
"init_service",
side_effect=lambda **_kwargs: call_order.append("init"),
):
module._reload_lock.acquire()
try:
reload_thread = threading.Thread(target=reload_module)
reload_thread.start()
assert reload_started.wait(1)
assert not reload_finished.wait(0.1)
finally:
module._reload_lock.release()
assert reload_finished.wait(1)
reload_thread.join()
assert call_order == ["stop", "init"]
def test_initialization_does_not_stop_a_fresh_module_generation():
"""首次初始化只创建资源,停止旧 generation 由重载入口负责。"""
module = TelegramModule()
with patch.object(module, "stop") as stop, patch.object(
_MessageBase, "init_service"
) as init_service:
module.init_module()
stop.assert_not_called()
init_service.assert_called_once()
def test_config_reload_initializes_latest_generation_after_stop_failure():
"""旧资源停止异常只记录错误,不阻止最新配置完成初始化。"""
module = TelegramModule()
call_order = []
def stop_with_failure():
call_order.append("stop")
raise RuntimeError("stop failed")
with patch.object(
module,
"stop",
side_effect=stop_with_failure,
), patch.object(
_MessageBase,
"init_service",
side_effect=lambda **_kwargs: call_order.append("init"),
):
module.on_config_changed()
assert call_order == ["stop", "init"]
def test_tmdb_reload_closes_old_client_when_cache_save_fails():
"""TMDB 缓存保存失败时仍须关闭旧客户端并初始化最新配置。"""
module = TheMovieDbModule()
module.cache = Mock()
module.cache.save.side_effect = OSError("cache write failed")
module.tmdb = Mock()
with patch.object(module, "init_module") as init_module:
module.on_config_changed()
module.tmdb.close.assert_called_once_with()
init_module.assert_called_once_with()
def test_filter_reload_uses_shared_module_lifecycle_lock():
"""过滤规则重载必须经过模块基类的串行 stop 和 init。"""
module = FilterModule()
reload_started = threading.Event()
reload_finished = threading.Event()
call_order = []
def reload_module():
reload_started.set()
module.on_config_changed()
reload_finished.set()
with patch(
"app.modules.filter.clear_rust_parse_options_cache",
side_effect=lambda: call_order.append("stop"),
), patch.object(
module, "init_module", side_effect=lambda: call_order.append("init")
):
module._reload_lock.acquire()
try:
reload_thread = threading.Thread(target=reload_module)
reload_thread.start()
assert reload_started.wait(1)
assert not reload_finished.wait(0.1)
finally:
module._reload_lock.release()
assert reload_finished.wait(1)
reload_thread.join()
assert call_order == ["stop", "init"]
@pytest.mark.parametrize(
("module_type", "stop_method", "requires_authentication"),
[
(DiscordModule, "stop", False),
(FeishuModule, "stop", False),
(QQBotModule, "stop", False),
(SlackModule, "stop", False),
(TelegramModule, "stop", False),
(WechatModule, "stop", False),
(WechatClawBotModule, "stop", False),
(PlexModule, "close", False),
(TrimeMediaModule, "disconnect", True),
(UgreenModule, "disconnect", True),
],
)
def test_module_stop_isolates_each_service_instance(
module_type, stop_method, requires_authentication
):
"""单个服务停止失败时必须继续关闭同模块的其余实例。"""
module = module_type()
failed_client = Mock()
healthy_client = Mock()
getattr(failed_client, stop_method).side_effect = RuntimeError("stop failed")
if requires_authentication:
failed_client.is_authenticated.return_value = True
healthy_client.is_authenticated.return_value = True
module._instances = {"failed": failed_client, "healthy": healthy_client}
module.stop()
getattr(failed_client, stop_method).assert_called_once_with()
getattr(healthy_client, stop_method).assert_called_once_with()
def test_telegram_stop_closes_sdk_and_waits_for_polling_thread():
"""客户端停止完成后不得保留 SDK worker 或 polling 线程句柄。"""
client = Telegram.__new__(Telegram)
bot = Mock()
client._bot = bot
polling_thread = Mock()
client._polling_thread = polling_thread
client.stop()
client.stop()
bot.stop_bot.assert_called_once_with()
polling_thread.join.assert_called_once_with()
assert client._bot is None
assert client._polling_thread is None

View File

@@ -0,0 +1,22 @@
import socket
import pytest
from app.testing.network_guard import block_real_network
def test_network_guard_fails_when_blocked_attempt_is_swallowed(monkeypatch):
"""业务代码即使捕获网络异常,网络守卫仍应在用例收尾报告失败"""
fixture = block_real_network.__wrapped__(monkeypatch)
next(fixture)
try:
try:
socket.getaddrinfo("external.example", 443)
except RuntimeError:
pass
with pytest.raises(pytest.fail.Exception, match="external.example"):
next(fixture)
finally:
monkeypatch.undo()

View File

@@ -1,3 +1,4 @@
import threading
from pathlib import Path
from types import SimpleNamespace
from typing import Iterator
@@ -7,9 +8,11 @@ import pytest
from packaging.version import Version
from watchfiles import Change
from app.core.event import Event, eventmanager
from app.core.plugin import PluginManager
from app.helper.plugin import PluginHelper
from app.schemas.types import SystemConfigKey
from app.scheduler import Scheduler
from app.schemas.types import EventType, SystemConfigKey
from app.utils.singleton import Singleton
@@ -79,6 +82,34 @@ def _set_running_render_mode(
)
class _FakeSchedulerBackend:
"""提供插件服务增删所需的最小 APScheduler 契约。"""
def __init__(self, job_ids: list[str]):
self.jobs = {job_id: {"id": job_id} for job_id in job_ids}
def get_jobs(self):
"""返回当前注册的 APScheduler job。"""
return [SimpleNamespace(id=job_id) for job_id in self.jobs]
def remove_job(self, job_id: str) -> None:
"""移除指定 APScheduler job。"""
self.jobs.pop(job_id)
def add_job(self, func, trigger, **kwargs) -> None:
"""记录并替换指定 APScheduler job。"""
self.jobs[kwargs["id"]] = {"func": func, "trigger": trigger, **kwargs}
def _build_scheduler_for_plugin_reload(jobs: dict, backend) -> Scheduler:
"""构造不启动后台线程的插件服务 Scheduler。"""
scheduler = object.__new__(Scheduler)
scheduler._lock = threading.RLock()
scheduler._jobs = jobs
scheduler._scheduler = backend
return scheduler
def test_dev_local_plugin_candidate_keeps_hot_sync_allowed_when_system_version_lags(
tmp_path,
monkeypatch,
@@ -511,3 +542,53 @@ def test_local_python_and_federated_changes_share_one_batch_sync(
assert sync_spy.call_count == 1
reload_spy.assert_called_once_with("DemoPlugin")
def test_plugin_reload_refreshes_scheduler_services_idempotently(monkeypatch):
"""插件重载事件必须按当前服务拓扑幂等刷新 Scheduler。"""
current_func = Mock()
plugin_manager = Mock()
plugin_manager.get_plugin_services.return_value = [
{
"id": "new",
"name": "新服务",
"func": current_func,
"trigger": "interval",
"kwargs": {"minutes": 5},
"func_kwargs": {"marker": "new"},
}
]
plugin_manager.get_plugin_attr.return_value = "测试插件"
monkeypatch.setattr("app.scheduler.PluginManager", lambda: plugin_manager)
backend = _FakeSchedulerBackend(["DemoPlugin_old"])
scheduler = _build_scheduler_for_plugin_reload(
jobs={
"DemoPlugin_old": {
"func": Mock(),
"name": "旧服务",
"pid": "DemoPlugin",
}
},
backend=backend,
)
event = Event(EventType.PluginReload, {"plugin_id": "DemoPlugin"})
reload_handlers = {
item["handler_identifier"]
for item in eventmanager.visualize_handlers()
if item["event_type"] == EventType.PluginReload.value
and item["status"] == "enabled"
}
assert "app.scheduler.Scheduler.on_plugin_reload" in reload_handlers
scheduler.on_plugin_reload(event)
scheduler.on_plugin_reload(event)
assert set(scheduler._jobs) == {"DemoPlugin_new"}
service = scheduler._jobs["DemoPlugin_new"]
assert service["func"] is current_func
assert service["kwargs"] == {"marker": "new"}
assert set(backend.jobs) == {"DemoPlugin_new"}
registered_job = backend.jobs["DemoPlugin_new"]
assert registered_job["trigger"] == "interval"
assert registered_job["minutes"] == 5
assert registered_job["kwargs"] == {"job_id": "DemoPlugin_new"}

29
tests/test_singleton.py Normal file
View File

@@ -0,0 +1,29 @@
from app.utils.singleton import Singleton, SingletonClass
def test_singleton_class_can_read_existing_instance_without_creating(monkeypatch):
"""按类单例可以只读取已存在实例"""
class Example(metaclass=SingletonClass):
pass
monkeypatch.setattr(SingletonClass, "_instances", {})
assert Example.get_existing_instance() is None
instance = Example()
assert Example.get_existing_instance() is instance
def test_parameterized_singleton_can_read_matching_instance_without_creating(monkeypatch):
"""参数化单例按相同参数读取已存在实例"""
class Example(metaclass=Singleton):
def __init__(self, name):
self.name = name
monkeypatch.setattr(Singleton, "_instances", {})
assert Example.get_existing_instance("first") is None
instance = Example("first")
assert Example.get_existing_instance("first") is instance
assert Example.get_existing_instance("second") is None

View File

@@ -362,7 +362,7 @@ class SubscribeEndpointTest(TestCase):
with patch(
"app.api.endpoints.subscribe.Subscribe.async_list_by_username",
new=AsyncMock(return_value=owned),
):
), patch("app.api.endpoints.subscribe.Scheduler") as scheduler_cls:
response = asyncio.run(
search_subscribes(
background_tasks=background_tasks,
@@ -376,6 +376,7 @@ class SubscribeEndpointTest(TestCase):
[task["kwargs"]["sid"] for task in background_tasks.tasks],
[17, 18],
)
self.assertEqual(scheduler_cls.return_value.start.call_count, 0)
def test_subscribe_files_hides_other_user_row(self):
"""

View File

@@ -0,0 +1,143 @@
"""订阅文件统计相关测试"""
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from app.chain.subscribe import SubscribeChain
from app.modules.filemanager import FileManagerModule
from app.schemas.mediaserver import ExistMediaInfo
from app.schemas.types import MediaType
def _build_subscribe(**overrides):
data = {
"id": 1,
"name": "Test Show",
"year": "2026",
"type": MediaType.TV.value,
"season": 1,
"tmdbid": None,
"doubanid": None,
"imdbid": None,
"tvdbid": None,
"bangumiid": None,
"episode_group": None,
"start_episode": 1,
"total_episode": 2,
}
data.update(overrides)
subscribe = SimpleNamespace(**data)
subscribe.to_dict = lambda: dict(data)
return subscribe
def _build_mediainfo():
return SimpleNamespace(
type=MediaType.TV,
title="Test Show",
title_year="Test Show (2026)",
year="2026",
tmdb_id=None,
douban_id=None,
)
def test_filemanager_media_exists_skips_local_when_server_specified():
module = FileManagerModule()
mediainfo = _build_mediainfo()
with patch.object(module, "media_files", return_value=[SimpleNamespace(path="/media/test.mkv")]) as media_files:
result = module.media_exists(mediainfo, server="Emby1")
assert result is None
media_files.assert_not_called()
def test_subscribe_files_info_merges_multiple_mediaservers():
subscribe = _build_subscribe(season=1, total_episode=2)
mediainfo = _build_mediainfo()
def _media_exists_side_effect(*, mediainfo, server=None, **kwargs):
if server == "Emby1":
return ExistMediaInfo(
type=MediaType.TV,
seasons={1: [1]},
server_type="emby",
server="Emby1",
itemid="emby-series",
)
if server == "Jellyfin1":
return ExistMediaInfo(
type=MediaType.TV,
seasons={1: [1]},
server_type="jellyfin",
server="Jellyfin1",
itemid="jf-series",
)
return None
helper = MagicMock()
helper.get_services.return_value = {"Emby1": object(), "Jellyfin1": object()}
mediaserver_chain = MagicMock()
mediaserver_chain.get_play_url.side_effect = lambda server, item_id: f"https://{server}/item/{item_id}"
mediaserver_chain.get_season_episode_ids.side_effect = lambda server, item_id, season: {1: f"{item_id}-ep1"}
chain = SubscribeChain()
with patch("app.chain.subscribe.DownloadHistoryOper") as download_oper, \
patch.object(chain, "recognize_media", return_value=mediainfo), \
patch.object(chain, "media_files", return_value=None), \
patch.object(chain, "media_exists", side_effect=_media_exists_side_effect), \
patch("app.chain.subscribe.MediaServerHelper", return_value=helper), \
patch("app.chain.subscribe.MediaServerChain", return_value=mediaserver_chain), \
patch("app.chain.subscribe.Subscribe", side_effect=lambda **kwargs: SimpleNamespace(**kwargs)):
download_oper.return_value.get_by_mediaid.return_value = []
result = chain.subscribe_files_info(subscribe)
library = result.episodes[1].library
servers = {item.server for item in library}
assert servers == {"Emby1", "Jellyfin1"}
assert all(str(item.file_path).startswith("https://") for item in library)
def test_subscribe_files_info_uses_season_zero_for_tv():
subscribe = _build_subscribe(season=0, total_episode=1, start_episode=1)
mediainfo = _build_mediainfo()
captured_seasons = []
def _media_exists_side_effect(*, mediainfo, server=None, **kwargs):
if server == "Emby1":
return ExistMediaInfo(
type=MediaType.TV,
seasons={0: [1]},
server_type="emby",
server="Emby1",
itemid="emby-special",
)
return None
def _get_season_episode_ids(server, item_id, season):
captured_seasons.append(season)
return {1: f"{item_id}-ep1"}
helper = MagicMock()
helper.get_services.return_value = {"Emby1": object()}
mediaserver_chain = MagicMock()
mediaserver_chain.get_play_url.return_value = "https://emby/item/1"
mediaserver_chain.get_season_episode_ids.side_effect = _get_season_episode_ids
chain = SubscribeChain()
with patch("app.chain.subscribe.DownloadHistoryOper") as download_oper, \
patch.object(chain, "recognize_media", return_value=mediainfo), \
patch.object(chain, "media_files", return_value=None), \
patch.object(chain, "media_exists", side_effect=_media_exists_side_effect), \
patch("app.chain.subscribe.MediaServerHelper", return_value=helper), \
patch("app.chain.subscribe.MediaServerChain", return_value=mediaserver_chain), \
patch("app.chain.subscribe.Subscribe", side_effect=lambda **kwargs: SimpleNamespace(**kwargs)):
download_oper.return_value.get_by_mediaid.return_value = []
result = chain.subscribe_files_info(subscribe)
assert captured_seasons == [0]
assert len(result.episodes[1].library) == 1
assert result.episodes[1].library[0].server == "Emby1"

View File

@@ -3,6 +3,7 @@
Telegram 模块单元测试pytest 原生)。
"""
import json
import warnings
from types import SimpleNamespace
from unittest.mock import MagicMock, Mock, patch
@@ -286,13 +287,29 @@ def test_send_msg_markdown_escaping(telegram):
assert send_kwargs["text"].startswith("*测试标题*\n")
def test_telegramify_new_content_fields_are_used_directly():
"""新版telegramify对象直接使用已渲染的MarkdownV2字段"""
text_item = SimpleNamespace(content="已转义\\_文本")
file_item = SimpleNamespace(caption="已转义\\_说明")
def test_telegramify_current_fields_are_used_directly():
"""telegramify 对象直接使用当前 MarkdownV2 字段"""
from telegramify_markdown.content import ContentTrace, File, Text
assert Telegram._telegramify_item_text(text_item) == "已转义\\_文本"
assert Telegram._telegramify_item_caption(file_item) == "已转义\\_说明"
text_item = Text(
text="已转义_文本",
entities=[],
content_trace=ContentTrace(source_type="test"),
)
file_item = File(
file_name="test.txt",
file_data=b"test",
caption_text="已转义_说明",
caption_entities=[],
content_trace=ContentTrace(source_type="test"),
)
with warnings.catch_warnings(record=True) as warning_records:
warnings.simplefilter("always")
assert Telegram._telegramify_item_text(text_item) == "已转义\\_文本"
assert Telegram._telegramify_item_caption(file_item) == "已转义\\_说明"
assert not warning_records
def test_send_msg_with_html_parse_mode_keeps_html(telegram):

View File

@@ -101,8 +101,8 @@ def test_request_json_default_verify_ssl_true() -> None:
assert fake_session.calls[1][1].get("verify") is True
def test_login_logout_follow_verify_ssl_flag() -> None:
"""登录与登出请求应沿用用户配置的证书校验开关"""
def test_login_logout_requests_follow_client_configuration() -> None:
"""登录与登出请求应沿用证书配置并携带一致的客户端标识"""
api = Api(host="https://example.com", verify_ssl=False)
fake_session = _FakeSession(
get_responses=[_FakeResponse({})],
@@ -141,6 +141,10 @@ def test_login_logout_follow_verify_ssl_flag() -> None:
assert fake_session.calls[0][1].get("verify") is False
assert fake_session.calls[1][1].get("verify") is False
assert fake_session.calls[2][1].get("verify") is False
check_headers = fake_session.calls[0][1]["headers"]
login_headers = fake_session.calls[1][1]["headers"]
assert check_headers["UG-Client-Id"] == check_headers["Client-Id"]
assert login_headers["UG-Client-Id"] == check_headers["UG-Client-Id"]
def test_login_accepts_token_id_and_reuses_check_public_key() -> None:

View File

@@ -183,7 +183,8 @@ def test_build_web_agent_command_items_returns_slash_commands():
def test_build_web_agent_command_items_includes_sites_command():
"""WebAgent 命令建议应包含内建站点管理命令。"""
commands = _build_web_agent_command_items()
with patch("app.command.Scheduler"), patch("app.command.ThreadHelper"):
commands = _build_web_agent_command_items()
assert any(command["command"] == "/sites" for command in commands)

View File

@@ -1,2 +1,2 @@
APP_VERSION = 'v2.14.3'
FRONTEND_VERSION = 'v2.14.3'
APP_VERSION = 'v2.14.5'
FRONTEND_VERSION = 'v2.14.5'