Compare commits

..
60 Commits
Author SHA1 Message Date
InfinityPacer 4b9af5b8c7 fix(auth): secure passkey challenge transactions (#6178) 2026-07-24 06:20:34 +08:00
qqcomeup 059a50f7f8 fix(docker): repair helper resource permissions (#6177) 2026-07-24 06:19:41 +08:00
jxxghp 14fed2d70b 更新 version.py 2026-07-23 17:53:24 +08:00
jxxghp 875984ad39 feat: support torrent description filtering (#6172) 2026-07-23 17:09:45 +08:00
jxxghp 297cd04fbc fix: deduplicate SunnyPT site messages 2026-07-23 14:12:41 +08:00
jxxghp 3dde94be0f feat: support SunnyPT API indexer 2026-07-23 14:04:07 +08:00
jxxghp 98ee939236 feat: allow access to AniList domain in image proxy configuration 2026-07-22 18:10:43 +08:00
zangse c6611f6210 fix(system): add opt-in Btrfs FSID space deduplication (#6141) 2026-07-22 17:55:17 +08:00
InfinityPacer 503ee90c0c fix(cache): align subscribe share region capacity (#6173) 2026-07-22 17:54:23 +08:00
jxxghp fb32c59713 feat: add AniList rankings and discovery 2026-07-22 12:53:58 +08:00
jxxghp a3c90c64ca refactor: remove unused result handling and related test for empty task results 2026-07-22 08:15:19 +08:00
Ekko de97cb3c0a test: 删除重复的逐键 TTL 缓存测试 (#6161) (#6168) 2026-07-21 21:51:09 +08:00
jxxghp 3b709b7f2e feat: unify media source identity flow (#6129) 2026-07-21 20:20:09 +08:00
jxxghp 6f8b6cfbc9 feat: support explicit media ids for file scraping 2026-07-21 12:57:26 +08:00
jxxghp e3f80af74f feat: expand metadata sources and media server sync (#6129) 2026-07-21 11:34:18 +08:00
jxxghp 1d708870c9 fix: avoid blocking plugins on pre-existing pip errors 2026-07-21 08:25:03 +08:00
jxxghp 053e1b7562 fix: handle files in newly moved monitor directories 2026-07-21 08:22:46 +08:00
4ca3e40507 修复共享内存缓存的逐键 TTL (#6161)
MemoryBackend 原先为每个 region 保存一个 cachetools.TTLCache,导致首次写入的 TTL 成为后续所有 key 的统一生命周期。p115strmhelper 等动态 TTL 调用方因此可能把短期有效的 URL 缓存数天。

改用 TLRUCache 为每次写入独立计算过期时间,同时保留同步与异步 backend 共享 region、缓存值原样返回等既有行为。

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: jxxghp <jxxghp@gmail.com>
2026-07-21 08:10:20 +08:00
jxxghpandtraeagent 318d2ab7d7 feat: 修复单元测试失败 (#6165)
Co-authored-by: traeagent <traeagent@users.noreply.github.com>
2026-07-21 07:41:34 +08:00
Ekko 7c2390908a Fix/memory cache ttl (#6160) 2026-07-21 07:08:21 +08:00
InfinityPacer 5c2b503a74 fix(subscribe): protect runtime facts in public writes (#6157) 2026-07-20 16:44:49 +08:00
jxxghp 44fa202778 fix: map Bangumi movie subjects correctly (#6155) 2026-07-20 16:16:25 +08:00
jxxghp ed92be08af fix(transfer): support nested download categories (#6156) 2026-07-20 16:14:17 +08:00
jxxghp 9ed0704c5b fix: 重识别时清理旧下载器上下文 2026-07-20 15:35:56 +08:00
jxxghp e46b4e5ba0 fix: 重新整理时忽略旧下载哈希 (#6154) 2026-07-20 15:31:54 +08:00
jxxghp 87ad7988b2 Merge remote-tracking branch 'origin/v2' into v2 2026-07-20 15:19:49 +08:00
jxxghp 1382975b18 Centralize optional dependency stubs in test bootstrap 2026-07-20 14:53:36 +08:00
InfinityPacer d9a42c672a fix(subscribe): refine TV wash priority lifecycle (#6153) 2026-07-20 14:02:19 +08:00
jxxghp b042086efa test(indexer): anonymize TorrentLeech fixture 2026-07-20 13:49:02 +08:00
jxxghp 36fefa14e0 fix(indexer): repair TorrentLeech user data parsing 2026-07-20 13:46:25 +08:00
jxxghp 1332576c3f fix(download): accept legacy remote subscription paths (#6147) 2026-07-20 13:42:27 +08:00
jxxghp 4300af0e9c chore: update version to v2.14.6 2026-07-20 12:58:41 +08:00
jxxghp 405350c774 Preserve plugin diagnostics without degrading overall status 2026-07-20 10:15:15 +08:00
jxxghp d666134ed2 Route autonomous agent tasks through notification broadcasting 2026-07-20 08:54:32 +08:00
InfinityPacer 5588e37c6d fix: preserve season zero semantics (#6150) 2026-07-20 06:55:23 +08:00
InfinityPacer 2056aa0b2c fix(startup): keep systemd autostart services alive (#6149) 2026-07-20 06:54:09 +08:00
jxxghp b0ff3ae3c7 Fix Agent task relative-time validation (#6146) 2026-07-19 21:07:27 +08:00
jxxghp 31544629b4 Add autonomous Agent task scheduling 2026-07-19 20:19:06 +08:00
InfinityPacer 142393f2d3 fix(subscribe): reset manual episode tracking (#6144) 2026-07-19 17:36:54 +08:00
Xuanjie Xia 5cf79e0360 feat(site): 模拟登录支持勾选「记住我/保持登录」选项,获取长期会话 (#6138) 2026-07-18 19:24:18 +08:00
Xuanjie Xia f152a0381d fix(site): 模拟登录适配JS渲染(SPA)登录页 (#6137) 2026-07-18 16:47:24 +08:00
InfinityPacer 428c19b6ba fix(download): restore classification for configured roots (#6135) 2026-07-18 09:35:25 +08:00
jxxghp 8b5524a321 更新 version.py 2026-07-17 14:50:58 +08:00
jxxghp b972b46747 Merge remote-tracking branch 'origin/v2' into v2 2026-07-17 09:47:01 +08:00
jxxghp 0598fbdd75 fix(media): add support for custom words in media recognition 2026-07-17 09:46:55 +08:00
freeman 572299a45e feat(llm): 新增 Amazon Bedrock 提供商,支持 AK/SK 与 Bedrock API Key 双认证 (#6130) 2026-07-16 19:27:06 +08:00
kuke2733 229824a417 feat(subscribe): merge mediaserver library entries into files info (#6131) 2026-07-16 17:36:56 +08:00
DDSRem a0ee99aacc chore: bump moviepilot-rust to 0.2.3 (#6128) 2026-07-16 06:31:31 +08:00
InfinityPacer 92918ce380 ci(pr-agent): use shared review runner (#6127) 2026-07-16 06:24:52 +08:00
InfinityPacer a4335fe753 fix(lifecycle): harden application shutdown (#6125) 2026-07-16 06:24:31 +08:00
jxxghp 107ba37834 更新 version.py 2026-07-15 20:22:27 +08:00
jxxghp c27678ce06 fix(ugreen): send client id during login 2026-07-15 17:38:31 +08:00
InfinityPacer 7725342a80 fix(scheduler): refresh plugin jobs after reload (#6124) 2026-07-15 17:29:31 +08:00
InfinityPacer 893269f8c1 fix(modules): serialize configuration reload lifecycle (#6122) 2026-07-15 17:28:49 +08:00
InfinityPacer 00d46f3aab docs: clarify docstring punctuation style (#6121) 2026-07-15 16:01:47 +08:00
jxxghp 077241b6ed Merge remote-tracking branch 'origin/v2' into v2 2026-07-15 10:46:28 +08:00
jxxghp b24a07e388 fix: enhance response data structure in filtering rules with media info 2026-07-15 10:46:21 +08:00
InfinityPacer f814c271cc refactor(runtime): tighten resource cleanup and test isolation (#6116) 2026-07-14 16:03:29 +08:00
InfinityPacer e015c67689 chore(db): add driver error diagnostics (#6115) 2026-07-14 12:31:46 +08:00
qqcomeup 98b16bda8d 优化 Docker 启动完成日志 (#6112) 2026-07-14 12:31:07 +08:00
256 changed files with 18154 additions and 2801 deletions
+11 -495
View File
@@ -2,8 +2,7 @@ name: PR-Agent
on:
pull_request_target:
# PR-Agent 通过 base repo 上下文读取 PR diff 并发布 Review,不 checkout 或执行 PR 分支代码。
# pull_request_target 允许 fork PR 使用仓库 secrets,因此 workflow 只运行固定 digest 的 PR-Agent 容器。
# Fork 审查需要目标仓库凭据;该 job 仅通过 GitHub API 读取 PR 内容,不 checkout 或执行 PR 分支代码。
types:
- opened
- reopened
@@ -11,17 +10,13 @@ on:
- review_requested
- synchronize
issue_comment:
# 手动命令只在 PR 评论中有意义;编辑后的命令也可重新触发。
types:
- created
- edited
permissions:
# 读取仓库内容和 PR diff。
contents: read
# 更新 PR 描述、发布 PR Review 或修改 PR 相关元数据。
pull-requests: write
# PR 评论在 GitHub API 中属于 issue comments,手动问答需要该权限。
issues: write
jobs:
@@ -29,21 +24,11 @@ jobs:
if: >-
github.event.sender.type != 'Bot' &&
(
(
github.event_name == 'pull_request_target'
) ||
github.event_name == 'pull_request_target' ||
(
github.event_name == 'issue_comment' &&
github.event.issue.pull_request != null &&
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR"]'), github.event.comment.author_association) &&
(
github.event.comment.body == '/describe' ||
startsWith(github.event.comment.body, '/describe ') ||
github.event.comment.body == '/review' ||
startsWith(github.event.comment.body, '/review ') ||
github.event.comment.body == '/ask' ||
startsWith(github.event.comment.body, '/ask ')
)
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR"]'), github.event.comment.author_association)
)
)
concurrency:
@@ -53,484 +38,15 @@ jobs:
timeout-minutes: 20
steps:
- name: Capture PR context
id: pr_context
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
run: |
set -euo pipefail
pull="$(mktemp)"
gh api "repos/${REPO}/pulls/${PR_NUMBER}" > "${pull}"
python3 - "${pull}" >> "${GITHUB_OUTPUT}" <<'PY'
import json
import re
import sys
from pathlib import Path
pull = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
title = pull.get("title") or ""
body = pull.get("body") or ""
labels = {item.get("name", "") for item in pull.get("labels") or []}
skip_pr_agent = "true" if "skip pr-agent" in labels or re.search(r"^(?:\[Auto\]|Auto)", title) else "false"
body = re.sub(
r"<!-- pr-agent-summary:start -->.*?<!-- pr-agent-summary:end -->",
" ",
body,
flags=re.DOTALL,
)
text = f"{title}\n{body}"
cjk_count = len(re.findall(r"[\u4e00-\u9fff]", text))
latin_words = len(re.findall(r"\b[A-Za-z][A-Za-z]{2,}\b", text))
if cjk_count >= 4 or latin_words < 8:
response_language = "zh-CN"
summary_heading = "PR-Agent 摘要"
else:
response_language = "en-US"
summary_heading = "PR-Agent Summary"
print(f"head_sha={pull['head']['sha']}")
print(f"changed_files={pull.get('changed_files') or 0}")
print(f"response_language={response_language}")
print(f"summary_heading={summary_heading}")
print(f"skip_pr_agent={skip_pr_agent}")
PY
- name: Prepare PR-Agent description markers
if: >-
steps.pr_context.outputs.skip_pr_agent != 'true' &&
(
github.event_name == 'pull_request_target' ||
github.event.comment.body == '/describe' ||
startsWith(github.event.comment.body, '/describe ')
)
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
SUMMARY_HEADING: ${{ steps.pr_context.outputs.summary_heading }}
CHANGED_FILES: ${{ steps.pr_context.outputs.changed_files }}
run: |
set -euo pipefail
payload="$(mktemp)"
python3 - "${payload}" <<'PY'
import json
import os
import re
import subprocess
import sys
pull = json.loads(subprocess.check_output(
["gh", "api", f"repos/{os.environ['REPO']}/pulls/{os.environ['PR_NUMBER']}"],
text=True,
))
body = pull.get("body") or ""
start = "<!-- pr-agent-summary:start -->"
end = "<!-- pr-agent-summary:end -->"
heading = os.environ.get("SUMMARY_HEADING") or "PR-Agent 摘要"
changed_files = int(os.environ.get("CHANGED_FILES") or 0)
block = f"## {heading}\n\n{start}\npr_agent:summary\n{end}"
start_index = body.find(start)
end_index = body.find(end, start_index + len(start)) if start_index >= 0 else -1
owned_block = re.compile(
r"(?ims)^##\s+(?:PR-Agent\s+摘要|PR-Agent\s+Summary)\s*\n\s*"
r"<!-- pr-agent-summary:start -->.*?<!-- pr-agent-summary:end -->\s*"
)
if changed_files == 0:
updated = owned_block.sub("", body)
if updated != body:
updated = updated.rstrip()
elif start_index >= 0 and end_index >= 0:
body = re.sub(
r"(?im)^##\s+(PR-Agent\s+摘要|PR-Agent\s+Summary)\s*\n\s*(?=<!-- pr-agent-summary:start -->)",
f"## {heading}\n\n",
body,
)
start_index = body.find(start)
end_index = body.find(end, start_index + len(start))
updated = body[:start_index] + f"{start}\npr_agent:summary\n{end}" + body[end_index + len(end):]
elif start_index >= 0 or end in body:
updated = body
elif body.strip():
updated = f"{body.rstrip()}\n\n{block}\n"
else:
updated = f"{block}\n"
if updated != body:
with open(sys.argv[1], "w", encoding="utf-8") as handle:
json.dump({"body": updated}, handle, ensure_ascii=False)
PY
if [ -s "${payload}" ]; then
gh api --method PATCH "repos/${REPO}/pulls/${PR_NUMBER}" --input "${payload}" >/dev/null
fi
- name: Update PR description
if: >-
steps.pr_context.outputs.skip_pr_agent != 'true' &&
steps.pr_context.outputs.changed_files != '0' &&
(
github.event_name == 'pull_request_target' ||
github.event.comment.body == '/describe' ||
startsWith(github.event.comment.body, '/describe ')
)
uses: docker://pragent/pr-agent:0.39.0-github_action@sha256:b253845caa8c7ff5ce8be78f32996647982bdd4890826a962b78eff2e385a825
- name: Run PR Review
uses: docker://ghcr.io/infinitypacer/pr-review-runner:latest
env:
GITHUB_TOKEN: ${{ github.token }}
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
OPENAI.API_BASE: ${{ secrets.OPENAI_API_BASE }}
config.model: 'gpt-5.6-terra'
config.fallback_models: '["gpt-5.5", "gpt-5.4"]'
config.custom_model_max_tokens: '1050000'
config.reasoning_effort: 'medium'
config.ai_timeout: '900'
config.response_language: ${{ steps.pr_context.outputs.response_language }}
config.large_patch_policy: 'clip'
config.ignore_pr_title: '["^\\[Auto\\]", "^Auto"]'
config.ignore_pr_labels: '["skip pr-agent"]'
github_action_config.auto_review: 'false'
github_action_config.auto_describe: 'true'
github_action_config.auto_improve: 'false'
github_action_config.pr_actions: '["opened", "reopened", "ready_for_review", "review_requested"]'
github_action_config.handle_push_trigger: 'true'
github_action_config.push_commands: '["/describe"]'
pr_description.generate_ai_title: 'false'
pr_description.publish_labels: 'false'
pr_description.publish_description_as_comment: 'false'
pr_description.publish_description_as_comment_persistent: 'false'
pr_description.enable_pr_diagram: 'false'
pr_description.enable_pr_type: 'false'
pr_description.enable_help_text: 'false'
pr_description.enable_help_comment: 'false'
pr_description.enable_semantic_files_types: 'false'
pr_description.collapsible_file_list: 'adaptive'
pr_description.add_original_user_description: 'true'
pr_description.use_description_markers: 'true'
pr_description.final_update_message: 'false'
pr_description.extra_instructions: |
Match the configured response language.
Summarize the change goal, key implementation details, compatibility impact, tests, and notable risks.
Use 2-4 bullets for small pull requests and 4-8 bullets for larger changes.
Avoid file lists and local command transcripts.
- name: Remove unfilled PR-Agent description marker
if: >-
always() &&
steps.pr_context.outputs.skip_pr_agent != 'true' &&
(
github.event_name == 'pull_request_target' ||
github.event.comment.body == '/describe' ||
startsWith(github.event.comment.body, '/describe ')
)
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
run: |
set -euo pipefail
payload="$(mktemp)"
python3 - "${payload}" <<'PY'
import json
import os
import re
import subprocess
import sys
pull = json.loads(subprocess.check_output(
["gh", "api", f"repos/{os.environ['REPO']}/pulls/{os.environ['PR_NUMBER']}"],
text=True,
))
body = pull.get("body") or ""
placeholder = "<!-- pr-agent-summary:start -->\npr_agent:summary\n<!-- pr-agent-summary:end -->"
owned_block = re.compile(
r"(?ims)^##\s+(?:PR-Agent\s+摘要|PR-Agent\s+Summary)\s*\n\s*"
r"<!-- pr-agent-summary:start -->\s*pr_agent:summary\s*<!-- pr-agent-summary:end -->\s*"
)
if placeholder in body:
updated = owned_block.sub("", body).rstrip()
if updated != body:
with open(sys.argv[1], "w", encoding="utf-8") as handle:
json.dump({"body": updated}, handle, ensure_ascii=False)
PY
if [ -s "${payload}" ]; then
gh api --method PATCH "repos/${REPO}/pulls/${PR_NUMBER}" --input "${payload}" >/dev/null
fi
- name: Analyze PR review
id: review_analysis
if: >-
steps.pr_context.outputs.skip_pr_agent != 'true' &&
(
github.event_name == 'pull_request_target' ||
github.event.comment.body == '/review' ||
startsWith(github.event.comment.body, '/review ')
)
uses: docker://pragent/pr-agent:0.39.0-github_action@sha256:b253845caa8c7ff5ce8be78f32996647982bdd4890826a962b78eff2e385a825
env:
GITHUB_TOKEN: ${{ github.token }}
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
OPENAI.API_BASE: ${{ secrets.OPENAI_API_BASE }}
config.model: ${{ github.event_name == 'issue_comment' && 'gpt-5.6-sol' || 'gpt-5.6-terra' }}
config.fallback_models: '["gpt-5.5", "gpt-5.4"]'
config.custom_model_max_tokens: '1050000'
config.reasoning_effort: 'xhigh'
config.ai_timeout: '900'
config.response_language: ${{ steps.pr_context.outputs.response_language }}
config.large_patch_policy: 'clip'
config.ignore_pr_title: '["^\\[Auto\\]", "^Auto"]'
config.ignore_pr_labels: '["skip pr-agent"]'
config.publish_output: 'false'
github_action_config.auto_review: 'true'
github_action_config.auto_describe: 'false'
github_action_config.auto_improve: 'false'
github_action_config.pr_actions: '["opened", "reopened", "ready_for_review", "review_requested"]'
github_action_config.handle_push_trigger: 'true'
github_action_config.push_commands: '["/review"]'
github_action_config.enable_output: 'true'
pr_reviewer.num_max_findings: '4'
pr_reviewer.require_score_review: 'false'
pr_reviewer.require_tests_review: 'false'
pr_reviewer.require_security_review: 'false'
pr_reviewer.require_estimate_effort_to_review: 'false'
pr_reviewer.require_estimate_contribution_time_cost: 'false'
pr_reviewer.require_can_be_split_review: 'false'
pr_reviewer.require_todo_scan: 'false'
pr_reviewer.require_ticket_analysis_review: 'false'
pr_reviewer.enable_review_labels_effort: 'false'
pr_reviewer.enable_review_labels_security: 'false'
pr_reviewer.extra_instructions: |
Return key_issues_to_review only for concrete behavior defects introduced by this pull request.
Each finding must identify the affected behavior, a reachable trigger, and the existing contract or invariant it violates.
Use issue_content to state the smallest correction boundary, not a code patch.
Do not report style preferences, comments, refactors, architecture alternatives, speculative races, extra hardening, optional tests, or hypothetical concerns.
Return no findings when the evidence is incomplete.
- name: Publish review comments and summary
if: >-
steps.pr_context.outputs.skip_pr_agent != 'true' &&
steps.review_analysis.outcome == 'success' &&
(
github.event_name == 'pull_request_target' ||
github.event.comment.body == '/review' ||
startsWith(github.event.comment.body, '/review ')
)
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
REVIEWED_HEAD_SHA: ${{ steps.pr_context.outputs.head_sha }}
CHANGED_FILES: ${{ steps.pr_context.outputs.changed_files }}
RESPONSE_LANGUAGE: ${{ steps.pr_context.outputs.response_language }}
REVIEW_JSON: ${{ steps.review_analysis.outputs.review }}
run: |
set -euo pipefail
current_head_sha="$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.head.sha')"
if [ "${current_head_sha}" != "${REVIEWED_HEAD_SHA}" ]; then
echo "PR head changed during analysis; skip stale review publication."
exit 0
fi
review_payload="$(mktemp)"
python3 - "${review_payload}" <<'PY'
import hashlib
import json
import os
import re
import subprocess
import sys
from urllib.parse import quote
review_raw = os.environ.get("REVIEW_JSON") or "{}"
review = json.loads(review_raw)
if not review_raw.strip() or review == {}:
if int(os.environ.get("CHANGED_FILES") or 0):
raise SystemExit("Review analysis produced no structured output for a non-empty PR.")
review = {}
repo = os.environ["REPO"]
number = os.environ["PR_NUMBER"]
head_sha = os.environ["REVIEWED_HEAD_SHA"]
language = os.environ.get("RESPONSE_LANGUAGE") or "zh-CN"
def paged(endpoint):
result = json.loads(subprocess.check_output(
["gh", "api", "--paginate", "--slurp", endpoint], text=True
))
if result and all(isinstance(page, list) for page in result):
return [item for page in result for item in page]
return result
files = paged(f"repos/{repo}/pulls/{number}/files?per_page=100")
comments = paged(f"repos/{repo}/pulls/{number}/comments?per_page=100")
reviews = paged(f"repos/{repo}/pulls/{number}/reviews?per_page=100")
hunk_pattern = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@")
changed_lines = {}
for file_data in files:
path = str(file_data.get("filename") or "")
line = None
lines = set()
for patch_line in (file_data.get("patch") or "").splitlines():
hunk = hunk_pattern.match(patch_line)
if hunk:
line = int(hunk.group(1))
continue
if line is None or patch_line.startswith("\\"):
continue
if patch_line.startswith("+") and not patch_line.startswith("+++"):
lines.add(line)
line += 1
elif patch_line.startswith("-") and not patch_line.startswith("---"):
continue
else:
line += 1
changed_lines[path] = lines
def fingerprint(path, line):
normalized = "\n".join((path, str(line)))
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:16]
current_fingerprints = set()
current_locations = set()
marker_pattern = re.compile(r"<!-- pr-agent-review:([0-9a-f]{16}) -->")
for comment in comments:
if comment.get("user", {}).get("login") != "github-actions[bot]":
continue
if comment.get("line") is None:
continue
match = marker_pattern.search(str(comment.get("body") or ""))
if match:
current_fingerprints.add(match.group(1))
path = str(comment.get("path") or "")
try:
line = int(comment.get("line") or 0)
except (TypeError, ValueError):
line = 0
if path and line > 0:
current_locations.add((path, line))
def code_url(path, line):
return f"https://github.com/{repo}/blob/{head_sha}/{quote(path, safe='/')}#L{line}"
issues = review.get("key_issues_to_review") or []
findings = []
seen = set()
for issue in issues:
if not isinstance(issue, dict):
continue
path = str(issue.get("relevant_file") or "").strip()
header = str(issue.get("issue_header") or "").strip()
content = str(issue.get("issue_content") or "").strip()
try:
line = int(issue.get("start_line") or 0)
except (TypeError, ValueError):
line = 0
if not path or not header or not content or line < 1:
continue
finding_key = (path, line, header.lower(), " ".join(content.split()).lower())
if finding_key in seen:
continue
seen.add(finding_key)
findings.append({
"path": path,
"line": line,
"header": header,
"content": content,
"fingerprint": fingerprint(path, line),
})
new_comments = []
for finding in findings:
if finding["line"] not in changed_lines.get(finding["path"], set()):
continue
if finding["fingerprint"] in current_fingerprints or (finding["path"], finding["line"]) in current_locations:
continue
new_comments.append({
"path": finding["path"],
"line": finding["line"],
"side": "RIGHT",
"body": "\n".join([
f"<!-- pr-agent-review:{finding['fingerprint']} -->",
f"**{finding['header']}**",
"",
finding["content"],
]),
})
marker = "<!-- pr-agent-review-summary -->"
short_sha = head_sha[:7]
commit_url = f"https://github.com/{repo}/commit/{head_sha}"
chinese = language == "zh-CN"
lines = [marker, "## PR-Agent Code Review", ""]
if findings:
for finding in findings:
location = f"{finding['path']}:{finding['line']}"
concise = " ".join(finding["content"].split())[:360]
separator = "" if chinese else ":"
lines.append(f"- [{location}]({code_url(finding['path'], finding['line'])}){separator} **{finding['header']}** - {concise}")
elif chinese:
lines.append("本次变更无需提出审查意见,暂无其他反馈。")
else:
lines.append("There are no review comments for the current changes. I have no additional feedback to provide.")
lines.extend([
"",
f"审查提交:[{short_sha}]({commit_url})" if chinese else f"Reviewed commit: [{short_sha}]({commit_url})",
"",
])
payload = {
"body": "\n".join(lines),
"commit_id": head_sha,
"event": "COMMENT",
}
if new_comments:
payload["comments"] = new_comments
has_matching_summary = not new_comments and any(
existing.get("user", {}).get("login") == "github-actions[bot]"
and existing.get("commit_id") == head_sha
and str(existing.get("body") or "") == payload["body"]
for existing in reviews
)
# 同一提交的手工重审仍会完成分析;完全相同的结果不重复发布 Review。
if not has_matching_summary:
with open(sys.argv[1], "w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=False)
PY
latest_head_sha="$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.head.sha')"
if [ "${latest_head_sha}" != "${REVIEWED_HEAD_SHA}" ]; then
echo "PR head changed while rendering review; skip stale review publication."
exit 0
fi
if [ -s "${review_payload}" ]; then
gh api --method POST "repos/${REPO}/pulls/${PR_NUMBER}/reviews" --input "${review_payload}" >/dev/null
fi
old_summary_ids="$(gh api --paginate "repos/${REPO}/issues/${PR_NUMBER}/comments?per_page=100" --jq ".[] | select(.user.login == \"github-actions[bot]\" and ((.body | startswith(\"<!-- pr-agent-update-notification -->\")) or (.body | startswith(\"<!-- pr-agent-code-review-summary -->\")) or (.body | startswith(\"<!-- pr-agent-review-summary -->\")) or (.body | startswith(\"<!-- pr-agent-lab:review -->\")))) | .id")"
while IFS= read -r comment_id; do
[ -z "${comment_id}" ] && continue
gh api --method DELETE "repos/${REPO}/issues/comments/${comment_id}" >/dev/null
done <<< "${old_summary_ids}"
- name: Answer PR question
if: >-
steps.pr_context.outputs.skip_pr_agent != 'true' &&
github.event_name == 'issue_comment' &&
(
github.event.comment.body == '/ask' ||
startsWith(github.event.comment.body, '/ask ')
)
uses: docker://pragent/pr-agent:0.39.0-github_action@sha256:b253845caa8c7ff5ce8be78f32996647982bdd4890826a962b78eff2e385a825
env:
GITHUB_TOKEN: ${{ github.token }}
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
OPENAI.API_BASE: ${{ secrets.OPENAI_API_BASE }}
config.model: 'gpt-5.6-terra'
config.fallback_models: '["gpt-5.5", "gpt-5.4"]'
config.custom_model_max_tokens: '1050000'
config.reasoning_effort: 'high'
config.ai_timeout: '900'
config.response_language: ${{ steps.pr_context.outputs.response_language }}
config.large_patch_policy: 'clip'
config.ignore_pr_title: '["^\\[Auto\\]", "^Auto"]'
config.ignore_pr_labels: '["skip pr-agent"]'
github_action_config.auto_review: 'false'
github_action_config.auto_describe: 'false'
github_action_config.auto_improve: 'false'
PRR_AUTO_REVIEW_SCOPE: all
PRR_ALLOWED_ASSOCIATIONS: '["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR"]'
PRR_DISABLED_COMMANDS: '["/improve"]'
PRR_SKIP_LABEL: skip pr-agent
PRR_SKIP_TITLE_PATTERN: '^(?:\[Auto\]|Auto)'
config.response_language: zh-CN
+87 -14
View File
@@ -58,6 +58,7 @@ from app.chain import ChainBase
from app.core.config import settings
from app.core.event import eventmanager
from app.db.agentchat_oper import AgentChatOper
from app.db.agenttask_oper import AgentTaskOper
from app.db.user_oper import UserOper
from app.log import logger
from app.schemas import AgentLLMProviderEventData, AgentTokensUsageEventData, Notification, NotificationType
@@ -1627,20 +1628,21 @@ class MoviePilotAgent:
if not streaming_stopped:
await self.stream_handler.stop_streaming()
async def send_agent_message(self, message: str, title: str = ""):
async def send_agent_message(self, message: str, title: str = "") -> None:
"""
通过原渠道发送消息给用户
发送 Agent 消息;后台任务不绑定原渠道,交由通知链广播。
"""
broadcast = self.is_background
self._save_assistant_display_message_once(message)
await AgentChain().async_post_message(
Notification(
channel=self.channel,
source=self.source,
channel=None if broadcast else self.channel,
source=None if broadcast else self.source,
mtype=NotificationType.Agent,
userid=self.user_id,
username=self.username,
original_message_id=self.original_message_id,
original_chat_id=self.original_chat_id,
userid=None if broadcast else self.user_id,
username=self.username or (settings.SUPERUSER if broadcast else None),
original_message_id=None if broadcast else self.original_message_id,
original_chat_id=None if broadcast else self.original_chat_id,
title=title,
text=message,
save_history=False,
@@ -2000,12 +2002,11 @@ class AgentManager:
else:
agent = self.active_agents[session_id]
agent.user_id = task.user_id
if task.channel:
agent.channel = task.channel
if task.source:
agent.source = task.source
if task.username:
agent.username = task.username
# 每条队列任务都携带完整消息上下文,None 也必须覆盖,避免后台任务
# 复用会话 Agent 时继续沿用上一条入站消息的渠道。
agent.channel = task.channel
agent.source = task.source
agent.username = task.username
agent.original_message_id = task.original_message_id
agent.original_chat_id = task.original_chat_id
agent.reply_mode = task.reply_mode
@@ -2123,6 +2124,78 @@ class AgentManager:
await agent.cleanup()
memory_manager.clear_memory(session_id, user_id)
async def execute_scheduled_task(self, task_id: int) -> tuple[bool, str]:
"""
按持久化上下文唤醒 Agent 执行自主定时任务并向用户回传结果。
:param task_id: Agent 定时任务 ID
:return: 执行是否成功及结果摘要
"""
if not settings.AI_AGENT_ENABLE:
return False, "AI Agent 未启用"
oper = AgentTaskOper()
task = oper.get(task_id)
if not task or not task.enabled:
return False, "Agent 定时任务不存在或已停用"
if not oper.mark_running(task_id):
return False, "Agent 定时任务当前不可执行"
task_message = (
f"定时任务已按计划触发。请立即完成下面的任务,不要只确认收到,"
f"也不要重复创建同一个定时任务。\n\n"
f"任务名称:{task.name}\n"
f"任务内容:{task.content}\n\n"
"完成后请直接向用户发送消息报告本次执行结果;如果无法完成,也需发送消息说明原因。"
)
success = True
result = ""
notification_username = task.username or settings.SUPERUSER
try:
result = await self.process_message(
session_id=task.session_id,
user_id=task.user_id,
message=task_message,
channel=None,
source=None,
username=notification_username,
original_chat_id=None,
reply_mode=ReplyMode.DISPATCH,
allow_message_tools=True,
wait_for_completion=True,
)
result_text = str(result or "").strip()
success = not result_text.startswith(
(AGENT_EXECUTION_ERROR_PREFIX, "处理消息时发生错误")
)
except Exception as err:
success = False
result = f"Agent 定时任务执行失败:{str(err)}"
logger.error(f"Agent 定时任务 {task_id} 执行失败: {str(err)}")
await AgentChain().async_post_message(
Notification(
mtype=NotificationType.Agent,
username=notification_username,
title=f"定时任务执行失败:{task.name}",
text=result,
save_history=False,
)
)
finally:
current_task = oper.get(task_id)
oper.finish(
task_id=task_id,
success=success,
result=str(result or ""),
disable=bool(
current_task
and task.trigger_type == "date"
and current_task.trigger_type == task.trigger_type
and current_task.run_at == task.run_at
),
)
return success, str(result or "任务执行完成")
@staticmethod
def _build_heartbeat_prompt() -> str:
"""使用程序内置 System Tasks 定义构建心跳任务提示词。"""
+28 -1
View File
@@ -1058,6 +1058,29 @@ class LLMHelper:
http_async_client=_build_httpx_client(llm_proxy, async_client=True),
**thinking_kwargs,
)
elif runtime["runtime"] == "bedrock":
from langchain_aws import ChatBedrockConverse
from app.agent.llm.provider import LLMProviderManager
aws_region = runtime.get("aws_region") or "us-east-1"
aws_auth = runtime.get("aws_auth") or {}
# Bearer 认证需要跳过 SigV4 签名并注入 Authorization 头,SigV4 认证
# 直接以 AK/SK 签名;两种方式统一由 provider 管理器构造 boto3 客户端。
bedrock_client = LLMProviderManager().create_bedrock_client(
"bedrock-runtime",
region=aws_region,
credentials=aws_auth,
base_url=runtime.get("base_url"),
use_proxy=use_proxy,
read_timeout=settings.LLM_TOOL_TIMEOUT,
)
model = ChatBedrockConverse(
model_id=model_name,
client=bedrock_client,
temperature=temperature_value,
disable_streaming=not streaming,
)
elif runtime["runtime"] in {"anthropic_compatible", "copilot_anthropic"}:
from langchain_anthropic import ChatAnthropic
@@ -1107,7 +1130,11 @@ class LLMHelper:
# 优先使用 provider / models.dev 目录中的上下文上限,减少用户手填成本。
model_profile = getattr(model, "profile", None)
if model_profile:
logger.debug(f"使用LLM模型: {model.model}Profile: {model.profile}")
# ChatBedrockConverse 等模型类没有 model 属性,模型名存放在 model_id。
logged_model_name = getattr(model, "model", None) or getattr(
model, "model_id", model_name
)
logger.debug(f"使用LLM模型: {logged_model_name}Profile: {model_profile}")
else:
model_record = runtime.get("model_record") or {}
model_metadata = runtime.get("model_metadata") or {}
+488 -1
View File
@@ -7,13 +7,14 @@ import base64
import copy
import hashlib
import json
import re
import secrets
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from urllib.parse import urlencode
from urllib.parse import urlencode, urlsplit
import aiofiles
import httpx
@@ -106,6 +107,90 @@ class LLMProviderManager(metaclass=Singleton):
_MODELS_DEV_BUNDLED_PATH = Path(__file__).with_name("models.json")
_MODELS_DEV_CACHE_TTL = 7 * 24 * 60 * 60
_AUTH_SESSION_DONE_RETENTION = 300
_BEDROCK_DEFAULT_REGION = "us-east-1"
_BEDROCK_API_KEY_PREFIX = "bedrock-api-key-"
_BEDROCK_GPT_OSS_BASE_REGIONS = (
"ap-northeast-1",
"ap-south-1",
"ap-southeast-2",
"eu-central-1",
"eu-north-1",
"eu-west-1",
"eu-west-2",
"sa-east-1",
"us-east-1",
"us-east-2",
"us-west-2",
)
_BEDROCK_GPT_OSS_SAFEGUARD_REGIONS = (
"ap-northeast-1",
"ap-south-1",
"ap-southeast-2",
"eu-west-1",
"eu-west-2",
"sa-east-1",
"us-east-1",
"us-east-2",
"us-west-2",
)
_BEDROCK_ON_DEMAND_MODEL_REGIONS = {
"openai.gpt-oss-120b-1:0": _BEDROCK_GPT_OSS_BASE_REGIONS,
"openai.gpt-oss-20b-1:0": _BEDROCK_GPT_OSS_BASE_REGIONS,
"openai.gpt-oss-safeguard-120b": _BEDROCK_GPT_OSS_SAFEGUARD_REGIONS,
"openai.gpt-oss-safeguard-20b": _BEDROCK_GPT_OSS_SAFEGUARD_REGIONS,
"amazon.nova-lite-v1:0": (
"ap-northeast-1",
"ap-southeast-2",
"eu-west-2",
"us-east-1",
"us-gov-west-1",
),
"amazon.nova-micro-v1:0": (
"ap-southeast-2",
"eu-west-2",
"us-east-1",
"us-gov-west-1",
),
"amazon.nova-pro-v1:0": (
"ap-southeast-2",
"eu-west-2",
"us-east-1",
"us-gov-west-1",
),
"anthropic.claude-3-5-haiku-20241022-v1:0": (
"us-west-2",
),
"anthropic.claude-3-5-sonnet-20240620-v1:0": (
"ap-northeast-1",
"ap-northeast-2",
"ap-southeast-1",
"eu-central-1",
"eu-central-2",
"us-east-1",
"us-gov-west-1",
"us-west-2",
),
"anthropic.claude-3-5-sonnet-20241022-v2:0": (
"ap-southeast-2",
"us-west-2",
),
"anthropic.claude-3-7-sonnet-20250219-v1:0": (
"eu-west-2",
"us-gov-west-1",
),
"anthropic.claude-3-haiku-20240307-v1:0": (
"ap-northeast-1",
"ap-northeast-2",
"ap-south-1",
"ap-southeast-2",
"eu-central-1",
"eu-west-1",
"eu-west-3",
"us-east-1",
"us-gov-west-1",
"us-west-2",
),
}
_CHATGPT_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
_CHATGPT_ISSUER = "https://auth.openai.com"
_CHATGPT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
@@ -367,6 +452,50 @@ class LLMProviderManager(metaclass=Singleton):
api_key_hint="填写 Anthropic API Key。",
description="Anthropic Claude 官方端点。",
),
ProviderSpec(
id="amazon-bedrock",
name="Amazon Bedrock",
runtime="bedrock",
models_dev_provider_id="amazon-bedrock",
default_base_url="https://bedrock-runtime.us-east-1.amazonaws.com",
base_url_presets=(
url_preset(
id="bedrock-us-east-1",
label="美东(弗吉尼亚北部)us-east-1",
value="https://bedrock-runtime.us-east-1.amazonaws.com",
),
url_preset(
id="bedrock-us-west-2",
label="美西(俄勒冈)us-west-2",
value="https://bedrock-runtime.us-west-2.amazonaws.com",
),
url_preset(
id="bedrock-eu-central-1",
label="欧洲(法兰克福)eu-central-1",
value="https://bedrock-runtime.eu-central-1.amazonaws.com",
),
url_preset(
id="bedrock-ap-northeast-1",
label="亚太(东京)ap-northeast-1",
value="https://bedrock-runtime.ap-northeast-1.amazonaws.com",
),
url_preset(
id="bedrock-ap-southeast-1",
label="亚太(新加坡)ap-southeast-1",
value="https://bedrock-runtime.ap-southeast-1.amazonaws.com",
),
),
base_url_editable=True,
api_key_label="Bedrock API Key / AK:SK",
api_key_hint=(
"支持两种认证方式:填写 Amazon Bedrock API Keybedrock-api-key- 开头,"
"Bearer 认证);或填写 Access Key ID:Secret Access Key(可选追加 :Session Token"
"SigV4 认证)。Base URL 决定 AWS Region。"
),
model_list_strategy="bedrock",
description="Amazon Bedrock 托管模型服务,支持 Bedrock API Key 与 AK/SK 双认证。",
sort_order=35,
),
ProviderSpec(
id="deepseek",
name="DeepSeek",
@@ -1743,6 +1872,112 @@ class LLMProviderManager(metaclass=Singleton):
return normalized[:-3]
return normalized
@classmethod
def _extract_bedrock_region(cls, base_url: Optional[str]) -> str:
"""
从 Bedrock 运行时端点 URL 中提取 AWS Region
兼容标准端点、FIPS 端点与 PrivateLinkVPCE)端点等主机名形态,
从中识别 Region 段。
:param base_url: 形如 https://bedrock-runtime.us-east-1.amazonaws.com 的端点地址
:return: 提取到的 Region,无法识别时回退 us-east-1
"""
hostname = urlsplit((base_url or "").strip().lower()).hostname or ""
match = re.search(
r"(?:^|\.)(?:bedrock(?:-runtime)?(?:-fips)?)"
r"\.([a-z0-9-]+-\d+)(?:\.|$)",
hostname,
)
if match:
return match.group(1)
return cls._BEDROCK_DEFAULT_REGION
# Inference Profile 的地理前缀与可用 Region 的对应关系,用于降级目录按
# 当前 Region 过滤掉不可调用的 Profile 条目。
_BEDROCK_GEO_PREFIXES: dict[str, tuple[str, ...]] = {
"us": ("us-east-", "us-west-"),
"eu": ("eu-",),
"apac": ("ap-",),
"au": ("ap-southeast-2", "ap-southeast-4"),
"jp": ("ap-northeast-1", "ap-northeast-3"),
"ca": ("ca-",),
}
_BEDROCK_NON_COMMERCIAL_REGION_PREFIXES = (
"cn-",
"eu-isoe-",
"us-gov-",
"us-iso-",
"us-isob-",
"us-isof-",
)
@classmethod
def _bedrock_model_matches_region(cls, model_id: str, region: str) -> bool:
"""
判断目录中的模型 ID 在指定 Region 是否可调用
models.dev 目录同时收录裸模型 ID(直连调用)与带地理前缀的
Inference Profile IDus./eu./apac./global. 等)。带前缀的条目只在
对应地理分区和 AWS 分区的 Region 可用;global Profile 仅允许商业
AWS 分区。裸 ID 仅在明确记录的 ON_DEMAND Region 可用,未知条目
按不可直连处理。
:param model_id: 目录中的模型 ID
:param region: 当前 Base URL 对应的 AWS Region
:return: 该模型在当前 Region 可调用时返回 True
"""
prefix = model_id.split(".", 1)[0]
if prefix == "global":
return not region.startswith(cls._BEDROCK_NON_COMMERCIAL_REGION_PREFIXES)
region_prefixes = cls._BEDROCK_GEO_PREFIXES.get(prefix)
if region_prefixes is not None:
return (
not region.startswith(cls._BEDROCK_NON_COMMERCIAL_REGION_PREFIXES)
and region.startswith(region_prefixes)
)
on_demand_regions = cls._BEDROCK_ON_DEMAND_MODEL_REGIONS.get(model_id)
return on_demand_regions is not None and region in on_demand_regions
@classmethod
def _parse_bedrock_credentials(cls, api_key: Optional[str]) -> dict[str, Any]:
"""
解析 Bedrock 凭证字符串,识别 Bearer 与 SigV4 两种认证方式
- Bedrock API Keybedrock-api-key- 开头的长期 Key,或控制台生成的短期
Token)走 Bearer 认证;
- `AccessKeyId:SecretAccessKey` 或 `AccessKeyId:SecretAccessKey:SessionToken`
走 SigV4 认证,AWS Access Key ID 均以 "AKIA"/"ASIA" 开头。
:param api_key: 用户在 API Key 输入框填写的凭证内容
:return: 含 auth_scheme 及对应凭证字段的字典
"""
normalized = str(api_key or "").strip()
if not normalized:
raise LLMProviderAuthError(
"Amazon Bedrock 需要填写 Bedrock API Key 或 Access Key ID:Secret Access Key"
)
if not normalized.startswith(cls._BEDROCK_API_KEY_PREFIX):
parts = [part.strip() for part in normalized.split(":")]
if len(parts) in {2, 3} and all(parts):
credentials = {
"auth_scheme": "sigv4",
"access_key_id": parts[0],
"secret_access_key": parts[1],
}
if len(parts) == 3:
credentials["session_token"] = parts[2]
return credentials
if ":" in normalized:
raise LLMProviderAuthError(
"Amazon Bedrock AK/SK 凭证格式不正确,"
"请按 AccessKeyId:SecretAccessKey 或 "
"AccessKeyId:SecretAccessKey:SessionToken 填写"
)
return {"auth_scheme": "bearer", "bearer_token": normalized}
async def _list_models_from_google(
self,
api_key: str,
@@ -1857,6 +2092,235 @@ class LLMProviderManager(metaclass=Singleton):
)
return sorted(results, key=lambda item: item["name"].lower())
def _build_bedrock_boto3_config(
self,
use_proxy: Optional[bool] = None,
) -> Any:
"""
构造 Bedrock boto3 客户端配置,统一超时、重试与代理策略
:param use_proxy: 是否使用系统代理,None 时读取 LLM_USE_PROXY 配置
:return: botocore Config 实例
"""
from botocore.config import Config
should_use_proxy = settings.LLM_USE_PROXY if use_proxy is None else use_proxy
proxies = None
if should_use_proxy and settings.PROXY_HOST:
proxies = {"http": settings.PROXY_HOST, "https": settings.PROXY_HOST}
return Config(
connect_timeout=10,
read_timeout=60,
retries={"max_attempts": 3, "mode": "standard"},
proxies=proxies,
)
@staticmethod
def _bedrock_endpoint_url(
service_name: str, base_url: Optional[str]
) -> Optional[str]:
"""
解析应传给 boto3 客户端的自定义端点 URL
标准公有端点交由 boto3 按 Region 自行推导;用户填写 PrivateLink、
FIPS 等非标准端点时才显式透传,保证所选网络路径实际生效。
:param service_name: boto3 服务名(bedrock 或 bedrock-runtime
:param base_url: 用户配置的 Base URL
:return: 需要显式指定端点时返回 URL,否则返回 None
"""
normalized = (base_url or "").strip().rstrip("/")
if not normalized:
return None
if re.fullmatch(
rf"https://{service_name}\.[a-z0-9-]+\.amazonaws\.com",
normalized,
):
return None
return normalized
def create_bedrock_client(
self,
service_name: str,
region: str,
credentials: dict[str, Any],
base_url: Optional[str] = None,
use_proxy: Optional[bool] = None,
read_timeout: Optional[int] = None,
) -> Any:
"""
按解析后的凭证创建 Bedrock boto3 客户端,Bearer 方式注入 Authorization 头
:param service_name: boto3 服务名(bedrock 或 bedrock-runtime
:param region: AWS Region
:param credentials: `_parse_bedrock_credentials` 的解析结果
:param base_url: 用户配置的 Base URL,非标准端点(PrivateLink/FIPS 等)时透传给 boto3
:param use_proxy: 是否使用系统代理
:param read_timeout: 读取超时秒数,None 时使用默认值
:return: boto3 客户端实例
"""
import boto3
from botocore import UNSIGNED
config = self._build_bedrock_boto3_config(use_proxy)
if read_timeout:
config = config.merge(type(config)(read_timeout=read_timeout))
endpoint_kwargs: dict[str, Any] = {}
endpoint_url = self._bedrock_endpoint_url(service_name, base_url)
if endpoint_url:
endpoint_kwargs["endpoint_url"] = endpoint_url
if credentials["auth_scheme"] == "sigv4":
return boto3.client(
service_name,
region_name=region,
aws_access_key_id=credentials["access_key_id"],
aws_secret_access_key=credentials["secret_access_key"],
aws_session_token=credentials.get("session_token"),
config=config,
**endpoint_kwargs,
)
# Bearer 认证:以 UNSIGNED 跳过 SigV4 签名,再把 API Key 注入 Authorization 头。
bearer_token = credentials["bearer_token"]
config = config.merge(type(config)(signature_version=UNSIGNED))
client = boto3.client(
service_name,
region_name=region,
aws_access_key_id="unsigned",
aws_secret_access_key="unsigned",
config=config,
**endpoint_kwargs,
)
def _inject_bearer(request: Any, **_kwargs: Any) -> None:
request.headers["Authorization"] = f"Bearer {bearer_token}"
client.meta.events.register(
f"request-created.{service_name}",
_inject_bearer,
)
return client
async def _list_models_from_bedrock_fallback(
self,
region: str,
use_proxy: Optional[bool] = None,
) -> list[dict[str, Any]]:
"""
从 models.dev 目录筛选当前 Region 可调用的 Bedrock 模型
:param region: 当前 Base URL 对应的 AWS Region
:param use_proxy: 是否使用系统代理
:return: 过滤后的标准化模型记录列表
"""
models = await self._list_models_from_models_dev_only(
provider_id="amazon-bedrock",
use_proxy=use_proxy,
)
return [
model
for model in models
if self._bedrock_model_matches_region(model["id"], region)
]
async def _list_models_from_bedrock(
self,
api_key: str,
base_url: Optional[str],
use_proxy: Optional[bool] = None,
) -> list[dict[str, Any]]:
"""
从 Bedrock 控制面拉取模型目录,聚合跨区 Inference Profile 与直连模型
Bedrock 多数新模型仅允许通过 Inference Profileus./eu./apac./global. 前缀)
调用,因此优先列出 Profile,再补充支持 ON_DEMAND 直连的基础模型。
:param api_key: 用户填写的凭证内容(Bedrock API Key 或 AK/SK
:param base_url: Bedrock 运行时端点,决定 Region
:param use_proxy: 是否使用系统代理
:return: 标准化后的模型记录列表
"""
credentials = self._parse_bedrock_credentials(api_key)
region = self._extract_bedrock_region(base_url)
# runtime VPCE 无法安全推导对应的控制面 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
+9 -4
View File
@@ -204,9 +204,14 @@ You have a scheduled jobs system for user-requested delayed or recurring work.
{jobs_list}
Rules:
- Create jobs only when the user asks for delayed, recurring, reminder, or monitoring behavior.
- Do not create jobs for immediate one-time work or work already handled by MoviePilot schedulers.
- Each job lives in its own directory with a `JOB.md`; read the listed file before executing or updating an active job.
- For new delayed, recurring, reminder, or monitoring work, use the dedicated
`create_agent_task`, `query_agent_tasks`, `update_agent_task`, `run_agent_task`,
and `delete_agent_task` tools. These tools use integer task IDs. Do not create
or edit JOB.md files for new tasks.
- Use `query_schedulers` and `run_scheduler` only for MoviePilot system, plugin,
or workflow runtime services; never pass their string job IDs to Agent task tools.
- Do not create tasks for immediate one-time work or work already handled by MoviePilot schedulers.
- Entries listed above are legacy JOB.md tasks. Read their files only when a heartbeat asks you to execute them.
- During heartbeat checks, act only on `pending` or `in_progress` jobs, update status/last_run/logs, and leave recurring jobs `pending` after each run.
</jobs_system>
"""
@@ -230,7 +235,7 @@ class JobsMiddleware(AgentMiddleware[JobsState, ContextT, ResponseT]): # noqa
def _format_jobs_list(jobs: list[JobMetadata]) -> str:
"""格式化任务元数据列表用于系统提示词。"""
if not jobs:
return "(No active jobs. You can create jobs when users request periodic or scheduled tasks.)"
return "(No active legacy JOB.md tasks. Use create_agent_task for new scheduled work.)"
lines = []
for job in jobs:
+1
View File
@@ -24,6 +24,7 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel
- Do not stop for approval on read-only operations.
- If the user has not explicitly requested an operation that changes system behavior, ask for confirmation before proceeding. This includes modifying system settings, updating plugin configuration, reloading plugins, running restart/stop/start commands, or triggering slash commands such as `/restart`.
- Always get explicit consent before destructive or high-impact actions such as starting downloads, deleting subscriptions, deleting download tasks or files, removing history, installing/uninstalling plugins, changing site authentication, changing scheduler or workflow execution state, restarting services, or stopping services.
- When the user explicitly asks for delayed, recurring, reminder, or monitoring work, use `create_agent_task` instead of promising to remember it or writing a JOB.md file. Use a `date` trigger with `delay_minutes` for requests such as "in 30 minutes", an exact `date` trigger for other single future runs, and a five-field `cron` trigger for recurring work. Manage existing autonomous tasks with `query_agent_tasks`, `update_agent_task`, `run_agent_task`, and `delete_agent_task`; these tools use integer `task_id` values. Use `query_schedulers` and `run_scheduler` only for MoviePilot system, plugin, or workflow runtime services, whose string `job_id` values must never be passed to autonomous-task tools.
- If the user explicitly requested the exact write action, perform the smallest correct change and then validate the result.
- If a requested action is ambiguous between read-only inspection and state change, inspect first and ask a short confirmation question before the state-changing step.
</confirmation_policy>
+6 -2
View File
@@ -79,6 +79,10 @@ task_types:
- "- Transfer mode: {transfer_mode}"
- "- Current TMDB ID: {tmdbid}"
- "- Current Douban ID: {doubanid}"
- "- Current Bangumi ID: {bangumiid}"
- "- Current AniList ID: {anilistid}"
- "- Current media source: {media_source}"
- "- Current source-native ID: {media_id}"
- "- Error message: {error_message}"
steps_title: "Required workflow"
steps:
@@ -90,7 +94,7 @@ task_types:
- "Only continue when you have high confidence in the target media."
- "Before re-organizing, delete the old transfer history record with `delete_transfer_history` so the system will not skip the source file."
- "Then use `transfer_file` to organize the source path directly."
- "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, tmdbid or doubanid, and media_type."
- "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, all known media IDs, media_source, media_id, and media_type."
- "If this record is already correct and no re-organize is needed, do not perform destructive actions; simply report that no change is necessary."
task_rules:
- "Do NOT rely on previous chat context. Work only from the record above."
@@ -116,7 +120,7 @@ task_types:
- "If a source file no longer exists or cannot be safely processed, skip that record and note the reason."
- "Before re-organizing a record, delete the old transfer history record with `delete_transfer_history` so the system will not skip the source file."
- "Then use `transfer_file` to organize the source path directly."
- "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, tmdbid or doubanid, and media_type."
- "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, all known media IDs, media_source, media_id, and media_type."
- "If a record is already correct and no re-organize is needed, do not perform destructive actions; simply mark it as skipped."
- "Report only the aggregate outcome, including how many records succeeded, skipped, and failed."
task_rules:
+8
View File
@@ -32,6 +32,10 @@ def build_manual_redo_template_context(history: Any) -> dict[str, int | str]:
"transfer_mode": history.mode or "unknown",
"tmdbid": history.tmdbid or "none",
"doubanid": history.doubanid or "none",
"bangumiid": history.bangumiid or "none",
"anilistid": history.anilistid or "none",
"media_source": history.media_source or "none",
"media_id": history.media_id or "none",
"error_message": history.errmsg or "none",
}
@@ -55,6 +59,10 @@ def format_manual_redo_record_context(history: Any) -> str:
f"- Transfer mode: {context['transfer_mode']}",
f"- Current TMDB ID: {context['tmdbid']}",
f"- Current Douban ID: {context['doubanid']}",
f"- Current Bangumi ID: {context['bangumiid']}",
f"- Current AniList ID: {context['anilistid']}",
f"- Current media source: {context['media_source']}",
f"- Current source-native ID: {context['media_id']}",
f"- Error message: {context['error_message']}",
]
)
+16 -1
View File
@@ -620,7 +620,8 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
发送工具通知消息。
WebAgent 渠道没有后端模块实例,前端流式面板通过 Agent 上下文中的
回调直接接收通知;其它渠道继续走统一消息链。
回调直接接收通知;无渠道的后台任务清空渠道侧定位信息后交由消息链广播,
其它渠道继续走统一消息链。
"""
callback = self._agent_context.get("notification_callback")
if (
@@ -630,6 +631,20 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
callback(notification)
return
if not self._channel or not self._source:
notification = notification.model_copy(
update={
"channel": None,
"source": None,
"userid": None,
"username": notification.username
or self._username
or settings.SUPERUSER,
"original_message_id": None,
"original_chat_id": None,
}
)
await ToolChain().async_post_message(notification)
async def send_tool_message(
+12
View File
@@ -42,8 +42,13 @@ from app.agent.tools.impl.send_message import SendMessageTool
from app.agent.tools.impl.ask_user_choice import AskUserChoiceTool
from app.agent.tools.impl.send_local_file import SendLocalFileTool
from app.agent.tools.impl.send_voice_message import SendVoiceMessageTool
from app.agent.tools.impl.create_agent_task import CreateAgentTaskTool
from app.agent.tools.impl.delete_agent_task import DeleteAgentTaskTool
from app.agent.tools.impl.query_agent_tasks import QueryAgentTasksTool
from app.agent.tools.impl.query_schedulers import QuerySchedulersTool
from app.agent.tools.impl.run_agent_task import RunAgentTaskTool
from app.agent.tools.impl.run_scheduler import RunSchedulerTool
from app.agent.tools.impl.update_agent_task import UpdateAgentTaskTool
from app.agent.tools.impl.query_workflows import QueryWorkflowsTool
from app.agent.tools.impl.run_workflow import RunWorkflowTool
from app.agent.tools.impl.query_personas import QueryPersonasTool
@@ -141,6 +146,11 @@ class MoviePilotToolFactory:
QueryTransferHistoryTool,
TransferFileTool,
SendMessageTool,
CreateAgentTaskTool,
QueryAgentTasksTool,
UpdateAgentTaskTool,
RunAgentTaskTool,
DeleteAgentTaskTool,
QuerySchedulersTool,
RunSchedulerTool,
QueryWorkflowsTool,
@@ -181,6 +191,8 @@ class MoviePilotToolFactory:
"edit_file",
"execute_command",
"ask_user_choice",
"create_agent_task",
"query_agent_tasks",
)
@staticmethod
+15 -2
View File
@@ -127,8 +127,19 @@ def filter_contexts(items: List[Context],
return filtered_items
def simplify_search_result(context: Context, index: int) -> dict:
"""精简单条搜索结果"""
def simplify_search_result(
context: Context,
index: int,
include_description: bool = False,
) -> dict:
"""
精简单条搜索结果
:param context: 搜索结果上下文
:param index: 搜索结果在原始缓存中的序号
:param include_description: 是否返回种子简介
:return: 精简后的搜索结果
"""
simplified = {}
torrent_info = context.torrent_info
meta_info = context.meta_info
@@ -147,6 +158,8 @@ def simplify_search_result(context: Context, index: int) -> dict:
"freedate_diff": torrent_info.freedate_diff,
"pubdate": torrent_info.pubdate,
}
if include_description:
simplified["torrent_info"]["description"] = torrent_info.description
if media_info:
simplified["media_info"] = {
+13 -1
View File
@@ -39,6 +39,10 @@ class AddSubscribeInput(BaseModel):
None,
description="Douban ID for precise media identification (optional, alternative to tmdb_id)",
)
bangumi_id: Optional[int] = Field(None, description="Bangumi media ID")
anilist_id: Optional[int] = Field(None, description="AniList media ID")
media_source: Optional[str] = Field(None, description="Media metadata source")
media_id: Optional[str] = Field(None, description="Native ID for media_source")
start_episode: Optional[int] = Field(
None,
description="Starting episode number for TV shows (optional, defaults to 1 if not specified)",
@@ -97,7 +101,7 @@ class AddSubscribeTool(MoviePilotTool):
message += f" ({year})"
if media_type:
message += f" [{media_type}]"
if season:
if season is not None:
message += f"{season}"
elif media_type == "tv":
message += " 第1季(默认)"
@@ -144,6 +148,10 @@ class AddSubscribeTool(MoviePilotTool):
season: Optional[int] = None,
tmdb_id: Optional[int] = None,
douban_id: Optional[str] = None,
bangumi_id: Optional[int] = None,
anilist_id: Optional[int] = None,
media_source: Optional[str] = None,
media_id: Optional[str] = None,
start_episode: Optional[int] = None,
total_episode: Optional[int] = None,
quality: Optional[str] = None,
@@ -197,6 +205,10 @@ class AddSubscribeTool(MoviePilotTool):
year=year,
tmdbid=tmdb_id,
doubanid=douban_id,
bangumiid=bangumi_id,
anilistid=anilist_id,
media_source=media_source,
media_id=media_id,
season=season,
username=subscribe_username,
**subscribe_kwargs,
+161
View File
@@ -0,0 +1,161 @@
import json
from datetime import datetime, timedelta
from typing import Literal, Optional, Type
import pytz
from pydantic import BaseModel, Field, model_validator
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.core.config import settings
from app.db.agentchat_oper import AgentChatOper
from app.db.agenttask_oper import AgentTaskOper
from app.utils.timer import TimerUtils
class CreateAgentTaskInput(BaseModel):
"""创建 Agent 自主定时任务的输入参数。"""
name: str = Field(
...,
min_length=1,
max_length=100,
description="Short task name shown in task management and execution reports.",
)
content: str = Field(
...,
min_length=1,
max_length=10000,
description="Complete instructions that the agent must execute when the task fires.",
)
trigger_type: Literal["date", "cron"] = Field(
...,
description="Use 'date' for one exact future run or 'cron' for recurring work.",
)
trigger: Optional[str] = Field(
None,
min_length=1,
max_length=200,
description=(
"For date, an ISO 8601 local or timezone-aware time such as "
"2026-07-19 20:30:00; for cron, a standard five-field expression "
"(minute hour day month weekday). The MoviePilot system timezone is used."
),
)
delay_minutes: Optional[int] = Field(
None,
ge=1,
le=525600,
description=(
"For a one-time date task expressed as 'in N minutes', provide this instead "
"of trigger. MoviePilot calculates and persists the exact future run time."
),
)
@model_validator(mode="after")
def validate_trigger(self) -> "CreateAgentTaskInput":
"""校验任务触发配置并统一格式。"""
self.name = self.name.strip()
self.content = self.content.strip()
if not self.name or not self.content:
raise ValueError("name 和 content 不能只包含空白字符")
if self.trigger_type == "date":
if self.delay_minutes is not None:
# LangChain 会在 run() 前后各校验一次,延迟时间在持久化前统一计算。
self.trigger = None
return self
if self.trigger is None:
raise ValueError("date 任务必须提供 trigger 或 delay_minutes")
elif self.trigger is None or self.delay_minutes is not None:
raise ValueError("cron 任务必须提供 trigger,且不能提供 delay_minutes")
self.trigger_type, self.trigger = TimerUtils.normalize_schedule_trigger(
trigger_type=self.trigger_type,
trigger_value=self.trigger,
timezone_name=settings.TZ,
require_future=True,
)
return self
class CreateAgentTaskTool(MoviePilotTool):
"""创建可精确唤醒当前 Agent 会话的自主定时任务。"""
name: str = "create_agent_task"
tags: list[str] = [ToolTag.Write, ToolTag.AgentTask, ToolTag.Admin]
description: str = (
"Create a persistent autonomous agent task only when the user explicitly asks "
"for delayed, scheduled, recurring, reminder, or monitoring work. Use trigger_type "
"'date' with delay_minutes for requests such as 'check in 30 minutes', an exact "
"trigger time for other one-time work, and 'cron' for recurring schedules. When "
"fired, MoviePilot wakes the agent in this conversation, executes content, and "
"broadcasts user-facing messages through the configured notification channels."
)
args_schema: Type[BaseModel] = CreateAgentTaskInput
require_admin: bool = True
def get_tool_message(self, **kwargs: object) -> Optional[str]:
"""生成创建定时任务的提示消息。"""
return f"创建自主定时任务:{kwargs.get('name', '')}"
def _create_task(self, payload: CreateAgentTaskInput) -> dict:
"""持久化任务并立即注册到运行时调度器。"""
from app.scheduler import Scheduler
trigger_value = payload.trigger
if payload.trigger_type == "date" and payload.delay_minutes is not None:
timezone = pytz.timezone(settings.TZ)
trigger_value = (
datetime.now(timezone) + timedelta(minutes=payload.delay_minutes)
).isoformat(timespec="seconds")
_, trigger_value = TimerUtils.normalize_schedule_trigger(
trigger_type=payload.trigger_type,
trigger_value=trigger_value,
timezone_name=settings.TZ,
require_future=True,
)
chat = AgentChatOper().get(
session_id=self._session_id,
user_id=self._user_id,
)
task = AgentTaskOper().add(
name=payload.name.strip(),
content=payload.content.strip(),
trigger_type=payload.trigger_type,
cron_expression=trigger_value if payload.trigger_type == "cron" else None,
run_at=trigger_value if payload.trigger_type == "date" else None,
user_id=str(self._user_id),
username=self._username or (chat.username if chat else None),
session_id=str(self._session_id),
channel=self._channel or (chat.channel if chat else None),
source=self._source or (chat.source if chat else None),
original_chat_id=chat.original_chat_id if chat else None,
)
scheduler = Scheduler()
next_run_at = scheduler.update_agent_task_job(task.id)
return AgentTaskOper.to_dict(
task,
next_run_at=next_run_at,
timezone=settings.TZ,
)
async def run(
self,
name: str,
content: str,
trigger_type: str,
trigger: Optional[str] = None,
delay_minutes: Optional[int] = None,
**kwargs: object,
) -> str:
"""创建 Agent 自主定时任务。"""
if not settings.AI_AGENT_ENABLE:
return "AI Agent 未启用,无法创建自主定时任务"
payload = CreateAgentTaskInput(
name=name,
content=content,
trigger_type=trigger_type,
trigger=trigger,
delay_minutes=delay_minutes,
)
task = await self.run_blocking("db", self._create_task, payload)
return json.dumps(task, ensure_ascii=False, indent=2)
+50
View File
@@ -0,0 +1,50 @@
from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.db.agenttask_oper import AgentTaskOper
class DeleteAgentTaskInput(BaseModel):
"""删除 Agent 自主定时任务的输入参数。"""
task_id: int = Field(..., ge=1, description="ID of the task to permanently delete.")
class DeleteAgentTaskTool(MoviePilotTool):
"""永久删除 Agent 自主定时任务。"""
name: str = "delete_agent_task"
tags: list[str] = [ToolTag.Write, ToolTag.AgentTask, ToolTag.Admin]
description: str = (
"Permanently delete an autonomous agent task and remove its runtime schedule. "
"Use update_agent_task with enabled=false when the user only wants to pause it."
)
args_schema: Type[BaseModel] = DeleteAgentTaskInput
require_admin: bool = True
def get_tool_message(self, **kwargs: object) -> Optional[str]:
"""生成删除定时任务的提示消息。"""
return f"删除自主定时任务:{kwargs.get('task_id', '')}"
def _delete_task(self, task_id: int) -> bool:
"""删除当前用户的任务并移除运行时调度。"""
from app.scheduler import Scheduler
deleted = AgentTaskOper().delete(
task_id=task_id,
user_id=str(self._user_id),
)
if deleted:
Scheduler().remove_agent_task_job(task_id)
return deleted
async def run(self, task_id: int, **kwargs: object) -> str:
"""删除 Agent 自主定时任务。"""
payload = DeleteAgentTaskInput(task_id=task_id)
deleted = await self.run_blocking("db", self._delete_task, payload.task_id)
if not deleted:
return f"Agent 定时任务 {task_id} 不存在或不属于当前用户"
return f"Agent 定时任务 {task_id} 已删除"
+8 -1
View File
@@ -54,7 +54,14 @@ class DeleteSubscribeTool(MoviePilotTool):
await subscribe_oper.async_delete(subscribe_id)
# 分享订阅统计刷新本身已异步化,这里只需要在删除后触发即可。
MoviePilotServerHelper.sub_done_async(
{"tmdbid": subscribe.tmdbid, "doubanid": subscribe.doubanid}
{
"tmdbid": subscribe.tmdbid,
"doubanid": subscribe.doubanid,
"bangumiid": subscribe.bangumiid,
"anilistid": subscribe.anilistid,
"media_source": subscribe.media_source,
"media_id": subscribe.media_id,
}
)
# 发送事件
@@ -210,6 +210,10 @@ class GetRecommendationsTool(MoviePilotTool):
"tmdb_id": r.get("tmdb_id"),
"imdb_id": r.get("imdb_id"),
"douban_id": r.get("douban_id"),
"bangumi_id": r.get("bangumi_id"),
"anilist_id": r.get("anilist_id"),
"media_source": r.get("source"),
"media_id": r.get("media_id"),
"vote_average": r.get("vote_average"),
"poster_path": r.get("poster_path"),
"detail_link": r.get("detail_link"),
+64 -6
View File
@@ -34,6 +34,14 @@ class GetSearchResultsInput(BaseModel):
None,
description="Regular expression pattern to filter torrent titles (e.g., '4K|2160p|UHD', '1080p.*BluRay')",
)
content_pattern: Optional[str] = Field(
None,
description="Regular expression pattern to filter torrent titles, descriptions, and labels (e.g., '特效字幕|国语|DIY')",
)
include_description: Optional[bool] = Field(
False,
description="Whether to include torrent descriptions in returned results",
)
show_filter_options: Optional[bool] = Field(
False,
description="Whether to return only optional filter options for re-checking available conditions",
@@ -45,6 +53,8 @@ class GetSearchResultsInput(BaseModel):
class GetSearchResultsTool(MoviePilotTool):
"""获取并筛选最近一次种子搜索结果"""
name: str = "get_search_results"
tags: list[str] = [
ToolTag.Read,
@@ -54,6 +64,7 @@ class GetSearchResultsTool(MoviePilotTool):
args_schema: Type[BaseModel] = GetSearchResultsInput
def get_tool_message(self, **kwargs) -> Optional[str]:
"""返回工具执行提示"""
return "获取搜索结果"
async def run(
@@ -66,13 +77,33 @@ class GetSearchResultsTool(MoviePilotTool):
resolution: Optional[List[str]] = None,
release_group: Optional[List[str]] = None,
title_pattern: Optional[str] = None,
content_pattern: Optional[str] = None,
include_description: bool = False,
show_filter_options: bool = False,
page: Optional[int] = 1,
**kwargs,
) -> str:
"""
获取并筛选最近一次种子搜索结果
:param site: 站点名称筛选项
:param season: 季集筛选项
:param free_state: 促销状态筛选项
:param video_code: 视频编码筛选项
:param edition: 制作版本筛选项
:param resolution: 分辨率筛选项
:param release_group: 发布组筛选项
:param title_pattern: 仅匹配种子标题的正则表达式
:param content_pattern: 匹配种子标题、简介和标签的正则表达式
:param include_description: 是否在结果中返回种子简介
:param show_filter_options: 是否只返回可用筛选项
:param page: 分页页码
:param kwargs: 工具框架附加参数
:return: JSON 格式的搜索结果或错误提示
"""
page = max(1, page or 1)
logger.info(
f"执行工具: {self.name}, 参数: site={site}, season={season}, free_state={free_state}, video_code={video_code}, edition={edition}, resolution={resolution}, release_group={release_group}, title_pattern={title_pattern}, show_filter_options={show_filter_options}, page={page}"
f"执行工具: {self.name}, 参数: site={site}, season={season}, free_state={free_state}, video_code={video_code}, edition={edition}, resolution={resolution}, release_group={release_group}, title_pattern={title_pattern}, content_pattern={content_pattern}, include_description={include_description}, show_filter_options={show_filter_options}, page={page}"
)
try:
@@ -87,14 +118,22 @@ class GetSearchResultsTool(MoviePilotTool):
}
return json.dumps(payload, ensure_ascii=False, indent=2)
regex_pattern = None
title_regex_pattern = None
if title_pattern:
try:
regex_pattern = re.compile(title_pattern, re.IGNORECASE)
title_regex_pattern = re.compile(title_pattern, re.IGNORECASE)
except re.error as e:
logger.warning(f"正则表达式编译失败: {title_pattern}, 错误: {e}")
return f"正则表达式格式错误: {str(e)}"
content_regex_pattern = None
if content_pattern:
try:
content_regex_pattern = re.compile(content_pattern, re.IGNORECASE)
except re.error as e:
logger.warning(f"正则表达式编译失败: {content_pattern}, 错误: {e}")
return f"正则表达式格式错误: {str(e)}"
filtered_items = filter_contexts(
items=items,
site=site,
@@ -105,14 +144,29 @@ class GetSearchResultsTool(MoviePilotTool):
resolution=resolution,
release_group=release_group,
)
if regex_pattern:
if title_regex_pattern:
filtered_items = [
item
for item in filtered_items
if item.torrent_info
and item.torrent_info.title
and regex_pattern.search(item.torrent_info.title)
and title_regex_pattern.search(item.torrent_info.title)
]
if content_regex_pattern:
content_filtered_items = []
for item in filtered_items:
torrent_info = item.torrent_info
if not torrent_info:
continue
content_values = [torrent_info.title, torrent_info.description]
content_values.extend(torrent_info.labels or [])
if any(
content_regex_pattern.search(str(value))
for value in content_values
if value
):
content_filtered_items.append(item)
filtered_items = content_filtered_items
if not filtered_items:
return "没有符合筛选条件的搜索结果,请调整筛选条件"
@@ -135,7 +189,11 @@ class GetSearchResultsTool(MoviePilotTool):
return f"{page} 页没有数据,共 {total_count} 条结果,共 {(total_count + page_size - 1) // page_size} 页。"
results = [
simplify_search_result(item, index)
simplify_search_result(
item,
index,
include_description=include_description,
)
for item, index in zip(page_items, page_indices)
]
total_pages = (total_count + page_size - 1) // page_size
+88
View File
@@ -0,0 +1,88 @@
import json
from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.core.config import settings
from app.db.agenttask_oper import AgentTaskOper
class QueryAgentTasksInput(BaseModel):
"""查询 Agent 自主定时任务的输入参数。"""
task_id: Optional[int] = Field(
None,
ge=1,
description="Optional task ID. Omit it to list tasks owned by the current user.",
)
enabled: Optional[bool] = Field(
None,
description="Optional enabled-state filter used when listing tasks.",
)
class QueryAgentTasksTool(MoviePilotTool):
"""查询当前用户创建的 Agent 自主定时任务。"""
name: str = "query_agent_tasks"
tags: list[str] = [ToolTag.Read, ToolTag.AgentTask, ToolTag.Admin]
description: str = (
"Query persistent autonomous agent tasks owned by the current user, including "
"reminders, monitoring tasks, and recurring agent work. Returns the integer "
"task_id, instructions, trigger, enabled state, next run time, and latest result. "
"Do not use this for MoviePilot system, plugin, or workflow scheduler services."
)
args_schema: Type[BaseModel] = QueryAgentTasksInput
require_admin: bool = True
def get_tool_message(self, **kwargs: object) -> Optional[str]:
"""生成查询定时任务的提示消息。"""
task_id = kwargs.get("task_id")
return f"查询自主定时任务:{task_id}" if task_id else "查询自主定时任务"
def _query_tasks(
self,
task_id: Optional[int],
enabled: Optional[bool],
) -> list[dict]:
"""读取当前用户的任务及运行时下一次触发时间。"""
from app.scheduler import Scheduler
oper = AgentTaskOper()
if task_id:
task = oper.get(task_id=task_id, user_id=str(self._user_id))
tasks = [task] if task else []
else:
tasks = oper.list(user_id=str(self._user_id), enabled=enabled)
scheduler = Scheduler()
result = []
for task in tasks:
data = oper.to_dict(
task,
next_run_at=scheduler.get_agent_task_next_run(task.id),
timezone=settings.TZ,
)
result.append(data)
return result
async def run(
self,
task_id: Optional[int] = None,
enabled: Optional[bool] = None,
**kwargs: object,
) -> str:
"""查询 Agent 自主定时任务。"""
payload = QueryAgentTasksInput(task_id=task_id, enabled=enabled)
tasks = await self.run_blocking(
"db",
self._query_tasks,
payload.task_id,
payload.enabled,
)
return json.dumps(
{"total": len(tasks), "tasks": tasks},
ensure_ascii=False,
indent=2,
)
+4 -1
View File
@@ -44,7 +44,9 @@ class QueryDoctorReportTool(MoviePilotTool):
description: str = (
"Run MoviePilot Doctor in read-only mode and return a structured diagnostic report for troubleshooting. "
"Use this tool when analyzing startup failures, Docker/runtime issues, port conflicts, dependency problems, "
"database health, frontend assets, safe mode, or recent log error clues. This tool never applies fixes."
"database health, frontend assets, safe mode, or recent log error clues. Plugin-only log findings remain "
"visible with affects_report_status=false and do not downgrade the overall status. This tool never applies "
"fixes."
)
require_admin: bool = True
args_schema: Type[BaseModel] = QueryDoctorReportInput
@@ -73,6 +75,7 @@ class QueryDoctorReportTool(MoviePilotTool):
"title": item.get("title"),
"fixable": item.get("fixable"),
"fixed": item.get("fixed"),
"affects_report_status": item.get("affects_report_status", True),
}
for item in report.get("findings") or []
if isinstance(item, dict)
+29 -16
View File
@@ -77,8 +77,12 @@ def _build_tv_server_result(existing_seasons: OrderedDict, total_seasons: Ordere
class QueryLibraryExistsInput(BaseModel):
"""查询媒体库工具的输入参数模型"""
tmdb_id: Optional[int] = Field(None, description="TMDB ID (can be obtained from search_media tool). Either tmdb_id or douban_id must be provided.")
douban_id: Optional[str] = Field(None, description="Douban ID (can be obtained from search_media tool). Either tmdb_id or douban_id must be provided.")
tmdb_id: Optional[int] = Field(None, description="TMDB media ID")
douban_id: Optional[str] = Field(None, description="Douban media ID")
bangumi_id: Optional[int] = Field(None, description="Bangumi media ID")
anilist_id: Optional[int] = Field(None, description="AniList media ID")
media_source: Optional[str] = Field(None, description="Media metadata source")
media_id: Optional[str] = Field(None, description="Native ID for media_source")
media_type: Optional[str] = Field(None, description="Allowed values: movie, tv")
@@ -89,21 +93,24 @@ class QueryLibraryExistsTool(MoviePilotTool):
ToolTag.Library,
ToolTag.Media,
]
description: str = "Check whether media already exists in Plex, Emby, or Jellyfin by media ID. Results are grouped by media server; TV results include existing episodes, total episodes, and missing episodes/seasons. Requires tmdb_id or douban_id from search_media."
description: str = "Check whether media already exists in Plex, Emby, or Jellyfin by a TMDB, Douban, Bangumi, AniList, or source-native media ID. Results are grouped by media server; TV results include existing episodes, total episodes, and missing episodes/seasons."
args_schema: Type[BaseModel] = QueryLibraryExistsInput
def get_tool_message(self, **kwargs) -> Optional[str]:
"""根据查询参数生成友好的提示消息"""
tmdb_id = kwargs.get("tmdb_id")
douban_id = kwargs.get("douban_id")
media_type = kwargs.get("media_type")
if tmdb_id:
message = f"查询媒体库: TMDB={tmdb_id}"
elif douban_id:
message = f"查询媒体库: 豆瓣={douban_id}"
else:
message = "查询媒体库"
identities = (
("TMDB", kwargs.get("tmdb_id")),
("豆瓣", kwargs.get("douban_id")),
("Bangumi", kwargs.get("bangumi_id")),
("AniList", kwargs.get("anilist_id")),
(kwargs.get("media_source") or "媒体源", kwargs.get("media_id")),
)
label, identity = next(
((label, identity) for label, identity in identities if identity is not None),
(None, None),
)
message = f"查询媒体库: {label}={identity}" if label else "查询媒体库"
if media_type:
message += f" [{media_type}]"
return message
@@ -119,11 +126,13 @@ class QueryLibraryExistsTool(MoviePilotTool):
return MediaServerChain().media_exists(mediainfo=mediainfo, server=server)
async def run(self, tmdb_id: Optional[int] = None, douban_id: Optional[str] = None,
bangumi_id: Optional[int] = None, anilist_id: Optional[int] = None,
media_source: Optional[str] = None, media_id: Optional[str] = None,
media_type: Optional[str] = None, **kwargs) -> str:
logger.info(f"执行工具: {self.name}, 参数: tmdb_id={tmdb_id}, douban_id={douban_id}, media_type={media_type}")
try:
if not tmdb_id and not douban_id:
return "参数错误:tmdb_id 和 douban_id 至少需要提供一个,请先使用 search_media 工具获取媒体 ID"
if not any((tmdb_id, douban_id, bangumi_id, anilist_id, media_id)):
return "参数错误:至少需要提供一个媒体 ID,请先使用 search_media 工具获取媒体信息"
media_type_enum = None
if media_type:
@@ -135,11 +144,15 @@ class QueryLibraryExistsTool(MoviePilotTool):
mediainfo = await media_chain.async_recognize_media(
tmdbid=tmdb_id,
doubanid=douban_id,
bangumiid=bangumi_id,
anilistid=anilist_id,
source=media_source,
mediaid=media_id,
mtype=media_type_enum,
)
if not mediainfo:
media_id = f"TMDB={tmdb_id}" if tmdb_id else f"豆瓣={douban_id}"
return f"未识别到媒体信息: {media_id}"
identity = media_id or tmdb_id or douban_id or bangumi_id or anilist_id
return f"未识别到媒体信息: {identity}"
# 2. 遍历所有媒体服务器,分别查询存在性信息
server_results = OrderedDict()
+45 -12
View File
@@ -20,6 +20,10 @@ class QueryMediaDetailInput(BaseModel):
"""查询媒体详情工具的输入参数模型"""
tmdb_id: Optional[int] = Field(None, description="TMDB ID of the media (movie or TV series, can be obtained from search_media tool)")
douban_id: Optional[str] = Field(None, description="Douban ID of the media (alternative to tmdb_id)")
bangumi_id: Optional[int] = Field(None, description="Bangumi media ID")
anilist_id: Optional[int] = Field(None, description="AniList media ID")
media_source: Optional[str] = Field(None, description="Media metadata source")
media_id: Optional[str] = Field(None, description="Native ID for media_source")
media_type: str = Field(..., description="Allowed values: movie, tv")
@@ -29,24 +33,37 @@ class QueryMediaDetailTool(MoviePilotTool):
ToolTag.Read,
ToolTag.Media,
]
description: str = "Query supplementary media details from TMDB by ID and media_type. Accepts tmdb_id or douban_id (at least one required). media_type accepts 'movie' or 'tv'. Returns non-duplicated detail fields such as status, genres, directors, actors, and season info for TV series."
description: str = "Query supplementary media details from a metadata source by ID and media_type. Accepts a TMDB, Douban, Bangumi, AniList, or source-native media ID. media_type accepts 'movie' or 'tv'. Returns non-duplicated detail fields such as status, genres, directors, actors, and season info for TV series."
args_schema: Type[BaseModel] = QueryMediaDetailInput
def get_tool_message(self, **kwargs) -> Optional[str]:
"""根据查询参数生成友好的提示消息"""
tmdb_id = kwargs.get("tmdb_id")
douban_id = kwargs.get("douban_id")
if tmdb_id:
return f"查询媒体详情: TMDB ID {tmdb_id}"
return f"查询媒体详情: 豆瓣 ID {douban_id}"
identities = (
("TMDB", kwargs.get("tmdb_id")),
("豆瓣", kwargs.get("douban_id")),
("Bangumi", kwargs.get("bangumi_id")),
("AniList", kwargs.get("anilist_id")),
)
for label, identity in identities:
if identity is not None:
return f"查询媒体详情: {label} ID {identity}"
return (
f"查询媒体详情: {kwargs.get('media_source') or '媒体源'} "
f"ID {kwargs.get('media_id')}"
)
async def run(self, media_type: str, tmdb_id: Optional[int] = None, douban_id: Optional[str] = None, **kwargs) -> str:
async def run(
self, media_type: str, tmdb_id: Optional[int] = None,
douban_id: Optional[str] = None, bangumi_id: Optional[int] = None,
anilist_id: Optional[int] = None, media_source: Optional[str] = None,
media_id: Optional[str] = None, **kwargs,
) -> str:
logger.info(f"执行工具: {self.name}, 参数: tmdb_id={tmdb_id}, douban_id={douban_id}, media_type={media_type}")
if tmdb_id is None and douban_id is None:
if not any((tmdb_id, douban_id, bangumi_id, anilist_id, media_id)):
return json.dumps({
"success": False,
"message": "必须提供 tmdb_id 或 douban_id 之一"
"message": "必须提供至少一个媒体 ID"
}, ensure_ascii=False)
try:
@@ -59,10 +76,22 @@ class QueryMediaDetailTool(MoviePilotTool):
"message": f"无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'"
}, ensure_ascii=False)
mediainfo = await media_chain.async_recognize_media(tmdbid=tmdb_id, doubanid=douban_id, mtype=media_type_enum)
mediainfo = await media_chain.async_recognize_media(
tmdbid=tmdb_id,
doubanid=douban_id,
bangumiid=bangumi_id,
anilistid=anilist_id,
source=media_source,
mediaid=media_id,
mtype=media_type_enum,
)
if not mediainfo:
id_info = f"TMDB ID {tmdb_id}" if tmdb_id else f"豆瓣 ID {douban_id}"
id_info = (
f"{media_source or '媒体源'} ID {media_id}"
if media_id else
f"媒体 ID {tmdb_id or douban_id or bangumi_id or anilist_id}"
)
return json.dumps({
"success": False,
"message": f"未找到 {id_info} 的媒体信息"
@@ -139,5 +168,9 @@ class QueryMediaDetailTool(MoviePilotTool):
"success": False,
"message": error_message,
"tmdb_id": tmdb_id,
"douban_id": douban_id
"douban_id": douban_id,
"bangumi_id": bangumi_id,
"anilist_id": anilist_id,
"media_source": media_source,
"media_id": media_id,
}, ensure_ascii=False)
@@ -118,7 +118,7 @@ class QueryPopularSubscribesTool(MoviePilotTool):
# 处理标题
title = sub.get("name")
season = sub.get("season")
if season and int(season) > 1 and media.tmdb_id:
if season not in (None, "") and int(season) != 1 and media.tmdb_id:
# 小写数据转大写
season_str = cn2an.an2cn(season, "low")
title = f"{title}{season_str}"
@@ -126,6 +126,8 @@ class QueryPopularSubscribesTool(MoviePilotTool):
media.year = sub.get("year")
media.douban_id = sub.get("doubanid")
media.bangumi_id = sub.get("bangumiid")
media.anilist_id = sub.get("anilistid")
media.source = sub.get("media_source")
media.tvdb_id = sub.get("tvdbid")
media.imdb_id = sub.get("imdbid")
media.season = sub.get("season")
@@ -149,6 +151,9 @@ class QueryPopularSubscribesTool(MoviePilotTool):
"tmdb_id": media_dict.get("tmdb_id"),
"douban_id": media_dict.get("douban_id"),
"bangumi_id": media_dict.get("bangumi_id"),
"anilist_id": media_dict.get("anilist_id"),
"media_source": media_dict.get("source"),
"media_id": media_dict.get("media_id"),
"tvdb_id": media_dict.get("tvdb_id"),
"imdb_id": media_dict.get("imdb_id"),
"season": media_dict.get("season"),
+45 -23
View File
@@ -3,7 +3,7 @@
import json
from typing import Optional, Type
from pydantic import BaseModel, Field
from pydantic import BaseModel
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
@@ -11,47 +11,69 @@ from app.log import logger
class QuerySchedulersInput(BaseModel):
"""查询定时服务工具的输入参数模型"""
"""查询运行时定时服务的输入参数模型"""
class QuerySchedulersTool(MoviePilotTool):
"""查询系统、插件和工作流注册的运行时定时服务。"""
name: str = "query_schedulers"
tags: list[str] = [
ToolTag.Read,
ToolTag.Scheduler,
ToolTag.Admin,
]
description: str = "Query scheduled tasks and list all available scheduler jobs. Shows job status, next run time, and provider information."
description: str = (
"Query runtime scheduler services registered by MoviePilot system components, "
"plugins, and workflows. It excludes user-created autonomous agent tasks; use "
"query_agent_tasks for reminders, monitoring tasks, and other agent schedules."
)
args_schema: Type[BaseModel] = QuerySchedulersInput
require_admin: bool = True
def get_tool_message(self, **kwargs) -> Optional[str]:
"""生成友好的提示消息"""
return "查询定时服务"
def get_tool_message(self, **kwargs: object) -> Optional[str]:
"""生成查询运行时定时服务的提示消息"""
return "查询系统定时服务"
async def run(self, **kwargs) -> str:
async def run(self, **kwargs: object) -> str:
"""查询非 Agent 自主任务的运行时定时服务。"""
logger.info(f"执行工具: {self.name}")
try:
from app.scheduler import Scheduler
from app.scheduler import AGENT_TASK_JOB_PREFIX, Scheduler
scheduler = Scheduler()
schedulers = scheduler.list()
agent_task_prefix = f"{AGENT_TASK_JOB_PREFIX}-"
schedulers = [
scheduler_item
for scheduler_item in scheduler.list()
if not str(scheduler_item.id or "").startswith(agent_task_prefix)
]
if schedulers:
# 转换为字典列表以便JSON序列化
schedulers_list = []
for s in schedulers:
schedulers_list.append({
"id": s.id,
"name": s.name,
"provider": s.provider,
"status": s.status,
"next_run": s.next_run
})
schedulers_list = [
{
"id": scheduler_item.id,
"name": scheduler_item.name,
"provider": scheduler_item.provider,
"status": scheduler_item.status,
"next_run": scheduler_item.next_run,
}
for scheduler_item in schedulers
]
result_json = json.dumps(schedulers_list, ensure_ascii=False, indent=2)
# 限制最多30条结果
total_count = len(schedulers_list)
if total_count > 30:
limited_schedulers = schedulers_list[:30]
limited_json = json.dumps(limited_schedulers, ensure_ascii=False, indent=2)
return f"注意:查询结果共找到 {total_count} 条,为节省上下文空间,仅显示前 30 条结果。\n\n{limited_json}"
limited_json = json.dumps(
limited_schedulers,
ensure_ascii=False,
indent=2,
)
return (
f"注意:查询结果共找到 {total_count} 条,为节省上下文空间,"
f"仅显示前 30 条结果。\n\n{limited_json}"
)
return result_json
return "未找到定时服务"
return "未找到系统、插件或工作流定时服务"
except Exception as e:
logger.error(f"查询定时服务失败: {e}", exc_info=True)
return f"查询定时服务时发生错误: {str(e)}"
@@ -170,6 +170,9 @@ class QuerySubscribeHistoryTool(MoviePilotTool):
"tmdbid": record.tmdbid,
"doubanid": record.doubanid,
"bangumiid": record.bangumiid,
"anilistid": record.anilistid,
"media_source": record.media_source,
"media_id": record.media_id,
"poster": record.poster,
"vote": record.vote,
"total_episode": record.total_episode,
@@ -97,6 +97,9 @@ class QuerySubscribeSharesTool(MoviePilotTool):
"tmdbid": share.get("tmdbid"),
"doubanid": share.get("doubanid"),
"bangumiid": share.get("bangumiid"),
"anilistid": share.get("anilistid"),
"media_source": share.get("media_source"),
"media_id": share.get("media_id"),
"poster": share.get("poster"),
"vote": share.get("vote"),
"share_title": share.get("share_title"),
+16
View File
@@ -63,6 +63,10 @@ class QuerySubscribesInput(BaseModel):
None,
description="Filter by Douban ID to check if a specific media is already subscribed",
)
bangumi_id: Optional[int] = Field(None, description="Filter by Bangumi ID")
anilist_id: Optional[int] = Field(None, description="Filter by AniList ID")
media_source: Optional[str] = Field(None, description="Filter by media source")
media_id: Optional[str] = Field(None, description="Filter by source-native media ID")
page: Optional[int] = Field(
1, description="Page number for pagination (default: 1, 100 items per page)"
)
@@ -104,6 +108,10 @@ class QuerySubscribesTool(MoviePilotTool):
media_type: Optional[str] = "all",
tmdb_id: Optional[int] = None,
douban_id: Optional[str] = None,
bangumi_id: Optional[int] = None,
anilist_id: Optional[int] = None,
media_source: Optional[str] = None,
media_id: Optional[str] = None,
page: Optional[int] = 1,
**kwargs,
) -> str:
@@ -130,6 +138,14 @@ class QuerySubscribesTool(MoviePilotTool):
continue
if douban_id is not None and sub.doubanid != douban_id:
continue
if bangumi_id is not None and sub.bangumiid != bangumi_id:
continue
if anilist_id is not None and sub.anilistid != anilist_id:
continue
if media_source is not None and sub.media_source != media_source:
continue
if media_id is not None and sub.media_id != media_id:
continue
filtered_subscribes.append(sub)
if filtered_subscribes:
total_count = len(filtered_subscribes)
@@ -120,6 +120,14 @@ class QueryTransferHistoryTool(MoviePilotTool):
simplified["imdbid"] = record.imdbid
if record.doubanid:
simplified["doubanid"] = record.doubanid
if record.bangumiid:
simplified["bangumiid"] = record.bangumiid
if record.anilistid:
simplified["anilistid"] = record.anilistid
if record.media_source:
simplified["media_source"] = record.media_source
if record.media_id:
simplified["media_id"] = record.media_id
simplified_records.append(simplified)
result_json = json.dumps(simplified_records, ensure_ascii=False, indent=2)
+8 -1
View File
@@ -142,6 +142,9 @@ class RecognizeMediaTool(MoviePilotTool):
"imdb_id": media_info.get("imdb_id"),
"douban_id": media_info.get("douban_id"),
"bangumi_id": media_info.get("bangumi_id"),
"anilist_id": media_info.get("anilist_id"),
"media_source": media_info.get("source"),
"media_id": media_info.get("media_id"),
"overview": media_info.get("overview"),
"vote_average": media_info.get("vote_average"),
"poster_path": media_info.get("poster_path"),
@@ -167,7 +170,11 @@ class RecognizeMediaTool(MoviePilotTool):
"season_episode": meta_info.get("season_episode"),
"episode_list": meta_info.get("episode_list"),
"tmdbid": meta_info.get("tmdbid"),
"doubanid": meta_info.get("doubanid")
"doubanid": meta_info.get("doubanid"),
"bangumiid": meta_info.get("bangumiid"),
"anilistid": meta_info.get("anilistid"),
"media_source": meta_info.get("media_source"),
"media_id": meta_info.get("media_id"),
}
return json.dumps(result, ensure_ascii=False, indent=2)
+78
View File
@@ -0,0 +1,78 @@
"""立即执行 Agent 自主定时任务工具。"""
from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.db.agenttask_oper import AgentTaskOper
class RunAgentTaskInput(BaseModel):
"""立即执行 Agent 自主定时任务的输入参数。"""
task_id: int = Field(
...,
ge=1,
description=(
"Integer autonomous task ID returned by query_agent_tasks. Do not pass a "
"runtime scheduler job_id such as agent-task-12."
),
)
class RunAgentTaskTool(MoviePilotTool):
"""将当前用户的 Agent 自主定时任务提交为立即执行。"""
name: str = "run_agent_task"
tags: list[str] = [ToolTag.Write, ToolTag.AgentTask, ToolTag.Admin]
description: str = (
"Queue an enabled autonomous agent task owned by the current user for immediate "
"execution. Use the integer task_id returned by query_agent_tasks. The task runs "
"after the current agent turn can finish and broadcasts its result through the "
"configured notification channels."
)
args_schema: Type[BaseModel] = RunAgentTaskInput
require_admin: bool = True
def get_tool_message(self, **kwargs: object) -> Optional[str]:
"""生成立即执行 Agent 任务的提示消息。"""
return f"立即执行自主定时任务:{kwargs.get('task_id', '')}"
def _get_task_state(self, task_id: int) -> tuple[str, Optional[str]]:
"""校验任务归属和状态,返回可执行性及任务名称。"""
task = AgentTaskOper().get(
task_id=task_id,
user_id=str(self._user_id),
)
if not task:
return "not_found", None
if not task.enabled:
return "disabled", task.name
if task.last_status == "running":
return "running", task.name
return "ready", task.name
async def run(self, task_id: int, **kwargs: object) -> str:
"""立即执行当前用户拥有且已启用的 Agent 自主定时任务。"""
from app.scheduler import Scheduler
payload = RunAgentTaskInput(task_id=task_id)
status, task_name = await self.run_blocking(
"db",
self._get_task_state,
payload.task_id,
)
if status == "not_found":
return f"Agent 定时任务 {task_id} 不存在或不属于当前用户"
if status == "disabled":
return f"Agent 定时任务 {task_id} 已暂停,请先恢复后再执行"
if status == "running":
return f"Agent 定时任务 {task_id} 正在执行,请勿重复触发"
if not Scheduler().start_agent_task(payload.task_id):
return f"Agent 定时任务 {task_id} 尚未注册到运行时调度器,无法立即执行"
return (
f"Agent 定时任务 {task_id} 已提交立即执行:{task_name}"
"执行完成后将通过已配置的通知渠道广播结果"
)
+22 -5
View File
@@ -14,23 +14,32 @@ class RunSchedulerInput(BaseModel):
job_id: str = Field(
...,
description="The ID of the scheduled job to run (can be obtained from query_schedulers tool)",
description=(
"Runtime scheduler job ID returned by query_schedulers. Do not pass an "
"autonomous agent task ID or an agent-task-* runtime ID."
),
)
class RunSchedulerTool(MoviePilotTool):
"""立即运行系统、插件或工作流注册的定时服务。"""
name: str = "run_scheduler"
tags: list[str] = [
ToolTag.Write,
ToolTag.Scheduler,
ToolTag.Admin,
]
description: str = "Manually trigger a scheduled task to run immediately. This will execute the specified scheduler job by its ID."
description: str = (
"Manually trigger a MoviePilot system, plugin, or workflow scheduler service by "
"the runtime job_id returned from query_schedulers. This tool does not run "
"user-created autonomous agent tasks; use run_agent_task with an integer task_id."
)
args_schema: Type[BaseModel] = RunSchedulerInput
require_admin: bool = True
def get_tool_message(self, **kwargs) -> Optional[str]:
"""根据运行参数生成友好的提示消息"""
def get_tool_message(self, **kwargs: object) -> Optional[str]:
"""根据运行参数生成友好的提示消息"""
job_id = kwargs.get("job_id", "")
return f"运行定时服务 (ID: {job_id})"
@@ -46,10 +55,18 @@ class RunSchedulerTool(MoviePilotTool):
return True, scheduler_item.name
return False, ""
async def run(self, job_id: str, **kwargs) -> str:
async def run(self, job_id: str, **kwargs: object) -> str:
"""立即运行非 Agent 自主任务的运行时定时服务。"""
logger.info(f"执行工具: {self.name}, 参数: job_id={job_id}")
try:
from app.scheduler import AGENT_TASK_JOB_PREFIX
if job_id.startswith(f"{AGENT_TASK_JOB_PREFIX}-"):
return (
"Agent 自主定时任务不能通过 run_scheduler 运行,"
"请使用 query_agent_tasks 查询整数 task_id 后调用 run_agent_task"
)
job_exists, job_name = await self.run_blocking(
"workflow", self._run_scheduler_sync, job_id
)
+7 -1
View File
@@ -10,6 +10,7 @@ from app.agent.tools.tags import ToolTag
from app.chain.media import MediaChain
from app.log import logger
from app.schemas.types import MediaType, media_type_to_agent
from app.utils.media import resolve_media_identity
class SearchMediaInput(BaseModel):
@@ -43,7 +44,7 @@ class SearchMediaTool(MoviePilotTool):
message += f" ({year})"
if media_type:
message += f" [{media_type}]"
if season:
if season is not None:
message += f"{season}"
return message
@@ -83,6 +84,7 @@ class SearchMediaTool(MoviePilotTool):
# 精简字段,只保留关键信息
simplified_results = []
for r in limited_results:
media_source, media_id = resolve_media_identity(media=r)
simplified = {
"title": r.title,
"en_title": r.en_title,
@@ -92,6 +94,10 @@ class SearchMediaTool(MoviePilotTool):
"tmdb_id": r.tmdb_id,
"imdb_id": r.imdb_id,
"douban_id": r.douban_id,
"bangumi_id": r.bangumi_id,
"anilist_id": r.anilist_id,
"media_source": media_source,
"media_id": media_id,
"overview": r.overview[:200] + "..." if r.overview and len(r.overview) > 200 else r.overview,
"vote_average": r.vote_average,
"poster_path": r.poster_path,
@@ -11,6 +11,7 @@ from app.chain.douban import DoubanChain
from app.chain.tmdb import TmdbChain
from app.chain.bangumi import BangumiChain
from app.log import logger
from app.utils.media import resolve_media_identity
class SearchPersonCreditsInput(BaseModel):
@@ -59,6 +60,7 @@ class SearchPersonCreditsTool(MoviePilotTool):
# 精简字段,只保留关键信息
simplified_results = []
for media in limited_medias:
media_source, media_id = resolve_media_identity(media=media)
simplified = {
"title": media.title,
"en_title": media.en_title,
@@ -68,6 +70,10 @@ class SearchPersonCreditsTool(MoviePilotTool):
"tmdb_id": media.tmdb_id,
"imdb_id": media.imdb_id,
"douban_id": media.douban_id,
"bangumi_id": media.bangumi_id,
"anilist_id": media.anilist_id,
"media_source": media_source,
"media_id": media_id,
"overview": media.overview[:200] + "..." if media.overview and len(media.overview) > 200 else media.overview,
"vote_average": media.vote_average,
"poster_path": media.poster_path,
+5 -1
View File
@@ -70,7 +70,11 @@ class SearchSubscribeTool(MoviePilotTool):
"total_episode": subscribe.total_episode,
"lack_episode": subscribe.lack_episode,
"tmdbid": subscribe.tmdbid,
"doubanid": subscribe.doubanid
"doubanid": subscribe.doubanid,
"bangumiid": subscribe.bangumiid,
"anilistid": subscribe.anilistid,
"media_source": subscribe.media_source,
"media_id": subscribe.media_id,
}
# 检查订阅状态
+33 -18
View File
@@ -20,13 +20,18 @@ from ._torrent_search_utils import (
class SearchTorrentsInput(BaseModel):
"""搜索种子工具的输入参数模型"""
tmdb_id: Optional[int] = Field(None, description="TMDB ID (can be obtained from search_media tool). Either tmdb_id or douban_id must be provided.")
douban_id: Optional[str] = Field(None, description="Douban ID (can be obtained from search_media tool). Either tmdb_id or douban_id must be provided.")
tmdb_id: Optional[int] = Field(None, description="TMDB media ID")
douban_id: Optional[str] = Field(None, description="Douban media ID")
bangumi_id: Optional[int] = Field(None, description="Bangumi media ID")
anilist_id: Optional[int] = Field(None, description="AniList media ID")
media_source: Optional[str] = Field(None, description="Media metadata source")
media_id: Optional[str] = Field(None, description="Native ID for media_source")
media_type: Optional[str] = Field(None, description="Allowed values: movie, tv")
area: Optional[str] = Field(None, description="Search scope: 'title' (default) or 'imdbid'")
sites: Optional[List[int]] = Field(None,
description="Array of specific site IDs to search on (optional, if not provided searches all configured sites)")
class SearchTorrentsTool(MoviePilotTool):
name: str = "search_torrents"
tags: list[str] = [
@@ -35,23 +40,27 @@ class SearchTorrentsTool(MoviePilotTool):
ToolTag.Site,
ToolTag.Media,
]
description: str = ("Search for torrent files by media ID across configured indexer sites, cache the matched results, "
"and return available filter options for follow-up selection. "
"Requires tmdb_id or douban_id (can be obtained from search_media tool) for accurate matching.")
description: str = (
"Search for torrent files by media ID across configured indexer sites, cache the matched results, "
"and return available filter options for follow-up selection. "
"Accepts a TMDB, Douban, Bangumi, AniList, or source-native media ID for accurate matching.")
args_schema: Type[BaseModel] = SearchTorrentsInput
def get_tool_message(self, **kwargs) -> Optional[str]:
"""根据搜索参数生成友好的提示消息"""
tmdb_id = kwargs.get("tmdb_id")
douban_id = kwargs.get("douban_id")
media_type = kwargs.get("media_type")
if tmdb_id:
message = f"搜索种子: TMDB={tmdb_id}"
elif douban_id:
message = f"搜索种子: 豆瓣={douban_id}"
else:
message = "搜索种子"
identities = (
("TMDB", kwargs.get("tmdb_id")),
("豆瓣", kwargs.get("douban_id")),
("Bangumi", kwargs.get("bangumi_id")),
("AniList", kwargs.get("anilist_id")),
(kwargs.get("media_source") or "媒体源", kwargs.get("media_id")),
)
label, identity = next(
((label, identity) for label, identity in identities if identity is not None),
(None, None),
)
message = f"搜索种子: {label}={identity}" if label else "搜索种子"
if media_type:
message += f" [{media_type}]"
return message
@@ -62,13 +71,15 @@ class SearchTorrentsTool(MoviePilotTool):
return SystemConfigOper().get(SystemConfigKey.IndexerSites) or []
async def run(self, tmdb_id: Optional[int] = None, douban_id: Optional[str] = None,
bangumi_id: Optional[int] = None, anilist_id: Optional[int] = None,
media_source: Optional[str] = None, media_id: Optional[str] = None,
media_type: Optional[str] = None, area: Optional[str] = None,
sites: Optional[List[int]] = None, **kwargs) -> str:
logger.info(
f"执行工具: {self.name}, 参数: tmdb_id={tmdb_id}, douban_id={douban_id}, media_type={media_type}, area={area}, sites={sites}")
if not tmdb_id and not douban_id:
return "参数错误:tmdb_id 和 douban_id 至少需要提供一个,请先使用 search_media 工具获取媒体 ID"
if not any((tmdb_id, douban_id, bangumi_id, anilist_id, media_id)):
return "参数错误:至少需要提供一个媒体 ID,请先使用 search_media 工具获取媒体信息"
try:
search_chain = SearchChain()
@@ -81,6 +92,10 @@ class SearchTorrentsTool(MoviePilotTool):
filtered_torrents = await search_chain.async_search_by_id(
tmdbid=tmdb_id,
doubanid=douban_id,
bangumiid=bangumi_id,
anilistid=anilist_id,
source=media_source,
mediaid=media_id,
mtype=media_type_enum,
area=area or "title",
sites=sites,
@@ -107,9 +122,9 @@ class SearchTorrentsTool(MoviePilotTool):
}, ensure_ascii=False, indent=2)
return result_json
else:
media_id = f"TMDB={tmdb_id}" if tmdb_id else f"豆瓣={douban_id}"
identity = media_id or tmdb_id or douban_id or bangumi_id or anilist_id
result_json = json.dumps({
"message": f"未找到相关种子资源: {media_id}",
"message": f"未找到相关种子资源: {identity}",
"all_sites": all_sites,
"search_site_ids": search_site_ids,
}, ensure_ascii=False, indent=2)
+20
View File
@@ -38,6 +38,10 @@ class TransferFileInput(BaseModel):
doubanid: Optional[str] = Field(
None, description="Douban ID for media identification (optional)"
)
bangumiid: Optional[int] = Field(None, description="Bangumi media ID")
anilistid: Optional[int] = Field(None, description="AniList media ID")
media_source: Optional[str] = Field(None, description="Media metadata source")
media_id: Optional[str] = Field(None, description="Native ID for media_source")
season: Optional[int] = Field(
None, description="Season number for TV shows (optional)"
)
@@ -109,6 +113,10 @@ class TransferFileTool(MoviePilotTool):
media_type: Optional[str] = None,
tmdbid: Optional[int] = None,
doubanid: Optional[str] = None,
bangumiid: Optional[int] = None,
anilistid: Optional[int] = None,
media_source: Optional[str] = None,
media_id: Optional[str] = None,
season: Optional[int] = None,
transfer_type: Optional[str] = None,
background: Optional[bool] = False,
@@ -148,6 +156,10 @@ class TransferFileTool(MoviePilotTool):
target_path=target_path_obj,
tmdbid=tmdbid,
doubanid=doubanid,
bangumiid=bangumiid,
anilistid=anilistid,
media_source=media_source,
media_id=media_id,
mtype=media_type_enum,
season=season,
transfer_type=transfer_type,
@@ -178,6 +190,10 @@ class TransferFileTool(MoviePilotTool):
media_type: Optional[str] = None,
tmdbid: Optional[int] = None,
doubanid: Optional[str] = None,
bangumiid: Optional[int] = None,
anilistid: Optional[int] = None,
media_source: Optional[str] = None,
media_id: Optional[str] = None,
season: Optional[int] = None,
transfer_type: Optional[str] = None,
background: Optional[bool] = False,
@@ -200,6 +216,10 @@ class TransferFileTool(MoviePilotTool):
media_type,
tmdbid,
doubanid,
bangumiid,
anilistid,
media_source,
media_id,
season,
transfer_type,
background,
+193
View File
@@ -0,0 +1,193 @@
import json
from datetime import datetime, timedelta
from typing import Literal, Optional, Type
import pytz
from pydantic import BaseModel, Field, model_validator
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.core.config import settings
from app.db.agenttask_oper import AgentTaskOper
from app.utils.timer import TimerUtils
class UpdateAgentTaskInput(BaseModel):
"""更新 Agent 自主定时任务的输入参数。"""
task_id: int = Field(..., ge=1, description="ID of the task to update.")
name: Optional[str] = Field(None, min_length=1, max_length=100)
content: Optional[str] = Field(None, min_length=1, max_length=10000)
trigger_type: Optional[Literal["date", "cron"]] = Field(
None,
description="New trigger type. Must be provided together with trigger.",
)
trigger: Optional[str] = Field(
None,
min_length=1,
max_length=200,
description="New ISO 8601 date or five-field cron expression.",
)
delay_minutes: Optional[int] = Field(
None,
ge=1,
le=525600,
description=(
"For a one-time date task expressed as 'in N minutes', provide this instead "
"of trigger together with trigger_type='date'."
),
)
enabled: Optional[bool] = Field(
None,
description="Set false to pause the task or true to resume it.",
)
@model_validator(mode="after")
def validate_update(self) -> "UpdateAgentTaskInput":
"""校验更新内容和触发参数组合。"""
if self.name is not None:
self.name = self.name.strip()
if not self.name:
raise ValueError("name 不能只包含空白字符")
if self.content is not None:
self.content = self.content.strip()
if not self.content:
raise ValueError("content 不能只包含空白字符")
has_schedule_update = any(
value is not None
for value in (self.trigger_type, self.trigger, self.delay_minutes)
)
if has_schedule_update:
if self.trigger_type is None:
raise ValueError("修改触发配置时必须提供 trigger_type")
if self.trigger_type == "date":
if self.delay_minutes is not None:
# 保持校验幂等,具体绝对时间在更新调度前只计算一次。
self.trigger = None
elif self.trigger is None:
raise ValueError("date 任务必须提供 trigger 或 delay_minutes")
elif self.trigger is None or self.delay_minutes is not None:
raise ValueError("cron 任务必须提供 trigger,且不能提供 delay_minutes")
if all(
value is None
for value in (
self.name,
self.content,
self.trigger_type,
self.enabled,
)
):
raise ValueError("至少需要提供一个要更新的字段")
return self
class UpdateAgentTaskTool(MoviePilotTool):
"""修改、暂停或恢复 Agent 自主定时任务。"""
name: str = "update_agent_task"
tags: list[str] = [ToolTag.Write, ToolTag.AgentTask, ToolTag.Admin]
description: str = (
"Update an autonomous agent task's name, instructions, exact date or cron "
"trigger, relative delay_minutes, or enabled state. Use enabled=false to pause "
"and enabled=true to resume."
)
args_schema: Type[BaseModel] = UpdateAgentTaskInput
require_admin: bool = True
def get_tool_message(self, **kwargs: object) -> Optional[str]:
"""生成更新定时任务的提示消息。"""
return f"更新自主定时任务:{kwargs.get('task_id', '')}"
def _update_task(self, payload: UpdateAgentTaskInput) -> Optional[dict]:
"""更新当前用户的任务并刷新运行时调度。"""
from app.scheduler import Scheduler
oper = AgentTaskOper()
task = oper.get(task_id=payload.task_id, user_id=str(self._user_id))
if not task:
return None
if task.last_status == "running":
return {"error": f"Agent 定时任务 {payload.task_id} 正在执行,请稍后再修改"}
trigger_type = payload.trigger_type or task.trigger_type
trigger_value = payload.trigger
if trigger_type == "date" and payload.delay_minutes is not None:
timezone = pytz.timezone(settings.TZ)
trigger_value = (
datetime.now(timezone) + timedelta(minutes=payload.delay_minutes)
).isoformat(timespec="seconds")
if trigger_value is None:
trigger_value = (
task.cron_expression if trigger_type == "cron" else task.run_at
)
enabled = task.enabled if payload.enabled is None else payload.enabled
normalized_type, normalized_trigger = TimerUtils.normalize_schedule_trigger(
trigger_type=trigger_type,
trigger_value=trigger_value,
timezone_name=settings.TZ,
require_future=bool(enabled and trigger_type == "date"),
)
update_payload = {}
if payload.name is not None:
update_payload["name"] = payload.name.strip()
if payload.content is not None:
update_payload["content"] = payload.content.strip()
if payload.trigger_type is not None:
update_payload.update(
{
"trigger_type": normalized_type,
"cron_expression": (
normalized_trigger if normalized_type == "cron" else None
),
"run_at": normalized_trigger if normalized_type == "date" else None,
"last_status": "waiting",
"last_result": None,
}
)
if payload.enabled is not None:
update_payload["enabled"] = payload.enabled
if payload.enabled:
update_payload["last_status"] = "waiting"
oper.update(
task_id=payload.task_id,
payload=update_payload,
user_id=str(self._user_id),
)
scheduler = Scheduler()
next_run_at = scheduler.update_agent_task_job(payload.task_id)
updated_task = oper.get(task_id=payload.task_id, user_id=str(self._user_id))
return oper.to_dict(
updated_task,
next_run_at=next_run_at,
timezone=settings.TZ,
)
async def run(
self,
task_id: int,
name: Optional[str] = None,
content: Optional[str] = None,
trigger_type: Optional[str] = None,
trigger: Optional[str] = None,
delay_minutes: Optional[int] = None,
enabled: Optional[bool] = None,
**kwargs: object,
) -> str:
"""更新 Agent 自主定时任务。"""
payload = UpdateAgentTaskInput(
task_id=task_id,
name=name,
content=content,
trigger_type=trigger_type,
trigger=trigger,
delay_minutes=delay_minutes,
enabled=enabled,
)
task = await self.run_blocking("db", self._update_task, payload)
if not task:
return f"Agent 定时任务 {task_id} 不存在或不属于当前用户"
if task.get("error"):
return task["error"]
return json.dumps(task, ensure_ascii=False, indent=2)
+1
View File
@@ -25,6 +25,7 @@ class ToolTag(str, Enum):
Plugin = "plugin"
Workflow = "workflow"
Scheduler = "scheduler"
AgentTask = "agent_task"
File = "file"
Directory = "directory"
Web = "web"
+2 -1
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter
from app.api.endpoints import auth, login, user, webhook, message, agent, site, subscribe, \
from app.api.endpoints import anilist, auth, login, user, webhook, message, agent, site, subscribe, \
media, douban, search, plugin, tmdb, history, system, download, dashboard, \
transfer, mediaserver, bangumi, storage, discover, recommend, workflow, torrent, mcp, mfa, openai, anthropic, llm, notification
@@ -29,6 +29,7 @@ api_router.include_router(storage.router, prefix="/storage", tags=["storage"])
api_router.include_router(transfer.router, prefix="/transfer", tags=["transfer"])
api_router.include_router(mediaserver.router, prefix="/mediaserver", tags=["mediaserver"])
api_router.include_router(bangumi.router, prefix="/bangumi", tags=["bangumi"])
api_router.include_router(anilist.router, prefix="/anilist", tags=["anilist"])
api_router.include_router(discover.router, prefix="/discover", tags=["discover"])
api_router.include_router(recommend.router, prefix="/recommend", tags=["recommend"])
api_router.include_router(workflow.router, prefix="/workflow", tags=["workflow"])
+3 -1
View File
@@ -219,7 +219,9 @@ class _WebAgentMoviePilotAgent(MoviePilotAgent):
self.stream_handler = _WebAgentStreamingHandler(self._emit_output)
def _should_stream(self) -> bool:
"""Web 面板需要实时输出,即使 Web 渠道本身不支持消息编辑"""
"""Web 对话实时输出,复用会话执行后台任务时改用非流式广播"""
if self.is_background:
return False
return True
def set_notification_callback(
+169
View File
@@ -0,0 +1,169 @@
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, Query
from app import schemas
from app.chain.anilist import AniListChain
from app.core.context import MediaInfo
from app.core.security import verify_token
router = APIRouter()
PageParam = Annotated[int, Query(ge=1)]
CountParam = Annotated[int, Query(ge=1, le=50)]
def _serialize_medias(medias: list[MediaInfo]) -> list[schemas.MediaInfo]:
"""
将内部媒体对象转换为 REST 响应模型
:param medias: 统一媒体信息列表
:return: REST 媒体响应列表
"""
return [schemas.MediaInfo(**media.to_dict()) for media in medias]
@router.get(
"/trending",
summary="查询 AniList 当前趋势榜",
response_model=list[schemas.MediaInfo],
)
async def anilist_trending(
page: PageParam = 1,
count: CountParam = 20,
_: schemas.TokenPayload = Depends(verify_token),
) -> list[schemas.MediaInfo]:
"""查询 AniList TRENDING NOW 榜单"""
medias = await AniListChain().async_trending(page=page, count=count)
return _serialize_medias(medias)
@router.get(
"/popular-this-season",
summary="查询 AniList 本季热门榜",
response_model=list[schemas.MediaInfo],
)
async def anilist_popular_this_season(
page: PageParam = 1,
count: CountParam = 20,
_: schemas.TokenPayload = Depends(verify_token),
) -> list[schemas.MediaInfo]:
"""查询 AniList POPULAR THIS SEASON 榜单"""
medias = await AniListChain().async_popular_this_season(page=page, count=count)
return _serialize_medias(medias)
@router.get(
"/discover",
summary="探索 AniList 动画",
response_model=list[schemas.MediaInfo],
)
async def anilist_discover(
page: PageParam = 1,
count: CountParam = 20,
search: Optional[str] = None,
genre: Optional[str] = None,
media_format: Optional[str] = Query(None, alias="format"),
season: Optional[str] = None,
season_year: Optional[int] = None,
status: Optional[str] = None,
country: Optional[str] = None,
sort: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_token),
) -> list[schemas.MediaInfo]:
"""按标题、类型、风格、季度、年份、状态、地区和排序探索 AniList 动画"""
medias = await AniListChain().async_discover(
page=page,
count=count,
search=search,
genre=genre,
media_format=media_format,
season=season,
season_year=season_year,
status=status,
country=country,
sort=sort,
)
return _serialize_medias(medias)
@router.get(
"/credits/{anilist_id}",
summary="查询 AniList 配音演员",
response_model=list[schemas.MediaPerson],
)
async def anilist_credits(
anilist_id: int,
page: PageParam = 1,
count: CountParam = 20,
_: schemas.TokenPayload = Depends(verify_token),
) -> list[schemas.MediaPerson]:
"""查询 AniList 动画的日语配音演员"""
return await AniListChain().async_credits(
anilist_id=anilist_id, page=page, count=count
)
@router.get(
"/recommend/{anilist_id}",
summary="查询 AniList 相关推荐",
response_model=list[schemas.MediaInfo],
)
async def anilist_recommendations(
anilist_id: int,
page: PageParam = 1,
count: CountParam = 20,
_: schemas.TokenPayload = Depends(verify_token),
) -> list[schemas.MediaInfo]:
"""查询 AniList 动画相关推荐"""
medias = await AniListChain().async_recommendations(
anilist_id=anilist_id, page=page, count=count
)
return _serialize_medias(medias)
@router.get(
"/person/{person_id}",
summary="查询 AniList 人物详情",
response_model=schemas.MediaPerson,
)
async def anilist_person(
person_id: int,
_: schemas.TokenPayload = Depends(verify_token),
) -> Optional[schemas.MediaPerson]:
"""根据 AniList 人物 ID 查询详情"""
return await AniListChain().async_person_detail(person_id=person_id)
@router.get(
"/person/credits/{person_id}",
summary="查询 AniList 人物作品",
response_model=list[schemas.MediaInfo],
)
async def anilist_person_credits(
person_id: int,
page: PageParam = 1,
count: CountParam = 20,
_: schemas.TokenPayload = Depends(verify_token),
) -> list[schemas.MediaInfo]:
"""查询 AniList 人物参与的动画作品"""
medias = await AniListChain().async_person_credits(
person_id=person_id, page=page, count=count
)
return _serialize_medias(medias)
@router.get(
"/{anilist_id}",
summary="查询 AniList 动画详情",
response_model=schemas.MediaInfo,
)
async def anilist_info(
anilist_id: int,
_: schemas.TokenPayload = Depends(verify_token),
) -> schemas.MediaInfo:
"""根据 AniList 媒体 ID 查询动画详情"""
info = await AniListChain().async_info(anilist_id)
if not info:
return schemas.MediaInfo()
return schemas.MediaInfo(**MediaInfo(anilist_info=info).to_dict())
+3 -1
View File
@@ -7,6 +7,7 @@ from sqlalchemy.orm import Session
from app import schemas
from app.chain.dashboard import DashboardChain
from app.chain.storage import StorageChain
from app.core.config import settings
from app.core.security import verify_apitoken
from app.db import get_db
from app.db.models.transferhistory import TransferHistory
@@ -73,7 +74,8 @@ def _build_downloader(name: Optional[str] = None) -> schemas.DownloaderInfo:
# 下载目录空间
download_dirs = DirectoryHelper().get_local_download_dirs()
_, free_space = SystemUtils.space_usage(
[Path(d.download_path) for d in download_dirs]
[Path(d.download_path) for d in download_dirs],
btrfs_fsid_dedup=settings.BTRFS_FSID_DEDUP,
)
# 下载器信息
downloader_info = schemas.DownloaderInfo()
+20 -2
View File
@@ -1,4 +1,4 @@
from typing import Any, List, Annotated, Optional
from typing import Any, List, Annotated, Literal, Optional
from fastapi import APIRouter, Depends, Body
@@ -17,6 +17,7 @@ from app.schemas.types import SystemConfigKey
from app.utils.security import SecurityUtils
router = APIRouter()
MediaSource = Literal["themoviedb", "douban", "bangumi", "anilist"]
def _prepare_subtitle_download(subtitle: SubtitleInfo) -> tuple[bool, str]:
@@ -97,6 +98,10 @@ def add(
torrent_in: schemas.TorrentInfo,
tmdbid: Annotated[int | None, Body()] = None,
doubanid: Annotated[str | None, Body()] = None,
bangumiid: Annotated[int | None, Body()] = None,
anilistid: Annotated[int | None, Body()] = None,
media_source: Annotated[MediaSource | None, Body()] = None,
media_id: Annotated[str | None, Body()] = None,
downloader: Annotated[str | None, Body()] = None,
# 保存路径, 支持<storage>:<path>, 如rclone:/MP, smb:/server/share/Movies等
save_path: Annotated[str | None, Body()] = None,
@@ -108,15 +113,20 @@ def add(
# 元数据
metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description)
# 媒体信息
if tmdbid or doubanid:
if tmdbid or doubanid or bangumiid or anilistid or media_id:
mediainfo = MediaChain().recognize_media(
meta=metainfo,
source=media_source,
mediaid=media_id,
tmdbid=tmdbid,
doubanid=doubanid,
bangumiid=bangumiid,
anilistid=anilistid,
)
else:
mediainfo = MediaChain().recognize_by_meta(
metainfo,
source=media_source,
obtain_images=False,
)
if not mediainfo:
@@ -146,6 +156,10 @@ def download_subtitle(
subtitle_in: schemas.SubtitleInfo,
tmdbid: Annotated[int | None, Body()] = None,
doubanid: Annotated[str | None, Body()] = None,
bangumiid: Annotated[int | None, Body()] = None,
anilistid: Annotated[int | None, Body()] = None,
media_source: Annotated[MediaSource | None, Body()] = None,
media_id: Annotated[str | None, Body()] = None,
save_path: Annotated[str | None, Body()] = None,
current_user: User = Depends(get_current_active_user),
) -> Any:
@@ -160,8 +174,12 @@ def download_subtitle(
success, message, saved_files = DownloadChain().download_subtitle(
subtitle=subtitle_info,
media_source=media_source,
media_id=media_id,
tmdbid=tmdbid,
doubanid=doubanid,
bangumiid=bangumiid,
anilistid=anilistid,
save_path=save_path,
username=current_user.name,
)
+9 -5
View File
@@ -3,9 +3,10 @@ from typing import Any, List, Annotated
from fastapi import APIRouter, Depends, Form, HTTPException, Request, Response
from fastapi.security import OAuth2PasswordRequestForm
from fastapi.responses import JSONResponse
from app import schemas
from app.chain.user import UserChain
from app.chain.user import MfaRequired, UserChain
from app.core import security
from app.core.config import settings
from app.db.systemconfig_oper import SystemConfigOper
@@ -31,11 +32,14 @@ def login_access_token(
)
if not success:
# 如果是需要MFA验证,返回特殊标识
if user_or_message == "MFA_REQUIRED":
raise HTTPException(
# 只有密码已经验证通过时才返回 MFA 方法,避免泄露账号安全配置。
if isinstance(user_or_message, MfaRequired):
return JSONResponse(
status_code=401,
detail="需要双重验证,请提供验证码或使用通行密钥",
content={
"detail": "需要二次验证",
"mfa_methods": list(user_or_message.methods),
},
headers={"X-MFA-Required": "true"},
)
raise HTTPException(status_code=401, detail="用户名或密码错误")
+156 -53
View File
@@ -1,5 +1,5 @@
from pathlib import Path
from typing import List, Any, Union, Annotated, Optional
from typing import Annotated, Any, List, Optional, Union
from fastapi import APIRouter, Depends
@@ -9,15 +9,60 @@ from app.chain.tmdb import TmdbChain
from app.core.config import settings
from app.core.context import Context
from app.core.event import eventmanager
from app.core.metainfo import MetaInfo
from app.core.metainfo import MetaInfo, MetaInfoPath
from app.core.security import verify_token, verify_apitoken
from app.db.models import User
from app.db.user_oper import get_current_active_user, get_current_active_superuser
from app.schemas import MediaType, MediaRecognizeConvertEventData
from app.schemas.category import CategoryConfig
from app.schemas.types import ChainEventType
from app.utils.media import parse_media_key
router = APIRouter()
MediaSource = str
def _build_media_seasons(
mediainfo: Any, season: Optional[int] = None,
) -> List[schemas.MediaSeason]:
"""将任意数据源的统一媒体信息转换为季信息响应。"""
seasons_info = []
for item in mediainfo.season_info or []:
season_number = item.get("season_number")
if season is not None and season_number != season:
continue
seasons_info.append(schemas.MediaSeason(
air_date=item.get("air_date"),
episode_count=item.get("episode_count"),
name=item.get("name"),
overview=item.get("overview"),
poster_path=item.get("poster_path"),
season_number=season_number,
vote_average=item.get("vote_average"),
))
if seasons_info:
return seasons_info
season_numbers = sorted((mediainfo.seasons or {}).keys())
if season is not None:
season_numbers = [season]
elif not season_numbers:
season_numbers = [mediainfo.season or 1]
return [
schemas.MediaSeason(
season_number=season_number,
poster_path=mediainfo.poster_path,
name=f"{season_number}",
air_date=mediainfo.release_date,
overview=mediainfo.overview,
vote_average=mediainfo.vote_average,
episode_count=(
len((mediainfo.seasons or {}).get(season_number) or [])
or mediainfo.number_of_episodes
),
)
for season_number in season_numbers
]
@router.get(
@@ -26,14 +71,26 @@ router = APIRouter()
async def recognize(
title: str,
subtitle: Optional[str] = None,
custom_words: Optional[str] = None,
source: Optional[MediaSource] = None,
_: schemas.TokenPayload = Depends(verify_token),
) -> Any:
"""
根据标题副标题识别媒体信息
:param title: 标题
:param subtitle: 副标题
:param custom_words: 临时识别词每行一条规则传入时仅在本次识别中生效不会保存到系统配置
:param source: 请求级识别数据源
:param _:
"""
# 识别媒体信息
metainfo = MetaInfo(title, subtitle)
mediainfo = await MediaChain().async_recognize_by_meta(metainfo)
# 识别媒体信息,传入临时识别词时优先于系统配置的识别词生效
metainfo = MetaInfo(
title, subtitle, custom_words=custom_words.split("\n") if custom_words else None
)
mediainfo = await MediaChain().async_recognize_by_meta(
metainfo,
source=source,
)
if mediainfo:
return Context(meta_info=metainfo, media_info=mediainfo).to_dict()
return schemas.Context()
@@ -48,25 +105,29 @@ async def recognize2(
_: Annotated[str, Depends(verify_apitoken)],
title: str,
subtitle: Optional[str] = None,
custom_words: Optional[str] = None,
source: Optional[MediaSource] = None,
) -> Any:
"""
根据标题副标题识别媒体信息 API_TOKEN认证?token=xxx
"""
# 识别媒体信息
return await recognize(title, subtitle)
return await recognize(title, subtitle, custom_words, source)
@router.get(
"/recognize_file", summary="识别媒体信息(文件)", response_model=schemas.Context
)
async def recognize_file(
path: str, _: schemas.TokenPayload = Depends(verify_token)
path: str,
source: Optional[MediaSource] = None,
_: schemas.TokenPayload = Depends(verify_token),
) -> Any:
"""
根据文件路径识别媒体信息
"""
# 识别媒体信息
context = await MediaChain().async_recognize_by_path(path)
context = await MediaChain().async_recognize_by_path(path, source=source)
if context:
return context.to_dict()
return schemas.Context()
@@ -78,13 +139,15 @@ async def recognize_file(
response_model=schemas.Context,
)
async def recognize_file2(
path: str, _: Annotated[str, Depends(verify_apitoken)]
path: str,
_: Annotated[str, Depends(verify_apitoken)],
source: Optional[MediaSource] = None,
) -> Any:
"""
根据文件路径识别媒体信息 API_TOKEN认证?token=xxx
"""
# 识别媒体信息
return await recognize_file(path)
return await recognize_file(path, source)
@router.get("/search", summary="搜索媒体/人物信息", response_model=List[dict])
@@ -93,10 +156,19 @@ async def search(
type: Optional[str] = "media",
page: int = 1,
count: int = 8,
source: Optional[MediaSource] = None,
_: schemas.TokenPayload = Depends(verify_token),
) -> Any:
"""
模糊搜索媒体/人物信息列表 media媒体信息person人物信息
模糊搜索媒体合集或人物信息列表
:param title: 搜索关键词
:param type: 搜索类型支持 mediacollectionperson
:param page: 页码
:param count: 每页数量
:param source: 请求级搜索数据源
:param _: Token校验
:return: 搜索结果列表
"""
def __get_source(obj: Union[schemas.MediaInfo, schemas.MediaPerson, dict]):
@@ -109,15 +181,17 @@ async def search(
media_chain = MediaChain()
if type == "media":
_, medias = await media_chain.async_search(title=title)
_, medias = await media_chain.async_search(title=title, source=source)
result = [media.to_dict() for media in medias] if medias else []
elif type == "collection":
collections = await media_chain.async_search_collections(name=title)
collections = await media_chain.async_search_collections(
name=title, source=source
)
result = (
[collection.to_dict() for collection in collections] if collections else []
)
else: # person
persons = await media_chain.async_search_persons(name=title)
persons = await media_chain.async_search_persons(name=title, source=source)
result = [person.model_dump() for person in persons] if persons else []
if not result:
@@ -137,26 +211,64 @@ async def search(
def scrape(
fileitem: schemas.FileItem,
storage: Optional[str] = "local",
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
type_name: Optional[MediaType] = None,
_: schemas.TokenPayload = Depends(verify_token),
) -> Any:
"""
刮削媒体信息
刮削媒体信息可按请求指定媒体数据源及其原生ID
:param fileitem: 待刮削文件项
:param storage: 文件所在存储
:param media_source: 请求级媒体数据源
:param media_id: 数据源原生ID
:param type_name: 媒体类型
:param _: Token校验
"""
if not fileitem or not fileitem.path:
return schemas.Response(success=False, message="刮削路径无效")
normalized_media_id = media_id.strip() if media_id else None
if normalized_media_id and not media_source:
return schemas.Response(
success=False, message="指定媒体ID时必须同时指定媒体数据源"
)
if normalized_media_id and not normalized_media_id.isdigit():
return schemas.Response(success=False, message="媒体ID格式无效")
chain = MediaChain()
# 识别媒体信息
context = chain.recognize_by_path(fileitem.path, obtain_images=True)
if not context or not context.media_info:
if normalized_media_id:
meta_info = MetaInfoPath(Path(fileitem.path))
media_info = chain.recognize_media(
meta=meta_info,
mtype=type_name,
source=media_source,
mediaid=normalized_media_id,
)
if media_info:
media_info.scrape_source = media_source
chain.obtain_images(mediainfo=media_info)
else:
context = chain.recognize_by_path(
fileitem.path,
source=media_source,
obtain_images=True,
)
meta_info = context.meta_info if context else None
media_info = context.media_info if context else None
if not media_info:
return schemas.Response(success=False, message="刮削失败,无法识别媒体信息")
if media_source:
media_info.scrape_source = media_source
if storage == "local":
if not Path(fileitem.path).exists():
return schemas.Response(success=False, message="刮削路径不存在")
# 手动刮削 (暂时使用同步版本,可以后续优化为异步)
chain.scrape_metadata(
fileitem=fileitem,
meta=context.meta_info,
mediainfo=context.media_info,
meta=meta_info,
mediainfo=media_info,
overwrite=True,
)
return schemas.Response(success=True, message=f"{fileitem.path} 刮削完成")
@@ -237,13 +349,23 @@ async def seasons(
查询媒体季信息
"""
if mediaid:
if mediaid.startswith("tmdb:"):
tmdbid = int(mediaid[5:])
media_source, source_media_id = parse_media_key(mediaid)
if media_source == "themoviedb":
tmdbid = int(source_media_id)
seasons_info = await TmdbChain().async_tmdb_seasons(tmdbid=tmdbid)
if seasons_info:
if season is not None:
return [sea for sea in seasons_info if sea.season_number == season]
return seasons_info
elif media_source and source_media_id:
mediainfo = await MediaChain().async_recognize_media(
source=media_source,
mediaid=source_media_id,
mtype=MediaType.TV,
cache=False,
)
if mediainfo:
return _build_media_seasons(mediainfo, season)
if title:
meta = MetaInfo(title)
if year:
@@ -254,7 +376,7 @@ async def seasons(
obtain_images=False,
)
if mediainfo:
if settings.RECOGNIZE_SOURCE == "themoviedb":
if mediainfo.source == "themoviedb" and mediainfo.tmdb_id:
seasons_info = await TmdbChain().async_tmdb_seasons(
tmdbid=mediainfo.tmdb_id
)
@@ -264,19 +386,7 @@ async def seasons(
sea for sea in seasons_info if sea.season_number == season
]
return seasons_info
else:
sea = season if season is not None else 1
return [
schemas.MediaSeason(
season_number=sea,
poster_path=mediainfo.poster_path,
name=f"{sea}",
air_date=mediainfo.release_date,
overview=mediainfo.overview,
vote_average=mediainfo.vote_average,
episode_count=mediainfo.number_of_episodes,
)
]
return _build_media_seasons(mediainfo, season)
return []
@@ -289,22 +399,17 @@ async def detail(
_: schemas.TokenPayload = Depends(verify_token),
) -> Any:
"""
根据媒体ID查询themoviedb或豆瓣媒体信息type_name: 电影/电视剧
根据带来源前缀的媒体ID查询媒体信息type_name: 电影/电视剧
"""
mtype = MediaType(type_name)
mediainfo = None
mediachain = MediaChain()
if mediaid.startswith("tmdb:"):
media_source, source_media_id = parse_media_key(mediaid)
if media_source and source_media_id:
mediainfo = await mediachain.async_recognize_media(
tmdbid=int(mediaid[5:]), mtype=mtype
)
elif mediaid.startswith("douban:"):
mediainfo = await mediachain.async_recognize_media(
doubanid=mediaid[7:], mtype=mtype
)
elif mediaid.startswith("bangumi:"):
mediainfo = await mediachain.async_recognize_media(
bangumiid=int(mediaid[8:]), mtype=mtype
source=media_source,
mediaid=source_media_id,
mtype=mtype,
)
else:
# 广播事件解析媒体信息
@@ -318,13 +423,11 @@ async def detail(
if event and event.event_data and event.event_data.media_dict:
event_data: MediaRecognizeConvertEventData = event.event_data
new_id = event_data.media_dict.get("id")
if event_data.convert_type == "themoviedb":
if new_id is not None and event_data.convert_type:
mediainfo = await mediachain.async_recognize_media(
tmdbid=new_id, mtype=mtype
)
elif event_data.convert_type == "douban":
mediainfo = await mediachain.async_recognize_media(
doubanid=new_id, mtype=mtype
source=event_data.convert_type,
mediaid=str(new_id),
mtype=mtype,
)
elif title:
# 使用名称识别兜底
+3 -1
View File
@@ -16,6 +16,7 @@ from app.db.systemconfig_oper import SystemConfigOper
from app.helper.mediaserver import MediaServerHelper
from app.schemas import MediaType, NotExistMediaInfo
from app.schemas.types import SystemConfigKey
from app.utils.media import build_media_key, resolve_media_identity
router = APIRouter()
@@ -130,7 +131,8 @@ def not_exists(
exist_flag, no_exists = DownloadChain().get_no_exists_info(
meta=meta, mediainfo=mediainfo
)
mediakey = mediainfo.tmdb_id or mediainfo.douban_id
media_source, media_id = resolve_media_identity(media=mediainfo)
mediakey = build_media_key(media_source, media_id)
if mediainfo.type == MediaType.MOVIE:
# 电影已存在时返回空列表,不存在时返回空对像列表
return [] if exist_flag else [NotExistMediaInfo()]
+59 -84
View File
@@ -18,7 +18,12 @@ from app.db.models.passkey import PassKey
from app.db.models.user import User
from app.db.systemconfig_oper import SystemConfigOper
from app.db.user_oper import get_current_active_user, get_current_active_user_async
from app.helper.passkey import PassKeyHelper
from app.helper.passkey import (
PassKeyHelper,
PassKeyRegistrationOriginMismatchError,
PassKeyRegistrationVerificationError,
)
from app.helper.passkey_challenge import PasskeyChallengeStore
from app.log import logger
from app.schemas.types import SystemConfigKey
from app.utils.otp import OtpUtils
@@ -83,17 +88,6 @@ def _verify_passkey_and_update(
return success, new_sign_count
async def _check_user_has_passkey(db: AsyncSession, user_id: int) -> bool:
"""
检查用户是否有 PassKey
:param db: 数据库会话
:param user_id: 用户 ID
:return: 是否有 PassKey
"""
return bool(await PassKey.async_get_by_user_id(db=db, user_id=user_id))
# ==================== 请求模型 ====================
@@ -122,12 +116,12 @@ class PassKeyDeleteRequest(schemas.BaseModel):
@router.get(
"/status/{username}",
summary="判断用户是否开启双重验证(MFA)",
summary="判断用户是否开启二次验证",
response_model=schemas.Response,
)
async def mfa_status(username: str, db: AsyncSession = Depends(get_async_db)) -> Any:
"""
检查指定用户是否启用了任何双重验证方式OTP PassKey
检查指定用户是否启用了二次验证
"""
user: User = await User.async_get_by_name(db, username)
if not user:
@@ -136,11 +130,7 @@ async def mfa_status(username: str, db: AsyncSession = Depends(get_async_db)) ->
# 检查是否启用了OTP
has_otp = user.is_otp
# 检查是否有PassKey
has_passkey = await _check_user_has_passkey(db, user.id)
# 只要有任何一种验证方式,就需要双重验证
return schemas.Response(success=(has_otp or has_passkey))
return schemas.Response(success=has_otp)
# ==================== OTP 相关接口 ====================
@@ -181,14 +171,6 @@ async def otp_disable(
current_user: User = Depends(get_current_active_user_async),
) -> Any:
"""关闭当前用户的 OTP 验证功能"""
# 安全检查:如果存在 PassKey,默认不允许关闭 OTP,除非配置允许
has_passkey = await _check_user_has_passkey(db, current_user.id)
if has_passkey and not settings.PASSKEY_ALLOW_REGISTER_WITHOUT_OTP:
return schemas.Response(
success=False,
message="您已注册通行密钥,为了防止域名配置变更导致无法登录,请先删除所有通行密钥再关闭 OTP 验证",
)
# 验证密码
if not security.verify_password(data.password, str(current_user.hashed_password)):
return schemas.Response(success=False, message="密码错误")
@@ -209,7 +191,7 @@ class PassKeyRegistrationFinish(schemas.BaseModel):
"""PassKey注册完成请求"""
credential: dict
challenge: str
transaction_token: str
name: str = "通行密钥"
@@ -223,7 +205,7 @@ class PassKeyAuthenticationFinish(schemas.BaseModel):
"""PassKey认证完成请求"""
credential: dict
challenge: str
transaction_token: str
@router.post(
@@ -236,13 +218,6 @@ def passkey_register_start(
) -> Any:
"""开始注册 PassKey - 生成注册选项"""
try:
# 安全检查:默认需要先启用 OTP,除非配置允许在未启用 OTP 时注册
if not current_user.is_otp and not settings.PASSKEY_ALLOW_REGISTER_WITHOUT_OTP:
return schemas.Response(
success=False,
message="为了确保在域名配置错误时仍能找回访问权限,请先启用 OTP 验证码再注册通行密钥",
)
# 获取用户已有的PassKey
existing_passkeys = PassKey.get_by_user_id(db=None, user_id=current_user.id)
existing_credentials = (
@@ -259,8 +234,14 @@ def passkey_register_start(
existing_credentials=existing_credentials,
)
transaction_token = PasskeyChallengeStore.issue(
challenge=challenge,
purpose="registration",
user_id=current_user.id,
)
return schemas.Response(
success=True, data={"options": options_json, "challenge": challenge}
success=True,
data={"options": options_json, "transaction_token": transaction_token},
)
except Exception as e:
logger.error(f"生成PassKey注册选项失败: {e}")
@@ -278,11 +259,21 @@ def passkey_register_finish(
) -> Any:
"""完成注册 PassKey - 验证并保存凭证"""
try:
challenge_state = PasskeyChallengeStore.consume(
transaction_token=passkey_req.transaction_token,
purpose="registration",
)
if not challenge_state or challenge_state.user_id != current_user.id:
return schemas.Response(
success=False,
message="注册请求已失效,请重新发起注册",
)
# 验证注册响应
credential_id, public_key, sign_count, aaguid = (
PassKeyHelper.verify_registration_response(
credential=passkey_req.credential,
expected_challenge=passkey_req.challenge,
expected_challenge=challenge_state.challenge,
)
)
@@ -309,9 +300,19 @@ def passkey_register_finish(
logger.info(f"用户 {current_user.name} 成功注册PassKey: {passkey_req.name}")
return schemas.Response(success=True, message="通行密钥注册成功")
except PassKeyRegistrationOriginMismatchError:
return schemas.Response(
success=False,
message="访问域名与系统配置不一致,请使用配置的域名重试",
)
except PassKeyRegistrationVerificationError:
return schemas.Response(
success=False,
message="通行密钥注册验证失败,请重新发起注册后重试",
)
except Exception as e:
logger.error(f"注册PassKey失败: {e}")
return schemas.Response(success=False, message=f"注册失败: {str(e)}")
return schemas.Response(success=False, message="通行密钥注册失败,请稍后重试")
@router.post(
@@ -325,6 +326,7 @@ def passkey_authenticate_start(
"""开始 PassKey 认证 - 生成认证选项"""
try:
existing_credentials = None
user_id = None
# 如果指定了用户名,只允许该用户的PassKey
if passkey_req.username:
@@ -337,14 +339,21 @@ def passkey_authenticate_start(
return schemas.Response(success=False, message="认证失败")
existing_credentials = _build_credential_list(existing_passkeys)
user_id = user.id
# 生成认证选项
options_json, challenge = PassKeyHelper.generate_authentication_options(
existing_credentials=existing_credentials
)
transaction_token = PasskeyChallengeStore.issue(
challenge=challenge,
purpose="authentication",
user_id=user_id,
)
return schemas.Response(
success=True, data={"options": options_json, "challenge": challenge}
success=True,
data={"options": options_json, "transaction_token": transaction_token},
)
except Exception as e:
logger.error(f"生成PassKey认证选项失败: {e}")
@@ -361,6 +370,13 @@ def passkey_authenticate_finish(
) -> Any:
"""完成 PassKey 认证 - 验证凭证并返回 token"""
try:
challenge_state = PasskeyChallengeStore.consume(
transaction_token=passkey_req.transaction_token,
purpose="authentication",
)
if not challenge_state:
raise HTTPException(status_code=401, detail="认证请求已失效")
# 提取并标准化凭证ID
try:
credential_id = _extract_and_standardize_credential_id(
@@ -375,11 +391,13 @@ def passkey_authenticate_finish(
user = User.get_by_id(db=None, user_id=passkey.user_id) if passkey else None
if not passkey or not user or not user.is_active:
raise HTTPException(status_code=401, detail="认证失败")
if challenge_state.user_id is not None and challenge_state.user_id != user.id:
raise HTTPException(status_code=401, detail="认证失败")
# 验证认证响应并更新
success, _ = _verify_passkey_and_update(
credential=passkey_req.credential,
challenge=passkey_req.challenge,
challenge=challenge_state.challenge,
passkey=passkey,
)
@@ -493,46 +511,3 @@ async def passkey_delete(
except Exception as e:
logger.error(f"删除PassKey失败: {e}")
return schemas.Response(success=False, message=f"删除失败: {str(e)}")
@router.post(
"/passkey/verify", summary="PassKey 二次验证", response_model=schemas.Response
)
def passkey_verify_mfa(
passkey_req: PassKeyAuthenticationFinish,
current_user: Annotated[User, Depends(get_current_active_user)],
) -> Any:
"""使用 PassKey 进行二次验证(MFA"""
try:
# 提取并标准化凭证ID
try:
credential_id = _extract_and_standardize_credential_id(
passkey_req.credential
)
except ValueError as e:
logger.warning(f"PassKey二次验证失败,提供的凭证无效: {e}")
return schemas.Response(success=False, message="验证失败")
# 查找PassKey(必须属于当前用户)
passkey = PassKey.get_by_credential_id(db=None, credential_id=credential_id)
if not passkey or passkey.user_id != current_user.id:
return schemas.Response(
success=False, message="通行密钥不存在或不属于当前用户"
)
# 验证认证响应并更新
success, _ = _verify_passkey_and_update(
credential=passkey_req.credential,
challenge=passkey_req.challenge,
passkey=passkey,
)
if not success:
return schemas.Response(success=False, message="通行密钥验证失败")
logger.info(f"用户 {current_user.name} 通过PassKey二次验证成功")
return schemas.Response(success=True, message="二次验证成功")
except Exception as e:
logger.error(f"PassKey二次验证失败: {e}")
return schemas.Response(success=False, message="验证失败")
+112 -423
View File
@@ -16,6 +16,7 @@ from app.helper.locale import LocaleHelper
from app.log import logger
from app.schemas import MediaRecognizeConvertEventData
from app.schemas.types import MediaType, ChainEventType
from app.utils.media import parse_media_key, resolve_media_identity
from app.utils.security import SecurityUtils
router = APIRouter()
@@ -40,6 +41,67 @@ def _parse_media_type(mtype: Optional[str]) -> Optional[MediaType]:
return MediaType.from_agent(mtype) or MediaType(mtype)
def _resolve_media_season(
explicit_season: Optional[int],
recognized_season: Optional[int],
) -> Optional[int]:
"""合并显式季号与识别结果,显式值优先且季 0 属于有效业务值。"""
return explicit_season if explicit_season is not None else recognized_season
async def _resolve_media_search_params(
mediaid: str,
media_type: Optional[MediaType] = None,
title: Optional[str] = None,
year: Optional[str] = None,
media_season: Optional[int] = None,
) -> tuple[Optional[dict], str]:
"""将任意来源媒体键解析为 SearchChain 可直接使用的识别参数。"""
source, source_media_id = parse_media_key(mediaid)
if source and source_media_id:
if source in {"themoviedb", "bangumi", "anilist"} \
and not source_media_id.isdigit():
return None, "媒体ID格式错误"
return {"source": source, "mediaid": source_media_id}, ""
event_data = MediaRecognizeConvertEventData(
mediaid=mediaid, convert_type=settings.RECOGNIZE_SOURCE
)
event = await eventmanager.async_send_event(
ChainEventType.MediaRecognizeConvert, event_data
)
if event and event.event_data and event.event_data.media_dict:
event_data = event.event_data
search_id = event_data.media_dict.get("id")
if search_id is not None:
return {
"source": event_data.convert_type,
"mediaid": str(search_id),
}, ""
if not title:
return None, "未知的媒体ID"
meta = MetaInfo(title)
if year:
meta.year = year
if media_type:
meta.type = media_type
if media_season is not None:
meta.type = MediaType.TV
meta.begin_season = media_season
mediainfo = await MediaChain().async_recognize_by_meta(
meta,
obtain_images=False,
)
if not mediainfo:
return None, "未识别到媒体信息"
source, source_media_id = resolve_media_identity(media=mediainfo)
if not source or not source_media_id:
return None, "媒体信息缺少有效ID"
return {"source": source, "mediaid": source_media_id}, ""
def _sse_event(data: dict, locale: Optional[str] = None) -> str:
"""
转换为SSE事件
@@ -254,187 +316,27 @@ async def search_by_id_stream(
media_type = _parse_media_type(mtype)
media_season = int(season) if season else None
site_list = _parse_site_list(sites)
media_chain = MediaChain()
search_chain = SearchChain()
async def event_source():
nonlocal media_season
torrents = None
if mediaid.startswith("tmdb:"):
tmdbid = int(mediaid.replace("tmdb:", ""))
if settings.RECOGNIZE_SOURCE == "douban":
doubaninfo = await media_chain.async_get_doubaninfo_by_tmdbid(
tmdbid=tmdbid, mtype=media_type
)
if doubaninfo:
torrents = search_chain.async_search_by_id_stream(
doubanid=doubaninfo.get("id"),
mtype=media_type,
area=area,
season=media_season,
sites=site_list,
cache_local=True,
)
else:
yield {
"type": "error",
"success": False,
"message": "未识别到豆瓣媒体信息",
}
return
else:
torrents = search_chain.async_search_by_id_stream(
tmdbid=tmdbid,
mtype=media_type,
area=area,
season=media_season,
sites=site_list,
cache_local=True,
)
elif mediaid.startswith("douban:"):
doubanid = mediaid.replace("douban:", "")
if settings.RECOGNIZE_SOURCE == "themoviedb":
tmdbinfo = await media_chain.async_get_tmdbinfo_by_doubanid(
doubanid=doubanid, mtype=media_type
)
if tmdbinfo:
if tmdbinfo.get("season") and not media_season:
media_season = tmdbinfo.get("season")
torrents = search_chain.async_search_by_id_stream(
tmdbid=tmdbinfo.get("id"),
mtype=media_type,
area=area,
season=media_season,
sites=site_list,
cache_local=True,
)
else:
yield {
"type": "error",
"success": False,
"message": "未识别到TMDB媒体信息",
}
return
else:
torrents = search_chain.async_search_by_id_stream(
doubanid=doubanid,
mtype=media_type,
area=area,
season=media_season,
sites=site_list,
cache_local=True,
)
elif mediaid.startswith("bangumi:"):
bangumiid = int(mediaid.replace("bangumi:", ""))
if settings.RECOGNIZE_SOURCE == "themoviedb":
tmdbinfo = await media_chain.async_get_tmdbinfo_by_bangumiid(
bangumiid=bangumiid
)
if tmdbinfo:
torrents = search_chain.async_search_by_id_stream(
tmdbid=tmdbinfo.get("id"),
mtype=media_type,
area=area,
season=media_season,
sites=site_list,
cache_local=True,
)
else:
yield {
"type": "error",
"success": False,
"message": "未识别到TMDB媒体信息",
}
return
else:
doubaninfo = await media_chain.async_get_doubaninfo_by_bangumiid(
bangumiid=bangumiid
)
if doubaninfo:
torrents = search_chain.async_search_by_id_stream(
doubanid=doubaninfo.get("id"),
mtype=media_type,
area=area,
season=media_season,
sites=site_list,
cache_local=True,
)
else:
yield {
"type": "error",
"success": False,
"message": "未识别到豆瓣媒体信息",
}
return
else:
event_data = MediaRecognizeConvertEventData(
mediaid=mediaid, convert_type=settings.RECOGNIZE_SOURCE
)
event = await eventmanager.async_send_event(
ChainEventType.MediaRecognizeConvert, event_data
)
if event and event.event_data:
event_data = event.event_data
if event_data.media_dict:
search_id = event_data.media_dict.get("id")
if event_data.convert_type == "themoviedb":
torrents = search_chain.async_search_by_id_stream(
tmdbid=search_id,
mtype=media_type,
area=area,
season=media_season,
sites=site_list,
cache_local=True,
)
elif event_data.convert_type == "douban":
torrents = search_chain.async_search_by_id_stream(
doubanid=search_id,
mtype=media_type,
area=area,
season=media_season,
sites=site_list,
cache_local=True,
)
else:
if not title:
yield {"type": "error", "success": False, "message": "未知的媒体ID"}
return
meta = MetaInfo(title)
if year:
meta.year = year
if media_type:
meta.type = media_type
if media_season:
meta.type = MediaType.TV
meta.begin_season = media_season
mediainfo = await media_chain.async_recognize_by_meta(
meta,
obtain_images=False,
)
if mediainfo:
if settings.RECOGNIZE_SOURCE == "themoviedb":
torrents = search_chain.async_search_by_id_stream(
tmdbid=mediainfo.tmdb_id,
mtype=media_type,
area=area,
season=media_season,
sites=site_list,
cache_local=True,
)
else:
torrents = search_chain.async_search_by_id_stream(
doubanid=mediainfo.douban_id,
mtype=media_type,
area=area,
season=media_season,
sites=site_list,
cache_local=True,
)
if not torrents:
yield {"type": "error", "success": False, "message": "未搜索到任何资源"}
search_params, message = await _resolve_media_search_params(
mediaid=mediaid,
media_type=media_type,
title=title,
year=year,
media_season=media_season,
)
if not search_params:
yield {"type": "error", "success": False, "message": message}
return
torrents = search_chain.async_search_by_id_stream(
**search_params,
mtype=media_type,
area=area,
season=media_season,
sites=site_list,
cache_local=True,
)
async for event in torrents:
yield event
@@ -455,180 +357,32 @@ async def search_by_id(
_: schemas.TokenPayload = Depends(verify_token),
) -> Any:
"""
根据TMDBID/豆瓣ID精确搜索站点资源 tmdb:/douban:/bangumi:
根据带来源前缀的媒体 ID 精确搜索站点资源
"""
media_type = _parse_media_type(mtype)
if season:
media_season = int(season)
else:
media_season = None
if sites:
site_list = [int(site) for site in sites.split(",") if site]
else:
site_list = None
torrents = None
media_chain = MediaChain()
search_chain = SearchChain()
# 根据前缀识别媒体ID
if mediaid.startswith("tmdb:"):
tmdbid = int(mediaid.replace("tmdb:", ""))
if settings.RECOGNIZE_SOURCE == "douban":
# 通过TMDBID识别豆瓣ID
doubaninfo = await media_chain.async_get_doubaninfo_by_tmdbid(
tmdbid=tmdbid, mtype=media_type
)
if doubaninfo:
torrents = await search_chain.async_search_by_id(
doubanid=doubaninfo.get("id"),
mtype=media_type,
area=area,
season=media_season,
sites=site_list,
cache_local=True,
)
else:
return schemas.Response(success=False, message="未识别到豆瓣媒体信息")
else:
torrents = await search_chain.async_search_by_id(
tmdbid=tmdbid,
mtype=media_type,
area=area,
season=media_season,
sites=site_list,
cache_local=True,
)
elif mediaid.startswith("douban:"):
doubanid = mediaid.replace("douban:", "")
if settings.RECOGNIZE_SOURCE == "themoviedb":
# 通过豆瓣ID识别TMDBID
tmdbinfo = await media_chain.async_get_tmdbinfo_by_doubanid(
doubanid=doubanid, mtype=media_type
)
if tmdbinfo:
if tmdbinfo.get("season") and not media_season:
media_season = tmdbinfo.get("season")
torrents = await search_chain.async_search_by_id(
tmdbid=tmdbinfo.get("id"),
mtype=media_type,
area=area,
season=media_season,
sites=site_list,
cache_local=True,
)
else:
return schemas.Response(success=False, message="未识别到TMDB媒体信息")
else:
torrents = await search_chain.async_search_by_id(
doubanid=doubanid,
mtype=media_type,
area=area,
season=media_season,
sites=site_list,
cache_local=True,
)
elif mediaid.startswith("bangumi:"):
bangumiid = int(mediaid.replace("bangumi:", ""))
if settings.RECOGNIZE_SOURCE == "themoviedb":
# 通过BangumiID识别TMDBID
tmdbinfo = await media_chain.async_get_tmdbinfo_by_bangumiid(
bangumiid=bangumiid
)
if tmdbinfo:
torrents = await search_chain.async_search_by_id(
tmdbid=tmdbinfo.get("id"),
mtype=media_type,
area=area,
season=media_season,
sites=site_list,
cache_local=True,
)
else:
return schemas.Response(success=False, message="未识别到TMDB媒体信息")
else:
# 通过BangumiID识别豆瓣ID
doubaninfo = await media_chain.async_get_doubaninfo_by_bangumiid(
bangumiid=bangumiid
)
if doubaninfo:
torrents = await search_chain.async_search_by_id(
doubanid=doubaninfo.get("id"),
mtype=media_type,
area=area,
season=media_season,
sites=site_list,
cache_local=True,
)
else:
return schemas.Response(success=False, message="未识别到豆瓣媒体信息")
else:
# 未知前缀,广播事件解析媒体信息
event_data = MediaRecognizeConvertEventData(
mediaid=mediaid, convert_type=settings.RECOGNIZE_SOURCE
)
event = await eventmanager.async_send_event(
ChainEventType.MediaRecognizeConvert, event_data
)
# 使用事件返回的上下文数据
if event and event.event_data:
event_data: MediaRecognizeConvertEventData = event.event_data
if event_data.media_dict:
search_id = event_data.media_dict.get("id")
if event_data.convert_type == "themoviedb":
torrents = await search_chain.async_search_by_id(
tmdbid=search_id,
mtype=media_type,
area=area,
season=media_season,
cache_local=True,
)
elif event_data.convert_type == "douban":
torrents = await search_chain.async_search_by_id(
doubanid=search_id,
mtype=media_type,
area=area,
season=media_season,
cache_local=True,
)
else:
if not title:
return schemas.Response(success=False, message="未知的媒体ID")
# 使用名称识别兜底
meta = MetaInfo(title)
if year:
meta.year = year
if media_type:
meta.type = media_type
if media_season:
meta.type = MediaType.TV
meta.begin_season = media_season
mediainfo = await media_chain.async_recognize_by_meta(
meta,
obtain_images=False,
)
if mediainfo:
if settings.RECOGNIZE_SOURCE == "themoviedb":
torrents = await search_chain.async_search_by_id(
tmdbid=mediainfo.tmdb_id,
mtype=media_type,
area=area,
season=media_season,
cache_local=True,
)
else:
torrents = await search_chain.async_search_by_id(
doubanid=mediainfo.douban_id,
mtype=media_type,
area=area,
season=media_season,
cache_local=True,
)
# 返回搜索结果
media_season = int(season) if season else None
search_params, message = await _resolve_media_search_params(
mediaid=mediaid,
media_type=media_type,
title=title,
year=year,
media_season=media_season,
)
if not search_params:
return schemas.Response(success=False, message=message)
torrents = await SearchChain().async_search_by_id(
**search_params,
mtype=media_type,
area=area,
season=media_season,
sites=_parse_site_list(sites),
cache_local=True,
)
if not torrents:
return schemas.Response(success=False, message="未搜索到任何资源")
else:
return schemas.Response(
success=True, data=[torrent.to_dict() for torrent in torrents]
)
return schemas.Response(
success=True, data=[torrent.to_dict() for torrent in torrents]
)
@router.get("/title/stream", summary="渐进式模糊搜索资源")
@@ -732,7 +486,6 @@ async def _build_subtitle_search_source(
media_season = int(season) if season else None
media_episode = int(episode) if episode else None
site_list = _parse_site_list(sites)
media_chain = MediaChain()
search_chain = SearchChain()
def call_search(**kwargs):
@@ -751,80 +504,16 @@ async def _build_subtitle_search_source(
return search_chain.async_search_subtitles_by_id_stream(**params)
return search_chain.async_search_subtitles_by_id(**params)
if mediaid.startswith("tmdb:"):
tmdbid = int(mediaid.replace("tmdb:", ""))
if settings.RECOGNIZE_SOURCE == "douban":
doubaninfo = await media_chain.async_get_doubaninfo_by_tmdbid(
tmdbid=tmdbid, mtype=media_type
)
if not doubaninfo:
return None, "未识别到豆瓣媒体信息"
return call_search(doubanid=doubaninfo.get("id")), ""
return call_search(tmdbid=tmdbid), ""
if mediaid.startswith("douban:"):
doubanid = mediaid.replace("douban:", "")
if settings.RECOGNIZE_SOURCE == "themoviedb":
tmdbinfo = await media_chain.async_get_tmdbinfo_by_doubanid(
doubanid=doubanid, mtype=media_type
)
if not tmdbinfo:
return None, "未识别到TMDB媒体信息"
if tmdbinfo.get("season") and not media_season:
media_season = tmdbinfo.get("season")
return call_search(tmdbid=tmdbinfo.get("id")), ""
return call_search(doubanid=doubanid), ""
if mediaid.startswith("bangumi:"):
bangumiid = int(mediaid.replace("bangumi:", ""))
if settings.RECOGNIZE_SOURCE == "themoviedb":
tmdbinfo = await media_chain.async_get_tmdbinfo_by_bangumiid(
bangumiid=bangumiid
)
if not tmdbinfo:
return None, "未识别到TMDB媒体信息"
return call_search(tmdbid=tmdbinfo.get("id")), ""
doubaninfo = await media_chain.async_get_doubaninfo_by_bangumiid(
bangumiid=bangumiid
)
if not doubaninfo:
return None, "未识别到豆瓣媒体信息"
return call_search(doubanid=doubaninfo.get("id")), ""
event_data = MediaRecognizeConvertEventData(
mediaid=mediaid, convert_type=settings.RECOGNIZE_SOURCE
search_params, message = await _resolve_media_search_params(
mediaid=mediaid,
media_type=media_type,
title=title,
year=year,
media_season=media_season,
)
event = await eventmanager.async_send_event(
ChainEventType.MediaRecognizeConvert, event_data
)
if event and event.event_data and event.event_data.media_dict:
event_data = event.event_data
search_id = event_data.media_dict.get("id")
if event_data.convert_type == "themoviedb":
return call_search(tmdbid=search_id), ""
if event_data.convert_type == "douban":
return call_search(doubanid=search_id), ""
if not title:
return None, "未知的媒体ID"
meta = MetaInfo(title)
if year:
meta.year = year
if media_type:
meta.type = media_type
if media_season:
meta.type = MediaType.TV
meta.begin_season = media_season
mediainfo = await media_chain.async_recognize_by_meta(
meta,
obtain_images=False,
)
if not mediainfo:
return None, "未识别到媒体信息"
if settings.RECOGNIZE_SOURCE == "themoviedb":
return call_search(tmdbid=mediainfo.tmdb_id), ""
return call_search(doubanid=mediainfo.douban_id), ""
if not search_params:
return None, message
return call_search(**search_params), ""
@router.get("/subtitle/media/{mediaid}/stream", summary="渐进式精确搜索字幕")
@@ -840,7 +529,7 @@ async def search_subtitle_by_id_stream(
_: schemas.TokenPayload = Depends(verify_resource_token),
) -> Any:
"""
根据TMDBID/豆瓣ID渐进式精确搜索站点字幕资源返回格式为SSE
根据带来源前缀的媒体 ID 渐进式精确搜索站点字幕资源返回格式为SSE
"""
subtitles, message = await _build_subtitle_search_source(
mediaid=mediaid,
@@ -884,7 +573,7 @@ async def search_subtitle_by_id(
_: schemas.TokenPayload = Depends(verify_token),
) -> Any:
"""
根据TMDBID/豆瓣ID精确搜索站点字幕资源
根据带来源前缀的媒体 ID 精确搜索站点字幕资源
"""
subtitles, message = await _build_subtitle_search_source(
mediaid=mediaid,
+65 -73
View File
@@ -23,6 +23,7 @@ from app.log import logger
from app.scheduler import Scheduler
from app.schemas.event import SubscribeModifiedEventData
from app.schemas.types import MediaType, EventType, SystemConfigKey
from app.utils.media import normalize_media_source, parse_media_key
router = APIRouter()
@@ -104,6 +105,35 @@ def select_accessible_subscribe(
return None
async def list_subscribes_by_media_key(
db: AsyncSession, media_key: str, season: Optional[int] = None,
) -> List[Subscribe]:
"""按统一媒体键查询订阅,并兼容迁移前的专用 ID 字段。"""
source, media_id = parse_media_key(media_key)
if not source or not media_id:
return await Subscribe.async_list_by_mediaid(db, media_key)
subscribes = list(await Subscribe.async_list_by_media_identity(
db, media_source=source, media_id=media_id
))
if source == "themoviedb" and media_id.isdigit():
subscribes.extend(await Subscribe.async_get_by_tmdbid(db, int(media_id), season))
elif source == "douban":
subscribes.extend(await Subscribe.async_list_by_doubanid(db, media_id))
elif source == "bangumi" and media_id.isdigit():
subscribes.extend(await Subscribe.async_list_by_bangumiid(db, int(media_id)))
elif source == "anilist" and media_id.isdigit():
subscribes.extend(await Subscribe.async_list_by_anilistid(db, int(media_id)))
unique_subscribes = {subscribe.id: subscribe for subscribe in subscribes}
if season is not None:
return [
subscribe for subscribe in unique_subscribes.values()
if subscribe.season == season
]
return list(unique_subscribes.values())
@router.get("/", summary="查询所有订阅", response_model=List[schemas.Subscribe])
async def read_subscribes(
db: AsyncSession = Depends(get_async_db),
@@ -141,8 +171,13 @@ async def create_subscribe(
mtype = MediaType(subscribe_in.type)
else:
mtype = None
# 豆瓣标理
if subscribe_in.doubanid or subscribe_in.bangumiid:
# 非 TMDB 来源的标题可能自带季标记,入库前统一拆分。
if (
subscribe_in.doubanid
or subscribe_in.bangumiid
or subscribe_in.anilistid
or normalize_media_source(subscribe_in.media_source) not in (None, "themoviedb")
):
meta = MetaInfo(subscribe_in.name)
subscribe_in.name = meta.name
if subscribe_in.season is None:
@@ -152,14 +187,8 @@ async def create_subscribe(
title = subscribe_in.name
else:
title = None
# 订阅用户
subscribe_in.username = current_user.name
# 转化为字典
subscribe_dict = subscribe_in.model_dump()
if subscribe_in.id:
subscribe_dict.pop("id", None)
# completed_episode 是响应派生字段,禁止写入持久层
subscribe_dict.pop("completed_episode", None)
subscribe_dict = subscribe_in.to_public_write_payload()
subscribe_dict["username"] = current_user.name
sid, message = await SubscribeChain().async_add(
mtype=mtype,
title=title,
@@ -183,23 +212,14 @@ async def update_subscribe(
subscribe = await get_accessible_subscribe(db, subscribe_in.id, current_user)
if not subscribe:
return schemas.Response(success=False, message="订阅不存在")
# 避免更新缺失集数
old_subscribe_dict = subscribe.to_dict()
subscribe_dict = subscribe_in.model_dump()
subscribe_dict = subscribe_in.to_public_write_payload()
subscribe_dict["username"] = subscribe.username
if subscribe_in.episode_priority is None:
subscribe_dict.pop("episode_priority", None)
# completed_episode 是响应派生字段,禁止写入持久层
subscribe_dict.pop("completed_episode", None)
if not subscribe_in.lack_episode:
# 没有缺失集数时,缺失集数清空,避免更新为0
subscribe_dict.pop("lack_episode")
elif subscribe_in.total_episode:
# 总集数增加时,缺失集数也要增加
if subscribe_in.total_episode > (subscribe.total_episode or 0):
subscribe_dict["lack_episode"] = subscribe.lack_episode + (
subscribe_in.total_episode - (subscribe.total_episode or 0)
)
if subscribe_in.total_episode and subscribe_in.total_episode > (subscribe.total_episode or 0):
# 扩大目标范围时,新增加的集数尚无下载事实,应同步计入缺失集数。
subscribe_dict["lack_episode"] = (subscribe.lack_episode or 0) + (
subscribe_in.total_episode - (subscribe.total_episode or 0)
)
# 是否手动修改过总集数
if subscribe_in.total_episode != subscribe.total_episode:
subscribe_dict["manual_total_episode"] = 1
@@ -262,36 +282,12 @@ async def subscribe_mediaid(
current_user: User = Depends(get_current_active_user_async),
) -> Any:
"""
根据 TMDBID/豆瓣ID/BangumiId 查询订阅 tmdb:/douban:
根据 TMDB豆瓣BangumiAniList 或插件媒体键查询订阅
"""
title_check = False
if mediaid.startswith("tmdb:"):
tmdbid = mediaid[5:]
if not tmdbid or not str(tmdbid).isdigit():
return Subscribe()
subscribes = await Subscribe.async_get_by_tmdbid(db, int(tmdbid), season)
result = select_accessible_subscribe(subscribes, current_user)
elif mediaid.startswith("douban:"):
doubanid = mediaid[7:]
if not doubanid:
return Subscribe()
subscribes = await Subscribe.async_list_by_doubanid(db, doubanid)
result = select_accessible_subscribe(subscribes, current_user)
if not result and title:
title_check = True
elif mediaid.startswith("bangumi:"):
bangumiid = mediaid[8:]
if not bangumiid or not str(bangumiid).isdigit():
return Subscribe()
subscribes = await Subscribe.async_list_by_bangumiid(db, int(bangumiid))
result = select_accessible_subscribe(subscribes, current_user)
if not result and title:
title_check = True
else:
subscribes = await Subscribe.async_list_by_mediaid(db, mediaid)
result = select_accessible_subscribe(subscribes, current_user)
if not result and title:
title_check = True
subscribes = await list_subscribes_by_media_key(db, mediaid, season)
result = select_accessible_subscribe(subscribes, current_user)
source, _ = parse_media_key(mediaid)
title_check = not result and bool(title) and source != "themoviedb"
# 使用名称检查订阅
if title_check and title:
meta = MetaInfo(title)
@@ -339,6 +335,8 @@ async def reset_subscribes(
"lack_episode": subscribe.total_episode,
"current_priority": None,
"episode_priority": {},
# 重置代表放弃手动总集数,后续订阅检查重新按 TMDB 集数更新。
"manual_total_episode": 0,
"state": "R",
},
)
@@ -432,24 +430,9 @@ async def delete_subscribe_by_mediaid(
current_user: User = Depends(get_current_active_user_async),
) -> Any:
"""
根据TMDBID或豆瓣ID删除订阅 tmdb:/douban:
根据任意媒体数据源 ID 删除订阅
"""
delete_subscribes = []
if mediaid.startswith("tmdb:"):
tmdbid = mediaid[5:]
if not tmdbid or not str(tmdbid).isdigit():
return schemas.Response(success=False)
subscribes = await Subscribe.async_get_by_tmdbid(db, int(tmdbid), season)
delete_subscribes.extend(subscribes)
elif mediaid.startswith("douban:"):
doubanid = mediaid[7:]
if not doubanid:
return schemas.Response(success=False)
subscribes = await Subscribe.async_list_by_doubanid(db, doubanid)
delete_subscribes.extend(subscribes)
else:
subscribes = await Subscribe.async_list_by_mediaid(db, mediaid)
delete_subscribes.extend(subscribes)
delete_subscribes = await list_subscribes_by_media_key(db, mediaid, season)
delete_events = []
for subscribe in [
subscribe
@@ -637,7 +620,7 @@ async def popular_subscribes(
# 处理标题
title = sub.get("name")
season = sub.get("season")
if season and int(season) > 1 and media.tmdb_id:
if season not in (None, "") and int(season) != 1 and media.tmdb_id:
# 小写数据转大写
season_str = cn2an.an2cn(season, "low")
title = f"{title}{season_str}"
@@ -645,6 +628,8 @@ async def popular_subscribes(
media.year = sub.get("year")
media.douban_id = sub.get("doubanid")
media.bangumi_id = sub.get("bangumiid")
media.anilist_id = sub.get("anilistid")
media.source = sub.get("media_source")
media.tvdb_id = sub.get("tvdbid")
media.imdb_id = sub.get("imdbid")
media.season = sub.get("season")
@@ -871,6 +856,13 @@ async def delete_subscribe(
)
# 统计订阅
MoviePilotServerHelper.sub_done_async(
{"tmdbid": subscribe_info.get("tmdbid"), "doubanid": subscribe_info.get("doubanid")}
{
"tmdbid": subscribe_info.get("tmdbid"),
"doubanid": subscribe_info.get("doubanid"),
"bangumiid": subscribe_info.get("bangumiid"),
"anilistid": subscribe_info.get("anilistid"),
"media_source": subscribe_info.get("media_source"),
"media_id": subscribe_info.get("media_id"),
}
)
return schemas.Response(success=True)
+36 -15
View File
@@ -695,7 +695,6 @@ async def get_user_global_setting(_: User = Depends(get_current_active_user_asyn
"RECOGNIZE_SOURCE",
"SEARCH_SOURCE",
"AI_RECOMMEND_ENABLED",
"PASSKEY_ALLOW_REGISTER_WITHOUT_OTP",
}
)
# 智能助手总开关未开启,智能推荐状态强制返回False
@@ -1123,33 +1122,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 +1338,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 +1356,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)
+16 -2
View File
@@ -174,6 +174,10 @@ async def reidentify_cache(
torrent_hash: str,
tmdbid: Optional[int] = None,
doubanid: Optional[str] = None,
bangumiid: Optional[int] = None,
anilistid: Optional[int] = None,
media_source: Optional[str] = None,
media_id: Optional[str] = None,
_: User = Depends(get_current_active_superuser_async),
):
"""
@@ -182,6 +186,10 @@ async def reidentify_cache(
:param torrent_hash: 种子hash使用title+description的md5
:param tmdbid: 手动指定的TMDB ID
:param doubanid: 手动指定的豆瓣ID
:param bangumiid: 手动指定的 Bangumi ID
:param anilistid: 手动指定的 AniList ID
:param media_source: 媒体数据源
:param media_id: 数据源原生 ID
:param _: 当前用户必须是超级用户
"""
@@ -215,10 +223,16 @@ async def reidentify_cache(
title=target_context.torrent_info.title,
subtitle=target_context.torrent_info.description,
)
if tmdbid or doubanid:
if tmdbid or doubanid or bangumiid or anilistid or media_source or media_id:
# 手动指定媒体信息
mediainfo = await media_chain.async_recognize_media(
meta=meta, tmdbid=tmdbid, doubanid=doubanid
meta=meta,
tmdbid=tmdbid,
doubanid=doubanid,
bangumiid=bangumiid,
anilistid=anilistid,
source=media_source,
mediaid=media_id,
)
else:
# 自动重新识别
+19 -2
View File
@@ -269,8 +269,9 @@ def manual_transfer(
)
# 强制转移
force = True
downloader = history.downloader
download_hash = history.download_hash
# 下载器与 Hash 是同一组下载上下文,重新识别时由当前文件路径重新匹配。
downloader = history.downloader if transer_item.from_history else None
download_hash = history.download_hash if transer_item.from_history else None
if history.status and ("move" in history.mode):
# 重新整理成功的转移,则使用成功的 dest 做 in_path
src_fileitems = [FileItem(**history.dest_fileitem)]
@@ -291,6 +292,14 @@ def manual_transfer(
transer_item.doubanid = (
str(history.doubanid) if history.doubanid else transer_item.doubanid
)
transer_item.bangumiid = history.bangumiid or transer_item.bangumiid
transer_item.anilistid = history.anilistid or transer_item.anilistid
transer_item.media_source = (
history.media_source or transer_item.media_source
)
transer_item.media_id = (
history.media_id or transer_item.media_id
)
transer_item.season = (
int(str(history.seasons).replace("S", ""))
if history.seasons
@@ -408,6 +417,10 @@ def manual_transfer(
target_path=target_path,
tmdbid=transer_item.tmdbid,
doubanid=transer_item.doubanid,
bangumiid=transer_item.bangumiid,
anilistid=transer_item.anilistid,
media_source=transer_item.media_source,
media_id=transer_item.media_id,
mtype=mtype,
season=transer_item.season,
episode_group=transer_item.episode_group,
@@ -490,6 +503,10 @@ def manual_transfer(
target_path=target_path,
tmdbid=transer_item.tmdbid,
doubanid=transer_item.doubanid,
bangumiid=transer_item.bangumiid,
anilistid=transer_item.anilistid,
media_source=transer_item.media_source,
media_id=transer_item.media_id,
mtype=mtype,
season=transer_item.season,
episode_group=transer_item.episode_group,
+193 -51
View File
@@ -40,6 +40,7 @@ from app.schemas import (
MessageResponse,
)
from app.utils.identity import normalize_internal_user_id
from app.utils.media import normalize_media_source
from app.schemas.message import ChannelCapability, ChannelCapabilityManager
from app.schemas.category import CategoryConfig
from app.schemas.types import (
@@ -464,15 +465,20 @@ class ChainBase(metaclass=ABCMeta):
)
return result
def run_module(self, method: str, *args, **kwargs) -> Any:
def run_module(
self,
method: str,
*args,
**kwargs,
) -> Any:
"""
运行包含该方法的所有模块然后返回结果
当kwargs包含命名参数raise_exception时如模块方法抛出异常且raise_exception为True则同步抛出异常
"""
result = None
:param method: 模块方法名称
"""
# 执行插件模块
result = self.__execute_plugin_modules(method, result, *args, **kwargs)
result = self.__execute_plugin_modules(method, None, *args, **kwargs)
if not self.__is_valid_empty(result) and not isinstance(result, list):
# 插件模块返回结果不为空且不是列表,直接返回
@@ -481,17 +487,22 @@ class ChainBase(metaclass=ABCMeta):
# 执行系统模块
return self.__execute_system_modules(method, result, *args, **kwargs)
async def async_run_module(self, method: str, *args, **kwargs) -> Any:
async def async_run_module(
self,
method: str,
*args,
**kwargs,
) -> Any:
"""
异步运行包含该方法的所有模块然后返回结果
当kwargs包含命名参数raise_exception时如模块方法抛出异常且raise_exception为True则同步抛出异常
支持异步和同步方法的混合调用
"""
result = None
:param method: 模块方法名称
"""
# 执行插件模块
result = await self.__async_execute_plugin_modules(
method, result, *args, **kwargs
method, None, *args, **kwargs
)
if not self.__is_valid_empty(result) and not isinstance(result, list):
@@ -509,6 +520,7 @@ class ChainBase(metaclass=ABCMeta):
tmdbid: Optional[int],
doubanid: Optional[str],
bangumiid: Optional[int],
anilistid: Optional[int],
) -> bool:
"""
仅在名称识别场景下使用共享识别显式ID识别不再重复回查
@@ -516,7 +528,7 @@ class ChainBase(metaclass=ABCMeta):
return bool(
settings.MEDIA_RECOGNIZE_SHARE
and meta
and not any([tmdbid, doubanid, bangumiid])
and not any([tmdbid, doubanid, bangumiid, anilistid])
)
@staticmethod
@@ -560,13 +572,68 @@ class ChainBase(metaclass=ABCMeta):
mediainfo=mediainfo,
)
@staticmethod
def _resolve_media_source_params(
source: Optional[str] = None,
mediaid: Optional[str] = None,
tmdbid: Optional[int] = None,
doubanid: Optional[str] = None,
bangumiid: Optional[int] = None,
anilistid: Optional[int] = None,
) -> Tuple[Optional[str], Optional[int], Optional[str], Optional[int], Optional[int]]:
"""
统一请求级数据源ID与兼容字段并保证同一次识别只携带一个来源ID
:param source: 数据源名称
:param mediaid: 数据源原生ID
:param tmdbid: TMDB兼容ID
:param doubanid: 豆瓣兼容ID
:param bangumiid: Bangumi兼容ID
:param anilistid: AniList兼容ID
:return: 数据源及四种兼容ID
"""
source = normalize_media_source(source)
def to_int(value) -> Optional[int]:
"""将数字ID安全转换为整数。"""
return int(value) if value is not None and str(value).isdigit() else None
if source:
source_ids = {
"themoviedb": to_int(mediaid) if mediaid else to_int(tmdbid),
"douban": str(mediaid) if mediaid else str(doubanid) if doubanid else None,
"bangumi": to_int(mediaid) if mediaid else to_int(bangumiid),
"anilist": to_int(mediaid) if mediaid else to_int(anilistid),
}
selected_id = source_ids.get(source)
return (
source,
selected_id if source == "themoviedb" else None,
selected_id if source == "douban" else None,
selected_id if source == "bangumi" else None,
selected_id if source == "anilist" else None,
)
if tmdbid:
return "themoviedb", int(tmdbid), None, None, None
if doubanid:
return "douban", None, str(doubanid), None, None
if bangumiid:
return "bangumi", None, None, int(bangumiid), None
if anilistid:
return "anilist", None, None, None, int(anilistid)
return source, None, None, None, None
def recognize_media(
self,
meta: MetaBase = None,
mtype: Optional[MediaType] = None,
source: Optional[str] = None,
mediaid: Optional[str] = None,
tmdbid: Optional[int] = None,
doubanid: Optional[str] = None,
bangumiid: Optional[int] = None,
anilistid: Optional[int] = None,
episode_group: Optional[str] = None,
cache: bool = True,
share_meta: MetaBase = None,
@@ -576,9 +643,12 @@ class ChainBase(metaclass=ABCMeta):
:param meta: 识别的元数据
:param share_meta: 共享识别查询/上报使用的原始元数据
:param mtype: 识别的媒体类型与tmdbid配套
:param source: 请求级识别数据源
:param mediaid: 与source配套的数据源原生ID
:param tmdbid: tmdbid
:param doubanid: 豆瓣ID
:param bangumiid: BangumiID
:param anilistid: AniList ID
:param episode_group: 剧集组
:param cache: 是否使用缓存
:return: 识别的媒体信息包括剧集信息
@@ -588,25 +658,41 @@ class ChainBase(metaclass=ABCMeta):
tmdbid = meta.tmdbid
if not doubanid and hasattr(meta, "doubanid"):
doubanid = meta.doubanid
if not source and hasattr(meta, "media_source"):
source = meta.media_source
if not mediaid and hasattr(meta, "media_id"):
mediaid = meta.media_id
requested_mediaid = mediaid
if not episode_group and hasattr(meta, "episode_group"):
episode_group = meta.episode_group
# 有tmdbid时,不使用meta推断的类型(由消歧逻辑决定),也不使用其它ID
if tmdbid:
doubanid = None
bangumiid = None
elif not mtype and meta and meta.type in [MediaType.TV, MediaType.MOVIE]:
source, tmdbid, doubanid, bangumiid, anilistid = self._resolve_media_source_params(
source=source,
mediaid=mediaid,
tmdbid=tmdbid,
doubanid=doubanid,
bangumiid=bangumiid,
anilistid=anilistid,
)
# 显式 TMDB ID 由模块自行消歧,不能被标题推断类型误导。
if not mtype and not tmdbid and meta and meta.type in [MediaType.TV, MediaType.MOVIE]:
mtype = meta.type
share_query_meta = share_meta or meta
module_kwargs = {
"meta": meta,
"mtype": mtype,
"source": source,
"mediaid": requested_mediaid,
"tmdbid": tmdbid,
"doubanid": doubanid,
"bangumiid": bangumiid,
"anilistid": anilistid,
"episode_group": episode_group,
"cache": cache,
}
with fresh(not cache):
mediainfo = self.run_module(
"recognize_media",
meta=meta,
mtype=mtype,
tmdbid=tmdbid,
doubanid=doubanid,
bangumiid=bangumiid,
episode_group=episode_group,
cache=cache,
**module_kwargs,
)
if mediainfo:
if not mediainfo.recognize_cache_hit:
@@ -617,8 +703,8 @@ class ChainBase(metaclass=ABCMeta):
)
return mediainfo
if self._can_use_media_recognize_share(
share_query_meta, tmdbid, doubanid, bangumiid
if not source and self._can_use_media_recognize_share(
share_query_meta, tmdbid, doubanid, bangumiid, anilistid
):
shared_cache_meta = self._snapshot_recognize_cache_meta(meta)
shared_item = MoviePilotServerHelper.query_recognize_share(
@@ -633,9 +719,12 @@ class ChainBase(metaclass=ABCMeta):
"recognize_media",
meta=meta,
mtype=shared_params.get("mtype") or mtype,
source=shared_params.get("source"),
mediaid=shared_params.get("mediaid"),
tmdbid=shared_params.get("tmdbid"),
doubanid=shared_params.get("doubanid"),
bangumiid=shared_params.get("bangumiid"),
anilistid=shared_params.get("anilistid"),
episode_group=episode_group,
cache=cache,
)
@@ -648,9 +737,12 @@ class ChainBase(metaclass=ABCMeta):
self,
meta: MetaBase = None,
mtype: Optional[MediaType] = None,
source: Optional[str] = None,
mediaid: Optional[str] = None,
tmdbid: Optional[int] = None,
doubanid: Optional[str] = None,
bangumiid: Optional[int] = None,
anilistid: Optional[int] = None,
episode_group: Optional[str] = None,
cache: bool = True,
share_meta: MetaBase = None,
@@ -660,9 +752,12 @@ class ChainBase(metaclass=ABCMeta):
:param meta: 识别的元数据
:param share_meta: 共享识别查询/上报使用的原始元数据
:param mtype: 识别的媒体类型与tmdbid配套
:param source: 请求级识别数据源
:param mediaid: 与source配套的数据源原生ID
:param tmdbid: tmdbid
:param doubanid: 豆瓣ID
:param bangumiid: BangumiID
:param anilistid: AniList ID
:param episode_group: 剧集组
:param cache: 是否使用缓存
:return: 识别的媒体信息包括剧集信息
@@ -672,25 +767,41 @@ class ChainBase(metaclass=ABCMeta):
tmdbid = meta.tmdbid
if not doubanid and hasattr(meta, "doubanid"):
doubanid = meta.doubanid
if not source and hasattr(meta, "media_source"):
source = meta.media_source
if not mediaid and hasattr(meta, "media_id"):
mediaid = meta.media_id
requested_mediaid = mediaid
if not episode_group and hasattr(meta, "episode_group"):
episode_group = meta.episode_group
# 有tmdbid时,不使用meta推断的类型(由消歧逻辑决定),也不使用其它ID
if tmdbid:
doubanid = None
bangumiid = None
elif not mtype and meta and meta.type in [MediaType.TV, MediaType.MOVIE]:
source, tmdbid, doubanid, bangumiid, anilistid = self._resolve_media_source_params(
source=source,
mediaid=mediaid,
tmdbid=tmdbid,
doubanid=doubanid,
bangumiid=bangumiid,
anilistid=anilistid,
)
# 显式 TMDB ID 由模块自行消歧,不能被标题推断类型误导。
if not mtype and not tmdbid and meta and meta.type in [MediaType.TV, MediaType.MOVIE]:
mtype = meta.type
share_query_meta = share_meta or meta
module_kwargs = {
"meta": meta,
"mtype": mtype,
"source": source,
"mediaid": requested_mediaid,
"tmdbid": tmdbid,
"doubanid": doubanid,
"bangumiid": bangumiid,
"anilistid": anilistid,
"episode_group": episode_group,
"cache": cache,
}
async with async_fresh(not cache):
mediainfo = await self.async_run_module(
"async_recognize_media",
meta=meta,
mtype=mtype,
tmdbid=tmdbid,
doubanid=doubanid,
bangumiid=bangumiid,
episode_group=episode_group,
cache=cache,
**module_kwargs,
)
if mediainfo:
if not mediainfo.recognize_cache_hit:
@@ -701,8 +812,8 @@ class ChainBase(metaclass=ABCMeta):
)
return mediainfo
if self._can_use_media_recognize_share(
share_query_meta, tmdbid, doubanid, bangumiid
if not source and self._can_use_media_recognize_share(
share_query_meta, tmdbid, doubanid, bangumiid, anilistid
):
shared_cache_meta = self._snapshot_recognize_cache_meta(meta)
shared_item = await MoviePilotServerHelper.async_query_recognize_share(
@@ -717,9 +828,12 @@ class ChainBase(metaclass=ABCMeta):
"async_recognize_media",
meta=meta,
mtype=shared_params.get("mtype") or mtype,
source=shared_params.get("source"),
mediaid=shared_params.get("mediaid"),
tmdbid=shared_params.get("tmdbid"),
doubanid=shared_params.get("doubanid"),
bangumiid=shared_params.get("bangumiid"),
anilistid=shared_params.get("anilistid"),
episode_group=episode_group,
cache=cache,
)
@@ -984,49 +1098,77 @@ class ChainBase(metaclass=ABCMeta):
"""
return self.run_module("webhook_parser", body=body, form=form, args=args)
def search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]:
def search_medias(
self, meta: MetaBase, source: Optional[str] = None
) -> Optional[List[MediaInfo]]:
"""
搜索媒体信息
:param meta: 识别的元数据
:reutrn: 媒体信息列表
:param source: 请求级搜索数据源
:return: 媒体信息列表
"""
return self.run_module("search_medias", meta=meta)
return self.run_module("search_medias", meta=meta, source=source)
async def async_search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]:
async def async_search_medias(
self, meta: MetaBase, source: Optional[str] = None
) -> Optional[List[MediaInfo]]:
"""
搜索媒体信息异步版本
:param meta: 识别的元数据
:reutrn: 媒体信息列表
:param source: 请求级搜索数据源
:return: 媒体信息列表
"""
return await self.async_run_module("async_search_medias", meta=meta)
return await self.async_run_module(
"async_search_medias", meta=meta, source=source
)
def search_persons(self, name: str) -> Optional[List[MediaPerson]]:
def search_persons(
self, name: str, source: Optional[str] = None
) -> Optional[List[MediaPerson]]:
"""
搜索人物信息
:param name: 人物名称
:param source: 请求级搜索数据源
:return: 人物信息列表
"""
return self.run_module("search_persons", name=name)
return self.run_module("search_persons", name=name, source=source)
async def async_search_persons(self, name: str) -> Optional[List[MediaPerson]]:
async def async_search_persons(
self, name: str, source: Optional[str] = None
) -> Optional[List[MediaPerson]]:
"""
搜索人物信息异步版本
:param name: 人物名称
:param source: 请求级搜索数据源
:return: 人物信息列表
"""
return await self.async_run_module("async_search_persons", name=name)
return await self.async_run_module(
"async_search_persons", name=name, source=source
)
def search_collections(self, name: str) -> Optional[List[MediaInfo]]:
def search_collections(
self, name: str, source: Optional[str] = None
) -> Optional[List[MediaInfo]]:
"""
搜索集合信息
:param name: 集合名称
:param source: 请求级搜索数据源
:return: 合集信息列表
"""
return self.run_module("search_collections", name=name)
return self.run_module("search_collections", name=name, source=source)
async def async_search_collections(self, name: str) -> Optional[List[MediaInfo]]:
async def async_search_collections(
self, name: str, source: Optional[str] = None
) -> Optional[List[MediaInfo]]:
"""
搜索集合信息异步版本
:param name: 集合名称
:param source: 请求级搜索数据源
:return: 合集信息列表
"""
return await self.async_run_module("async_search_collections", name=name)
return await self.async_run_module(
"async_search_collections", name=name, source=source
)
def get_search_page_size(
self,
+181
View File
@@ -0,0 +1,181 @@
from typing import Optional
from app import schemas
from app.chain import ChainBase
from app.core.context import MediaInfo
class AniListChain(ChainBase):
"""
AniList 榜单探索与深度浏览处理链
"""
def info(self, anilist_id: int) -> Optional[dict]:
"""
获取 AniList 动画详情
:param anilist_id: AniList 媒体 ID
:return: AniList 媒体详情
"""
return self.run_module("anilist_info", anilist_id=anilist_id)
async def async_info(self, anilist_id: int) -> Optional[dict]:
"""
异步获取 AniList 动画详情
:param anilist_id: AniList 媒体 ID
:return: AniList 媒体详情
"""
return await self.async_run_module("async_anilist_info", anilist_id=anilist_id)
def trending(self, page: int = 1, count: int = 20) -> list[MediaInfo]:
"""
获取 AniList 当前趋势榜
:return: 统一媒体信息列表
"""
return self.run_module("anilist_trending", page=page, count=count) or []
async def async_trending(self, page: int = 1, count: int = 20) -> list[MediaInfo]:
"""
异步获取 AniList 当前趋势榜
:return: 统一媒体信息列表
"""
return await self.async_run_module(
"async_anilist_trending", page=page, count=count
) or []
def popular_this_season(self, page: int = 1, count: int = 20) -> list[MediaInfo]:
"""
获取 AniList 本季热门榜
:return: 统一媒体信息列表
"""
return self.run_module(
"anilist_popular_this_season", page=page, count=count
) or []
async def async_popular_this_season(
self, page: int = 1, count: int = 20
) -> list[MediaInfo]:
"""
异步获取 AniList 本季热门榜
:return: 统一媒体信息列表
"""
return await self.async_run_module(
"async_anilist_popular_this_season", page=page, count=count
) or []
def discover(self, **kwargs) -> list[MediaInfo]:
"""
按组合条件探索 AniList 动画
:return: 统一媒体信息列表
"""
return self.run_module("anilist_discover", **kwargs) or []
async def async_discover(self, **kwargs) -> list[MediaInfo]:
"""
异步按组合条件探索 AniList 动画
:return: 统一媒体信息列表
"""
return await self.async_run_module("async_anilist_discover", **kwargs) or []
def credits(
self, anilist_id: int, page: int = 1, count: int = 20
) -> list[schemas.MediaPerson]:
"""
获取 AniList 动画配音演员
:return: 媒体人物列表
"""
return self.run_module(
"anilist_credits", anilist_id=anilist_id, page=page, count=count
) or []
async def async_credits(
self, anilist_id: int, page: int = 1, count: int = 20
) -> list[schemas.MediaPerson]:
"""
异步获取 AniList 动画配音演员
:return: 媒体人物列表
"""
return await self.async_run_module(
"async_anilist_credits", anilist_id=anilist_id, page=page, count=count
) or []
def recommendations(
self, anilist_id: int, page: int = 1, count: int = 20
) -> list[MediaInfo]:
"""
获取 AniList 动画相关推荐
:return: 统一媒体信息列表
"""
return self.run_module(
"anilist_recommendations", anilist_id=anilist_id, page=page, count=count
) or []
async def async_recommendations(
self, anilist_id: int, page: int = 1, count: int = 20
) -> list[MediaInfo]:
"""
异步获取 AniList 动画相关推荐
:return: 统一媒体信息列表
"""
return await self.async_run_module(
"async_anilist_recommendations",
anilist_id=anilist_id,
page=page,
count=count,
) or []
def person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:
"""
获取 AniList 人物详情
:return: 媒体人物信息
"""
return self.run_module("anilist_person_detail", person_id=person_id)
async def async_person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:
"""
异步获取 AniList 人物详情
:return: 媒体人物信息
"""
return await self.async_run_module(
"async_anilist_person_detail", person_id=person_id
)
def person_credits(
self, person_id: int, page: int = 1, count: int = 20
) -> list[MediaInfo]:
"""
获取 AniList 人物参与的动画作品
:return: 统一媒体信息列表
"""
return self.run_module(
"anilist_person_credits", person_id=person_id, page=page, count=count
) or []
async def async_person_credits(
self, person_id: int, page: int = 1, count: int = 20
) -> list[MediaInfo]:
"""
异步获取 AniList 人物参与的动画作品
:return: 统一媒体信息列表
"""
return await self.async_run_module(
"async_anilist_person_credits",
person_id=person_id,
page=page,
count=count,
) or []
+143 -52
View File
@@ -7,7 +7,7 @@ import shutil
import time
from pathlib import Path
from typing import List, Optional, Tuple, Set, Dict, Union
from urllib.parse import parse_qs, urlparse
from urllib.parse import parse_qs, urljoin, urlparse
from app import schemas
from app.chain import ChainBase
@@ -30,6 +30,7 @@ from app.schemas import ExistMediaInfo, FileURI, NotExistMediaInfo, DownloaderTo
from app.schemas.types import MediaType, TorrentStatus, EventType, MessageChannel, NotificationType, ContentType, \
ChainEventType
from app.utils.http import RequestUtils
from app.utils.media import build_media_key, resolve_media_identity
from app.utils.string import StringUtils
from app.utils.system import SystemUtils
@@ -59,6 +60,45 @@ class DownloadChain(ChainBase):
".rar": "rar",
}
@staticmethod
def _normalize_indirect_download_url(url: str, base_url: Optional[str] = None) -> str:
"""
将两段式下载结果约束到索引器配置的可信 API 地址
:param url: 换票接口返回的临时下载地址
:param base_url: 索引器配置的可信 API Base URL
:return: 使用可信 API 来源的临时下载地址
"""
if not url or not base_url:
return url
base_parts = urlparse(base_url)
if not base_parts.scheme or not base_parts.netloc:
return url
url_parts = urlparse(url)
if not url_parts.netloc:
return urljoin(f"{base_url.rstrip('/')}/", url)
return url_parts._replace(
scheme=base_parts.scheme,
netloc=base_parts.netloc,
).geturl()
@staticmethod
def _media_identity_keys(media: Optional[MediaInfo]) -> Set[str]:
"""返回媒体的统一身份键及全部兼容 ID,用于临时缺失集映射匹配。"""
if not media:
return set()
source, media_id = resolve_media_identity(media=media)
values = {
media.tmdb_id, media.douban_id, media.bangumi_id, media.anilist_id,
build_media_key(source, media_id),
}
return {str(value) for value in values if value is not None and str(value)}
@classmethod
def _matches_media_identity(cls, media: Optional[MediaInfo], media_key: object) -> bool:
"""判断媒体是否命中统一身份键或任一兼容 ID。"""
return media_key is not None and str(media_key) in cls._media_identity_keys(media)
@staticmethod
def _safe_subtitle_file_name(file_name: str, fallback_name: str) -> str:
"""
@@ -137,9 +177,23 @@ class DownloadChain(ChainBase):
logger.warn(str(err))
return None, None, str(err)
if re.match(r"^[A-Za-z]:/", validated_save_path):
return storage, Path(validated_save_path), ""
file_uri = FileURI.from_uri(validated_save_path)
return file_uri.storage or storage, Path(file_uri.path), ""
target_dir = Path(validated_save_path)
else:
file_uri = FileURI.from_uri(validated_save_path)
storage = file_uri.storage or storage
target_dir = Path(file_uri.path)
dir_info = DirectoryHelper().get_download_dir_by_save_path(
media=media_info,
save_path=validated_save_path,
)
if dir_info:
target_dir = DownloadChain._append_download_classification(
root_path=target_dir,
dir_info=dir_info,
media_info=media_info,
)
return storage, target_dir, ""
dir_info = DirectoryHelper().get_dir(media_info, include_unsorted=True)
storage = dir_info.storage if dir_info else storage
@@ -147,15 +201,33 @@ class DownloadChain(ChainBase):
logger.error(f"未找到下载目录:{media_info.type.value} {media_info.title_year}")
return None, None, "未找到下载目录"
if not dir_info.media_type and dir_info.download_type_folder:
download_dir = Path(dir_info.download_path) / media_info.type.value
else:
download_dir = Path(dir_info.download_path)
download_dir = DownloadChain._append_download_classification(
root_path=Path(dir_info.download_path),
dir_info=dir_info,
media_info=media_info,
)
return storage, download_dir, ""
@staticmethod
def _append_download_classification(
root_path: Path,
dir_info: schemas.TransferDirectoryConf,
media_info: MediaInfo,
) -> Path:
"""
按下载目录配置拼装媒体类型和类别子目录
:param root_path: 下载根目录
:param dir_info: 下载目录配置
:param media_info: 媒体信息
:return: 应传给存储或下载器的媒体下载目录
"""
download_dir = root_path
if not dir_info.media_type and dir_info.download_type_folder:
download_dir = download_dir / media_info.type.value
if not dir_info.media_category and dir_info.download_category_folder and media_info.category:
download_dir = download_dir / media_info.category
return storage, download_dir, ""
return download_dir
@staticmethod
def _upload_subtitle_file(
@@ -292,17 +364,25 @@ class DownloadChain(ChainBase):
def download_subtitle(
self,
subtitle: SubtitleInfo,
media_source: Optional[str] = None,
media_id: Optional[str] = None,
tmdbid: Optional[int] = None,
doubanid: Optional[str] = None,
save_path: Optional[str] = None,
username: Optional[str] = None,
bangumiid: Optional[int] = None,
anilistid: Optional[int] = None,
) -> Tuple[bool, str, List[str]]:
"""
下载字幕文件并保存到媒体对应的下载目录
:param subtitle: 字幕搜索结果
:param media_source: 媒体数据源
:param media_id: 数据源原生ID
:param tmdbid: TMDB ID
:param doubanid: 豆瓣 ID
:param bangumiid: Bangumi ID
:param anilistid: AniList ID
:param save_path: 保存路径
:param username: 调用下载的用户名
:return: 成功状态提示消息保存文件列表
@@ -313,8 +393,12 @@ class DownloadChain(ChainBase):
metainfo = MetaInfo(title=subtitle.title, subtitle=subtitle.description)
mediainfo = self.recognize_media(
meta=metainfo,
source=media_source,
mediaid=media_id,
tmdbid=tmdbid,
doubanid=doubanid,
bangumiid=bangumiid,
anilistid=anilistid,
)
if not mediainfo:
return False, "无法识别媒体信息", []
@@ -447,19 +531,23 @@ class DownloadChain(ChainBase):
return None
media_type = getattr(getattr(media, "type", None), "value", getattr(media, "type", None))
media_source, media_id = resolve_media_identity(media=media)
media_key = (
getattr(media, "tmdb_id", None)
or getattr(media, "douban_id", None)
or getattr(media, "imdb_id", None)
f"{media_source}:{media_id}"
if media_source and media_id
else getattr(media, "imdb_id", None)
or getattr(media, "tvdb_id", None)
or f"{getattr(media, 'title', '')}:{getattr(media, 'year', '')}"
)
meta = getattr(context, "meta_info", None)
site = getattr(torrent, "site", None) or getattr(torrent, "site_name", None)
meta_season = getattr(meta, "season", None)
media_season = getattr(media, "season", None)
season = meta_season if meta_season is not None else media_season
payload = {
"media_type": str(media_type or ""),
"media_key": str(media_key or ""),
"season": str(getattr(meta, "season", None) or getattr(media, "season", None) or ""),
"season": str(season) if season is not None else "",
"episodes": cls._format_failure_episodes(meta) or "",
"site": str(site or ""),
"resource": cls._torrent_resource_key(torrent),
@@ -501,6 +589,7 @@ class DownloadChain(ChainBase):
time.localtime(now_timestamp + self._download_failure_ttl(error_msg)),
)
media = context.media_info
media_source, media_id = resolve_media_identity(media=media)
meta = context.meta_info
torrent = context.torrent_info
site = getattr(torrent, "site", None)
@@ -514,6 +603,10 @@ class DownloadChain(ChainBase):
year=getattr(media, "year", None),
tmdbid=getattr(media, "tmdb_id", None),
doubanid=getattr(media, "douban_id", None),
bangumiid=media.bangumi_id,
anilistid=media.anilist_id,
media_source=media_source,
media_id=media_id,
seasons=getattr(meta, "season", None),
episodes=StringUtils.format_ep(list(episodes)) if episodes else self._format_failure_episodes(meta),
site=site if isinstance(site, int) else None,
@@ -622,7 +715,11 @@ class DownloadChain(ChainBase):
data = data.get(key)
if not data:
return None
logger.info(f"获取到下载地址:{data}")
data = self._normalize_indirect_download_url(
url=data,
base_url=req_params.get('result_base_url'),
)
logger.info("已获取到站点临时下载地址")
return data
return None
@@ -633,7 +730,8 @@ class DownloadChain(ChainBase):
return torrent.enclosure, "", []
# Cookie
site_cookie = torrent.site_cookie
if torrent.enclosure.startswith("["):
indirect_download = torrent.enclosure.startswith("[")
if indirect_download:
# 需要解码获取下载地址
torrent_url = __get_redict_url(url=torrent.enclosure,
ua=torrent.site_ua,
@@ -643,21 +741,22 @@ class DownloadChain(ChainBase):
else:
torrent_url = torrent.enclosure
if not torrent_url:
logger.error(f"{torrent.title} 无法获取下载地址{torrent.enclosure}")
logger.error(f"{torrent.title} 无法获取下载地址!")
return None, "", []
# 下载种子文件
_, content, download_folder, files, error_msg = TorrentHelper().download_torrent(
url=torrent_url,
cookie=site_cookie,
ua=torrent.site_ua or settings.USER_AGENT,
proxy=torrent.site_proxy)
proxy=torrent.site_proxy,
cache_invalid=not indirect_download)
if isinstance(content, str):
# 磁力链
return content, "", []
if not content:
logger.error(f"下载种子文件失败:{torrent.title} - {torrent_url}")
logger.error(f"下载种子文件失败:{torrent.title}")
self.post_message(Notification(
channel=channel,
source=source if channel else None,
@@ -744,6 +843,7 @@ class DownloadChain(ChainBase):
if not _media.genre_ids:
new_media = self.recognize_media(mtype=_media.type, tmdbid=_media.tmdb_id,
doubanid=_media.douban_id, bangumiid=_media.bangumi_id,
anilistid=_media.anilist_id,
episode_group=_media.episode_group)
if new_media:
_media = new_media
@@ -785,36 +885,17 @@ class DownloadChain(ChainBase):
# 获取种子文件的文件夹名和文件清单
_folder_name, _file_list = TorrentHelper().get_fileinfo_from_torrent_content(torrent_content)
storage = 'local'
# 下载目录
if save_path is not None:
download_dir = Path(save_path)
else:
# 根据媒体信息查询下载目录配置
dir_info = DirectoryHelper().get_dir(_media, include_unsorted=True)
storage = dir_info.storage if dir_info else storage
# 拼装子目录
if dir_info:
# 一级目录
if not dir_info.media_type and dir_info.download_type_folder:
# 一级自动分类
download_dir = Path(dir_info.download_path) / _media.type.value
else:
# 一级不分类
download_dir = Path(dir_info.download_path)
# 二级目录
if not dir_info.media_category and dir_info.download_category_folder and _media and _media.category:
# 二级自动分类
download_dir = download_dir / _media.category
else:
# 未找到下载目录,且没有自定义下载目录
logger.error(f"未找到下载目录:{_media.type.value} {_media.title_year}")
storage, download_dir, error_msg = self._resolve_media_download_dir(
media_info=_media,
save_path=save_path,
)
if not download_dir:
if error_msg == "未找到下载目录":
self.messagehelper.put(f"{_media.type.value} {_media.title_year} 未找到下载目录!",
title="下载失败", role="system")
return (None, "未找到下载目录") if return_detail else None
fileURI = FileURI(storage=storage, path=download_dir.as_posix())
download_dir = Path(fileURI.uri)
return (None, error_msg or "未找到下载目录") if return_detail else None
file_uri = FileURI(storage=storage, path=download_dir.as_posix())
download_dir = Path(file_uri.uri)
# 添加下载
result: Optional[tuple] = self.download(content=torrent_content,
@@ -845,6 +926,7 @@ class DownloadChain(ChainBase):
# 登记下载记录
downloadhis = DownloadHistoryOper()
media_source, media_id = resolve_media_identity(media=_media)
downloadhis.add(
path=download_path.as_posix(),
type=_media.type.value,
@@ -854,6 +936,10 @@ class DownloadChain(ChainBase):
imdbid=_media.imdb_id,
tvdbid=_media.tvdb_id,
doubanid=_media.douban_id,
bangumiid=_media.bangumi_id,
anilistid=_media.anilist_id,
media_source=media_source,
media_id=media_id,
seasons=_meta.season,
episodes=download_episodes or _meta.episode,
image=_media.get_backdrop_image(),
@@ -1164,7 +1250,7 @@ class DownloadChain(ChainBase):
if not tv.episodes:
if not need_seasons.get(need_mid):
need_seasons[need_mid] = []
need_seasons[need_mid].append(tv.season or 1)
need_seasons[need_mid].append(tv.season if tv.season is not None else 1)
logger.info(f"缺失整季:{need_seasons}")
# 查找整季包含的种子,只处理整季没集的种子或者是集数超过季的种子
for need_mid, need_season in need_seasons.items():
@@ -1190,7 +1276,7 @@ class DownloadChain(ChainBase):
if meta.episode_list:
continue
# 匹配TMDBID
if need_mid == media.tmdb_id or need_mid == media.douban_id:
if self._matches_media_identity(media, need_mid):
# 不重复添加
if context in downloaded_list:
continue
@@ -1321,7 +1407,7 @@ class DownloadChain(ChainBase):
if media.type != MediaType.TV:
continue
# 匹配TMDB
if media.tmdb_id == need_mid or media.douban_id == need_mid:
if self._matches_media_identity(media, need_mid):
# 不重复添加
if context in downloaded_list:
continue
@@ -1423,7 +1509,7 @@ class DownloadChain(ChainBase):
if not effective_need:
continue
# 选中一个单季整季的或单季包括需要的所有集的
if (media.tmdb_id == need_mid or media.douban_id == need_mid) \
if self._matches_media_identity(media, need_mid) \
and (not meta.episode_list
or set(meta.episode_list).intersection(effective_need)) \
and len(meta.season_list) == 1 \
@@ -1501,6 +1587,7 @@ class DownloadChain(ChainBase):
:param totals: 电视剧每季的总集数
:return: 当前媒体是否缺失各标题总的季集和缺失的季集
"""
media_source, media_id = resolve_media_identity(media=mediainfo)
def __append_no_exists(_season: int, _episodes: list, _total: int, _start: int):
"""
@@ -1512,7 +1599,7 @@ class DownloadChain(ChainBase):
"start_episode": int
]}
"""
mediakey = mediainfo.tmdb_id or mediainfo.douban_id
mediakey = build_media_key(media_source, media_id)
if not no_exists.get(mediakey):
no_exists[mediakey] = {
_season: NotExistMediaInfo(
@@ -1553,6 +1640,10 @@ class DownloadChain(ChainBase):
mediainfo: MediaInfo = self.recognize_media(mtype=mediainfo.type,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
bangumiid=mediainfo.bangumi_id,
anilistid=mediainfo.anilist_id,
source=media_source,
mediaid=media_id,
episode_group=mediainfo.episode_group)
if not mediainfo:
logger.error(f"媒体信息识别失败!")
+96 -19
View File
@@ -592,14 +592,21 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
def recognize_by_meta(
self,
metainfo: MetaBase,
source: Optional[str] = None,
episode_group: Optional[str] = None,
obtain_images: bool = False,
) -> Optional[MediaInfo]:
"""
根据主副标题识别媒体信息
:param metainfo: 标题解析元数据
:param source: 请求级识别数据源
:param episode_group: 剧集组
:param obtain_images: 是否补充图片
"""
mediainfo = self._recognize_with_fallback_by_meta(
metainfo=metainfo,
source=source,
episode_group=episode_group,
obtain_images=obtain_images,
)
@@ -610,11 +617,18 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
def _recognize_with_fallback_by_meta(
self,
metainfo: MetaBase,
source: Optional[str] = None,
episode_group: Optional[str] = None,
obtain_images: bool = False,
) -> Optional[MediaInfo]:
"""
根据标题识别媒体信息必要时回退到辅助识别
:param metainfo: 标题解析元数据
:param source: 请求级识别数据源
:param episode_group: 剧集组
:param obtain_images: 是否补充图片
:return: 统一媒体信息
"""
if not metainfo:
return None
@@ -622,17 +636,21 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
share_meta = deepcopy(metainfo)
def native_recognize() -> Optional[MediaInfo]:
"""使用请求级数据源执行原生识别。"""
return self.recognize_media(
meta=metainfo,
source=source,
share_meta=share_meta,
episode_group=episode_group,
)
def plugin_recognize() -> Optional[MediaInfo]:
"""执行辅助识别并保持请求级数据源约束。"""
return self.recognize_help(
title=title,
org_meta=metainfo,
share_meta=share_meta,
source=source,
episode_group=episode_group,
)
@@ -653,11 +671,22 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
self.obtain_images(mediainfo=mediainfo)
return mediainfo
@staticmethod
def _parse_recognize_event_number(value) -> Optional[int]:
"""
解析辅助识别返回的季集号兼容整数和数字字符串并保留数值 0
"""
if value is None:
return None
text = str(value).strip()
return int(text) if text.isdigit() else None
def recognize_help(
self,
title: str,
org_meta: MetaBase,
share_meta: MetaBase = None,
source: Optional[str] = None,
episode_group: Optional[str] = None,
) -> Optional[MediaInfo]:
"""
@@ -666,6 +695,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
:param title: 标题
:param org_meta: 原始元数据
:param share_meta: 共享识别查询/上报使用的原始元数据
:param source: 请求级识别数据源
:param episode_group: 剧集组
"""
# 发送请求事件,等待结果
@@ -686,10 +716,8 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
title = str(event_data["name"]).split("/")[0].strip().replace(".", " ")
if event_data.get("year"):
year = str(event_data["year"]).split("/")[0].strip()
if event_data.get("season") and str(event_data["season"]).isdigit():
season_number = int(event_data["season"])
if event_data.get("episode") and str(event_data["episode"]).isdigit():
episode_number = int(event_data["episode"])
season_number = self._parse_recognize_event_number(event_data.get("season"))
episode_number = self._parse_recognize_event_number(event_data.get("episode"))
if not title:
return None
if title == "Unknown":
@@ -710,6 +738,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
# 重新识别
return self.recognize_media(
meta=org_meta,
source=source,
share_meta=share_meta,
episode_group=episode_group,
)
@@ -717,11 +746,18 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
def recognize_by_path(
self,
path: str,
source: Optional[str] = None,
episode_group: Optional[str] = None,
obtain_images: bool = False,
) -> Optional[Context]:
"""
根据文件路径识别媒体信息
:param path: 文件路径
:param source: 请求级识别数据源
:param episode_group: 剧集组
:param obtain_images: 是否补充图片
:return: 识别上下文
"""
logger.info(f"开始识别媒体信息,文件:{path} ...")
file_path = Path(path)
@@ -729,6 +765,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
file_meta = MetaInfoPath(file_path)
mediainfo = self._recognize_with_fallback_by_meta(
metainfo=file_meta,
source=source,
episode_group=episode_group,
obtain_images=obtain_images,
)
@@ -738,11 +775,14 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
# 返回上下文
return Context(meta_info=file_meta, media_info=mediainfo)
def search(self, title: str) -> Tuple[Optional[MetaBase], List[MediaInfo]]:
def search(
self, title: str, source: Optional[str] = None
) -> Tuple[Optional[MetaBase], List[MediaInfo]]:
"""
搜索媒体/人物信息
:param title: 搜索内容
:param source: 请求级搜索数据源
:return: 识别元数据媒体信息列表
"""
# 提取要素
@@ -764,7 +804,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
meta.year = year
# 开始搜索
logger.info(f"开始搜索媒体信息:{meta.name}")
medias: Optional[List[MediaInfo]] = self.search_medias(meta=meta)
medias: Optional[List[MediaInfo]] = self.search_medias(meta=meta, source=source)
if not medias:
logger.warn(f"{meta.name} 没有找到对应的媒体信息!")
return meta, []
@@ -837,7 +877,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
tmdbinfo = self._match_tmdb_with_names(
meta_names=meta_names,
year=year,
mtype=MediaType.TV,
mtype=MediaInfo.get_bangumi_media_type(bangumiinfo),
season=meta.begin_season,
)
return tmdbinfo
@@ -877,7 +917,10 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
year = self._extract_year_from_bangumi(bangumiinfo)
# 使用名称识别豆瓣媒体信息
return self.match_doubaninfo(
name=meta.name, year=year, mtype=MediaType.TV, season=meta.begin_season
name=meta.name,
year=year,
mtype=MediaInfo.get_bangumi_media_type(bangumiinfo),
season=meta.begin_season,
)
return None
@@ -1542,14 +1585,22 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
async def async_recognize_by_meta(
self,
metainfo: MetaBase,
source: Optional[str] = None,
episode_group: Optional[str] = None,
obtain_images: bool = False,
) -> Optional[MediaInfo]:
"""
根据主副标题识别媒体信息异步版本
:param metainfo: 标题解析元数据
:param source: 请求级识别数据源
:param episode_group: 剧集组
:param obtain_images: 是否补充图片
:return: 统一媒体信息
"""
mediainfo = await self._async_recognize_with_fallback_by_meta(
metainfo=metainfo,
source=source,
episode_group=episode_group,
obtain_images=obtain_images,
)
@@ -1560,29 +1611,40 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
async def _async_recognize_with_fallback_by_meta(
self,
metainfo: MetaBase,
source: Optional[str] = None,
episode_group: Optional[str] = None,
obtain_images: bool = False,
) -> Optional[MediaInfo]:
"""
异步根据标题识别媒体信息必要时回退到辅助识别
:param metainfo: 标题解析元数据
:param source: 请求级识别数据源
:param episode_group: 剧集组
:param obtain_images: 是否补充图片
:return: 统一媒体信息
"""
if not metainfo:
return None
title = metainfo.title
share_meta = deepcopy(metainfo)
async def native_recognize():
async def native_recognize() -> Optional[MediaInfo]:
"""异步使用请求级数据源执行原生识别。"""
return await self.async_recognize_media(
meta=metainfo,
source=source,
share_meta=share_meta,
episode_group=episode_group,
)
async def plugin_recognize():
async def plugin_recognize() -> Optional[MediaInfo]:
"""异步执行辅助识别并保持请求级数据源约束。"""
return await self.async_recognize_help(
title=title,
org_meta=metainfo,
share_meta=share_meta,
source=source,
episode_group=episode_group,
)
@@ -1607,6 +1669,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
title: str,
org_meta: MetaBase,
share_meta: MetaBase = None,
source: Optional[str] = None,
episode_group: Optional[str] = None,
) -> Optional[MediaInfo]:
"""
@@ -1615,6 +1678,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
:param title: 标题
:param org_meta: 原始元数据
:param share_meta: 共享识别查询/上报使用的原始元数据
:param source: 请求级识别数据源
:param episode_group: 剧集组
"""
# 发送请求事件,等待结果
@@ -1635,10 +1699,8 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
title = str(event_data["name"]).split("/")[0].strip().replace(".", " ")
if event_data.get("year"):
year = str(event_data["year"]).split("/")[0].strip()
if event_data.get("season") and str(event_data["season"]).isdigit():
season_number = int(event_data["season"])
if event_data.get("episode") and str(event_data["episode"]).isdigit():
episode_number = int(event_data["episode"])
season_number = self._parse_recognize_event_number(event_data.get("season"))
episode_number = self._parse_recognize_event_number(event_data.get("episode"))
if not title:
return None
if title == "Unknown":
@@ -1654,11 +1716,12 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
org_meta.year = year
org_meta.begin_season = season_number
org_meta.begin_episode = episode_number
if org_meta.begin_season or org_meta.begin_episode:
if org_meta.begin_season is not None or org_meta.begin_episode is not None:
org_meta.type = MediaType.TV
# 重新识别
return await self.async_recognize_media(
meta=org_meta,
source=source,
share_meta=share_meta,
episode_group=episode_group,
)
@@ -1666,11 +1729,18 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
async def async_recognize_by_path(
self,
path: str,
source: Optional[str] = None,
episode_group: Optional[str] = None,
obtain_images: bool = False,
) -> Optional[Context]:
"""
根据文件路径识别媒体信息异步版本
:param path: 文件路径
:param source: 请求级识别数据源
:param episode_group: 剧集组
:param obtain_images: 是否补充图片
:return: 识别上下文
"""
logger.info(f"开始识别媒体信息,文件:{path} ...")
file_path = Path(path)
@@ -1678,6 +1748,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
file_meta = MetaInfoPath(file_path)
mediainfo = await self._async_recognize_with_fallback_by_meta(
metainfo=file_meta,
source=source,
episode_group=episode_group,
obtain_images=obtain_images,
)
@@ -1688,12 +1759,13 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
return Context(meta_info=file_meta, media_info=mediainfo)
async def async_search(
self, title: str
self, title: str, source: Optional[str] = None
) -> Tuple[Optional[MetaBase], List[MediaInfo]]:
"""
搜索媒体/人物信息异步版本
:param title: 搜索内容
:param source: 请求级搜索数据源
:return: 识别元数据媒体信息列表
"""
# 提取要素
@@ -1715,7 +1787,9 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
meta.year = year
# 开始搜索
logger.info(f"开始搜索媒体信息:{meta.name}")
medias: Optional[List[MediaInfo]] = await self.async_search_medias(meta=meta)
medias: Optional[List[MediaInfo]] = await self.async_search_medias(
meta=meta, source=source
)
if not medias:
logger.warn(f"{meta.name} 没有找到对应的媒体信息!")
return meta, []
@@ -1855,7 +1929,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
tmdbinfo = await self._async_match_tmdb_with_names(
meta_names=meta_names,
year=year,
mtype=MediaType.TV,
mtype=MediaInfo.get_bangumi_media_type(bangumiinfo),
season=meta.begin_season,
)
return tmdbinfo
@@ -1895,6 +1969,9 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
year = self._extract_year_from_bangumi(bangumiinfo)
# 使用名称识别豆瓣媒体信息
return await self.async_match_doubaninfo(
name=meta.name, year=year, mtype=MediaType.TV, season=meta.begin_season
name=meta.name,
year=year,
mtype=MediaInfo.get_bangumi_media_type(bangumiinfo),
season=meta.begin_season,
)
return None
+40 -4
View File
@@ -1,6 +1,6 @@
import threading
from datetime import datetime
from typing import Callable, List, Union, Optional, Generator, Any
from typing import Callable, Dict, List, Union, Optional, Generator, Any
from app.chain import ChainBase
from app.core.config import global_vars
@@ -210,6 +210,24 @@ class MediaServerChain(ChainBase):
"""
return self.run_module("mediaserver_play_url", server=server, item_id=item_id)
def get_season_episode_ids(self, server: str, item_id: Union[str, int],
season: int) -> Dict[int, str]:
"""
获取指定季的集号到媒体服务器条目 ID 映射
:param server: 媒体服务器名称
:param item_id: 剧集在媒体服务器中的条目 ID
:param season: 季号
:return: 集号到条目 ID 的映射无数据时返回空字典
"""
result = self.run_module(
"mediaserver_season_episode_ids",
server=server,
item_id=item_id,
season=season,
)
return result or {}
def get_image_cookies(
self, server: Optional[str], image_url: str
) -> Optional[str | dict]:
@@ -220,11 +238,16 @@ class MediaServerChain(ChainBase):
"mediaserver_image_cookies", server=server, image_url=image_url
)
def sync(self, progress_callback: Optional[Callable[..., None]] = None) -> None:
def sync(
self,
progress_callback: Optional[Callable[..., None]] = None,
server: Optional[str] = None,
) -> None:
"""
同步媒体库所有数据到本地数据库
同步全部或指定媒体服务器的媒体库数据到本地数据库
:param progress_callback: 定时服务进度更新回调
:param server: 指定媒体服务器名称为空时同步全部已启用服务器
"""
# 设置的媒体服务器
mediaservers = ServiceConfigHelper.get_mediaserver_configs()
@@ -239,7 +262,14 @@ class MediaServerChain(ChainBase):
enabled_servers = [mediaserver.name for mediaserver in mediaservers
if mediaserver and mediaserver.enabled and mediaserver.name]
dboper.delete_excluded_servers(enabled_servers)
if server:
mediaservers = [
mediaserver for mediaserver in mediaservers
if mediaserver and mediaserver.enabled and mediaserver.name == server
]
total_servers = len(enabled_servers)
if server:
total_servers = len(mediaservers)
if progress_callback:
progress_callback(
value=0,
@@ -248,7 +278,13 @@ class MediaServerChain(ChainBase):
)
if not total_servers:
if progress_callback:
progress_callback(value=100, text="没有已启用的媒体服务器")
progress_callback(
value=100,
text=(
f"媒体服务器 {server} 未启用或不存在"
if server else "没有已启用的媒体服务器"
),
)
return
server_sync_contexts = {}
+10 -3
View File
@@ -42,6 +42,7 @@ from app.schemas.message import ChannelCapabilityManager, ChannelCapability
from app.schemas.system import TransferDirectoryConf
from app.schemas.types import EventType, MessageChannel, MediaType
from app.utils.http import RequestUtils
from app.utils.media import build_media_key, resolve_media_identity
from app.utils.string import StringUtils
@@ -2071,6 +2072,10 @@ class MediaInteractionChain(ChainBase):
mtype=mediainfo.type,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
bangumiid=mediainfo.bangumi_id,
anilistid=mediainfo.anilist_id,
source=resolve_media_identity(media=mediainfo)[0],
mediaid=resolve_media_identity(media=mediainfo)[1],
cache=False,
)
if not mediainfo:
@@ -2085,9 +2090,10 @@ class MediaInteractionChain(ChainBase):
)
return {}
mediakey = mediainfo.tmdb_id or mediainfo.douban_id
media_source, media_id = resolve_media_identity(media=mediainfo)
mediakey = build_media_key(media_source, media_id)
no_exists = {mediakey: {}}
if meta.begin_season:
if meta.begin_season is not None:
episodes = mediainfo.seasons.get(meta.begin_season)
if not episodes:
return {}
@@ -3528,7 +3534,8 @@ class MediaInteractionChain(ChainBase):
"""
if not no_exists:
return []
mediakey = mediainfo.tmdb_id or mediainfo.douban_id
media_source, media_id = resolve_media_identity(media=mediainfo)
mediakey = build_media_key(media_source, media_id)
season_map = no_exists.get(mediakey) or {}
if show_missing_only:
return [
+191 -62
View File
@@ -24,6 +24,7 @@ from app.helper.torrent import TorrentHelper
from app.log import logger
from app.schemas import NotExistMediaInfo
from app.schemas.types import MediaType, ProgressKey, SystemConfigKey, EventType
from app.utils.media import build_media_key, resolve_media_identity
from app.utils.string import StringUtils
@@ -171,16 +172,38 @@ class SearchChain(ChainBase):
@staticmethod
def _build_search_keyword(
tmdbid: Optional[int] = None, doubanid: Optional[str] = None
source: Optional[str] = None,
mediaid: Optional[str] = None,
tmdbid: Optional[int] = None,
doubanid: Optional[str] = None,
bangumiid: Optional[int] = None,
anilistid: Optional[int] = None,
) -> str:
"""
根据媒体ID生成可重放的搜索关键字
"""
if tmdbid is not None:
return f"tmdb:{tmdbid}"
if doubanid:
return f"douban:{doubanid}"
return ""
media_source, media_id = resolve_media_identity(
source=source,
media_id=mediaid,
tmdbid=tmdbid,
doubanid=doubanid,
bangumiid=bangumiid,
anilistid=anilistid,
)
return build_media_key(media_source, media_id)
@staticmethod
def _media_recognize_kwargs(mediainfo: MediaInfo) -> dict:
"""从统一媒体信息构造完整的识别 ID 参数。"""
media_source, media_id = resolve_media_identity(media=mediainfo)
return {
"source": media_source,
"mediaid": media_id,
"tmdbid": mediainfo.tmdb_id,
"doubanid": mediainfo.douban_id,
"bangumiid": mediainfo.bangumi_id,
"anilistid": mediainfo.anilist_id,
}
@staticmethod
def _stringify_sites(sites: Optional[List[int]]) -> str:
@@ -203,7 +226,7 @@ class SearchChain(ChainBase):
"area": str(params.get("area") or ""),
"title": str(params.get("title") or ""),
"year": str(params.get("year") or ""),
"season": str(params.get("season") or ""),
"season": str(params["season"]) if params.get("season") is not None else "",
"episode": str(params.get("episode") or ""),
"sites": str(params.get("sites") or ""),
"result_type": str(params.get("result_type") or "torrent"),
@@ -488,13 +511,22 @@ class SearchChain(ChainBase):
state._ai_recommend_task = asyncio.create_task(run_recommend())
def search_by_id(self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
mtype: MediaType = None, area: Optional[str] = "title", season: Optional[int] = None,
sites: List[int] = None, cache_local: bool = False) -> List[Context]:
def search_by_id(
self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
mtype: MediaType = None, area: Optional[str] = "title",
season: Optional[int] = None, sites: List[int] = None,
cache_local: bool = False,
bangumiid: Optional[int] = None, anilistid: Optional[int] = None,
source: Optional[str] = None, mediaid: Optional[str] = None,
) -> List[Context]:
"""
根据TMDBID/豆瓣ID搜索资源精确匹配不过滤本地存在的资源
根据数据源媒体 ID 搜索资源精确匹配不过滤本地存在的资源
:param tmdbid: TMDB ID
:param doubanid: 豆瓣 ID
:param bangumiid: Bangumi ID
:param anilistid: AniList ID
:param source: 媒体数据源
:param mediaid: 数据源原生 ID
:param mtype: 媒体电影 or 电视剧
:param area: 搜索范围title or imdbid
:param season: 季数
@@ -504,20 +536,26 @@ class SearchChain(ChainBase):
if cache_local:
self.cancel_ai_recommend()
self.save_last_search_params(
keyword=self._build_search_keyword(tmdbid=tmdbid, doubanid=doubanid),
keyword=self._build_search_keyword(
source, mediaid, tmdbid, doubanid, bangumiid, anilistid
),
mtype=mtype,
area=area,
season=season,
sites=sites,
)
mediainfo = self.recognize_media(tmdbid=tmdbid, doubanid=doubanid, mtype=mtype)
mediainfo = self.recognize_media(
source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid,
bangumiid=bangumiid, anilistid=anilistid, mtype=mtype,
)
if not mediainfo:
logger.error(f'{tmdbid} 媒体信息识别失败!')
logger.error(f'{self._build_search_keyword(source, mediaid, tmdbid, doubanid, bangumiid, anilistid)} 媒体信息识别失败!')
return []
no_exists = None
if season is not None:
media_source, media_id = resolve_media_identity(media=mediainfo)
no_exists = {
tmdbid or doubanid: {
build_media_key(media_source, media_id): {
season: NotExistMediaInfo(episodes=[])
}
}
@@ -658,14 +696,22 @@ class SearchChain(ChainBase):
"total_items": len(subtitles)
}
async def async_search_subtitles_by_id(self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
mtype: MediaType = None, season: Optional[int] = None,
episode: Optional[int] = None, sites: List[int] = None,
cache_local: bool = False) -> List[SubtitleInfo]:
async def async_search_subtitles_by_id(
self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
mtype: MediaType = None, season: Optional[int] = None,
episode: Optional[int] = None, sites: List[int] = None,
cache_local: bool = False,
bangumiid: Optional[int] = None, anilistid: Optional[int] = None,
source: Optional[str] = None, mediaid: Optional[str] = None,
) -> List[SubtitleInfo]:
"""
根据TMDBID/豆瓣ID异步精确搜索字幕不应用过滤规则
根据数据源媒体 ID 异步精确搜索字幕不应用过滤规则
:param tmdbid: TMDB ID
:param doubanid: 豆瓣 ID
:param bangumiid: Bangumi ID
:param anilistid: AniList ID
:param source: 媒体数据源
:param mediaid: 数据源原生 ID
:param mtype: 媒体电影 or 电视剧
:param season: 季数
:param episode: 集数
@@ -675,7 +721,9 @@ class SearchChain(ChainBase):
if cache_local:
self.cancel_ai_recommend()
await self.async_save_last_search_params(
keyword=self._build_search_keyword(tmdbid=tmdbid, doubanid=doubanid),
keyword=self._build_search_keyword(
source, mediaid, tmdbid, doubanid, bangumiid, anilistid
),
mtype=mtype,
area="title",
season=season,
@@ -683,14 +731,24 @@ class SearchChain(ChainBase):
sites=sites,
result_type="subtitle",
)
mediainfo = await self.async_recognize_media(tmdbid=tmdbid, doubanid=doubanid, mtype=mtype)
mediainfo = await self.async_recognize_media(
source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid,
bangumiid=bangumiid, anilistid=anilistid, mtype=mtype,
)
if not mediainfo:
logger.error(f'{tmdbid} 媒体信息识别失败!')
logger.error(
f'{self._build_search_keyword(source, mediaid, tmdbid, doubanid, bangumiid, anilistid)} '
'媒体信息识别失败!'
)
return []
subtitles = await self.__async_search_subtitles_for_media(
mediainfo=mediainfo,
tmdbid=tmdbid,
doubanid=doubanid,
bangumiid=bangumiid,
anilistid=anilistid,
source=source,
mediaid=mediaid,
season=season,
episode=episode,
sites=sites,
@@ -708,14 +766,20 @@ class SearchChain(ChainBase):
episode: Optional[int] = None,
sites: List[int] = None,
cache_local: bool = False,
bangumiid: Optional[int] = None,
anilistid: Optional[int] = None,
source: Optional[str] = None,
mediaid: Optional[str] = None,
) -> AsyncIterator[dict]:
"""
根据TMDBID/豆瓣ID渐进式精确搜索字幕先返回站点候选再返回标题和剧集匹配后的结果
根据数据源媒体 ID 渐进式精确搜索字幕先返回站点候选再返回标题和剧集匹配后的结果
"""
if cache_local:
self.cancel_ai_recommend()
await self.async_save_last_search_params(
keyword=self._build_search_keyword(tmdbid=tmdbid, doubanid=doubanid),
keyword=self._build_search_keyword(
source, mediaid, tmdbid, doubanid, bangumiid, anilistid
),
mtype=mtype,
area="title",
season=season,
@@ -723,9 +787,15 @@ class SearchChain(ChainBase):
sites=sites,
result_type="subtitle",
)
mediainfo = await self.async_recognize_media(tmdbid=tmdbid, doubanid=doubanid, mtype=mtype)
mediainfo = await self.async_recognize_media(
source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid,
bangumiid=bangumiid, anilistid=anilistid, mtype=mtype,
)
if not mediainfo:
logger.error(f'{tmdbid} 媒体信息识别失败!')
logger.error(
f'{self._build_search_keyword(source, mediaid, tmdbid, doubanid, bangumiid, anilistid)} '
'媒体信息识别失败!'
)
yield {
"type": "error",
"success": False,
@@ -738,6 +808,10 @@ class SearchChain(ChainBase):
mediainfo=mediainfo,
tmdbid=tmdbid,
doubanid=doubanid,
bangumiid=bangumiid,
anilistid=anilistid,
source=source,
mediaid=mediaid,
season=season,
episode=episode,
sites=sites):
@@ -753,13 +827,22 @@ class SearchChain(ChainBase):
if cache_local:
await self.async_save_cache(subtitles, self.__subtitle_result_temp_file)
async def async_search_by_id(self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
mtype: MediaType = None, area: Optional[str] = "title", season: Optional[int] = None,
sites: List[int] = None, cache_local: bool = False) -> List[Context]:
async def async_search_by_id(
self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
mtype: MediaType = None, area: Optional[str] = "title",
season: Optional[int] = None, sites: List[int] = None,
cache_local: bool = False,
bangumiid: Optional[int] = None, anilistid: Optional[int] = None,
source: Optional[str] = None, mediaid: Optional[str] = None,
) -> List[Context]:
"""
根据TMDBID/豆瓣ID异步搜索资源精确匹配不过滤本地存在的资源
根据数据源媒体 ID 异步搜索资源精确匹配不过滤本地存在的资源
:param tmdbid: TMDB ID
:param doubanid: 豆瓣 ID
:param bangumiid: Bangumi ID
:param anilistid: AniList ID
:param source: 媒体数据源
:param mediaid: 数据源原生 ID
:param mtype: 媒体电影 or 电视剧
:param area: 搜索范围title or imdbid
:param season: 季数
@@ -769,20 +852,29 @@ class SearchChain(ChainBase):
if cache_local:
self.cancel_ai_recommend()
await self.async_save_last_search_params(
keyword=self._build_search_keyword(tmdbid=tmdbid, doubanid=doubanid),
keyword=self._build_search_keyword(
source, mediaid, tmdbid, doubanid, bangumiid, anilistid
),
mtype=mtype,
area=area,
season=season,
sites=sites,
)
mediainfo = await self.async_recognize_media(tmdbid=tmdbid, doubanid=doubanid, mtype=mtype)
mediainfo = await self.async_recognize_media(
source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid,
bangumiid=bangumiid, anilistid=anilistid, mtype=mtype,
)
if not mediainfo:
logger.error(f'{tmdbid} 媒体信息识别失败!')
logger.error(
f'{self._build_search_keyword(source, mediaid, tmdbid, doubanid, bangumiid, anilistid)} '
'媒体信息识别失败!'
)
return []
no_exists = None
if season is not None:
media_source, media_id = resolve_media_identity(media=mediainfo)
no_exists = {
tmdbid or doubanid: {
build_media_key(media_source, media_id): {
season: NotExistMediaInfo(episodes=[])
}
}
@@ -913,25 +1005,37 @@ class SearchChain(ChainBase):
logger.info(f'标题搜索过滤完成,剩余 {len(filtered_torrents)} 个资源')
return filtered_torrents
async def async_search_by_id_stream(self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
mtype: MediaType = None, area: Optional[str] = "title",
season: Optional[int] = None, sites: List[int] = None,
cache_local: bool = False) -> AsyncIterator[dict]:
async def async_search_by_id_stream(
self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
mtype: MediaType = None, area: Optional[str] = "title",
season: Optional[int] = None, sites: List[int] = None,
cache_local: bool = False,
bangumiid: Optional[int] = None, anilistid: Optional[int] = None,
source: Optional[str] = None, mediaid: Optional[str] = None,
) -> AsyncIterator[dict]:
"""
根据TMDBID/豆瓣ID渐进式搜索资源先返回站点原始候选再返回过滤匹配后的最终结果
根据数据源媒体 ID 渐进式搜索资源先返回站点原始候选再返回过滤匹配后的最终结果
"""
if cache_local:
self.cancel_ai_recommend()
await self.async_save_last_search_params(
keyword=self._build_search_keyword(tmdbid=tmdbid, doubanid=doubanid),
keyword=self._build_search_keyword(
source, mediaid, tmdbid, doubanid, bangumiid, anilistid
),
mtype=mtype,
area=area,
season=season,
sites=sites,
)
mediainfo = await self.async_recognize_media(tmdbid=tmdbid, doubanid=doubanid, mtype=mtype)
mediainfo = await self.async_recognize_media(
source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid,
bangumiid=bangumiid, anilistid=anilistid, mtype=mtype,
)
if not mediainfo:
logger.error(f'{tmdbid} 媒体信息识别失败!')
logger.error(
f'{self._build_search_keyword(source, mediaid, tmdbid, doubanid, bangumiid, anilistid)} '
'媒体信息识别失败!'
)
yield {
"type": "error",
"success": False,
@@ -941,8 +1045,9 @@ class SearchChain(ChainBase):
no_exists = None
if season is not None:
media_source, media_id = resolve_media_identity(media=mediainfo)
no_exists = {
tmdbid or doubanid: {
build_media_key(media_source, media_id): {
season: NotExistMediaInfo(episodes=[])
}
}
@@ -970,7 +1075,8 @@ class SearchChain(ChainBase):
准备搜索参数
"""
# 缺失的季集
mediakey = mediainfo.tmdb_id or mediainfo.douban_id
media_source, media_id = resolve_media_identity(media=mediainfo)
mediakey = build_media_key(media_source, media_id)
if no_exists and no_exists.get(mediakey):
# 过滤剧集
season_episodes = {sea: info.episodes
@@ -1230,9 +1336,10 @@ class SearchChain(ChainBase):
# 补充媒体信息
if not mediainfo.names:
mediainfo: MediaInfo = self.recognize_media(mtype=mediainfo.type,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id)
mediainfo: MediaInfo = self.recognize_media(
mtype=mediainfo.type,
**self._media_recognize_kwargs(mediainfo),
)
if not mediainfo:
logger.error(f'媒体信息识别失败!')
return []
@@ -1313,9 +1420,10 @@ class SearchChain(ChainBase):
# 补充媒体信息
if not mediainfo.names:
mediainfo: MediaInfo = await self.async_recognize_media(mtype=mediainfo.type,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id)
mediainfo: MediaInfo = await self.async_recognize_media(
mtype=mediainfo.type,
**self._media_recognize_kwargs(mediainfo),
)
if not mediainfo:
logger.error(f'媒体信息识别失败!')
return []
@@ -1385,9 +1493,10 @@ class SearchChain(ChainBase):
# 补充媒体信息
if not mediainfo.names:
mediainfo = await self.async_recognize_media(mtype=mediainfo.type,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id)
mediainfo = await self.async_recognize_media(
mtype=mediainfo.type,
**self._media_recognize_kwargs(mediainfo),
)
if not mediainfo:
logger.error(f'媒体信息识别失败!')
yield {
@@ -1619,6 +1728,10 @@ class SearchChain(ChainBase):
mediainfo: MediaInfo,
tmdbid: Optional[int] = None,
doubanid: Optional[str] = None,
bangumiid: Optional[int] = None,
anilistid: Optional[int] = None,
source: Optional[str] = None,
mediaid: Optional[str] = None,
season: Optional[int] = None,
episode: Optional[int] = None,
sites: List[int] = None,
@@ -1633,17 +1746,23 @@ class SearchChain(ChainBase):
logger.info(f'开始精确搜索字幕,关键词:{mediainfo.title} ...')
if not mediainfo.names:
mediainfo = await self.async_recognize_media(mtype=mediainfo.type,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id)
mediainfo = await self.async_recognize_media(
mtype=mediainfo.type,
**self._media_recognize_kwargs(mediainfo),
)
if not mediainfo:
logger.error('媒体信息识别失败!')
return []
no_exists = None
if season is not None:
media_source, media_id = resolve_media_identity(
media=mediainfo, source=source, media_id=mediaid,
tmdbid=tmdbid, doubanid=doubanid,
bangumiid=bangumiid, anilistid=anilistid,
)
no_exists = {
tmdbid or doubanid: {
build_media_key(media_source, media_id): {
season: NotExistMediaInfo(episodes=[episode] if episode is not None else [])
}
}
@@ -1689,6 +1808,10 @@ class SearchChain(ChainBase):
mediainfo: MediaInfo,
tmdbid: Optional[int] = None,
doubanid: Optional[str] = None,
bangumiid: Optional[int] = None,
anilistid: Optional[int] = None,
source: Optional[str] = None,
mediaid: Optional[str] = None,
season: Optional[int] = None,
episode: Optional[int] = None,
sites: List[int] = None,
@@ -1704,9 +1827,10 @@ class SearchChain(ChainBase):
logger.info(f'开始渐进式精确搜索字幕,关键词:{mediainfo.title} ...')
if not mediainfo.names:
mediainfo = await self.async_recognize_media(mtype=mediainfo.type,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id)
mediainfo = await self.async_recognize_media(
mtype=mediainfo.type,
**self._media_recognize_kwargs(mediainfo),
)
if not mediainfo:
logger.error('媒体信息识别失败!')
yield {
@@ -1718,8 +1842,13 @@ class SearchChain(ChainBase):
no_exists = None
if season is not None:
media_source, media_id = resolve_media_identity(
media=mediainfo, source=source, media_id=mediaid,
tmdbid=tmdbid, doubanid=doubanid,
bangumiid=bangumiid, anilistid=anilistid,
)
no_exists = {
tmdbid or doubanid: {
build_media_key(media_source, media_id): {
season: NotExistMediaInfo(episodes=[episode] if episode is not None else [])
}
}
+70 -17
View File
@@ -46,6 +46,7 @@ class SiteChain(ChainBase):
_text_page_size = 10
def __init__(self):
"""初始化站点管理处理链及特殊站点测试器"""
super().__init__()
# 特殊站点登录验证
@@ -59,6 +60,7 @@ class SiteChain(ChainBase):
"yemapt.org": self.__yema_test,
"hddolby.com": self.__hddolby_test,
"rousi.pro": self.__rousi_test,
"sunnypt.top": self.__sunnypt_test,
}
def refresh_userdata(self, site: dict = None) -> Optional[SiteUserData]:
@@ -76,23 +78,7 @@ class SiteChain(ChainBase):
eventmanager.send_event(EventType.SiteRefreshed, {
"site_id": site.get("id")
})
# 发送站点消息
if userdata.message_unread:
if userdata.message_unread_contents and len(userdata.message_unread_contents) > 0:
for head, date, content in userdata.message_unread_contents:
msg_title = f"【站点 {site.get('name')} 消息】"
msg_text = f"时间:{date}\n标题:{head}\n内容:\n{content}"
self.post_message(Notification(
mtype=NotificationType.SiteMessage,
title=msg_title, text=msg_text, link=site.get("url")
))
else:
self.post_message(Notification(
mtype=NotificationType.SiteMessage,
title=f"站点 {site.get('name')} 收到 "
f"{userdata.message_unread} 条新消息,请登陆查看",
link=site.get("url")
))
self._post_site_messages(site=site, userdata=userdata)
# 低分享率警告
if userdata.ratio and float(userdata.ratio) < 1 and not bool(
re.search(r"(贵宾|VIP?)", userdata.user_level or "", re.IGNORECASE)):
@@ -103,6 +89,38 @@ class SiteChain(ChainBase):
))
return userdata
def _post_site_messages(self, site: dict, userdata: SiteUserData) -> None:
"""
发送站点未读消息并按解析器提供的来源标识做持久化去重
:param site: 站点索引配置
:param userdata: 本次刷新的站点用户数据
"""
if not userdata.message_unread:
return
if not userdata.message_unread_contents:
self.post_message(Notification(
mtype=NotificationType.SiteMessage,
title=f"站点 {site.get('name')} 收到 "
f"{userdata.message_unread} 条新消息,请登陆查看",
link=site.get("url")
))
return
for message in userdata.message_unread_contents:
head, date, content, *metadata = message
message_source = metadata[0] if metadata else None
if message_source and self.messageoper.exists_by_source(message_source):
continue
msg_title = f"【站点 {site.get('name')} 消息】"
msg_text = f"时间:{date}\n标题:{head}\n内容:\n{content}"
self.post_message(Notification(
source=message_source,
mtype=NotificationType.SiteMessage,
title=msg_title,
text=msg_text,
link=site.get("url")
))
def refresh_userdatas(
self,
progress_callback: Optional[Callable[..., None]] = None,
@@ -233,6 +251,41 @@ class SiteChain(ChainBase):
else:
return False, f"错误:{res.status_code} {res.reason}"
@staticmethod
def __sunnypt_test(site: Site) -> Tuple[bool, str]:
"""
通过 profile 接口测试 SunnyPT API Key 和下载权限
:param site: SunnyPT 站点配置
:return: 是否可用及状态信息
"""
indexer = SitesHelper().get_indexer(site.domain) or {}
api_url = str(
indexer.get("api_url") or "https://api.sunnypt.top/api/v1/mp"
).rstrip("/")
res = RequestUtils(
headers={
"Accept": "application/json",
"User-Agent": site.ua or settings.USER_AGENT,
"X-API-Key": site.apikey,
},
proxies=settings.PROXY if site.proxy else None,
timeout=site.timeout or 15,
).get_res(url=f"{api_url}/profile")
if res is None:
return False, "无法连接 SunnyPT API 服务"
if res.status_code != 200:
return False, f"错误:{res.status_code} {res.reason}"
try:
payload = res.json() or {}
except (TypeError, ValueError):
return False, "SunnyPT API 响应不是有效 JSON"
if str(payload.get("code")) != "0" or not isinstance(payload.get("data"), dict):
return False, payload.get("msg") or "API Key 已过期或无效"
if payload["data"].get("download_allowed") is False:
return False, "当前账号没有下载权限"
return True, "连接成功"
@staticmethod
def __yema_test(site: Site) -> Tuple[bool, str]:
"""
+527 -203
View File
File diff suppressed because it is too large Load Diff
-4
View File
@@ -38,8 +38,6 @@ class SystemChain(ChainBase):
"""
重启系统
"""
from app.core.config import global_vars
if channel and userid:
self.post_message(Notification(
channel=channel,
@@ -54,8 +52,6 @@ class SystemChain(ChainBase):
}, self._restart_file)
# 主动备份一次插件
self.backup_plugins()
# 设置停止标志,通知所有模块准备停止
global_vars.stop_system()
# 重启
SystemHelper.restart()
+72 -6
View File
@@ -17,6 +17,7 @@ from app.helper.torrent import TorrentHelper
from app.log import logger
from app.schemas import Notification
from app.schemas.types import SystemConfigKey, MessageChannel, NotificationType, MediaType
from app.utils.media import resolve_media_identity
from app.utils.string import StringUtils
@@ -131,12 +132,21 @@ class TorrentsChain(ChainBase):
subscribe_tmdbid = cls._normalize_id(getattr(subscribe, "tmdbid", None))
subscribe_doubanid = cls._normalize_id(getattr(subscribe, "doubanid", None))
subscribe_bangumiid = cls._normalize_id(subscribe.bangumiid)
subscribe_anilistid = cls._normalize_id(subscribe.anilistid)
context_tmdbids = cls._context_tmdb_ids(context)
context_doubanids = cls._context_douban_ids(context)
context_bangumiids = cls._context_bangumi_ids(context)
context_anilistids = cls._context_anilist_ids(context)
subscribe_identity = resolve_media_identity(media=subscribe)
context_identities = cls._context_media_identities(context)
return bool(
subscribe_tmdbid and subscribe_tmdbid in context_tmdbids
or subscribe_doubanid and subscribe_doubanid in context_doubanids
or subscribe_bangumiid and subscribe_bangumiid in context_bangumiids
or subscribe_anilistid and subscribe_anilistid in context_anilistids
or all(subscribe_identity) and subscribe_identity in context_identities
)
@classmethod
@@ -181,6 +191,9 @@ class TorrentsChain(ChainBase):
title=getattr(subscribe, "name", None),
tmdb_id=getattr(subscribe, "tmdbid", None),
douban_id=getattr(subscribe, "doubanid", None),
bangumi_id=subscribe.bangumiid,
anilist_id=subscribe.anilistid,
source=subscribe.media_source,
season=getattr(subscribe, "season", None),
)
@@ -255,7 +268,26 @@ class TorrentsChain(ChainBase):
"""
判断候选是否已经带有明确媒体 ID
"""
return bool(TorrentsChain._context_tmdb_ids(context) or TorrentsChain._context_douban_ids(context))
return bool(
TorrentsChain._context_tmdb_ids(context)
or TorrentsChain._context_douban_ids(context)
or TorrentsChain._context_bangumi_ids(context)
or TorrentsChain._context_anilist_ids(context)
or TorrentsChain._context_media_identities(context)
)
@staticmethod
def _context_media_identities(context: Context) -> set[tuple[str, str]]:
"""提取候选媒体信息与标题标签中的通用媒体身份。"""
identities = {
resolve_media_identity(media=getattr(context, "media_info", None)),
resolve_media_identity(media=getattr(context, "meta_info", None)),
}
return {
(source, media_id)
for source, media_id in identities
if source and media_id
}
@staticmethod
def _context_tmdb_ids(context: Context) -> set[str]:
@@ -285,6 +317,30 @@ class TorrentsChain(ChainBase):
) if value
}
@staticmethod
def _context_bangumi_ids(context: Context) -> set[str]:
"""提取候选已有 Bangumi ID,兼容媒体信息与标题显式标签。"""
media_info = getattr(context, "media_info", None)
meta_info = getattr(context, "meta_info", None)
return {
value for value in (
TorrentsChain._normalize_id(media_info.bangumi_id if media_info else None),
TorrentsChain._normalize_id(meta_info.bangumiid if meta_info else None),
) if value
}
@staticmethod
def _context_anilist_ids(context: Context) -> set[str]:
"""提取候选已有 AniList ID,兼容媒体信息与标题显式标签。"""
media_info = getattr(context, "media_info", None)
meta_info = getattr(context, "meta_info", None)
return {
value for value in (
TorrentsChain._normalize_id(media_info.anilist_id if media_info else None),
TorrentsChain._normalize_id(meta_info.anilistid if meta_info else None),
) if value
}
@staticmethod
def _normalize_id(value) -> Optional[str]:
"""
@@ -556,7 +612,9 @@ class TorrentsChain(ChainBase):
mediainfo = MediaInfo()
# 清理多余数据,减少内存占用
mediainfo.clear()
candidate_recognized = bool(mediainfo and (mediainfo.tmdb_id or mediainfo.douban_id))
candidate_recognized = bool(
mediainfo and all(resolve_media_identity(media=mediainfo))
)
match_source = self._get_media_id_match_source(mediainfo)
# 上下文
context = Context(
@@ -569,7 +627,7 @@ class TorrentsChain(ChainBase):
media_info_is_target=False,
)
# 如果未识别到媒体信息,设置初始失败次数为1
if not mediainfo or (not mediainfo.tmdb_id and not mediainfo.douban_id):
if not mediainfo or not all(resolve_media_identity(media=mediainfo)):
context.media_recognize_fail_count = 1
# 添加到缓存
if not torrents_cache.get(domain):
@@ -616,14 +674,16 @@ class TorrentsChain(ChainBase):
if "media_recognize_fail_count" not in context_fields:
context.media_recognize_fail_count = 0
# 如果媒体信息未识别,设置初始失败次数
if (not context.media_info or
(not context.media_info.tmdb_id and not context.media_info.douban_id)):
if not context.media_info or not all(
resolve_media_identity(media=context.media_info)
):
context.media_recognize_fail_count = 1
if "resource_source" not in context_fields:
context.resource_source = "spider" if stype == "spider" else "rss"
if "candidate_recognized" not in context_fields:
context.candidate_recognized = bool(
context.media_info and (context.media_info.tmdb_id or context.media_info.douban_id)
context.media_info
and all(resolve_media_identity(media=context.media_info))
)
if "match_source" not in context_fields:
context.match_source = (
@@ -642,6 +702,12 @@ class TorrentsChain(ChainBase):
return "tmdbid"
if mediainfo and mediainfo.douban_id:
return "doubanid"
if mediainfo and mediainfo.bangumi_id:
return "bangumiid"
if mediainfo and mediainfo.anilist_id:
return "anilistid"
if mediainfo and all(resolve_media_identity(media=mediainfo)):
return "plugin"
return "unknown"
def __renew_rss_url(self, domain: str, site: dict):
+114 -21
View File
@@ -55,6 +55,7 @@ from app.schemas.types import (
ContentType,
)
from app.utils.mixins import ConfigReloadMixin
from app.utils.media import parse_media_key
from app.utils.singleton import Singleton
from app.utils.string import StringUtils
from app.utils.system import SystemUtils
@@ -141,7 +142,19 @@ class JobManager:
"""
if not media:
return None, season
return media.tmdb_id or media.douban_id, season
media_ids = {
"themoviedb": media.tmdb_id,
"douban": media.douban_id,
"bangumi": media.bangumi_id,
"anilist": media.anilist_id,
}
source = media.source
if not source or media_ids.get(source) is None:
source = next(
(name for name, media_id in media_ids.items() if media_id is not None),
source,
)
return (source, media_ids.get(source)), season
@staticmethod
def __get_file_key(fileitem: FileItem) -> Optional[Tuple[str, str]]:
@@ -782,6 +795,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
}
def __init__(self):
"""初始化文件整理处理链。"""
super().__init__()
# 主要媒体文件后缀
self._media_exts = settings.RMT_MEDIAEXT
@@ -841,6 +855,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
logger.info("文件整理线程已停止")
def on_config_changed(self):
"""配置变更时重启文件整理线程。"""
self.__stop()
self.__init()
@@ -1577,7 +1592,13 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
task.meta, download_history
)
if (
(download_history.tmdbid or download_history.doubanid)
(
download_history.media_id
or download_history.tmdbid
or download_history.doubanid
or download_history.bangumiid
or download_history.anilistid
)
and not history_year_conflict
):
# 下载记录中已存在识别信息
@@ -1585,6 +1606,10 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
mtype=MediaType(download_history.type),
tmdbid=download_history.tmdbid,
doubanid=download_history.doubanid,
bangumiid=download_history.bangumiid,
anilistid=download_history.anilistid,
source=download_history.media_source,
mediaid=download_history.media_id,
episode_group=download_history.episode_group,
)
need_obtain_images = True
@@ -1598,23 +1623,30 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
f"{task.fileitem.name} 文件年份 {task.meta.year} 与下载记录年份 "
f"{download_history.year} 不一致,按文件名重新识别"
)
recognize_kwargs = {"obtain_images": True}
if task.media_source:
recognize_kwargs["source"] = task.media_source
mediainfo = MediaChain().recognize_by_meta(
task.meta,
obtain_images=True,
task.meta, **recognize_kwargs
)
if mediainfo and download_history.media_category:
mediainfo.category = download_history.media_category
else:
# 识别媒体信息
recognize_kwargs = {"obtain_images": True}
if task.media_source:
recognize_kwargs["source"] = task.media_source
mediainfo = MediaChain().recognize_by_meta(
task.meta,
obtain_images=True,
task.meta, **recognize_kwargs
)
# 按名称识别时已在识别链路补图,这里只补齐显式ID识别的场景。
if mediainfo and need_obtain_images:
self.obtain_images(mediainfo=mediainfo)
if mediainfo and task.media_source:
mediainfo.scrape_source = task.media_source
if not mediainfo:
if task.preview:
return False, "未识别到媒体信息"
@@ -2075,6 +2107,10 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
mtype=mtype,
tmdbid=downloadhis.tmdbid,
doubanid=downloadhis.doubanid,
bangumiid=downloadhis.bangumiid,
anilistid=downloadhis.anilistid,
source=downloadhis.media_source,
mediaid=downloadhis.media_id,
episode_group=downloadhis.episode_group,
)
if mediainfo:
@@ -2213,6 +2249,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
"""
shared_roots: set[str] = set()
media_type_dirs = {mtype.value for mtype in MediaType}
media_categories = None
for dir_info in DirectoryHelper().get_download_dirs():
if not dir_info.download_path:
@@ -2226,6 +2263,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
relative_parts = file_path.relative_to(download_root).parts
current_root = download_root
part_index = 0
media_type = dir_info.media_type
if (
not dir_info.media_type
@@ -2235,6 +2273,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
):
current_root = current_root / relative_parts[part_index]
shared_roots.add(current_root.as_posix())
media_type = relative_parts[part_index]
part_index += 1
if (
@@ -2242,8 +2281,32 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
and dir_info.download_category_folder
and len(relative_parts) > part_index
):
current_root = current_root / relative_parts[part_index]
shared_roots.add(current_root.as_posix())
category_root = current_root / relative_parts[part_index]
shared_roots.add(category_root.as_posix())
if media_categories is None:
media_categories = MediaChain().media_category() or {}
if media_type:
category_names = media_categories.get(media_type, [])
else:
category_names = {
category
for categories in media_categories.values()
for category in categories
}
category_paths = sorted(
(Path(category).parts for category in category_names if category),
key=len,
)
for category_parts in category_paths:
relative_category_parts = tuple(
relative_parts[part_index:part_index + len(category_parts)]
)
if relative_category_parts != category_parts:
continue
category_root = current_root
for category_part in category_parts:
category_root = category_root / category_part
shared_roots.add(category_root.as_posix())
return shared_roots
@@ -2543,6 +2606,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
fileitem: FileItem,
meta: MetaBase = None,
mediainfo: MediaInfo = None,
media_source: Optional[str] = None,
target_directory: TransferDirectoryConf = None,
target_storage: Optional[str] = None,
target_path: Path = None,
@@ -2568,6 +2632,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
:param fileitem: 文件项
:param meta: 元数据
:param mediainfo: 媒体信息
:param media_source: 请求级识别与刮削数据源
:param target_directory: 目标目录配置
:param target_storage: 目标存储器
:param target_path: 目标路径
@@ -3086,6 +3151,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
fileitem=file_item,
meta=file_meta,
mediainfo=task_mediainfo,
media_source=media_source,
target_directory=target_directory,
target_storage=target_storage,
target_path=target_path,
@@ -3281,7 +3347,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
source: Optional[str] = None,
):
"""
远程重新整理参数 历史记录ID TMDBID|类型
远程重新整理参数 历史记录ID 来源前缀:媒体ID|类型
"""
def args_error():
@@ -3289,7 +3355,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
Notification(
channel=channel,
source=source,
title="请输入正确的命令格式:/redo [id] 或 /redo [id] [tmdbid/豆瓣id]|[类型]"
title="请输入正确的命令格式:/redo [id] 或 /redo [id] [来源前缀:媒体ID]|[类型]"
"[id] 为整理记录编号",
userid=userid,
save_history=False,
@@ -3323,7 +3389,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
)
)
return
# TMDBID/豆瓣ID
# 带来源前缀的媒体 ID;旧格式继续兼容纯数字 TMDB ID 和非数字豆瓣 ID
id_strs = arg_strs[1].split("|")
media_id = id_strs[0]
if not logid.isdigit():
@@ -3383,7 +3449,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
根据历史记录重新识别整理只支持简单条件
:param logid: 历史记录ID
:param mtype: 媒体类型
:param mediaid: TMDB ID/豆瓣ID
:param mediaid: 带来源前缀的媒体 ID或旧格式 TMDB/豆瓣 ID
"""
# 查询历史记录
history: TransferHistory = TransferHistoryOper().get(logid)
@@ -3396,12 +3462,21 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
return False, f"源目录不存在:{src_path}"
# 查询媒体信息
if mtype and mediaid:
mediainfo = self.recognize_media(
mtype=mtype,
tmdbid=int(mediaid) if str(mediaid).isdigit() else None,
doubanid=mediaid,
episode_group=history.episode_group,
)
media_source, source_media_id = parse_media_key(mediaid)
if media_source and source_media_id:
mediainfo = self.recognize_media(
mtype=mtype,
source=media_source,
mediaid=source_media_id,
episode_group=history.episode_group,
)
else:
mediainfo = self.recognize_media(
mtype=mtype,
tmdbid=int(mediaid) if str(mediaid).isdigit() else None,
doubanid=mediaid if not str(mediaid).isdigit() else None,
episode_group=history.episode_group,
)
if mediainfo:
# 更新媒体图片
self.obtain_images(mediainfo=mediainfo)
@@ -3445,6 +3520,8 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
target_path: Path = None,
tmdbid: Optional[int] = None,
doubanid: Optional[str] = None,
media_source: Optional[str] = None,
media_id: Optional[str] = None,
mtype: MediaType = None,
season: Optional[int] = None,
episode_group: Optional[str] = None,
@@ -3461,6 +3538,8 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
preview: Optional[bool] = False,
sync_extra_files: Optional[bool] = True,
cleanup_dest_fileitem: Optional[FileItem] = None,
bangumiid: Optional[int] = None,
anilistid: Optional[int] = None,
) -> Tuple[bool, Union[str, dict]]:
"""
手动整理支持复杂条件带进度显示
@@ -3469,6 +3548,10 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
:param target_path: 目标路径
:param tmdbid: TMDB ID
:param doubanid: 豆瓣ID
:param bangumiid: Bangumi ID
:param anilistid: AniList ID
:param media_source: 媒体数据源
:param media_id: 数据源原生ID
:param mtype: 媒体类型
:param season: 季度
:param episode_group: 剧集组
@@ -3487,21 +3570,29 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
:param cleanup_dest_fileitem: 确认存在待整理任务后需要清理的旧目标文件
"""
logger.info(f"手动整理:{fileitem.path} ...")
if tmdbid or doubanid:
# 有输入TMDBID时单个识别
if tmdbid or doubanid or bangumiid or anilistid or media_id:
# 有输入媒体ID时单个识别
# 识别媒体信息
mediainfo: MediaInfo = MediaChain().recognize_media(
tmdbid=tmdbid,
doubanid=doubanid,
bangumiid=bangumiid,
anilistid=anilistid,
source=media_source,
mediaid=media_id,
mtype=mtype,
episode_group=episode_group,
)
if not mediainfo:
return (
False,
f"媒体信息识别失败,tmdbid{tmdbid}doubanid{doubanid}type: {mtype.value if mtype else None}",
f"媒体信息识别失败,source{media_source}media_id{media_id}"
f"tmdbid{tmdbid}doubanid{doubanid}"
f"type: {mtype.value if mtype else None}",
)
else:
if media_source:
mediainfo.scrape_source = media_source
# 更新媒体图片
self.obtain_images(mediainfo=mediainfo)
@@ -3511,6 +3602,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
target_storage=target_storage,
target_path=target_path,
mediainfo=mediainfo,
media_source=media_source,
transfer_type=transfer_type,
season=season,
epformat=epformat,
@@ -3538,6 +3630,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
fileitem=fileitem,
target_storage=target_storage,
target_path=target_path,
media_source=media_source,
transfer_type=transfer_type,
season=season,
epformat=epformat,
+35 -43
View File
@@ -1,5 +1,6 @@
import secrets
from typing import Optional, Tuple, Union
from dataclasses import dataclass
from typing import Literal, Optional, Tuple, Union
from app.chain import ChainBase
from app.core.config import settings
@@ -11,7 +12,17 @@ from app.schemas import AuthCredentials, AuthInterceptCredentials
from app.schemas.types import ChainEventType
from app.utils.otp import OtpUtils
PASSWORD_INVALID_CREDENTIALS_MESSAGE = "用户名密码或二次校验码不正确"
PASSWORD_INVALID_CREDENTIALS_MESSAGE = "用户名密码或验证码错误"
MfaMethod = Literal["otp"]
@dataclass(frozen=True)
class MfaRequired:
"""密码验证通过后,当前账号仍需完成的二次验证要求。"""
methods: Tuple[MfaMethod, ...]
class UserChain(ChainBase):
@@ -26,7 +37,7 @@ class UserChain(ChainBase):
mfa_code: Optional[str] = None,
code: Optional[str] = None,
grant_type: Optional[str] = "password"
) -> Union[Tuple[bool, Optional[str]], Tuple[bool, Optional[User]]]:
) -> Tuple[bool, Union[str, User, MfaRequired, None]]:
"""
认证用户根据不同的 grant_type 处理不同的认证流程
@@ -51,11 +62,11 @@ class UserChain(ChainBase):
# Password 认证
success, user_or_message = self.password_authenticate(credentials=credentials)
if success:
# 如果用户启用了二次验证,则进一步验证
# 如果用户启用了二次验证,则进一步验证
mfa_result = self._verify_mfa(user_or_message, credentials.mfa_code)
if mfa_result == "MFA_REQUIRED":
return False, "MFA_REQUIRED"
elif not mfa_result:
if isinstance(mfa_result, MfaRequired):
return False, mfa_result
if not mfa_result:
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
logger.info(f"用户 {username} 通过密码认证成功")
return True, user_or_message
@@ -65,11 +76,11 @@ class UserChain(ChainBase):
logger.warning("密码认证失败,尝试通过外部服务进行辅助认证 ...")
aux_success, aux_user_or_message = self.auxiliary_authenticate(credentials=credentials)
if aux_success:
# 辅助认证成功后再验证二次验证码
# 辅助认证成功后再验证 6 位验证码
mfa_result = self._verify_mfa(aux_user_or_message, credentials.mfa_code)
if mfa_result == "MFA_REQUIRED":
return False, "MFA_REQUIRED"
elif not mfa_result:
if isinstance(mfa_result, MfaRequired):
return False, mfa_result
if not mfa_result:
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
return True, aux_user_or_message
else:
@@ -165,46 +176,27 @@ class UserChain(ChainBase):
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
@staticmethod
def _verify_mfa(user: User, mfa_code: Optional[str]) -> Union[bool, str]:
def _verify_mfa(user: User, mfa_code: Optional[str]) -> Union[bool, MfaRequired]:
"""
验证 MFA二次验证码
检查用户是否启用了 OTP PassKey如果启用了任何一种都需要提供验证
验证密码登录后的 6 验证码
:param user: 用户对象
:param mfa_code: 二次验证码如果提供了则验证OTP
:return:
:param mfa_code: 身份验证器生成的 6 位验证码
:return:
- 如果验证成功返回 True
- 如果需要MFA但未提供返回 "MFA_REQUIRED"
- 如果需要 MFA 但未提供返回当前账号实际可用的验证方式
- 如果MFA验证失败返回 False
"""
# 检查用户是否有PassKey
from app.db.models.passkey import PassKey
has_passkey = bool(PassKey.get_by_user_id(db=None, user_id=user.id))
# 如果用户既没有启用OTP也没有PassKey,直接通过
if not user.is_otp and not has_passkey:
return True
# 如果用户启用了OTP或PassKey,但没有提供验证码,需要进行二次验证
if not mfa_code:
logger.info(f"用户 {user.name} 已启用双重验证(OTP: {user.is_otp}, PassKey: {has_passkey}),需要提供验证码")
return "MFA_REQUIRED"
# 如果提供了验证码,且用户启用了 OTP,则验证 OTP
if user.is_otp:
if not OtpUtils.check(str(user.otp_secret), mfa_code):
logger.info(f"用户 {user.name} 的 MFA 认证失败")
return False
# OTP 验证成功
if not user.is_otp:
return True
# 用户未启用 OTP,此时提供的 mfa_code 无效;如果启用了 PassKey,则仍需通过 PassKey 验证
if has_passkey:
logger.info(
f"用户 {user.name} 未启用 OTP,但已启用 PassKey,提供的 MFA 验证码将被忽略,仍需通过 PassKey 验证"
)
return "MFA_REQUIRED"
if not mfa_code:
logger.info(f"用户 {user.name} 已启用二次验证,需要提供验证码")
return MfaRequired(methods=("otp",))
if not OtpUtils.check(str(user.otp_secret), mfa_code):
logger.info(f"用户 {user.name} 的 MFA 认证失败")
return False
return True
def _process_auth_success(self, username: str, credentials: AuthCredentials) -> bool:
+1 -1
View File
@@ -988,7 +988,7 @@ def logs(lines: int, follow: bool, stdio: bool, frontend_log: bool) -> None:
@click.option("--fix", is_flag=True, help="执行白名单安全修复")
@click.option("--deep", is_flag=True, help="执行可能较慢的深度检查")
def doctor(json_output: bool, fix: bool, deep: bool) -> None:
"""离线诊断本地 MoviePilot 运行环境"""
"""离线诊断本地 MoviePilot 运行环境,插件日志告警不影响整体状态"""
from app.doctor import run_doctor
from app.doctor.formatters import format_json_report, format_text_report
+88 -38
View File
@@ -13,7 +13,7 @@ import aiofiles
import aioshutil
from anyio import Path as AsyncPath
from cachetools import LRUCache as MemoryLRUCache
from cachetools import TTLCache as MemoryTTLCache
from cachetools import TLRUCache as MemoryTLRUCache
from cachetools.keys import hashkey
from app.core.config import settings
@@ -357,15 +357,52 @@ class AsyncCacheBackend(CacheBackend):
pass
class _MemoryTLRUCache(MemoryTLRUCache):
"""
支持为每个 key 设置独立 TTL 的内存缓存
"""
def __init__(self, maxsize: int, ttl: int):
self.__ttl = ttl
self.__setting_ttls: Dict[str, int] = {}
super().__init__(maxsize=maxsize, ttu=self._get_expiration)
def _get_expiration(self, key: str, _value: Any, now: float) -> float:
return now + self.__setting_ttls.get(key, self.__ttl)
@property
def ttl(self) -> int:
"""
默认缓存存活时间单位秒
"""
return self.__ttl
def set(self, key: str, value: Any, ttl: int) -> None:
"""
使用指定 TTL 设置缓存值
"""
if ttl <= 0:
try:
del self[key]
except KeyError:
pass
return
self.__setting_ttls[key] = ttl
try:
super().__setitem__(key, value)
finally:
self.__setting_ttls.pop(key, None)
class MemoryBackend(CacheBackend):
"""
基于 `cachetools.TTLCache` 实现的缓存后端
基于 `cachetools.TLRUCache` 实现的缓存后端
"""
# 类变量 _region_caches 的互斥锁
_lock = threading.Lock()
# 存储各个 region 的缓存实例,region -> TTLCache
_region_caches: Dict[str, Union[MemoryTTLCache, MemoryLRUCache]] = {}
# 存储各个 region 的缓存实例,region -> TLRUCache/LRUCache
_region_caches: Dict[str, Union[_MemoryTLRUCache, MemoryLRUCache]] = {}
def __init__(self, cache_type: Literal['ttl', 'lru'] = 'ttl',
maxsize: Optional[int] = None, ttl: Optional[int] = None):
@@ -378,9 +415,9 @@ class MemoryBackend(CacheBackend):
"""
self.cache_type = cache_type
self.maxsize = maxsize or DEFAULT_CACHE_SIZE
self.ttl = ttl or DEFAULT_CACHE_TTL
self.ttl = DEFAULT_CACHE_TTL if ttl is None else ttl
def __get_region_cache(self, region: str) -> Optional[Union[MemoryTTLCache, MemoryLRUCache]]:
def __get_region_cache(self, region: str) -> Optional[Union[_MemoryTLRUCache, MemoryLRUCache]]:
"""
获取指定区域的缓存实例如果不存在则返回 None
"""
@@ -394,21 +431,29 @@ class MemoryBackend(CacheBackend):
:param key: 缓存的键
:param value: 缓存的值
:param ttl: 缓存的存活时间传入为永久缓存单位秒
:param ttl: 缓存的存活时间传入则使用 backend 默认值单位秒
:param region: 缓存的区
"""
ttl = ttl or self.ttl
maxsize = kwargs.get("maxsize", self.maxsize)
ttl = self.ttl if ttl is None else ttl
maxsize = kwargs.get("maxsize") or self.maxsize
region = self.get_region(region)
# 设置缓存值
with self._lock:
# 如果该 key 尚未有缓存实例,则创建一个新的 TTLCache 实例
region_cache = self._region_caches.setdefault(
region,
MemoryTTLCache(maxsize=maxsize, ttl=ttl) if self.cache_type == 'ttl'
else MemoryLRUCache(maxsize=maxsize)
)
region_cache[key] = value
region_cache = self._region_caches.get(region)
if region_cache is None:
region_cache = (
_MemoryTLRUCache(maxsize=maxsize, ttl=ttl) if self.cache_type == 'ttl'
else MemoryLRUCache(maxsize=maxsize)
)
self._region_caches[region] = region_cache
elif isinstance(region_cache, _MemoryTLRUCache) != (self.cache_type == 'ttl'):
raise ValueError(
f"Cache region {region!r} already uses a different cache type"
)
if isinstance(region_cache, _MemoryTLRUCache):
region_cache.set(key, value, ttl=ttl)
else:
region_cache[key] = value
def exists(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> bool:
"""
@@ -458,19 +503,18 @@ class MemoryBackend(CacheBackend):
:param region: 缓存的区为None时清空所有区缓存
"""
if region:
# 清理指定缓存区
region_cache = self.__get_region_cache(region)
if region_cache:
with self._lock:
with self._lock:
if region:
# 清理指定缓存区
region_cache = self.__get_region_cache(region)
if region_cache is not None:
region_cache.clear()
logger.debug(f"Cleared cache for region: {region}")
else:
# 清除所有区域的缓存
for region_cache in self._region_caches.values():
with self._lock:
logger.debug(f"Cleared cache for region: {region}")
else:
# 清除所有区域的缓存
for region_cache in self._region_caches.values():
region_cache.clear()
logger.info("Cleared all cache")
logger.info("Cleared all cache")
def items(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> Generator[Tuple[str, Any], None, None]:
"""
@@ -520,7 +564,7 @@ class AsyncMemoryBackend(AsyncCacheBackend):
:param key: 缓存的键
:param value: 缓存的值
:param ttl: 缓存的存活时间传入为永久缓存单位秒
:param ttl: 缓存的存活时间传入则使用 backend 默认值单位秒
:param region: 缓存的区
"""
return self._backend.set(key=key, value=value, ttl=ttl, region=region, **kwargs)
@@ -600,11 +644,14 @@ class RedisBackend(CacheBackend):
:param key: 缓存的键
:param value: 缓存的值
:param ttl: 缓存的存活时间未传入则为永久缓存单位秒
:param ttl: 缓存的存活时间未传入则使用 backend 默认值单位秒
:param region: 缓存的区
:param kwargs: kwargs
"""
ttl = ttl or self.ttl
ttl = self.ttl if ttl is None else ttl
if ttl is not None and ttl <= 0:
self.redis_helper.delete(key, region=region)
return
self.redis_helper.set(key, value, ttl=ttl, region=region, **kwargs)
def exists(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> bool:
@@ -681,11 +728,14 @@ class AsyncRedisBackend(AsyncCacheBackend):
:param key: 缓存的键
:param value: 缓存的值
:param ttl: 缓存的存活时间未传入则为永久缓存单位秒
:param ttl: 缓存的存活时间未传入则使用 backend 默认值单位秒
:param region: 缓存的区
:param kwargs: kwargs
"""
ttl = ttl or self.ttl
ttl = self.ttl if ttl is None else ttl
if ttl is not None and ttl <= 0:
await self.redis_helper.delete(key, region=region)
return
await self.redis_helper.set(key, value, ttl=ttl, region=region, **kwargs)
async def exists(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> bool:
@@ -1018,7 +1068,7 @@ def FileCache(base: Path = settings.TEMP_PATH, ttl: Optional[int] = None) -> Cac
"""
if settings.CACHE_BACKEND_TYPE == "redis":
# 如果使用 Redis,则设置缓存的存活时间为配置的天数转换为秒
return RedisBackend(ttl=ttl or settings.TEMP_FILE_DAYS * 24 * 3600)
return RedisBackend(ttl=ttl if ttl is not None else settings.TEMP_FILE_DAYS * 24 * 3600)
else:
# 如果使用文件系统,在停止服务时会自动清理过期文件
return FileBackend(base=base)
@@ -1030,7 +1080,7 @@ def AsyncFileCache(base: Path = settings.TEMP_PATH, ttl: Optional[int] = None) -
"""
if settings.CACHE_BACKEND_TYPE == "redis":
# 如果使用 Redis,则设置缓存的存活时间为配置的天数转换为秒
return AsyncRedisBackend(ttl=ttl or settings.TEMP_FILE_DAYS * 24 * 3600)
return AsyncRedisBackend(ttl=ttl if ttl is not None else settings.TEMP_FILE_DAYS * 24 * 3600)
else:
# 如果使用文件系统,在停止服务时会自动清理过期文件
return AsyncFileBackend(base=base)
@@ -1075,11 +1125,11 @@ def AsyncCache(cache_type: Literal['ttl', 'lru'] = 'ttl',
def cached(region: Optional[str] = None, maxsize: Optional[int] = 1024, ttl: Optional[int] = None,
skip_none: Optional[bool] = True, skip_empty: Optional[bool] = False, shared_key: Optional[str] = None):
"""
自定义缓存装饰器支持为每个 key 动态传递 maxsize ttl
自定义缓存装饰器支持配置缓存区域的 maxsize 和每个 key ttl
:param region: 缓存区域的标识符默认根据模块名函数名等自动生成标识
:param maxsize: 缓存区内的最大条目数
:param ttl: 缓存的存活时间单位秒未传入则为永久缓存单位秒
:param ttl: 缓存的存活时间单位秒未传入时使用 LRU 缓存
:param skip_none: 跳过 None 缓存默认为 True
:param skip_empty: 跳过空值缓存 None, [], {}, "", set()默认为 False
:param shared_key: 同步/异步函数共享缓存的键默认使用函数名异步函数名会标准化为同步格式如移除 `async_` 前缀
@@ -1186,7 +1236,7 @@ def cached(region: Optional[str] = None, maxsize: Optional[int] = 1024, ttl: Opt
if is_async:
# 异步函数使用异步缓存后端
cache_backend = AsyncCache(cache_type="ttl" if ttl else "lru", maxsize=maxsize, ttl=ttl)
cache_backend = AsyncCache(cache_type="ttl" if ttl is not None else "lru", maxsize=maxsize, ttl=ttl)
# 异步函数的缓存装饰器
@wraps(func)
async def async_wrapper(*args, **kwargs):
@@ -1230,7 +1280,7 @@ def cached(region: Optional[str] = None, maxsize: Optional[int] = 1024, ttl: Opt
return async_wrapper
else:
# 同步函数使用同步缓存后端
cache_backend = Cache(cache_type="ttl" if ttl else "lru", maxsize=maxsize, ttl=ttl)
cache_backend = Cache(cache_type="ttl" if ttl is not None else "lru", maxsize=maxsize, ttl=ttl)
# 同步函数的缓存装饰器
@wraps(func)
def wrapper(*args, **kwargs):
+8 -11
View File
@@ -38,6 +38,8 @@ class SystemConfModel(BaseModel):
douban: int = 0
# Bangumi请求缓存数量
bangumi: int = 0
# AniList请求缓存数量
anilist: int = 0
# Fanart请求缓存数量
fanart: int = 0
# 元数据缓存过期时间(秒)
@@ -76,6 +78,8 @@ class ConfigModel(BaseModel):
CONFIG_DIR: Optional[str] = None
# 安全模式,仅保留核心 API,跳过插件、调度器、监控、命令和工作流等扩展启动项
MOVIEPILOT_SAFE_MODE: bool = False
# 是否启用 Btrfs FSID 子卷容量去重(仅 Linux amd64/arm64
BTRFS_FSID_DEDUP: bool = False
# 是否调试模式
DEBUG: bool = False
# 是否开发模式
@@ -197,11 +201,11 @@ class ConfigModel(BaseModel):
DOH_RESOLVERS: str = "1.0.0.1,1.1.1.1,9.9.9.9,149.112.112.112"
# ==================== 媒体元数据配置 ====================
# 媒体搜索来源 themoviedb/douban/bangumi,多个用,分隔
# 媒体搜索来源 themoviedb/douban/bangumi/anilist,多个用,分隔
SEARCH_SOURCE: str = "themoviedb"
# 媒体识别来源 themoviedb/douban
# 媒体识别来源 themoviedb/douban/bangumi/anilist
RECOGNIZE_SOURCE: str = "themoviedb"
# 刮削来源 themoviedb/douban
# 刮削来源 themoviedb/douban/bangumi/anilist
SCRAP_SOURCE: str = "themoviedb"
# 电视剧动漫的分类genre_ids
ANIME_GENREIDS: List[int] = Field(default=[16])
@@ -522,6 +526,7 @@ class ConfigModel(BaseModel):
"cmvideo.cn",
"ykimg.com",
"qpic.cn",
"anilist.co",
]
)
# 图片代理允许访问的非公网 IP/CIDR,默认不放行任何非公网解析结果
@@ -532,8 +537,6 @@ class ConfigModel(BaseModel):
)
# PassKey 是否强制用户验证(生物识别等)
PASSKEY_REQUIRE_UV: bool = True
# 允许在未启用 OTP 时直接注册 PassKey
PASSKEY_ALLOW_REGISTER_WITHOUT_OTP: bool = False
# ==================== 工作流配置 ====================
# 工作流数据共享
@@ -1211,12 +1214,6 @@ class GlobalVar(object):
"""
self.STOP_EVENT.set()
def resume_system(self):
"""
恢复系统运行标记
"""
self.STOP_EVENT.clear()
@property
def is_system_stopped(self):
"""
+241 -6
View File
@@ -9,6 +9,11 @@ from app.core.metainfo import MetaInfo
from app.schemas.types import MediaType
from app.utils.string import StringUtils
BANGUMI_MOVIE_PLATFORMS = frozenset({"movie", "电影", "剧场版"})
ANILIST_MOVIE_FORMATS = frozenset({"MOVIE"})
ANILIST_CHINESE_TITLE_PATTERN = re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff]")
ANILIST_JAPANESE_KANA_PATTERN = re.compile(r"[\u3040-\u30ff]")
@dataclass
class TorrentInfo:
@@ -243,10 +248,18 @@ class SubtitleInfo:
@dataclass
class MediaInfo:
"""
统一媒体信息负责聚合各元数据源的标准字段
"""
# 内部标记:是否命中本地识别缓存,不参与序列化
recognize_cache_hit = False
# 来源:themoviedb、douban、bangumi
# 来源:themoviedb、douban、bangumi、anilist
source: str = None
# 当前数据源原生ID,主要用于保留插件自定义数据源身份
media_id: str = None
# 请求级刮削来源;为空时使用系统设置
scrape_source: str = None
# 类型 电影、电视剧
type: MediaType = None
# 媒体标题
@@ -273,6 +286,10 @@ class MediaInfo:
douban_id: str = None
# Bangumi ID
bangumi_id: int = None
# AniList ID
anilist_id: int = None
# AniDB IDAniList外部映射)
anidb_id: int = None
# 合集ID
collection_id: int = None
# 媒体原语种
@@ -309,6 +326,8 @@ class MediaInfo:
douban_info: dict = field(default_factory=dict)
# Bangumi INFO
bangumi_info: dict = field(default_factory=dict)
# AniList INFO
anilist_info: dict = field(default_factory=dict)
# 导演
directors: List[dict] = field(default_factory=list)
# 演员
@@ -374,6 +393,8 @@ class MediaInfo:
self.set_douban_info(self.douban_info)
if self.bangumi_info:
self.set_bangumi_info(self.bangumi_info)
if self.anilist_info:
self.set_anilist_info(self.anilist_info)
def __setattr__(self, name: str, value: Any):
self.__dict__[name] = value
@@ -721,7 +742,20 @@ class MediaInfo:
elif type(current_value) is type(value):
setattr(self, key, value)
def set_bangumi_info(self, info: dict):
@staticmethod
def get_bangumi_media_type(info: dict) -> MediaType:
"""
根据Bangumi媒介平台获取标准媒体类型未知平台兼容回退为电视剧
:param info: Bangumi条目信息
:return: 标准媒体类型
"""
platform = str(info.get("platform") or "").strip().casefold()
if platform in BANGUMI_MOVIE_PLATFORMS:
return MediaType.MOVIE
return MediaType.TV
def set_bangumi_info(self, info: dict) -> None:
"""
初始化Bangumi信息
"""
@@ -731,11 +765,11 @@ class MediaInfo:
self.source = "bangumi"
# 本体
self.bangumi_info = info
# 豆瓣ID
# Bangumi ID
self.bangumi_id = info.get("id")
# 类型
if not self.type:
self.type = MediaType.TV
self.type = self.get_bangumi_media_type(info)
# 标题
if not self.title:
self.title = info.get("name_cn") or info.get("name")
@@ -785,13 +819,196 @@ class MediaInfo:
if self.type == MediaType.TV and not self.seasons:
meta = MetaInfo(self.title)
season = meta.begin_season if meta.begin_season is not None else 1
episodes_count = info.get("total_episodes")
episodes_count = info.get("total_episodes") or info.get("eps")
if episodes_count:
self.seasons[season] = list(range(1, episodes_count + 1))
self.number_of_episodes = episodes_count
self.number_of_seasons = 1
# 风格
if not self.genres:
self.genres = [
{"id": tag.get("name"), "name": tag.get("name")}
for tag in info.get("tags") or []
if tag.get("name")
]
# 制作公司与导演
if info.get("infobox"):
companies = []
directors = []
for item in info.get("infobox"):
values = item.get("value")
if not isinstance(values, list):
values = [values]
normalized_values = [
value.get("v") if isinstance(value, dict) else value
for value in values
if value
]
if item.get("key") in {"动画制作", "制作"}:
companies.extend({"name": value} for value in normalized_values)
elif item.get("key") == "导演":
directors.extend({"name": value} for value in normalized_values)
if companies and not self.production_companies:
self.production_companies = companies
if directors and not self.directors:
self.directors = directors
# 演员
if not self.actors:
self.actors = info.get("actors") or []
@staticmethod
def get_anilist_media_type(info: dict) -> MediaType:
"""
根据 AniList 发布格式获取标准媒体类型
:param info: AniList 媒体信息
:return: 标准媒体类型
"""
return (
MediaType.MOVIE
if str(info.get("format") or "").upper() in ANILIST_MOVIE_FORMATS
else MediaType.TV
)
@staticmethod
def _anilist_date(date_info: dict) -> Optional[str]:
"""
AniList 模糊日期转换为标准日期文本
:param date_info: AniList FuzzyDate 字段
:return: YYYYYYYY-MM YYYY-MM-DD 日期文本
"""
if not date_info or not date_info.get("year"):
return None
values = [str(date_info.get("year"))]
if date_info.get("month"):
values.append(str(date_info.get("month")).zfill(2))
if date_info.get("day"):
values.append(str(date_info.get("day")).zfill(2))
return "-".join(values)
@staticmethod
def _anilist_chinese_title(info: dict) -> Optional[str]:
"""
anilist-chinese 注入的标题和别名中选择中文标题
:param info: AniList 媒体信息
:return: 中文标题未找到时返回 None
"""
translated_title = (info.get("title") or {}).get("chinese")
if not translated_title:
return None
if (
ANILIST_CHINESE_TITLE_PATTERN.search(str(translated_title))
and not ANILIST_JAPANESE_KANA_PATTERN.search(str(translated_title))
):
return str(translated_title)
for synonym in reversed(info.get("synonyms") or []):
if (
ANILIST_CHINESE_TITLE_PATTERN.search(str(synonym))
and not ANILIST_JAPANESE_KANA_PATTERN.search(str(synonym))
):
return str(synonym)
return str(translated_title)
def set_anilist_info(self, info: dict) -> None:
"""
初始化 AniList 媒体信息
:param info: AniList 媒体详情
"""
if not info:
return
self.source = "anilist"
self.anilist_info = info
self.anilist_id = info.get("id")
self.type = self.type or self.get_anilist_media_type(info)
titles = info.get("title") or {}
self.title = (
self.title
or self._anilist_chinese_title(info)
or titles.get("native")
or titles.get("romaji")
or titles.get("english")
)
self.en_title = self.en_title or titles.get("english")
self.original_title = self.original_title or titles.get("native") or titles.get("romaji")
self.names = list(
dict.fromkeys(
value
for value in [
titles.get("english"),
titles.get("romaji"),
titles.get("native"),
*(info.get("synonyms") or []),
]
if value and value != self.title
)
)
self.release_date = self.release_date or self._anilist_date(info.get("startDate") or {})
self.first_air_date = self.first_air_date or self.release_date
self.last_air_date = self.last_air_date or self._anilist_date(info.get("endDate") or {})
self.year = self.year or (
str(info.get("startDate", {}).get("year"))
if info.get("startDate", {}).get("year")
else str(info.get("seasonYear")) if info.get("seasonYear") else None
)
cover = info.get("coverImage") or {}
self.poster_path = self.poster_path or cover.get("extraLarge") or cover.get("large")
self.backdrop_path = self.backdrop_path or info.get("bannerImage")
self.overview = self.overview or re.sub(
r"<[^>]+>",
"",
str(info.get("description") or "").replace("<br>", "\n").replace("<br />", "\n"),
).strip()
self.vote_average = self.vote_average or (
round(float(info.get("averageScore")) / 10, 1)
if info.get("averageScore") is not None
else 0
)
self.popularity = self.popularity or info.get("popularity")
self.runtime = self.runtime or info.get("duration")
self.adult = self.adult or bool(info.get("isAdult"))
self.status = self.status or info.get("status")
self.original_language = self.original_language or (
"ja" if info.get("countryOfOrigin") == "JP" else None
)
self.origin_country = self.origin_country or (
[info.get("countryOfOrigin")] if info.get("countryOfOrigin") else []
)
self.production_companies = self.production_companies or [
{"name": studio.get("name")}
for studio in info.get("studios", {}).get("nodes") or []
if studio.get("name")
]
self.genres = self.genres or [
{"id": genre, "name": genre} for genre in info.get("genres") or []
]
self.actors = self.actors or info.get("actors") or []
self.directors = self.directors or info.get("directors") or []
if self.season is None:
self.season = MetaInfo(self.title).begin_season if self.title else None
episodes_count = info.get("episodes")
if self.type == MediaType.TV and episodes_count:
season = self.season if self.season is not None else 1
self.seasons[season] = list(range(1, episodes_count + 1))
self.number_of_episodes = episodes_count
self.number_of_seasons = 1
if self.year:
self.season_years[season] = self.year
for external_link in info.get("externalLinks") or []:
if str(external_link.get("site") or "").casefold() != "anidb":
continue
match = re.search(r"\d+", external_link.get("url") or "")
if match:
self.anidb_id = int(match.group())
break
@property
def title_year(self):
if self.title:
@@ -812,6 +1029,8 @@ class MediaInfo:
return "https://movie.douban.com/subject/%s" % self.douban_id
elif self.bangumi_id:
return "http://bgm.tv/subject/%s" % self.bangumi_id
elif self.anilist_id:
return "https://anilist.co/anime/%s" % self.anilist_id
return ""
@property
@@ -876,6 +1095,21 @@ class MediaInfo:
dicts["tmdb_info"] = None
dicts["douban_info"] = None
dicts["bangumi_info"] = None
dicts["anilist_info"] = None
source_ids = {
"themoviedb": self.tmdb_id,
"douban": self.douban_id,
"bangumi": self.bangumi_id,
"anilist": self.anilist_id,
}
media_source = self.source or next(
(source for source, media_id in source_ids.items() if media_id is not None),
None,
)
dicts["source"] = media_source
dicts["mediaid_prefix"] = media_source
media_id = self.media_id or source_ids.get(media_source)
dicts["media_id"] = str(media_id) if media_id is not None else None
return dicts
def clear(self):
@@ -885,6 +1119,7 @@ class MediaInfo:
self.tmdb_info = {}
self.douban_info = {}
self.bangumi_info = {}
self.anilist_info = {}
self.seasons = {}
self.genres = []
self.season_info = []
@@ -915,7 +1150,7 @@ class Context:
media_recognize_fail_count: int = 0
# 候选资源来源:rss、spider、search、unknown。
resource_source: str = "unknown"
# 候选匹配来源:tmdbid、doubanid、imdbid、title、plugin、unknown。
# 候选匹配来源:tmdbid、doubanid、bangumiid、anilistid、imdbid、title、plugin、unknown。
match_source: str = "unknown"
# 候选自身是否已经识别出有效媒体 ID。
candidate_recognized: bool = False
+4 -1
View File
@@ -23,7 +23,10 @@ def should_use_parent_title_for_file_stem(
"""
if not file_meta.isfile or not stem or not parent_dir_name:
return False
if file_meta.tmdbid or file_meta.doubanid:
if any((
file_meta.tmdbid, file_meta.doubanid,
file_meta.bangumiid, file_meta.anilistid, file_meta.media_id,
)):
return False
if not PARENT_LATIN_TITLE_RE.search(parent_dir_name):
return False
+19 -14
View File
@@ -38,6 +38,14 @@ class MetaAnime(MetaBase):
_name_nostring_pattern = re.compile(_name_nostring_re, re.IGNORECASE)
_fps_pattern = re.compile(r"(%s)" % _fps_re, re.IGNORECASE)
@staticmethod
def _parse_season_number(value):
"""解析第三方动漫季号,仅接受整数或纯数字字符串并保留数值 0。"""
if value is None:
return None
text = str(value).strip()
return int(text) if text.isdigit() else None
def __init__(self, title: str, subtitle: str = None, isfile: bool = False):
super().__init__(title, subtitle, isfile)
if not title:
@@ -111,22 +119,19 @@ class MetaAnime(MetaBase):
# 季号
anime_season = anitopy_info.get("anime_season")
if isinstance(anime_season, list):
if len(anime_season) == 1:
begin_season = anime_season[0]
end_season = None
else:
begin_season = anime_season[0]
end_season = anime_season[-1]
elif anime_season:
begin_season = anime_season
end_season = None
seasons = [
season for item in anime_season
if (season := self._parse_season_number(item)) is not None
]
begin_season = seasons[0] if seasons else None
end_season = seasons[-1] if len(seasons) > 1 else None
else:
begin_season = None
begin_season = self._parse_season_number(anime_season)
end_season = None
if begin_season:
self.begin_season = int(begin_season)
if end_season and int(end_season) != self.begin_season:
self.end_season = int(end_season)
if begin_season is not None:
self.begin_season = begin_season
if end_season is not None and end_season != self.begin_season:
self.end_season = end_season
self.total_season = (self.end_season - self.begin_season) + 1
else:
self.total_season = 1
+9
View File
@@ -97,6 +97,10 @@ class MetaBase(object):
# 附加信息
tmdbid: int = None
doubanid: str = None
bangumiid: int = None
anilistid: int = None
media_source: Optional[str] = None
media_id: Optional[str] = None
episode_group: Optional[str] = None
# 帧率信息(纯数值)
fps: Optional[int] = None
@@ -683,6 +687,11 @@ class MetaBase(object):
# doubanid
if not self.doubanid and meta.doubanid:
self.doubanid = meta.doubanid
# 通用媒体来源与ID
if not self.media_source and meta.media_source:
self.media_source = meta.media_source
if not self.media_id and meta.media_id:
self.media_id = meta.media_id
# 剧集组
if not self.episode_group and meta.episode_group:
self.episode_group = meta.episode_group
+6 -6
View File
@@ -251,7 +251,7 @@ class MetaVideo(MetaBase):
if name.isdecimal() \
and int(name) < 1800 \
and not self.year \
and not self.begin_season \
and self.begin_season is None \
and not self.resource_pix \
and not self.resource_type \
and not self.audio_encode \
@@ -259,7 +259,7 @@ class MetaVideo(MetaBase):
if self.begin_episode is None:
self.begin_episode = int(name)
name = None
elif self.is_in_episode(int(name)) and not self.begin_season:
elif self.is_in_episode(int(name)) and self.begin_season is None:
name = None
return name
@@ -366,7 +366,7 @@ class MetaVideo(MetaBase):
if not self.name:
return
if not self.year \
and not self.begin_season \
and self.begin_season is None \
and not self.begin_episode \
and not self.resource_pix \
and not self.resource_type:
@@ -690,7 +690,7 @@ class MetaVideo(MetaBase):
if not self.year \
and not self.resource_pix \
and not self.resource_type \
and not self.begin_season \
and self.begin_season is None \
and not self.begin_episode:
return
re_res = self._video_encode_pattern.search(token)
@@ -738,7 +738,7 @@ class MetaVideo(MetaBase):
if not self.year \
and not self.resource_pix \
and not self.resource_type \
and not self.begin_season \
and self.begin_season is None \
and not self.begin_episode:
return
video_bit = self.extract_video_bit(token)
@@ -759,7 +759,7 @@ class MetaVideo(MetaBase):
if not self.year \
and not self.resource_pix \
and not self.resource_type \
and not self.begin_season \
and self.begin_season is None \
and not self.begin_episode:
return
re_res = self._audio_encode_pattern.search(token)
+108 -7
View File
@@ -29,6 +29,8 @@ _ANIME_SQUARE_BRACKET_RE = re.compile(r'\[[+0-9XVPI-]+]\s*\[', re.IGNORECASE)
_BRACED_METAINFO_RE = re.compile(r'(?<={\[)[\W\w]+(?=]})')
_BRACED_TMDBID_RE = re.compile(r'(?<=tmdbid=)\d+')
_BRACED_DOUBANID_RE = re.compile(r'(?<=doubanid=)\d+')
_BRACED_BANGUMIID_RE = re.compile(r'(?<=bangumiid=)\d+')
_BRACED_ANILISTID_RE = re.compile(r'(?<=anilistid=)\d+')
_BRACED_TYPE_RE = re.compile(r'(?<=type=)\w+')
_BRACED_EPISODE_GROUP_RE = re.compile(r'(?:^|;)g=([0-9a-fA-F]+)(?=;|$)')
_BRACED_BEGIN_SEASON_RE = re.compile(r'(?<=s=)\d+')
@@ -41,6 +43,24 @@ _EMBY_TMDB_RE_LIST = (
re.compile(r'\{tmdbid[=\-](\d+)\}'),
re.compile(r'\{tmdb[=\-](\d+)\}'),
)
_EXTENDED_MEDIA_ID_RE_LIST = {
"bangumi": (
re.compile(r'\[bangumiid[=\-](\d+)\]'),
re.compile(r'\[bangumi[=\-](\d+)\]'),
re.compile(r'\{bangumiid[=\-](\d+)\}'),
re.compile(r'\{bangumi[=\-](\d+)\}'),
),
"anilist": (
re.compile(r'\[anilistid[=\-](\d+)\]'),
re.compile(r'\[anilist[=\-](\d+)\]'),
re.compile(r'\{anilistid[=\-](\d+)\}'),
re.compile(r'\{anilist[=\-](\d+)\}'),
),
}
_EXTENDED_MEDIA_ID_TAG_RE = re.compile(
r'(?:bangumi(?:id)?|anilist(?:id)?)[=\-]\d+',
re.IGNORECASE,
)
_RUST_PARSE_OPTIONS_CACHE_KEY = "_cache_key"
@@ -51,6 +71,10 @@ def _empty_metainfo() -> dict:
return {
'tmdbid': None,
'doubanid': None,
'bangumiid': None,
'anilistid': None,
'media_source': None,
'media_id': None,
'type': None,
'episode_group': None,
'begin_season': None,
@@ -115,6 +139,14 @@ def _find_metainfo_python(title: str) -> Tuple[str, dict]:
doubanid = _BRACED_DOUBANID_RE.search(result)
if doubanid and doubanid.group(0).isdigit():
metainfo['doubanid'] = doubanid.group(0)
# 查找Bangumi ID信息
bangumiid = _BRACED_BANGUMIID_RE.search(result)
if bangumiid and bangumiid.group(0).isdigit():
metainfo['bangumiid'] = bangumiid.group(0)
# 查找AniList ID信息
anilistid = _BRACED_ANILISTID_RE.search(result)
if anilistid and anilistid.group(0).isdigit():
metainfo['anilistid'] = anilistid.group(0)
# 查找媒体类型
mtype = _BRACED_TYPE_RE.search(result)
if mtype:
@@ -142,7 +174,18 @@ def _find_metainfo_python(title: str) -> Tuple[str, dict]:
if end_episode and end_episode.group(0).isdigit():
metainfo['end_episode'] = int(end_episode.group(0))
# 去除title中该部分
if tmdbid or mtype or episode_group or begin_season or end_season or begin_episode or end_episode:
if (
tmdbid
or doubanid
or bangumiid
or anilistid
or mtype
or episode_group
or begin_season
or end_season
or begin_episode
or end_episode
):
title = title.replace(f"{{[{result}]}}", '')
# 支持Emby格式的ID标签;第一个 [tmdbid] 历史上始终优先处理,用于覆盖前面 {[...]} 中的旧标签。
@@ -159,6 +202,31 @@ def _find_metainfo_python(title: str) -> Tuple[str, dict]:
title = tmdb_re.sub('', title).strip()
break
for source, patterns in _EXTENDED_MEDIA_ID_RE_LIST.items():
key = f"{source}id"
if metainfo.get(key):
continue
for media_id_re in patterns:
media_id_match = media_id_re.search(title)
if not media_id_match:
continue
metainfo[key] = media_id_match.group(1)
title = media_id_re.sub('', title).strip()
break
if metainfo.get('tmdbid'):
metainfo['media_source'] = 'themoviedb'
metainfo['media_id'] = metainfo['tmdbid']
elif metainfo.get('doubanid'):
metainfo['media_source'] = 'douban'
metainfo['media_id'] = metainfo['doubanid']
elif metainfo.get('bangumiid'):
metainfo['media_source'] = 'bangumi'
metainfo['media_id'] = metainfo['bangumiid']
elif metainfo.get('anilistid'):
metainfo['media_source'] = 'anilist'
metainfo['media_id'] = metainfo['anilistid']
# 计算季集总数
_apply_range_total(metainfo, 'begin_season', 'end_season', 'total_season')
_apply_range_total(metainfo, 'begin_episode', 'end_episode', 'total_episode')
@@ -202,6 +270,10 @@ def _build_meta_info(
logger.warn("tmdbid 必须是数字")
if metainfo.get('doubanid'):
meta.doubanid = metainfo['doubanid']
if metainfo.get('media_source'):
meta.media_source = metainfo['media_source']
if metainfo.get('media_id'):
meta.media_id = str(metainfo['media_id'])
if metainfo.get('type'):
meta.type = MediaType(metainfo['type']) if isinstance(metainfo['type'], str) else metainfo['type']
if metainfo.get('episode_group'):
@@ -319,6 +391,8 @@ def _meta_from_rust(parsed: dict) -> Optional[MetaBase]:
"apply_words": parsed.get("apply_words") or [],
"tmdbid": parsed.get("tmdbid"),
"doubanid": parsed.get("doubanid"),
"media_source": parsed.get("media_source"),
"media_id": parsed.get("media_id"),
"episode_group": parsed.get("episode_group"),
"fps": parsed.get("fps"),
}
@@ -327,6 +401,24 @@ def _meta_from_rust(parsed: dict) -> Optional[MetaBase]:
return meta
def _requires_python_metainfo(
title: str,
custom_words: Optional[List[str]] = None,
) -> bool:
"""
判断标题或临时识别词是否包含当前Rust扩展尚未支持的数据源ID标签
:param title: 原始标题
:param custom_words: 临时识别词
:return: 是否必须使用Python解析器
"""
candidates = [title or "", *(custom_words or [])]
contains_extended_id = any(
_EXTENDED_MEDIA_ID_TAG_RE.search(candidate) for candidate in candidates
)
return contains_extended_id and not rust_accel.supports_extended_media_ids()
def MetaInfo(title: str, subtitle: Optional[str] = None, custom_words: List[str] = None) -> MetaBase:
"""
根据标题和副标题识别元数据
@@ -335,9 +427,11 @@ def MetaInfo(title: str, subtitle: Optional[str] = None, custom_words: List[str]
:param custom_words: 自定义识别词列表
:return: MetaAnimeMetaVideo
"""
rust_meta = _meta_from_rust(
rust_accel.parse_metainfo(title, subtitle, _rust_parse_options(custom_words))
)
rust_meta = None
if not _requires_python_metainfo(title, custom_words):
rust_meta = _meta_from_rust(
rust_accel.parse_metainfo(title, subtitle, _rust_parse_options(custom_words))
)
if rust_meta:
return rust_meta
meta = _build_meta_info(title=title, subtitle=subtitle, custom_words=custom_words)
@@ -355,9 +449,14 @@ def MetaInfoPath(path: Path, custom_words: List[str] = None) -> MetaBase:
:param path: 路径
:param custom_words: 自定义识别词列表
"""
rust_meta = _meta_from_rust(
rust_accel.parse_metainfo_path(str(path), _rust_parse_options(custom_words))
path_context = " ".join(
[path.name, path.parent.name, path.parent.parent.name]
)
rust_meta = None
if not _requires_python_metainfo(path_context, custom_words):
rust_meta = _meta_from_rust(
rust_accel.parse_metainfo_path(str(path), _rust_parse_options(custom_words))
)
if rust_meta:
return rust_meta
# 文件元数据,不包含后缀
@@ -400,7 +499,9 @@ def find_metainfo(title: str) -> Tuple[str, dict]:
"""
从标题中提取媒体信息
"""
rust_result = rust_accel.find_metainfo(title)
rust_result = None
if not _requires_python_metainfo(title):
rust_result = rust_accel.find_metainfo(title)
if rust_result:
return rust_result["title"], rust_result["metainfo"]
return _find_metainfo_python(title)
+5 -6
View File
@@ -58,12 +58,11 @@ class ModuleManager(metaclass=Singleton):
"""
logger.info("正在停止所有模块...")
for module_id, module in self._running_modules.items():
if hasattr(module, "stop"):
try:
module.stop()
logger.debug(f"Moudle Stoped{module_id}")
except Exception as err:
logger.error(f"Stop Moudle Error{module_id}{str(err)} - {traceback.format_exc()}", exc_info=True)
try:
module.stop()
logger.debug(f"Moudle Stoped{module_id}")
except Exception as err:
logger.error(f"Stop Moudle Error{module_id}{str(err)} - {traceback.format_exc()}", exc_info=True)
logger.info("所有模块停止完成")
def reload(self):
+53 -1
View File
@@ -1,12 +1,60 @@
import asyncio
from typing import Any, Generator, List, Optional, Self, Tuple, AsyncGenerator, Union
from sqlalchemy import NullPool, QueuePool, and_, create_engine, inspect, text, select, delete, Column, Integer, \
from sqlalchemy import NullPool, QueuePool, and_, create_engine, event, inspect, text, select, delete, Column, Integer, \
Sequence, Identity
from sqlalchemy.engine import Engine as SQLAlchemyEngine, ExceptionContext
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import Session, as_declarative, declared_attr, scoped_session, sessionmaker
from app.core.config import settings
from app.log import logger
def _database_error_metadata(error: BaseException) -> Optional[dict[str, Any]]:
"""提取 SQLite 与 PostgreSQL 驱动提供的稳定错误分类字段。"""
metadata = {"error_type": type(error).__name__}
# DBAPI 驱动字段并不共享统一类型,动态读取可同时兼容 sqlite3、psycopg2 与 asyncpg。
sqlite_errorcode = getattr(error, "sqlite_errorcode", None)
sqlite_errorname = getattr(error, "sqlite_errorname", None)
if sqlite_errorcode is not None or sqlite_errorname:
if sqlite_errorcode is not None:
metadata["error_code"] = sqlite_errorcode
if sqlite_errorname:
metadata["error_name"] = sqlite_errorname
return metadata
sqlstate = getattr(error, "sqlstate", None) or getattr(error, "pgcode", None)
if not sqlstate:
sqlstate = getattr(getattr(error, "diag", None), "sqlstate", None)
if sqlstate:
metadata["sqlstate"] = sqlstate
return metadata
return None
def _log_database_error(exception_context: ExceptionContext) -> None:
"""记录非敏感驱动错误码,并保持 SQLAlchemy 原有异常传播。"""
metadata = _database_error_metadata(exception_context.original_exception)
if not metadata:
return
dialect = exception_context.dialect
fields = {
"database": dialect.name,
"driver": dialect.driver,
**metadata,
}
logger.error(
"数据库驱动异常:" + ", ".join(f"{key}={value}" for key, value in fields.items())
)
def _register_database_error_logging(engine: SQLAlchemyEngine) -> None:
"""为主程序 Engine 注册统一的底层驱动错误诊断。"""
event.listen(engine, "handle_error", _log_database_error)
def get_id_column():
@@ -71,6 +119,7 @@ def _get_sqlite_engine(is_async: bool = False):
# 创建数据库引擎
engine = create_engine(**_db_kwargs)
_register_database_error_logging(engine)
# 设置WAL模式
_journal_mode = "WAL" if settings.DB_WAL_ENABLE else "DELETE"
@@ -91,6 +140,7 @@ def _get_sqlite_engine(is_async: bool = False):
}
# 创建异步数据库引擎
async_engine = create_async_engine(**_db_kwargs)
_register_database_error_logging(async_engine.sync_engine)
# 设置WAL模式
_journal_mode = "WAL" if settings.DB_WAL_ENABLE else "DELETE"
@@ -146,6 +196,7 @@ def _get_postgresql_engine(is_async: bool = False):
# 创建数据库引擎
engine = create_engine(**_db_kwargs)
_register_database_error_logging(engine)
print(f"PostgreSQL database connected to {settings.DB_POSTGRESQL_TARGET}/{settings.DB_POSTGRESQL_DATABASE}")
return engine
@@ -163,6 +214,7 @@ def _get_postgresql_engine(is_async: bool = False):
}
# 创建异步数据库引擎
async_engine = create_async_engine(**_db_kwargs)
_register_database_error_logging(async_engine.sync_engine)
print(f"Async PostgreSQL database connected to {settings.DB_POSTGRESQL_TARGET}/{settings.DB_POSTGRESQL_DATABASE}")
return async_engine
+150
View File
@@ -0,0 +1,150 @@
from datetime import datetime
from typing import Optional
from app.db import DbOper
from app.db.models.agenttask import AgentTask
class AgentTaskOper(DbOper):
"""
Agent 自主定时任务管理
"""
@staticmethod
def _now() -> str:
"""生成当前数据库时间字符串。"""
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def add(self, **kwargs: object) -> AgentTask:
"""
新增 Agent 定时任务
"""
now = self._now()
task_id = AgentTask.add_task(
self._db,
**kwargs,
enabled=True,
last_status="waiting",
run_count=0,
created_at=now,
updated_at=now,
)
return self.get(task_id)
def get(
self,
task_id: int,
user_id: Optional[str] = None,
) -> Optional[AgentTask]:
"""
查询单个 Agent 定时任务
"""
return AgentTask.get_for_user(self._db, task_id=task_id, user_id=user_id)
def list(
self,
user_id: Optional[str] = None,
enabled: Optional[bool] = None,
) -> list[AgentTask]:
"""
查询 Agent 定时任务列表
"""
return AgentTask.list_for_user(self._db, user_id=user_id, enabled=enabled)
def update(
self,
task_id: int,
payload: dict,
user_id: Optional[str] = None,
) -> bool:
"""
更新 Agent 定时任务
"""
normalized_payload = {
key: value
for key, value in payload.items()
if key in {
"name",
"content",
"trigger_type",
"cron_expression",
"run_at",
"enabled",
"last_status",
"last_result",
}
}
if not normalized_payload:
return False
normalized_payload["updated_at"] = self._now()
return AgentTask.update_task(
self._db,
task_id=task_id,
payload=normalized_payload,
user_id=user_id,
)
def delete(self, task_id: int, user_id: Optional[str] = None) -> bool:
"""
删除 Agent 定时任务
"""
return AgentTask.delete_task(
self._db,
task_id=task_id,
user_id=user_id,
)
def mark_running(self, task_id: int) -> bool:
"""
Agent 定时任务标记为运行中
"""
return AgentTask.mark_running(
self._db,
task_id=task_id,
run_at=self._now(),
)
def finish(
self,
task_id: int,
success: bool,
result: str,
disable: bool = False,
) -> bool:
"""
记录 Agent 定时任务执行结果
"""
return AgentTask.finish_task(
self._db,
task_id=task_id,
success=success,
result=(result or "")[:20000],
disable=disable,
)
@staticmethod
def to_dict(
task: AgentTask,
next_run_at: Optional[str] = None,
timezone: Optional[str] = None,
) -> dict:
"""
Agent 定时任务转换为工具可返回的结构
"""
return {
"id": task.id,
"name": task.name,
"content": task.content,
"trigger_type": task.trigger_type,
"cron_expression": task.cron_expression,
"run_at": task.run_at,
"timezone": timezone,
"enabled": bool(task.enabled),
"last_status": task.last_status,
"last_run_at": task.last_run_at,
"last_result": task.last_result,
"run_count": task.run_count or 0,
"next_run_at": next_run_at,
"created_at": task.created_at,
"updated_at": task.updated_at,
}
+18 -2
View File
@@ -34,13 +34,29 @@ class DownloadHistoryOper(DbOper):
if history and history.download_hash
}
def get_by_mediaid(self, tmdbid: int, doubanid: str) -> List[DownloadHistory]:
def get_by_mediaid(
self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
bangumiid: Optional[int] = None, anilistid: Optional[int] = None,
media_source: Optional[str] = None, media_id: Optional[str] = None,
) -> List[DownloadHistory]:
"""
按媒体ID查询下载记录
:param tmdbid: tmdbid
:param doubanid: doubanid
:param bangumiid: Bangumi ID
:param anilistid: AniList ID
:param media_source: 媒体数据源
:param media_id: 数据源原生 ID
"""
return DownloadHistory.get_by_mediaid(self._db, tmdbid=tmdbid, doubanid=doubanid)
return DownloadHistory.get_by_mediaid(
self._db,
tmdbid=tmdbid,
doubanid=doubanid,
bangumiid=bangumiid,
anilistid=anilistid,
media_source=media_source,
media_id=media_id,
)
def add(self, **kwargs):
"""
+9
View File
@@ -105,6 +105,15 @@ class MessageOper(DbOper):
"""
return Message.list_by_page(self._db, page, count)
def exists_by_source(self, source: str) -> bool:
"""
判断指定来源标识的消息记录是否存在
:param source: 消息来源唯一标识
:return: 是否存在匹配记录
"""
return Message.exists_by_source(self._db, source)
async def async_list_by_page(
self, page: Optional[int] = 1, count: Optional[int] = 30
) -> list[Message]:
+1
View File
@@ -1,4 +1,5 @@
from .agentchat import AgentChat
from .agenttask import AgentTask
from .downloadfailure import DownloadFailure
from .downloadhistory import DownloadHistory, DownloadFiles
from .mediaserver import MediaServerItem
+170
View File
@@ -0,0 +1,170 @@
from datetime import datetime
from typing import Optional
from sqlalchemy import Boolean, Column, Index, Integer, String, Text
from sqlalchemy.orm import Session
from app.db import Base, db_query, db_update, get_id_column
class AgentTask(Base):
"""
Agent 自主定时任务表
"""
id = get_id_column()
# 任务名称
name = Column(String, nullable=False)
# 交给 Agent 执行的完整任务内容
content = Column(Text, nullable=False)
# 触发类型:date-单次触发,cron-周期触发
trigger_type = Column(String, nullable=False)
# 标准五段 cron 表达式
cron_expression = Column(String)
# 单次触发时间,使用带时区的 ISO 8601 格式
run_at = Column(String)
# 是否继续接受调度
enabled = Column(Boolean, nullable=False, default=True)
# 创建任务的用户与会话上下文
user_id = Column(String, nullable=False)
username = Column(String)
session_id = Column(String, nullable=False)
channel = Column(String)
source = Column(String)
original_chat_id = Column(String)
# 最近一次执行状态与结果
last_status = Column(String, nullable=False, default="waiting")
last_run_at = Column(String)
last_result = Column(Text)
run_count = Column(Integer, nullable=False, default=0)
created_at = Column(String, nullable=False)
updated_at = Column(String, nullable=False)
__table_args__ = (
Index("ix_agenttask_enabled", "enabled"),
Index("ix_agenttask_user_created", "user_id", "created_at", "id"),
)
@classmethod
@db_update
def add_task(cls, db: Session, **kwargs: object) -> int:
"""
新增 Agent 定时任务并返回任务 ID
"""
task = cls(**kwargs)
db.add(task)
db.flush()
return task.id
@classmethod
@db_query
def get_for_user(
cls,
db: Session,
task_id: int,
user_id: Optional[str] = None,
) -> Optional["AgentTask"]:
"""
按任务 ID 和可选用户 ID 查询 Agent 定时任务
"""
query = db.query(cls).filter(cls.id == task_id)
if user_id is not None:
query = query.filter(cls.user_id == user_id)
return query.first()
@classmethod
@db_query
def list_for_user(
cls,
db: Session,
user_id: Optional[str] = None,
enabled: Optional[bool] = None,
) -> list["AgentTask"]:
"""
按用户和启用状态查询 Agent 定时任务
"""
query = db.query(cls)
if user_id is not None:
query = query.filter(cls.user_id == user_id)
if enabled is not None:
query = query.filter(cls.enabled.is_(enabled))
return query.order_by(cls.created_at.desc(), cls.id.desc()).all()
@classmethod
@db_update
def update_task(
cls,
db: Session,
task_id: int,
payload: dict,
user_id: Optional[str] = None,
) -> bool:
"""
按任务 ID 和可选用户 ID 更新 Agent 定时任务
"""
query = db.query(cls).filter(cls.id == task_id)
if user_id is not None:
query = query.filter(cls.user_id == user_id)
return bool(query.update(payload))
@classmethod
@db_update
def delete_task(
cls,
db: Session,
task_id: int,
user_id: Optional[str] = None,
) -> bool:
"""
按任务 ID 和可选用户 ID 删除 Agent 定时任务
"""
query = db.query(cls).filter(cls.id == task_id)
if user_id is not None:
query = query.filter(cls.user_id == user_id)
return bool(query.delete())
@classmethod
@db_update
def mark_running(cls, db: Session, task_id: int, run_at: str) -> bool:
"""
将可执行任务标记为运行中
"""
updated_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
return bool(
db.query(cls)
.filter(
cls.id == task_id,
cls.enabled.is_(True),
cls.last_status != "running",
)
.update(
{
"last_status": "running",
"last_run_at": run_at,
"updated_at": updated_at,
}
)
)
@classmethod
@db_update
def finish_task(
cls,
db: Session,
task_id: int,
success: bool,
result: str,
disable: bool = False,
) -> bool:
"""
记录 Agent 定时任务执行结果并按需关闭单次任务
"""
payload = {
"last_status": "success" if success else "failed",
"last_result": result,
"run_count": cls.run_count + 1,
"updated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}
if disable:
payload["enabled"] = False
return bool(db.query(cls).filter(cls.id == task_id).update(payload))
+8
View File
@@ -24,6 +24,13 @@ class DownloadFailure(Base):
tmdbid = Column(Integer)
# 豆瓣ID
doubanid = Column(String)
# Bangumi ID
bangumiid = Column(Integer)
# AniList ID
anilistid = Column(Integer)
# 统一媒体数据源与原生ID
media_source = Column(String)
media_id = Column(String)
# Sxx
seasons = Column(String)
# Exx
@@ -57,6 +64,7 @@ class DownloadFailure(Base):
Index("ux_downloadfailure_fingerprint", "fingerprint", unique=True),
Index("ix_downloadfailure_next_retry_at", "next_retry_at"),
Index("ix_downloadfailure_media_site", "type", "tmdbid", "doubanid", "site"),
Index("ix_downloadfailure_media_identity_site", "type", "media_source", "media_id", "site"),
)
@classmethod
+26 -11
View File
@@ -31,6 +31,10 @@ class DownloadHistory(Base):
imdbid = Column(String)
tvdbid = Column(Integer)
doubanid = Column(String)
bangumiid = Column(Integer, index=True)
anilistid = Column(Integer, index=True)
media_source = Column(String, index=True)
media_id = Column(String, index=True)
# Sxx
seasons = Column(String)
# Exx
@@ -67,6 +71,7 @@ class DownloadHistory(Base):
__table_args__ = (
Index('ix_downloadhistory_download_hash_date', 'download_hash', 'date'),
Index('ix_downloadhistory_date_id', 'date', 'id'),
Index('ix_downloadhistory_media_identity', 'media_source', 'media_id'),
)
@classmethod
@@ -115,17 +120,27 @@ class DownloadHistory(Base):
@classmethod
@db_query
def get_by_mediaid(cls, db: Session, tmdbid: int, doubanid: str):
if tmdbid:
return (
db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid).all()
)
elif doubanid:
return (
db.query(DownloadHistory)
.filter(DownloadHistory.doubanid == doubanid)
.all()
)
def get_by_mediaid(
cls, db: Session, tmdbid: Optional[int] = None,
doubanid: Optional[str] = None, bangumiid: Optional[int] = None,
anilistid: Optional[int] = None, media_source: Optional[str] = None,
media_id: Optional[str] = None,
):
"""按统一媒体身份或兼容 ID 查询下载历史。"""
query = db.query(DownloadHistory)
if media_source and media_id:
return query.filter(
DownloadHistory.media_source == media_source,
DownloadHistory.media_id == str(media_id),
).all()
if tmdbid is not None:
return query.filter(DownloadHistory.tmdbid == tmdbid).all()
if doubanid:
return query.filter(DownloadHistory.doubanid == doubanid).all()
if bangumiid is not None:
return query.filter(DownloadHistory.bangumiid == bangumiid).all()
if anilistid is not None:
return query.filter(DownloadHistory.anilistid == anilistid).all()
return []
@classmethod
+12
View File
@@ -62,6 +62,18 @@ class Message(Base):
.all()
)
@classmethod
@db_query
def exists_by_source(cls, db: Session, source: str) -> bool:
"""
判断指定来源标识的消息记录是否存在
:param db: 数据库会话
:param source: 消息来源唯一标识
:return: 是否存在匹配记录
"""
return db.query(cls.id).filter(cls.source == source).first() is not None
@classmethod
@async_db_query
async def async_list_by_page(
+139 -94
View File
@@ -26,7 +26,10 @@ class Subscribe(Base):
tvdbid = Column(Integer)
doubanid = Column(String, index=True)
bangumiid = Column(Integer, index=True)
anilistid = Column(Integer, index=True)
mediaid = Column(String, index=True)
media_source = Column(String, index=True)
media_id = Column(String, index=True)
# 季号
season = Column(Integer)
# 海报
@@ -94,80 +97,116 @@ class Subscribe(Base):
__table_args__ = (
Index('ix_subscribe_type_date', 'type', 'date'),
Index('ix_subscribe_media_identity', 'media_source', 'media_id'),
)
@classmethod
@db_query
def exists(cls, db: Session, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
season: Optional[int] = None):
if tmdbid:
if season is not None:
return db.query(cls).filter(cls.tmdbid == tmdbid,
cls.season == season).first()
return db.query(cls).filter(cls.tmdbid == tmdbid).first()
elif doubanid:
return db.query(cls).filter(cls.doubanid == doubanid).first()
def _identity_condition(
cls,
media_source: Optional[str] = None,
media_id: Optional[str] = None,
tmdbid: Optional[int] = None,
doubanid: Optional[str] = None,
bangumiid: Optional[int] = None,
anilistid: Optional[int] = None,
):
"""按统一媒体身份优先级构造订阅查询条件。"""
if media_source and media_id:
return (cls.media_source == media_source) & (cls.media_id == str(media_id))
if tmdbid is not None:
return cls.tmdbid == tmdbid
if doubanid:
return cls.doubanid == doubanid
if bangumiid is not None:
return cls.bangumiid == bangumiid
if anilistid is not None:
return cls.anilistid == anilistid
return None
@classmethod
@async_db_query
async def async_exists(cls, db: AsyncSession, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
season: Optional[int] = None):
if tmdbid:
if season is not None:
result = await db.execute(
select(cls).filter(cls.tmdbid == tmdbid, cls.season == season)
)
else:
result = await db.execute(
select(cls).filter(cls.tmdbid == tmdbid)
)
elif doubanid:
result = await db.execute(
select(cls).filter(cls.doubanid == doubanid)
)
else:
@db_query
def exists(
cls, db: Session, tmdbid: Optional[int] = None,
doubanid: Optional[str] = None, bangumiid: Optional[int] = None,
anilistid: Optional[int] = None, media_source: Optional[str] = None,
media_id: Optional[str] = None, season: Optional[int] = None,
):
"""按媒体身份与季号查询已有订阅。"""
condition = cls._identity_condition(
media_source, media_id, tmdbid, doubanid, bangumiid, anilistid
)
if condition is None:
return None
query = db.query(cls).filter(condition)
if season is not None:
query = query.filter(cls.season == season)
return query.first()
@classmethod
@async_db_query
async def async_exists(
cls, db: AsyncSession, tmdbid: Optional[int] = None,
doubanid: Optional[str] = None, bangumiid: Optional[int] = None,
anilistid: Optional[int] = None, media_source: Optional[str] = None,
media_id: Optional[str] = None, season: Optional[int] = None,
):
"""异步按媒体身份与季号查询已有订阅。"""
condition = cls._identity_condition(
media_source, media_id, tmdbid, doubanid, bangumiid, anilistid
)
if condition is None:
return None
query = select(cls).filter(condition)
if season is not None:
query = query.filter(cls.season == season)
result = await db.execute(query)
return result.scalars().first()
@classmethod
@db_query
def exists_by_username(cls, db: Session, username: str, tmdbid: Optional[int] = None,
doubanid: Optional[str] = None, season: Optional[int] = None):
def exists_by_username(
cls, db: Session, username: str, tmdbid: Optional[int] = None,
doubanid: Optional[str] = None, bangumiid: Optional[int] = None,
anilistid: Optional[int] = None, media_source: Optional[str] = None,
media_id: Optional[str] = None, season: Optional[int] = None,
):
"""
按订阅 owner 查询同一媒体的订阅行
"""
if not username:
return None
if tmdbid:
query = db.query(cls).filter(cls.username == username, cls.tmdbid == tmdbid)
if season is not None:
query = query.filter(cls.season == season)
return query.first()
elif doubanid:
return db.query(cls).filter(cls.username == username, cls.doubanid == doubanid).first()
return None
condition = cls._identity_condition(
media_source, media_id, tmdbid, doubanid, bangumiid, anilistid
)
if condition is None:
return None
query = db.query(cls).filter(cls.username == username, condition)
if season is not None:
query = query.filter(cls.season == season)
return query.first()
@classmethod
@async_db_query
async def async_exists_by_username(cls, db: AsyncSession, username: str, tmdbid: Optional[int] = None,
doubanid: Optional[str] = None, season: Optional[int] = None):
async def async_exists_by_username(
cls, db: AsyncSession, username: str, tmdbid: Optional[int] = None,
doubanid: Optional[str] = None, bangumiid: Optional[int] = None,
anilistid: Optional[int] = None, media_source: Optional[str] = None,
media_id: Optional[str] = None, season: Optional[int] = None,
):
"""
异步按订阅 owner 查询同一媒体的订阅行
"""
if not username:
return None
if tmdbid:
query = select(cls).filter(cls.username == username, cls.tmdbid == tmdbid)
if season is not None:
query = query.filter(cls.season == season)
result = await db.execute(query)
elif doubanid:
result = await db.execute(
select(cls).filter(cls.username == username, cls.doubanid == doubanid)
)
else:
condition = cls._identity_condition(
media_source, media_id, tmdbid, doubanid, bangumiid, anilistid
)
if condition is None:
return None
query = select(cls).filter(cls.username == username, condition)
if season is not None:
query = query.filter(cls.season == season)
result = await db.execute(query)
return result.scalars().first()
@classmethod
@@ -300,6 +339,29 @@ class Subscribe(Base):
)
return result.scalars().all()
@classmethod
@async_db_query
async def async_list_by_anilistid(cls, db: AsyncSession, anilistid: int):
"""异步按 AniList ID 查询候选订阅列表。"""
result = await db.execute(
select(cls).filter(cls.anilistid == anilistid)
)
return result.scalars().all()
@classmethod
@async_db_query
async def async_list_by_media_identity(
cls, db: AsyncSession, media_source: str, media_id: str,
):
"""异步按统一媒体身份查询候选订阅列表。"""
result = await db.execute(
select(cls).filter(
cls.media_source == media_source,
cls.media_id == str(media_id),
)
)
return result.scalars().all()
@classmethod
@db_query
def get_by_mediaid(cls, db: Session, mediaid: str):
@@ -326,62 +388,45 @@ class Subscribe(Base):
@classmethod
@db_query
def get_by(cls, db: Session, type: str, season: Optional[str] = None,
tmdbid: Optional[int] = None, doubanid: Optional[str] = None, bangumiid: Optional[str] = None):
def get_by(
cls, db: Session, type: str, season: Optional[str] = None,
tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
bangumiid: Optional[int] = None, anilistid: Optional[int] = None,
media_source: Optional[str] = None, media_id: Optional[str] = None,
):
"""
根据条件查询订阅
"""
# TMDBID
if tmdbid:
if season is not None:
result = db.query(cls).filter(
cls.tmdbid == tmdbid, cls.type == type, cls.season == season
)
else:
result = db.query(cls).filter(cls.tmdbid == tmdbid, cls.type == type)
# 豆瓣ID
elif doubanid:
result = db.query(cls).filter(cls.doubanid == doubanid, cls.type == type)
# BangumiID
elif bangumiid:
result = db.query(cls).filter(cls.bangumiid == bangumiid, cls.type == type)
else:
condition = cls._identity_condition(
media_source, media_id, tmdbid, doubanid, bangumiid, anilistid
)
if condition is None:
return None
return result.first()
query = db.query(cls).filter(condition, cls.type == type)
if season is not None:
query = query.filter(cls.season == season)
return query.first()
@classmethod
@async_db_query
async def async_get_by(cls, db: AsyncSession, type: str, season: Optional[str] = None,
tmdbid: Optional[int] = None, doubanid: Optional[str] = None, bangumiid: Optional[str] = None):
async def async_get_by(
cls, db: AsyncSession, type: str, season: Optional[str] = None,
tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
bangumiid: Optional[int] = None, anilistid: Optional[int] = None,
media_source: Optional[str] = None, media_id: Optional[str] = None,
):
"""
根据条件查询订阅
"""
# TMDBID
if tmdbid:
if season is not None:
result = await db.execute(
select(cls).filter(
cls.tmdbid == tmdbid, cls.type == type, cls.season == season
)
)
else:
result = await db.execute(
select(cls).filter(cls.tmdbid == tmdbid, cls.type == type)
)
# 豆瓣ID
elif doubanid:
result = await db.execute(
select(cls).filter(cls.doubanid == doubanid, cls.type == type)
)
# BangumiID
elif bangumiid:
result = await db.execute(
select(cls).filter(cls.bangumiid == bangumiid, cls.type == type)
)
else:
condition = cls._identity_condition(
media_source, media_id, tmdbid, doubanid, bangumiid, anilistid
)
if condition is None:
return None
query = select(cls).filter(condition, cls.type == type)
if season is not None:
query = query.filter(cls.season == season)
result = await db.execute(query)
return result.scalars().first()
@db_update
+59 -27
View File
@@ -25,7 +25,10 @@ class SubscribeHistory(Base):
tvdbid = Column(Integer)
doubanid = Column(String, index=True)
bangumiid = Column(Integer, index=True)
anilistid = Column(Integer, index=True)
mediaid = Column(String, index=True)
media_source = Column(String, index=True)
media_id = Column(String, index=True)
# 季号
season = Column(Integer)
# 海报
@@ -79,6 +82,7 @@ class SubscribeHistory(Base):
__table_args__ = (
Index('ix_subscribehistory_type_date', 'type', 'date'),
Index('ix_subscribehistory_media_identity', 'media_source', 'media_id'),
)
@classmethod
@@ -128,35 +132,63 @@ class SubscribeHistory(Base):
return result.scalars().all()
@classmethod
@db_query
def exists(cls, db: Session, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
season: Optional[int] = None):
if tmdbid:
if season is not None:
return db.query(cls).filter(cls.tmdbid == tmdbid,
cls.season == season).first()
return db.query(cls).filter(cls.tmdbid == tmdbid).first()
elif doubanid:
return db.query(cls).filter(cls.doubanid == doubanid).first()
def _identity_condition(
cls,
media_source: Optional[str] = None,
media_id: Optional[str] = None,
tmdbid: Optional[int] = None,
doubanid: Optional[str] = None,
bangumiid: Optional[int] = None,
anilistid: Optional[int] = None,
):
"""按统一媒体身份优先级构造订阅历史查询条件。"""
if media_source and media_id:
return (cls.media_source == media_source) & (cls.media_id == str(media_id))
if tmdbid is not None:
return cls.tmdbid == tmdbid
if doubanid:
return cls.doubanid == doubanid
if bangumiid is not None:
return cls.bangumiid == bangumiid
if anilistid is not None:
return cls.anilistid == anilistid
return None
@classmethod
@async_db_query
async def async_exists(cls, db: AsyncSession, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
season: Optional[int] = None):
if tmdbid:
if season is not None:
result = await db.execute(
select(cls).filter(cls.tmdbid == tmdbid, cls.season == season)
)
else:
result = await db.execute(
select(cls).filter(cls.tmdbid == tmdbid)
)
elif doubanid:
result = await db.execute(
select(cls).filter(cls.doubanid == doubanid)
)
else:
@db_query
def exists(
cls, db: Session, tmdbid: Optional[int] = None,
doubanid: Optional[str] = None, bangumiid: Optional[int] = None,
anilistid: Optional[int] = None, media_source: Optional[str] = None,
media_id: Optional[str] = None, season: Optional[int] = None,
):
"""按媒体身份与季号查询订阅历史。"""
condition = cls._identity_condition(
media_source, media_id, tmdbid, doubanid, bangumiid, anilistid
)
if condition is None:
return None
query = db.query(cls).filter(condition)
if season is not None:
query = query.filter(cls.season == season)
return query.first()
@classmethod
@async_db_query
async def async_exists(
cls, db: AsyncSession, tmdbid: Optional[int] = None,
doubanid: Optional[str] = None, bangumiid: Optional[int] = None,
anilistid: Optional[int] = None, media_source: Optional[str] = None,
media_id: Optional[str] = None, season: Optional[int] = None,
):
"""异步按媒体身份与季号查询订阅历史。"""
condition = cls._identity_condition(
media_source, media_id, tmdbid, doubanid, bangumiid, anilistid
)
if condition is None:
return None
query = select(cls).filter(condition)
if season is not None:
query = query.filter(cls.season == season)
result = await db.execute(query)
return result.scalars().first()
+6
View File
@@ -48,6 +48,11 @@ class TransferHistory(Base):
imdbid = Column(String)
tvdbid = Column(Integer)
doubanid = Column(String)
bangumiid = Column(Integer, index=True)
anilistid = Column(Integer, index=True)
# 统一媒体数据源与原生ID
media_source = Column(String, index=True)
media_id = Column(String, index=True)
# Sxx
seasons = Column(String)
# Exx
@@ -72,6 +77,7 @@ class TransferHistory(Base):
__table_args__ = (
Index('ix_transferhistory_status_date', 'status', 'date'),
Index('ix_transferhistory_date_id', 'date', 'id'),
Index('ix_transferhistory_media_identity', 'media_source', 'media_id'),
)
@classmethod
+95 -53
View File
@@ -5,6 +5,7 @@ from app.core.context import MediaInfo
from app.db import DbOper
from app.db.models.subscribe import Subscribe
from app.db.models.subscribehistory import SubscribeHistory
from app.utils.media import resolve_media_identity
INTEGER_FLAG_FIELDS = ("best_version", "best_version_full", "search_imdbid", "manual_total_episode")
@@ -31,17 +32,26 @@ class SubscribeOper(DbOper):
"""
owner_scope = bool(kwargs.pop("owner_scope", False))
username = kwargs.get("username") if owner_scope else None
media_source, media_id = resolve_media_identity(
media=mediainfo,
source=kwargs.get("media_source"),
media_id=kwargs.get("media_id"),
)
identity_params = {
"tmdbid": mediainfo.tmdb_id,
"doubanid": mediainfo.douban_id,
"bangumiid": mediainfo.bangumi_id,
"anilistid": mediainfo.anilist_id,
"media_source": media_source,
"media_id": media_id,
"season": kwargs.get("season"),
}
if username:
subscribe = Subscribe.exists_by_username(self._db,
username=username,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=kwargs.get('season'))
**identity_params)
else:
subscribe = Subscribe.exists(self._db,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=kwargs.get('season'))
subscribe = Subscribe.exists(self._db, **identity_params)
kwargs.update({
"name": mediainfo.title,
"year": mediainfo.year,
@@ -51,6 +61,9 @@ class SubscribeOper(DbOper):
"tvdbid": mediainfo.tvdb_id,
"doubanid": mediainfo.douban_id,
"bangumiid": mediainfo.bangumi_id,
"anilistid": mediainfo.anilist_id,
"media_source": media_source,
"media_id": media_id,
"episode_group": mediainfo.episode_group,
"poster": mediainfo.get_poster_image(),
"backdrop": mediainfo.get_backdrop_image(),
@@ -67,14 +80,9 @@ class SubscribeOper(DbOper):
if username:
subscribe = Subscribe.exists_by_username(self._db,
username=username,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=kwargs.get('season'))
**identity_params)
else:
subscribe = Subscribe.exists(self._db,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=kwargs.get('season'))
subscribe = Subscribe.exists(self._db, **identity_params)
return subscribe.id, "新增订阅成功"
else:
return subscribe.id, "订阅已存在"
@@ -85,17 +93,26 @@ class SubscribeOper(DbOper):
"""
owner_scope = bool(kwargs.pop("owner_scope", False))
username = kwargs.get("username") if owner_scope else None
media_source, media_id = resolve_media_identity(
media=mediainfo,
source=kwargs.get("media_source"),
media_id=kwargs.get("media_id"),
)
identity_params = {
"tmdbid": mediainfo.tmdb_id,
"doubanid": mediainfo.douban_id,
"bangumiid": mediainfo.bangumi_id,
"anilistid": mediainfo.anilist_id,
"media_source": media_source,
"media_id": media_id,
"season": kwargs.get("season"),
}
if username:
subscribe = await Subscribe.async_exists_by_username(self._db,
username=username,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=kwargs.get('season'))
**identity_params)
else:
subscribe = await Subscribe.async_exists(self._db,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=kwargs.get('season'))
subscribe = await Subscribe.async_exists(self._db, **identity_params)
kwargs.update({
"name": mediainfo.title,
"year": mediainfo.year,
@@ -105,6 +122,9 @@ class SubscribeOper(DbOper):
"tvdbid": mediainfo.tvdb_id,
"doubanid": mediainfo.douban_id,
"bangumiid": mediainfo.bangumi_id,
"anilistid": mediainfo.anilist_id,
"media_source": media_source,
"media_id": media_id,
"episode_group": mediainfo.episode_group,
"poster": mediainfo.get_poster_image(),
"backdrop": mediainfo.get_backdrop_image(),
@@ -121,31 +141,32 @@ class SubscribeOper(DbOper):
if username:
subscribe = await Subscribe.async_exists_by_username(self._db,
username=username,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=kwargs.get('season'))
**identity_params)
else:
subscribe = await Subscribe.async_exists(self._db,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=kwargs.get('season'))
subscribe = await Subscribe.async_exists(self._db, **identity_params)
return subscribe.id, "新增订阅成功"
else:
return subscribe.id, "订阅已存在"
def exists(self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
season: Optional[int] = None) -> bool:
def exists(
self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
bangumiid: Optional[int] = None, anilistid: Optional[int] = None,
media_source: Optional[str] = None, media_id: Optional[str] = None,
season: Optional[int] = None,
) -> bool:
"""
判断是否存在
"""
if tmdbid:
if season is not None:
return True if Subscribe.exists(self._db, tmdbid=tmdbid, season=season) else False
else:
return True if Subscribe.exists(self._db, tmdbid=tmdbid) else False
elif doubanid:
return True if Subscribe.exists(self._db, doubanid=doubanid) else False
return False
return bool(Subscribe.exists(
self._db,
tmdbid=tmdbid,
doubanid=doubanid,
bangumiid=bangumiid,
anilistid=anilistid,
media_source=media_source,
media_id=media_id,
season=season,
))
def get(self, sid: int) -> Subscribe:
"""
@@ -159,19 +180,33 @@ class SubscribeOper(DbOper):
"""
return await Subscribe.async_get(self._db, rid=sid)
def get_by(self, type: str, season: Optional[str] = None, tmdbid: Optional[int] = None,
doubanid: Optional[str] = None, bangumiid: Optional[str] = None) -> Optional[Subscribe]:
def get_by(
self, type: str, season: Optional[str] = None,
tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
bangumiid: Optional[int] = None, anilistid: Optional[int] = None,
media_source: Optional[str] = None, media_id: Optional[str] = None,
) -> Optional[Subscribe]:
"""
根据条件查询订阅
"""
return Subscribe.get_by(self._db, type, season, tmdbid, doubanid, bangumiid)
return Subscribe.get_by(
self._db, type, season, tmdbid, doubanid, bangumiid, anilistid,
media_source, media_id,
)
async def async_get_by(self, type: str, season: Optional[str] = None, tmdbid: Optional[int] = None,
doubanid: Optional[str] = None, bangumiid: Optional[str] = None) -> Optional[Subscribe]:
async def async_get_by(
self, type: str, season: Optional[str] = None,
tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
bangumiid: Optional[int] = None, anilistid: Optional[int] = None,
media_source: Optional[str] = None, media_id: Optional[str] = None,
) -> Optional[Subscribe]:
"""
根据条件查询订阅
"""
return await Subscribe.async_get_by(self._db, type, season, tmdbid, doubanid, bangumiid)
return await Subscribe.async_get_by(
self._db, type, season, tmdbid, doubanid, bangumiid, anilistid,
media_source, media_id,
)
def list(self, state: Optional[str] = None) -> List[Subscribe]:
"""
@@ -261,15 +296,22 @@ class SubscribeOper(DbOper):
subscribe = SubscribeHistory(**kwargs)
subscribe.create(self._db)
def exist_history(self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, season: Optional[int] = None):
def exist_history(
self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
bangumiid: Optional[int] = None, anilistid: Optional[int] = None,
media_source: Optional[str] = None, media_id: Optional[str] = None,
season: Optional[int] = None,
) -> bool:
"""
判断是否存在订阅历史
"""
if tmdbid:
if season is not None:
return True if SubscribeHistory.exists(self._db, tmdbid=tmdbid, season=season) else False
else:
return True if SubscribeHistory.exists(self._db, tmdbid=tmdbid) else False
elif doubanid:
return True if SubscribeHistory.exists(self._db, doubanid=doubanid) else False
return False
return bool(SubscribeHistory.exists(
self._db,
tmdbid=tmdbid,
doubanid=doubanid,
bangumiid=bangumiid,
anilistid=anilistid,
media_source=media_source,
media_id=media_id,
season=season,
))
+14
View File
@@ -198,6 +198,10 @@ class TransferHistoryOper(DbOper):
imdbid=mediainfo.imdb_id,
tvdbid=mediainfo.tvdb_id,
doubanid=mediainfo.douban_id,
bangumiid=mediainfo.bangumi_id,
anilistid=mediainfo.anilist_id,
media_source=mediainfo.source,
media_id=mediainfo.to_dict().get("media_id"),
seasons=meta.season,
episodes=meta.episode,
image=mediainfo.get_poster_image(),
@@ -229,6 +233,10 @@ class TransferHistoryOper(DbOper):
imdbid=mediainfo.imdb_id,
tvdbid=mediainfo.tvdb_id,
doubanid=mediainfo.douban_id,
bangumiid=mediainfo.bangumi_id,
anilistid=mediainfo.anilist_id,
media_source=mediainfo.source,
media_id=mediainfo.to_dict().get("media_id"),
seasons=meta.season,
episodes=meta.episode,
image=mediainfo.get_poster_image(),
@@ -243,6 +251,12 @@ class TransferHistoryOper(DbOper):
his = self.add_force(
title=meta.name,
year=meta.year,
tmdbid=meta.tmdbid,
doubanid=meta.doubanid,
bangumiid=meta.bangumiid,
anilistid=meta.anilistid,
media_source=meta.media_source,
media_id=meta.media_id,
src=fileitem.path,
src_storage=fileitem.storage,
src_fileitem=fileitem.model_dump(),
+97 -17
View File
@@ -45,6 +45,14 @@ LOG_ERROR_PATTERNS = (
re.compile(r"加载插件.+出错"),
re.compile(r"数据库更新失败"),
)
LOG_RECORD_PATTERN = re.compile(
r"(?:【(?:DEBUG|INFO|WARNING|ERROR|CRITICAL)】|(?:DEBUG|INFO|WARNING|ERROR|CRITICAL):)"
)
CONSOLE_LOGGER_PATTERN = re.compile(r"\[([^\]]+)]")
PLUGIN_ERROR_PATTERNS = (
re.compile(r"(?:^|\s-\s)plugin\.py\s+-\s", re.IGNORECASE),
re.compile(r"插件.+(?:出错|失败|异常|错误)"),
)
SENSITIVE_PATTERNS = (
re.compile(r"(?i)(api[_-]?token|token|password|secret|cookie)(\s*[:=]\s*)[^\s&]+"),
re.compile(r"\bghp_[A-Za-z0-9]{20,}\b"),
@@ -92,10 +100,23 @@ class DoctorRunnerProtocol:
recommendation: str,
fixable: bool = False,
fixed: bool = False,
affects_report_status: bool = True,
context: Optional[dict[str, Any]] = None,
) -> DoctorFinding:
"""
添加诊断发现
:param finding_id: 诊断项稳定标识
:param severity: 诊断严重级别
:param status: 单项诊断状态
:param title: 诊断项标题
:param detail: 诊断详情
:param recommendation: 处理建议
:param fixable: 是否支持 Doctor 自动修复
:param fixed: 本次运行是否已修复
:param affects_report_status: 是否参与整体报告状态聚合
:param context: 可选结构化上下文
:return: 新增的诊断发现
"""
raise NotImplementedError
@@ -275,6 +296,7 @@ def _tail_lines(path: Path, max_lines: int = 120, max_bytes: int = 256 * 1024) -
def _find_error_lines(lines: list[str], max_matches: int = 12) -> list[str]:
"""从近期日志中提取错误关键词命中的行。"""
matches: list[str] = []
for line in lines:
if any(pattern.search(line) for pattern in LOG_ERROR_PATTERNS):
@@ -282,6 +304,39 @@ def _find_error_lines(lines: list[str], max_matches: int = 12) -> list[str]:
return matches[-max_matches:]
def _partition_error_lines(
lines: list[str],
plugin_logger_names: set[str],
max_matches: int = 12,
) -> tuple[list[str], list[str]]:
"""
将主日志错误线索拆分为核心错误和插件子系统错误
:param lines: 近期日志行
:param plugin_logger_names: 已发现的插件控制台 logger 名称
:param max_matches: 每类最多保留的错误行数
:return: 核心错误行和插件错误行
"""
core_matches: list[str] = []
plugin_matches: list[str] = []
plugin_context = False
for line in lines:
if LOG_RECORD_PATTERN.search(line):
logger_match = CONSOLE_LOGGER_PATTERN.search(line)
console_logger = logger_match.group(1).strip().lower() if logger_match else ""
plugin_context = (
console_logger in plugin_logger_names
or any(pattern.search(line) for pattern in PLUGIN_ERROR_PATTERNS)
)
if not any(pattern.search(line) for pattern in LOG_ERROR_PATTERNS):
continue
if plugin_context or any(pattern.search(line) for pattern in PLUGIN_ERROR_PATTERNS):
plugin_matches.append(line)
else:
core_matches.append(line)
return core_matches[-max_matches:], plugin_matches[-max_matches:]
def _frontend_dir() -> Path:
root_public = settings.ROOT_PATH / "public"
configured = Path(settings.FRONTEND_PATH)
@@ -687,14 +742,18 @@ def _check_frontend_assets(runner: DoctorRunnerProtocol) -> None:
def _check_logs(runner: DoctorRunnerProtocol) -> None:
"""扫描近期日志,并区分核心运行异常与插件扩展异常。"""
log_files = [
_backend_app_log_file(),
_backend_stdio_log_file(),
_frontend_stdio_log_file(),
]
plugin_log_dir = settings.LOG_PATH / "plugins"
plugin_logger_names: set[str] = set()
if plugin_log_dir.exists():
log_files.extend(sorted(plugin_log_dir.rglob("*.log"))[:20])
plugin_log_files = sorted(plugin_log_dir.rglob("*.log"))
plugin_logger_names = {path.stem.lower() for path in plugin_log_files}
log_files.extend(plugin_log_files[:20])
found_any = False
for path in log_files:
@@ -702,23 +761,44 @@ def _check_logs(runner: DoctorRunnerProtocol) -> None:
continue
found_any = True
lines = _tail_lines(path)
errors = _find_error_lines(lines)
if not errors:
is_plugin_log = plugin_log_dir in path.parents
if is_plugin_log:
scoped_errors = [(True, _find_error_lines(lines))]
else:
core_errors, plugin_errors = _partition_error_lines(
lines,
plugin_logger_names,
)
scoped_errors = [(False, core_errors), (True, plugin_errors)]
if not any(errors for _, errors in scoped_errors):
continue
is_plugin = plugin_log_dir in path.parents
runner.add(
finding_id=f"logs.{path.stem}.recent_errors",
severity=DoctorSeverity.Warn,
status=DoctorFindingStatus.Degraded,
title="最近日志存在插件异常" if is_plugin else "最近日志存在错误线索",
detail="\n".join(errors),
recommendation=(
"可使用安全模式启动后检查插件配置。"
if is_plugin
else "结合前后的启动日志定位异常;必要时执行 `moviepilot doctor --json` 交给 Agent 或 Issue 流程。"
),
context={"log_file": str(path), "matches": len(errors)},
)
has_core_errors = bool(scoped_errors[0][1]) if not is_plugin_log else False
for is_plugin_error, errors in scoped_errors:
if not errors:
continue
finding_suffix = (
"plugin_errors"
if is_plugin_error and has_core_errors
else "recent_errors"
)
runner.add(
finding_id=f"logs.{path.stem}.{finding_suffix}",
severity=DoctorSeverity.Warn,
status=DoctorFindingStatus.Degraded,
title="最近日志存在插件异常" if is_plugin_error else "最近日志存在错误线索",
detail="\n".join(errors),
recommendation=(
"可使用安全模式启动后检查插件配置。"
if is_plugin_error
else "结合前后的启动日志定位异常;必要时执行 `moviepilot doctor --json` 交给 Agent 或 Issue 流程。"
),
affects_report_status=not is_plugin_error,
context={
"log_file": str(path),
"matches": len(errors),
"component": "plugin" if is_plugin_error else "core",
},
)
if not found_any:
runner.add(
+2
View File
@@ -47,6 +47,8 @@ def _format_finding(finding: DoctorFinding) -> list[str]:
marker = finding.severity.value.upper()
if finding.fixed:
marker = "FIXED"
elif not finding.affects_report_status:
marker = f"{marker}/ADVISORY"
lines = [f"[{marker}] {finding.title}", f"ID: {finding.id}"]
if finding.detail:
lines.append(f"原因: {finding.detail}")
+7 -1
View File
@@ -52,6 +52,7 @@ class DoctorFinding:
recommendation: str
fixable: bool = False
fixed: bool = False
affects_report_status: bool = True
context: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
@@ -67,6 +68,7 @@ class DoctorFinding:
"recommendation": self.recommendation,
"fixable": self.fixable,
"fixed": self.fixed,
"affects_report_status": self.affects_report_status,
}
if self.context:
payload["context"] = self.context
@@ -90,7 +92,11 @@ class DoctorReport:
"""
根据诊断发现计算整体状态
"""
unresolved = [finding for finding in self.findings if not finding.fixed]
unresolved = [
finding
for finding in self.findings
if not finding.fixed and finding.affects_report_status
]
if any(finding.severity == DoctorSeverity.Error for finding in unresolved):
return DoctorReportStatus.Failed
if any(finding.severity == DoctorSeverity.Warn for finding in unresolved):
+15
View File
@@ -67,10 +67,23 @@ class DoctorRunner:
recommendation: str,
fixable: bool = False,
fixed: bool = False,
affects_report_status: bool = True,
context: Optional[dict[str, Any]] = None,
) -> DoctorFinding:
"""
添加诊断发现并返回该对象
:param finding_id: 诊断项稳定标识
:param severity: 诊断严重级别
:param status: 单项诊断状态
:param title: 诊断项标题
:param detail: 诊断详情
:param recommendation: 处理建议
:param fixable: 是否支持 Doctor 自动修复
:param fixed: 本次运行是否已修复
:param affects_report_status: 是否参与整体报告状态聚合
:param context: 可选结构化上下文
:return: 新增的诊断发现
"""
finding = DoctorFinding(
id=finding_id,
@@ -81,6 +94,7 @@ class DoctorRunner:
recommendation=recommendation,
fixable=fixable,
fixed=fixed,
affects_report_status=affects_report_status,
context=context or {},
)
self.report.add_finding(finding)
@@ -88,6 +102,7 @@ class DoctorRunner:
@staticmethod
def _environment() -> dict[str, Any]:
"""收集 Doctor 报告所需的本地运行环境信息。"""
return {
"runtime": "Docker" if SystemUtils.is_docker() else platform.system(),
"platform": platform.platform(),
+56 -5
View File
@@ -20,6 +20,7 @@ class CookieHelper:
'//input[@name="username"]',
'//input[@id="form_item_username"]',
'//input[@id="username"]',
'//input[contains(@placeholder,"用户名")]',
],
"password": [
'//input[@name="password"]',
@@ -51,6 +52,10 @@ class CookieHelper:
"error": [
"//table[@class='main']//td[@class='text']/text()",
],
"remember": [
'//input[@type="checkbox"][contains(@name,"remember") or contains(@id,"remember")]',
'//*[@role="checkbox"][contains(.,"保持登录") or contains(.,"记住我") or contains(.,"自动登录")]',
],
"twostep": [
'//input[@name="two_step_code"]',
'//input[@name="2fa_secret"]',
@@ -137,6 +142,21 @@ class CookieHelper:
if html.xpath(xpath):
username_xpath = xpath
break
if not username_xpath:
# 登录页可能为JS动态渲染(如SPA),等待用户名输入框出现后重试
try:
username_union_xpath = " | ".join(self._SITE_LOGIN_XPATH.get("username"))
page.wait_for_selector(f"xpath={username_union_xpath}", timeout=5000)
except Exception:
pass
html_text = self.get_page_content(page)
html = etree.HTML(html_text) if html_text else None
if html is None:
return None, None, "解析网页源码失败"
for xpath in self._SITE_LOGIN_XPATH.get("username"):
if html.xpath(xpath):
username_xpath = xpath
break
if not username_xpath:
return None, None, "未找到用户名输入框"
# 查找密码输入框
@@ -188,6 +208,22 @@ class CookieHelper:
page.fill(username_xpath, username)
# 输入密码
page.fill(password_xpath, password)
# 勾选“记住我/保持登录”等选项,获取长期会话(部分站点默认发放短期会话)
for xpath in self._SITE_LOGIN_XPATH.get("remember"):
remember_element = page.query_selector(xpath)
if not remember_element:
continue
try:
checked = remember_element.get_attribute("aria-checked")
if checked is None:
checked = "true" if remember_element.is_checked() else "false"
if checked != "true":
remember_element.click(timeout=3000)
break
except Exception as e:
# 当前候选不可操作(如隐藏元素)时继续尝试后续候选
logger.warning(f"勾选记住登录选项失败:{str(e)},尝试下一候选")
continue
# 输入二步验证码
if twostep_xpath:
page.fill(twostep_xpath, otp_code)
@@ -242,13 +278,28 @@ class CookieHelper:
return None, None, f"二次验证码输入失败:{str(e)}"
break
# 登录后的源码
html_text = self.get_page_content(page)
# 登录后的源码(部分站点登录成功后由前端脚本延迟跳转,等待并重试判定)
html_text = None
for i in range(3):
if i:
time.sleep(2)
latest_text = self.get_page_content(page)
if not latest_text:
continue
if SiteUtils.is_logged_in(latest_text):
return self.parse_cookies(page.context.cookies()), \
page.evaluate("() => window.navigator.userAgent"), ""
# 保留首个快照用于失败时解析错误信息,避免提示被后续跳转或自动消失覆盖
if html_text is None:
html_text = latest_text
# 页面已出现明确的登录错误信息时,以该快照为准并提前结束重试
latest_html = etree.HTML(latest_text)
if latest_html is not None and \
any(latest_html.xpath(x) for x in self._SITE_LOGIN_XPATH.get("error")):
html_text = latest_text
break
if not html_text:
return None, None, "获取网页源码失败"
if SiteUtils.is_logged_in(html_text):
return self.parse_cookies(page.context.cookies()), \
page.evaluate("() => window.navigator.userAgent"), ""
else:
# 从登录后的页面读取错误信息
html = etree.HTML(html_text)
+54 -4
View File
@@ -41,6 +41,43 @@ class DirectoryHelper:
"""
return [d for d in self.get_download_dirs() if d.storage == "local"]
def get_download_dir_by_save_path(
self,
media: Optional[MediaInfo],
save_path: str,
) -> Optional[schemas.TransferDirectoryConf]:
"""
按媒体信息和精确保存根路径匹配下载目录配置
仅配置根目录本身继承自动分类规则根目录下的自定义子目录保持调用方指定的完整路径
:param media: 媒体信息
:param save_path: 已选择的下载保存目录支持本地路径或远端 FileURI
:return: 匹配的下载目录配置
"""
value = str(save_path or "").strip()
try:
storage, raw_path = _split_file_uri(value)
target_style, target_path = _normalize_download_path(raw_path, storage)
except ValueError:
return None
media_type = media.type.value if media else None
for dir_info in self.get_download_dirs():
root = _normalize_download_root(dir_info)
if not root:
continue
root_storage, root_style, root_path = root
if storage != root_storage or target_style != root_style or target_path != root_path:
continue
if not media_type or not dir_info.media_type:
return dir_info
if dir_info.media_type == media_type and not dir_info.media_category:
return dir_info
if dir_info.media_type == media_type and dir_info.media_category == media.category:
return dir_info
return None
def get_library_dirs(self) -> List[schemas.TransferDirectoryConf]:
"""
获取所有媒体库目录
@@ -266,18 +303,21 @@ def validate_download_save_path(save_path: str) -> str:
"""
校验用户传入的下载保存目录/download/paths 暴露的下载目录配置是允许写入的公共合同
:param save_path: 下载保存目录支持本地 /path 远端 <storage>:/path
:param save_path: 下载保存目录支持本地 /path远端 <storage>:/path 和旧版订阅中的无前缀远程路径
:return: 可直接传给下载接口的规范化保存目录
"""
value = str(save_path or "").strip()
has_storage_prefix = any(value.startswith(f"{item.value}:") for item in StorageSchema)
storage, raw_path = _split_file_uri(value)
target_style, target_path = _normalize_download_path(raw_path, storage)
download_roots = []
for dir_info in DirectoryHelper().get_download_dirs():
root = _normalize_download_root(dir_info)
if not root:
continue
root_storage, root_style, root_path = root
if root:
download_roots.append(root)
for root_storage, root_style, root_path in download_roots:
if storage != root_storage:
continue
if target_style != root_style:
@@ -285,4 +325,14 @@ def validate_download_save_path(save_path: str) -> str:
if target_path == root_path or target_path.is_relative_to(root_path):
return _download_path_uri(storage, target_path)
# 旧版订阅界面只持久化 download_path,需要从已配置根目录恢复远程存储类型。
if (not has_storage_prefix
and storage == StorageSchema.Local.value
and target_style == "posix"):
for root_storage, root_style, root_path in download_roots:
if root_storage == StorageSchema.Local.value or target_style != root_style:
continue
if target_path == root_path or target_path.is_relative_to(root_path):
return _download_path_uri(root_storage, target_path)
raise ValueError("保存路径不在允许的下载目录范围内")
+44 -11
View File
@@ -18,8 +18,10 @@ from app.log import logger
from app.utils.mixins import ConfigReloadMixin
from app.utils.singleton import Singleton
# 定义一个全局线程池执行器
_executor = concurrent.futures.ThreadPoolExecutor()
# DoH 关闭时需要释放线程池;保持惰性创建可避免未启用 DoH 时占用进程级资源
_executor: Optional[concurrent.futures.ThreadPoolExecutor] = None
_executor_lock = Lock()
_doh_enabled = False
# 定义默认的DoH配置
_doh_timeout = 5
@@ -29,11 +31,21 @@ _doh_lock = Lock()
_orig_getaddrinfo = socket.getaddrinfo
def _get_executor_locked() -> concurrent.futures.ThreadPoolExecutor:
"""在持有执行器锁时按需获取 DoH 查询线程池"""
global _executor
if _executor is None:
_executor = concurrent.futures.ThreadPoolExecutor()
return _executor
def enable_doh(enable: bool) -> None:
"""
socket.getaddrinfo 进行补丁
"""
global _doh_enabled
def _patched_getaddrinfo(host: str, *args, **kwargs):
"""
socket.getaddrinfo的补丁版本
@@ -47,9 +59,15 @@ def enable_doh(enable: bool) -> None:
logger.info(f"已解析 [{host}] 为 [{ip}] (缓存)")
return _orig_getaddrinfo(ip, *args, **kwargs)
# 使用DoH解析主机
futures = []
for resolver in settings.DOH_RESOLVERS.split(","):
futures.append(_executor.submit(_doh_query, resolver, host))
with _executor_lock:
if not _doh_enabled:
return _orig_getaddrinfo(host, *args, **kwargs)
executor = _get_executor_locked()
# 一次解析的任务必须在同一临界区提交完,避免关闭过程中部分任务落入新线程池
futures = [
executor.submit(_doh_query, resolver, host)
for resolver in settings.DOH_RESOLVERS.split(",")
]
for future in concurrent.futures.as_completed(futures):
ip = future.result()
if ip is not None:
@@ -60,11 +78,9 @@ def enable_doh(enable: bool) -> None:
break
return _orig_getaddrinfo(host, *args, **kwargs)
if enable:
# 替换 socket.getaddrinfo 方法
socket.getaddrinfo = _patched_getaddrinfo
else:
socket.getaddrinfo = _orig_getaddrinfo
with _executor_lock:
_doh_enabled = enable
socket.getaddrinfo = _patched_getaddrinfo if enable else _orig_getaddrinfo
class DohHelper(ConfigReloadMixin, metaclass=Singleton):
@@ -77,14 +93,31 @@ class DohHelper(ConfigReloadMixin, metaclass=Singleton):
enable_doh(settings.DOH_ENABLE)
def on_config_changed(self) -> None:
if not settings.DOH_ENABLE:
self.shutdown()
return
with _doh_lock:
# DOH配置有变动的情况下,清空缓存
_doh_cache.clear()
enable_doh(settings.DOH_ENABLE)
enable_doh(True)
def get_reload_name(self) -> str:
return 'DoH'
def shutdown(self) -> None:
"""恢复系统 DNS 并释放 DoH 查询线程池"""
global _executor, _doh_enabled
with _executor_lock:
_doh_enabled = False
socket.getaddrinfo = _orig_getaddrinfo
executor = _executor
_executor = None
with _doh_lock:
_doh_cache.clear()
if executor:
executor.shutdown(wait=True)
def _doh_query(resolver: str, host: str) -> Optional[str]:
"""
使用给定的DoH解析器查询给定主机的IP地址
+28 -5
View File
@@ -92,6 +92,17 @@ class TemplateContextBuilder:
if not mediainfo:
return
season_fmt = f"S{mediainfo.season:02d}" if mediainfo.season is not None else None
source_ids = {
"themoviedb": mediainfo.tmdb_id,
"douban": mediainfo.douban_id,
"bangumi": mediainfo.bangumi_id,
"anilist": mediainfo.anilist_id,
}
media_source = mediainfo.source or next(
(source for source, media_id in source_ids.items() if media_id is not None),
None,
)
media_id = mediainfo.media_id or source_ids.get(media_source)
base_info = {
# 标题
"title": cls.__convert_invalid_characters(mediainfo.title),
@@ -135,6 +146,14 @@ class TemplateContextBuilder:
"imdbid": mediainfo.imdb_id,
# 豆瓣ID
"doubanid": mediainfo.douban_id,
# Bangumi ID
"bangumiid": mediainfo.bangumi_id,
# AniList ID
"anilistid": mediainfo.anilist_id,
# 当前媒体数据源
"media_source": media_source,
# 当前数据源原生ID
"media_id": str(media_id) if media_id is not None else None,
}
context.update({**base_info, **media_info})
@@ -605,6 +624,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 +772,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 +863,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()
+14
View File
@@ -26,11 +26,20 @@ from webauthn.helpers.structs import (
AuthenticatorSelectionCriteria
)
from webauthn.helpers.cose import COSEAlgorithmIdentifier
from webauthn.helpers.exceptions import InvalidRegistrationResponse
from app.core.config import settings
from app.log import logger
class PassKeyRegistrationVerificationError(Exception):
"""Passkey 注册响应未通过 WebAuthn 安全校验。"""
class PassKeyRegistrationOriginMismatchError(PassKeyRegistrationVerificationError):
"""浏览器来源与系统配置的 Passkey 注册来源不一致。"""
class PassKeyHelper:
"""
PassKey WebAuthn 辅助类
@@ -269,6 +278,11 @@ class PassKeyHelper:
return credential_id, public_key, sign_count, aaguid
except InvalidRegistrationResponse as e:
logger.error(f"验证注册响应失败: {e}")
if str(e).startswith("Unexpected client data origin "):
raise PassKeyRegistrationOriginMismatchError() from e
raise PassKeyRegistrationVerificationError() from e
except Exception as e:
logger.error(f"验证注册响应失败: {e}")
raise
+79
View File
@@ -0,0 +1,79 @@
import secrets
import threading
from dataclasses import dataclass
from typing import Literal, Optional
from app.core.cache import TTLCache
from app.helper.redis import RedisHelper
PASSKEY_CHALLENGE_TTL_SECONDS = 5 * 60
PasskeyChallengePurpose = Literal["authentication", "registration"]
@dataclass(frozen=True)
class PasskeyChallenge:
"""服务端保存的一次性 Passkey challenge 及其认证边界。"""
challenge: str
purpose: PasskeyChallengePurpose
user_id: Optional[int]
class PasskeyChallengeStore:
"""使用当前缓存后端签发并原子消费短时 Passkey challenge。"""
_cache = TTLCache(
region="passkey_challenge",
maxsize=4096,
ttl=PASSKEY_CHALLENGE_TTL_SECONDS,
)
_memory_consume_lock = threading.Lock()
@classmethod
def issue(
cls,
*,
challenge: str,
purpose: PasskeyChallengePurpose,
user_id: Optional[int],
) -> str:
"""保存 challenge 并返回不携带认证事实的随机事务 token。"""
transaction_token = secrets.token_urlsafe(32)
cls._cache.set(
transaction_token,
PasskeyChallenge(
challenge=challenge,
purpose=purpose,
user_id=user_id,
),
)
return transaction_token
@classmethod
def consume(
cls,
*,
transaction_token: str,
purpose: PasskeyChallengePurpose,
) -> Optional[PasskeyChallenge]:
"""原子领取 challenge;任何完成尝试都会使事务失效。"""
if not transaction_token:
return None
if cls._cache.is_redis():
challenge = RedisHelper().pop(
transaction_token,
region="passkey_challenge",
)
else:
with cls._memory_consume_lock:
try:
challenge = cls._cache.pop(transaction_token)
except KeyError:
challenge = None
if not isinstance(challenge, PasskeyChallenge):
return None
if challenge.purpose != purpose:
return None
return challenge
+65 -16
View File
@@ -1401,35 +1401,62 @@ class PluginHelper(metaclass=WeakSingleton):
)
@classmethod
def __repair_if_runtime_broken(cls, snapshot_file: Optional[Path] = None) -> Tuple[bool, str]:
def __repair_if_runtime_broken(
cls,
snapshot_file: Optional[Path] = None,
baseline_health: Optional[Dict[str, Tuple[bool, str]]] = None
) -> Tuple[bool, str]:
"""
安装失败后检查主运行环境异常先恢复主程序依赖再继续向上返回安装失败
安装失败后检查主运行环境相对安装前新增异常先恢复主程序依赖再返回
"""
health_ok, health_message = cls.__run_runtime_healthcheck()
if health_ok:
current_health = cls.__run_runtime_healthcheck()
health_message = cls.__runtime_health_regression_message(
baseline_health or {},
current_health
)
if not health_message:
return True, ""
repair_ok, repair_message = cls.__repair_main_runtime_dependencies(snapshot_file)
if not repair_ok:
return False, f"插件依赖安装失败后主运行环境异常,且恢复失败:{health_message}; {repair_message}"
restored, restored_message = cls.__run_runtime_healthcheck()
if not restored:
restored_health = cls.__run_runtime_healthcheck()
restored_message = cls.__runtime_health_regression_message(
baseline_health or {},
restored_health
)
if restored_message:
return False, f"插件依赖安装失败后主运行环境异常,恢复后仍异常:{restored_message}"
return True, "主运行环境已恢复"
@classmethod
def __run_runtime_healthcheck(cls) -> Tuple[bool, str]:
def __run_runtime_healthcheck(cls) -> Dict[str, Tuple[bool, str]]:
"""
安装完成后立即执行运行环境自检尽量在插件加载前发现依赖图已被污染
执行全部运行环境自检并返回逐项结果避免前一项失败遮蔽后续异常
"""
checks = [
("pip check", cls.__build_runtime_pip_command("check")),
("核心依赖导入检查", [sys.executable, "-c", cls._runtime_import_probe]),
]
health_snapshot = {}
for check_name, command in checks:
success, message = SystemUtils.execute_with_subprocess(command)
if not success:
return False, f"{check_name}失败:{message}"
return True, ""
health_snapshot[check_name] = (success, message)
return health_snapshot
@staticmethod
def __runtime_health_regression_message(
baseline_health: Dict[str, Tuple[bool, str]],
current_health: Dict[str, Tuple[bool, str]]
) -> str:
"""
汇总相对基线从正常变为异常的检查项不解析第三方工具的错误文本
"""
regressions = []
for check_name, (success, message) in current_health.items():
baseline_success = baseline_health.get(check_name, (True, ""))[0]
if baseline_success and not success:
regressions.append(f"{check_name}失败:{message}")
return "".join(regressions)
@classmethod
def __repair_main_runtime_dependencies(cls, snapshot_file: Optional[Path] = None) -> Tuple[bool, str]:
@@ -1526,6 +1553,12 @@ class PluginHelper(metaclass=WeakSingleton):
# pip 会修改当前解释器的 site-packages,安装与缓存刷新必须串行,避免运行态模块被并发安装窗口污染。
with cls._pip_install_lock:
loaded_modules_before_install = set(sys.modules.keys())
baseline_health = cls.__run_runtime_healthcheck()
baseline_health_message = cls.__runtime_health_regression_message({}, baseline_health)
if baseline_health_message:
logger.warning(
f"[PIP] 安装前运行环境已存在异常,本次安装仅拦截新增异常:{baseline_health_message}"
)
# 遍历策略进行安装
last_error = ""
for strategy in strategies:
@@ -1540,15 +1573,23 @@ class PluginHelper(metaclass=WeakSingleton):
)
if success:
logger.debug(f"[PIP] 策略:{strategy.strategy_name} 安装依赖成功,输出:{message}")
health_ok, health_message = cls.__run_runtime_healthcheck()
if not health_ok:
current_health = cls.__run_runtime_healthcheck()
health_message = cls.__runtime_health_regression_message(
baseline_health,
current_health
)
if health_message:
logger.error(f"[PIP] 依赖安装后运行环境自检失败:{health_message}")
repair_ok, repair_message = cls.__repair_main_runtime_dependencies(
constraints_file if protected_packages else None
)
if repair_ok:
health_restored, restored_message = cls.__run_runtime_healthcheck()
if health_restored:
restored_health = cls.__run_runtime_healthcheck()
restored_message = cls.__runtime_health_regression_message(
baseline_health,
restored_health
)
if not restored_message:
cls.__refresh_import_system()
return False, (
f"依赖安装后运行环境自检失败,已自动恢复主程序依赖:{health_message}"
@@ -1565,6 +1606,13 @@ class PluginHelper(metaclass=WeakSingleton):
f"{repair_message}"
)
remaining_health_message = cls.__runtime_health_regression_message({}, current_health)
if remaining_health_message:
logger.warning(
f"[PIP] 依赖安装成功,安装前已有的运行环境异常仍然存在:"
f"{remaining_health_message}"
)
cls.__refresh_import_system()
loaded_modules_after_install = set(sys.modules.keys())
loaded_modules_during_install = loaded_modules_after_install - loaded_modules_before_install
@@ -1573,7 +1621,8 @@ class PluginHelper(metaclass=WeakSingleton):
last_error = message
repair_ok, repair_message = cls.__repair_if_runtime_broken(
constraints_file if protected_packages else None
constraints_file if protected_packages else None,
baseline_health
)
logger.error(f"[PIP] 策略:{strategy.strategy_name} 安装依赖失败,错误信息:{message}")
if not repair_ok or repair_message:
+13
View File
@@ -244,6 +244,19 @@ class RedisHelper(ConfigReloadMixin, metaclass=Singleton):
logger.error(f"Failed to get key: {key} in region: {region}, error: {e}")
return None
def pop(self, key: str, region: Optional[str] = "DEFAULT") -> Optional[Any]:
"""原子读取并删除缓存值。"""
try:
self._connect()
redis_key = self.__make_redis_key(region, key)
value = self.client.getdel(redis_key)
return deserialize(value) if value is not None else None
except Exception as e:
logger.error(
f"Failed to pop key: {key} in region: {region}, error: {e}"
)
return None
def delete(self, key: str, region: Optional[str] = "DEFAULT") -> None:
"""
删除缓存

Some files were not shown because too many files have changed in this diff Show More