mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-25 10:10:38 +08:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b5524a321 | ||
|
|
b972b46747 | ||
|
|
0598fbdd75 | ||
|
|
572299a45e | ||
|
|
229824a417 | ||
|
|
a0ee99aacc | ||
|
|
92918ce380 | ||
|
|
a4335fe753 | ||
|
|
107ba37834 | ||
|
|
c27678ce06 | ||
|
|
7725342a80 | ||
|
|
893269f8c1 | ||
|
|
00d46f3aab | ||
|
|
077241b6ed | ||
|
|
b24a07e388 | ||
|
|
f814c271cc | ||
|
|
e015c67689 | ||
|
|
98b16bda8d |
506
.github/workflows/pr-agent.yml
vendored
506
.github/workflows/pr-agent.yml
vendored
@@ -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
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 Key(bedrock-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 端点与 PrivateLink(VPCE)端点等主机名形态,
|
||||
从中识别 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 ID(us./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 Key(bedrock-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 Profile(us./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 无法安全推导对应的控制面 VPCE;FIPS 端点也不能绕回
|
||||
# 公有非 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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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地址。
|
||||
|
||||
@@ -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()
|
||||
|
||||
93
app/log.py
93
app/log.py
@@ -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()
|
||||
|
||||
|
||||
|
||||
34
app/main.py
34
app/main.py
@@ -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()
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import threading
|
||||
from abc import abstractmethod, ABCMeta
|
||||
from typing import Generic, Tuple, Union, TypeVar, Type, Dict, Optional, Callable
|
||||
from pathlib import Path
|
||||
|
||||
from app.helper.service import ServiceConfigHelper
|
||||
from app.log import logger
|
||||
from app.schemas import Notification, NotificationConf, MediaServerConf, DownloaderConf
|
||||
from app.schemas.types import ModuleType, DownloaderType, MediaServerType, MessageChannel, StorageSchema, \
|
||||
OtherModulesType, SystemConfigKey
|
||||
@@ -15,8 +17,21 @@ class _ModuleBase(ConfigReloadMixin, metaclass=ABCMeta):
|
||||
输入参数与输出参数一致的,或没有输出的,可以被多个模块重复实现
|
||||
"""
|
||||
|
||||
def on_config_changed(self):
|
||||
self.init_module()
|
||||
def __init__(self) -> None:
|
||||
"""初始化模块生命周期锁"""
|
||||
super().__init__()
|
||||
self._reload_lock = threading.RLock()
|
||||
|
||||
def on_config_changed(self) -> None:
|
||||
"""串行停止旧资源并按最新配置重新初始化模块"""
|
||||
with self._reload_lock:
|
||||
try:
|
||||
self.stop()
|
||||
except Exception as err:
|
||||
logger.error(
|
||||
f"停止 {self.get_reload_name()} 旧资源失败,继续按最新配置初始化:{err}"
|
||||
)
|
||||
self.init_module()
|
||||
|
||||
def get_reload_name(self):
|
||||
return self.get_name()
|
||||
|
||||
@@ -58,7 +58,6 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]):
|
||||
if not Discord:
|
||||
logger.error("Discord 依赖未就绪(需要安装 discord.py==2.6.4),模块未启动")
|
||||
return
|
||||
self.stop()
|
||||
super().init_service(
|
||||
service_name=Discord.__name__.lower(), service_type=Discord
|
||||
)
|
||||
@@ -89,12 +88,13 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]):
|
||||
"""
|
||||
return 4
|
||||
|
||||
def stop(self):
|
||||
"""
|
||||
停止模块
|
||||
"""
|
||||
def stop(self) -> None:
|
||||
"""停止模块"""
|
||||
for client in self.get_instances().values():
|
||||
client.stop()
|
||||
try:
|
||||
client.stop()
|
||||
except Exception as err:
|
||||
logger.error(f"停止Discord模块实例失败:{err}")
|
||||
|
||||
def test(self) -> Optional[Tuple[bool, str]]:
|
||||
"""
|
||||
|
||||
@@ -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]:
|
||||
"""
|
||||
|
||||
@@ -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的图片地址
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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]:
|
||||
"""
|
||||
|
||||
@@ -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图片地址
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -46,7 +46,6 @@ class QQBotModule(_ModuleBase, _MessageBase[QQBot]):
|
||||
)
|
||||
|
||||
def init_module(self) -> None:
|
||||
self.stop()
|
||||
super().init_service(service_name=QQBot.__name__.lower(), service_type=QQBot)
|
||||
self._channel = MessageChannel.QQ
|
||||
|
||||
@@ -67,9 +66,12 @@ class QQBotModule(_ModuleBase, _MessageBase[QQBot]):
|
||||
return 10
|
||||
|
||||
def stop(self) -> None:
|
||||
"""停止模块"""
|
||||
for client in self.get_instances().values():
|
||||
if hasattr(client, "stop"):
|
||||
try:
|
||||
client.stop()
|
||||
except Exception as err:
|
||||
logger.error(f"停止QQ Bot模块实例失败:{err}")
|
||||
|
||||
def test(self) -> Optional[Tuple[bool, str]]:
|
||||
if not self.get_instances():
|
||||
|
||||
@@ -69,12 +69,13 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
|
||||
"""
|
||||
return 3
|
||||
|
||||
def stop(self):
|
||||
"""
|
||||
停止模块
|
||||
"""
|
||||
def stop(self) -> None:
|
||||
"""停止模块"""
|
||||
for client in self.get_instances().values():
|
||||
client.stop()
|
||||
try:
|
||||
client.stop()
|
||||
except Exception as err:
|
||||
logger.error(f"停止Slack模块实例失败:{err}")
|
||||
|
||||
def test(self) -> Optional[Tuple[bool, str]]:
|
||||
"""
|
||||
|
||||
@@ -62,12 +62,13 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
|
||||
"""
|
||||
return 0
|
||||
|
||||
def stop(self):
|
||||
"""
|
||||
停止模块
|
||||
"""
|
||||
def stop(self) -> None:
|
||||
"""停止模块"""
|
||||
for client in self.get_instances().values():
|
||||
client.stop()
|
||||
try:
|
||||
client.stop()
|
||||
except Exception as err:
|
||||
logger.error(f"停止Telegram模块实例失败:{err}")
|
||||
|
||||
def test(self) -> Optional[Tuple[bool, str]]:
|
||||
"""
|
||||
|
||||
@@ -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消息接收服务已停止")
|
||||
|
||||
@@ -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]:
|
||||
"""
|
||||
|
||||
@@ -114,7 +114,6 @@ class TheTvDbModule(_ModuleBase):
|
||||
return 4
|
||||
|
||||
def stop(self):
|
||||
logger.info("TheTvDbModule 停止。正在清除 TVDB 会话。")
|
||||
with self.__auth_lock:
|
||||
self.tvdb = None
|
||||
|
||||
|
||||
@@ -61,10 +61,14 @@ class TrimeMediaModule(_ModuleBase, _MediaServerBase[TrimeMedia]):
|
||||
logger.info(f"飞牛影视 {name} 连接断开,尝试重连 ...")
|
||||
server.reconnect()
|
||||
|
||||
def stop(self):
|
||||
def stop(self) -> None:
|
||||
"""停止模块"""
|
||||
for server in self.get_instances().values():
|
||||
if server.is_authenticated():
|
||||
server.disconnect()
|
||||
try:
|
||||
if server.is_authenticated():
|
||||
server.disconnect()
|
||||
except Exception as err:
|
||||
logger.error(f"停止飞牛影视模块实例失败:{err}")
|
||||
|
||||
def test(self) -> Optional[Tuple[bool, str]]:
|
||||
"""
|
||||
|
||||
@@ -60,10 +60,14 @@ class UgreenModule(_ModuleBase, _MediaServerBase[Ugreen]):
|
||||
logger.info(f"绿联影视 {name} 连接断开,尝试重连 ...")
|
||||
server.reconnect()
|
||||
|
||||
def stop(self):
|
||||
def stop(self) -> None:
|
||||
"""停止模块"""
|
||||
for server in self.get_instances().values():
|
||||
if server.is_authenticated():
|
||||
server.disconnect()
|
||||
try:
|
||||
if server.is_authenticated():
|
||||
server.disconnect()
|
||||
except Exception as err:
|
||||
logger.error(f"停止绿联影视模块实例失败:{err}")
|
||||
|
||||
def test(self) -> Optional[Tuple[bool, str]]:
|
||||
"""
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]]:
|
||||
"""测试模块连接性。"""
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
初始化工作流定时服务
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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 评论所需的写权限;不会向仓库推送代码或创建提交。
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
下载处理链,负责协调搜索结果的种子选择、下载器调度和下载后处理。
|
||||
下载处理链,负责协调搜索结果的种子选择、下载器调度和下载后处理
|
||||
"""
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
106
tests/test_db_error_diagnostics.py
Normal file
106
tests/test_db_error_diagnostics.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
405
tests/test_lifecycle_shutdown.py
Normal file
405
tests/test_lifecycle_shutdown.py
Normal 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
|
||||
392
tests/test_llm_provider_bedrock.py
Normal file
392
tests/test_llm_provider_bedrock.py
Normal 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 与 PrivateLink(VPCE)端点同样能识别 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
149
tests/test_log_shutdown.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
30
tests/test_message_queue_shutdown.py
Normal file
30
tests/test_message_queue_shutdown.py
Normal 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
|
||||
187
tests/test_module_lifecycle.py
Normal file
187
tests/test_module_lifecycle.py
Normal 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
|
||||
22
tests/test_network_guard.py
Normal file
22
tests/test_network_guard.py
Normal 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()
|
||||
@@ -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
29
tests/test_singleton.py
Normal 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
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
143
tests/test_subscribe_files_info.py
Normal file
143
tests/test_subscribe_files_info.py
Normal 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"
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
APP_VERSION = 'v2.14.3'
|
||||
FRONTEND_VERSION = 'v2.14.3'
|
||||
APP_VERSION = 'v2.14.5'
|
||||
FRONTEND_VERSION = 'v2.14.5'
|
||||
|
||||
Reference in New Issue
Block a user