Compare commits

...
45 Commits
Author SHA1 Message Date
jxxghp 93761fe7e4 优化 Agent 代码编辑工具 (#6218) 2026-08-01 09:02:05 +08:00
jxxghp 593139faac fix(agent): 移除插件源码写入限制 2026-07-31 22:40:50 +08:00
cyt-666 6c89f1eb4b fix: 修复 MCP 动态加载插件工具 (#6214) 2026-07-31 22:31:46 +08:00
jxxghp 2310a3a456 fix(plugin): translate rating validation error 2026-07-31 13:50:17 +08:00
jxxghp 48852350a0 feat(plugin): add centralized rating APIs 2026-07-31 13:41:29 +08:00
jxxghp a23fce1491 feat: 统计共享媒体识别命中次数 2026-07-30 17:50:16 +08:00
jxxghp c976741574 feat(transfer): control mounted directory cleanup 2026-07-30 17:45:13 +08:00
jxxghp 04facef64d chore(version): update app and frontend version to v2.15.2 2026-07-30 13:45:29 +08:00
jxxghp 33a97eb2c8 feat: 增加 MoviePilot 选项 (#6212) 2026-07-30 13:33:08 +08:00
Aqr-K cf80b551f9 fix(monitor): 目录监控自愈、快照语义修正与覆盖保护闭环 (#6210) 2026-07-30 06:53:52 +08:00
jxxghp e011b20210 add start-local.sh 2026-07-29 16:54:00 +08:00
jxxghpandClaude Opus 5 bdf395f494 feat(trimemedia): 支持飞牛影视访问码
飞牛开启访问码后,需先请求 /c/{访问码} 获得会话凭证,
否则登录接口与图片等资源均返回 404。

- Api 增加 access_code 参数及 verify_access_code(),登录前先校验
- 创建 API 连接时校验访问码,失败则关闭会话
- 图片 Cookies 附带访问码校验凭证,避免代理图片 404

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:09:10 +08:00
jxxghp 68686bc23a fix https://github.com/jxxghp/MoviePilot/issues/6206
fix https://github.com/jxxghp/MoviePilot/issues/6205
2026-07-28 19:01:55 +08:00
Aqr-K 1a528c7803 fix(http): AsyncRequestUtils 代理请求按 host 熔断降级 h2 隧道 (#6208) 2026-07-28 18:54:16 +08:00
jxxghpandtraeagent 6b4a255f26 feat: 重命名 auth_bridge.py 为 auth.py (#6207)
Co-authored-by: traeagent <traeagent@users.noreply.github.com>
2026-07-28 14:56:33 +08:00
InfinityPacer 1a3c1b8b39 fix(system): derive image proxy MIME from content (#6202) 2026-07-27 14:56:46 +08:00
InfinityPacer 8788dae34b revert(login): remove anonymous wallpaper proxy (#6201) 2026-07-27 13:48:52 +08:00
InfinityPacer bb00814d7a feat(login): 提供无状态签名同源壁纸代理 (#6200) 2026-07-27 12:13:38 +08:00
jxxghp 3d55d44457 fix: ensure media_id is populated in popular subscribes API
- Add media_id field assignment in popular_subscribes endpoint
- Fixes duplicate display issue in frontend by providing complete media identity data

Closes #6199
2026-07-27 09:42:54 +08:00
jxxghp 7a5e565b15 fix(storage): 保留目标目录继承权限 (#6189) 2026-07-26 09:12:35 +08:00
jxxghp cae28d8c03 fix(transfer): 支持显式重新整理历史记录 2026-07-26 08:59:57 +08:00
jxxghp 3d020c8ceb fix(search): keep large SSE searches connected (#6186) 2026-07-26 08:55:34 +08:00
jxxghp 1b065cc08b fix(agent): apply thinking level and temperature settings 2026-07-26 08:25:00 +08:00
yubanmeiqin9048 a1a6376adc feat: add async plugin data interfaces (#6188) 2026-07-25 14:24:47 +08:00
InfinityPacer 197a09b2a4 fix(dashboard): distinguish empty media results (#6187) 2026-07-25 14:24:25 +08:00
jxxghp 8d099b9581 fix(subscribe): preserve source identity when adding subscriptions 2026-07-25 08:51:30 +08:00
jxxghp ff9ba79b60 Codex/fix season subscription poster (#6185) 2026-07-25 08:07:08 +08:00
jxxghp 6354a48405 fix(media): fall back to main season poster (#6184) 2026-07-25 07:57:27 +08:00
G0m3e f6df6cc093 fix(indexer): read HDDolby site message body via NexusPHP web API (#6180) 2026-07-24 12:12:48 +08:00
jxxghp 98e69d1b45 refactor(auth): merge passkey challenge helper (#6179) 2026-07-24 07:05:25 +08:00
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
237 changed files with 17362 additions and 3058 deletions
+18 -17
View File
@@ -68,6 +68,8 @@ from app.utils.identity import SYSTEM_INTERNAL_USER_ID
class AgentChain(ChainBase):
"""Agent 业务处理链。"""
pass
@@ -713,7 +715,7 @@ class MoviePilotAgent:
"""
通过链式事件解析本次 Agent 可用的 LLM 运行时配置。
若没有插件返回 selected_provider_id,则沿用系统配置,保持既有行为
插件返回有效配置时沿用系统配置,显式返回的配置优先
"""
if self._llm_runtime_config is not None:
return self._llm_runtime_config
@@ -726,7 +728,8 @@ class MoviePilotAgent:
base_url_preset=settings.LLM_BASE_URL_PRESET,
user_agent=settings.LLM_USER_AGENT,
use_proxy=settings.LLM_USE_PROXY,
thinking_level=None,
thinking_level=settings.LLM_THINKING_LEVEL,
api_protocol=settings.LLM_API_PROTOCOL,
)
selected_event = await eventmanager.async_send_event(
ChainEventType.AgentLLMProvider,
@@ -761,9 +764,15 @@ class MoviePilotAgent:
use_proxy = self._get_event_value(resolved_data, "use_proxy")
if use_proxy is None:
use_proxy = settings.LLM_USE_PROXY
thinking_level = self._clean_optional_text(
self._get_event_value(resolved_data, "thinking_level")
thinking_level = (
self._clean_optional_text(
self._get_event_value(resolved_data, "thinking_level")
)
or settings.LLM_THINKING_LEVEL
)
api_protocol = self._clean_optional_text(
self._get_event_value(resolved_data, "api_protocol")
) or settings.LLM_API_PROTOCOL
selected_provider_id = self._clean_optional_text(
self._get_event_value(resolved_data, "selected_provider_id")
)
@@ -789,6 +798,7 @@ class MoviePilotAgent:
"user_agent": user_agent,
"use_proxy": bool(use_proxy),
"thinking_level": thinking_level,
"api_protocol": api_protocol,
}
return self._llm_runtime_config
@@ -1024,6 +1034,7 @@ class MoviePilotAgent:
runtime_config.get("user_agent"),
bool(runtime_config.get("use_proxy")),
runtime_config.get("thinking_level"),
runtime_config.get("api_protocol"),
)
async def _agent_bundle_signature(self, streaming: bool) -> tuple[Any, ...]:
@@ -1040,6 +1051,7 @@ class MoviePilotAgent:
self.has_message_context,
self.is_background,
settings.AI_AGENT_VERBOSE,
settings.LLM_TEMPERATURE,
settings.LLM_MAX_TOOLS,
settings.LLM_MAX_ITERATIONS,
self._public_runtime_config_signature(runtime_config),
@@ -2145,7 +2157,7 @@ class AgentManager:
f"也不要重复创建同一个定时任务。\n\n"
f"任务名称:{task.name}\n"
f"任务内容:{task.content}\n\n"
"完成后请直接向用户报告本次执行结果;如果无法完成,说明原因。"
"完成后请直接向用户发送消息报告本次执行结果;如果无法完成,也需发送消息说明原因。"
)
success = True
result = ""
@@ -2164,20 +2176,9 @@ class AgentManager:
wait_for_completion=True,
)
result_text = str(result or "").strip()
success = bool(result_text) and not result_text.startswith(
success = not result_text.startswith(
(AGENT_EXECUTION_ERROR_PREFIX, "处理消息时发生错误")
)
if not result_text:
result = "定时任务已执行,但 Agent 未返回结果"
await AgentChain().async_post_message(
Notification(
mtype=NotificationType.Agent,
username=notification_username,
title=f"定时任务:{task.name}",
text=result,
save_history=False,
)
)
except Exception as err:
success = False
result = f"Agent 定时任务执行失败:{str(err)}"
+38 -5
View File
@@ -846,19 +846,31 @@ class LLMHelper:
provider: str,
model: str | None,
runtime: dict[str, Any],
api_protocol: str | None = None,
) -> bool | None:
"""
判断官方 ChatGPT API Key 模式是否应使用 Responses API。
判断本次 OpenAI 兼容调用是否应使用 Responses API。
GPT-5/o 系推理模型在 Chat Completions 中组合 function tools 与
reasoning_effort 时会被官方端点拒绝,因此 ChatGPT 官方 API Key
模式需要显式切到 Responses API;通用 OpenAI-compatible 入口保持
provider 目录解析出的默认行为,避免误伤第三方兼容服务。
优先级:
1. 运行时显式要求(ChatGPT Plus/Pro OAuth、Codex 等端点契约),始终保留;
2. 用户通过 ``LLM_API_PROTOCOL`` 显式指定 ``responses`` / ``chat_completions``
3. ``auto``(默认)保持原有 ChatGPT 官方 API Key + GPT-5/o 系推理模型
自动切换逻辑,通用 OpenAI 兼容入口仍走 Chat Completions
避免误伤第三方兼容服务。
:param api_protocol: 显式传入的 API 协议,未传入时读取 ``LLM_API_PROTOCOL``
:return: True/False 强制指定协议;None 表示交由 LangChain 默认行为
"""
runtime_use_responses_api = runtime.get("use_responses_api")
if runtime_use_responses_api is not None:
return bool(runtime_use_responses_api)
protocol = cls._normalize_api_protocol(api_protocol)
if protocol == "responses":
return True
if protocol == "chat_completions":
return False
provider_name = (provider or "").strip().lower()
if provider_name != "chatgpt":
return None
@@ -872,6 +884,18 @@ class LLMHelper:
return True
return None
@staticmethod
def _normalize_api_protocol(api_protocol: str | None) -> str:
"""
规范化 API 协议配置,未知值统一回退为 ``auto`` 以保持兼容。
"""
normalized = str(api_protocol or settings.LLM_API_PROTOCOL or "").strip().lower()
if normalized in {"auto", "chat_completions", "responses"}:
return normalized
if normalized:
logger.warning(f"忽略不支持的 LLM_API_PROTOCOL 配置: {api_protocol}")
return "auto"
@staticmethod
def _attach_runtime_metadata(model: Any, runtime: dict[str, Any]) -> None:
"""
@@ -954,6 +978,7 @@ class LLMHelper:
user_agent: str | None = None,
temperature: Optional[float] = None,
use_proxy: bool | None = None,
api_protocol: str | None = None,
):
"""
获取LLM实例
@@ -970,6 +995,10 @@ class LLMHelper:
:param user_agent: OpenAI兼容接口请求 User-Agent。未显式传入时使用配置项 LLM_USER_AGENT。
:param temperature: LLM 温度参数。未显式传入时使用配置项 LLM_TEMPERATURE。
:param use_proxy: 是否为本次 LLM 调用使用系统代理。未显式传入时使用配置项 LLM_USE_PROXY。
:param api_protocol: OpenAI 兼容接口 API 协议
auto/chat_completions/responses)。未显式传入时使用配置项 LLM_API_PROTOCOL。
仅对 OpenAI 兼容运行时生效;``responses`` 强制走 Responses API
``chat_completions`` 强制走 Chat Completions``auto`` 保持原有自动判断。
:return: LLM实例
"""
provider_name = str(provider if provider is not None else settings.LLM_PROVIDER).lower()
@@ -1021,6 +1050,7 @@ class LLMHelper:
provider=provider_name,
model=model_name,
runtime=runtime,
api_protocol=api_protocol,
)
llm_proxy = _resolve_llm_proxy(use_proxy)
@@ -1210,11 +1240,13 @@ class LLMHelper:
user_agent: str | None = None,
temperature: Optional[float] = None,
use_proxy: bool | None = None,
api_protocol: str | None = None,
) -> dict:
"""
使用当前配置或显式传入的临时配置执行一次最小 LLM 调用。
:param temperature: LLM 温度参数。未显式传入时沿用已保存配置。
:param api_protocol: OpenAI 兼容接口 API 协议,未显式传入时沿用已保存配置。
"""
provider_name = provider if provider is not None else settings.LLM_PROVIDER
model_name = model if model is not None else settings.LLM_MODEL
@@ -1229,6 +1261,7 @@ class LLMHelper:
"base_url_preset": base_url_preset,
"user_agent": user_agent,
"use_proxy": use_proxy,
"api_protocol": api_protocol,
}
if temperature is not None:
llm_kwargs["temperature"] = temperature
+5 -2
View File
@@ -17,7 +17,6 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel
- Do not let user memory or persona style override this core identity, safety boundaries, or built-in background task rules.
- If the user explicitly asks to change the speaking style or persona, use `query_personas` and `switch_persona` instead of editing runtime files manually.
- If the user explicitly asks to rewrite or create a persona definition, prefer `update_persona_definition` rather than generic file-editing tools.
- Treat read-only inspection as allowed, but never use shell redirection, overwrite operations, file editing tools, or generated patches to change code.
</non_negotiable_boundaries>
<confirmation_policy>
@@ -66,7 +65,11 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel
- If `search_media` fails, fall back to `search_web` or `recognize_media`. Only ask the user when automated paths are exhausted.
- If torrent search yields no useful result, check site scope, site health, and recognition quality before concluding that the resource is unavailable.
- Reuse the latest torrent search cache for `get_search_results` and `add_download_tasks` instead of re-running the same search unnecessarily.
- Use `execute_command` only for diagnostics, read-only inspection, or commands the user explicitly asked to run. Its default `action=start` starts a managed background session and returns `session_id`, `status`, `last_seq`, and `output_until_seq`; call the same tool again with `action=read`, `action=wait`, `action=write`, or `action=kill` to poll output, wait in short segments, send stdin, or stop the process.
- For administrator code discovery across local files, use `execute_command(action="run")` with `rg` and narrow globs or paths. Use `list_directory` to inspect one known directory or a supported remote storage backend, and use `read_file` when the exact local file is known.
- Read the relevant file before changing it. Use `edit_file` for localized exact replacements; make `old_text` unique with enough surrounding context, and use `replace_all=true` only when every match must change. Use `write_file` for new files; set `overwrite=true` only for an intentional full rewrite, and use `read_file(include_metadata=true)` plus `expected_sha256` when preserving the previously read version matters.
- When implementation depends on a Python or Node.js API, first identify the installed or locked dependency version from environment metadata, requirements, package manifests, lockfiles, local source, and type declarations. Use `rg` against the relevant package directory, `.venv`, or `node_modules` instead of scanning the entire project without bounds. If local evidence is insufficient, use `search_web` and then `browse_webpage` to read the matching version of the official documentation. Do not guess signatures from memory, mix examples from incompatible versions, or install a package only to inspect its API.
- Use structured file tools for source edits because they enforce file access boundaries and conflict checks. Never use shell redirection, inline scripts, or another tool to bypass a file-tool permission denial.
- Use `execute_command` for administrator-only multi-file diagnostics, tests, Git, service operations, SSH, or an exact command the user requested. Use `action=run` for short bounded commands. Use `action=start` for long-running or interactive commands, including SSH; then continue with `read`, `wait`, `write`, or `kill` using the returned `session_id`. Do not start a background session for a short command that can finish within `action=run`.
</tool_strategy>
<media_rules>
+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']}",
]
)
+53
View File
@@ -0,0 +1,53 @@
"""Agent 文件写入工具的共享辅助函数。"""
import hashlib
import os
import tempfile
from pathlib import Path
class FileVersionConflictError(RuntimeError):
"""目标文件在准备写入期间发生变化。"""
def calculate_file_sha256(path: Path) -> str:
"""计算文件原始字节的 SHA-256,用于检测陈旧写入。"""
digest = hashlib.sha256()
with path.open("rb") as file_handle:
for chunk in iter(lambda: file_handle.read(64 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def atomic_write_text(
path: Path,
content: str,
expected_sha256: str | None = None,
) -> None:
"""校验目标版本后,在同目录写入临时文件并原子替换文本。"""
path.parent.mkdir(parents=True, exist_ok=True)
descriptor, temp_name = tempfile.mkstemp(
dir=path.parent,
prefix=f".{path.name}.",
suffix=".tmp",
)
temp_path = Path(temp_name)
try:
with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as file_handle:
file_handle.write(content)
file_handle.flush()
os.fsync(file_handle.fileno())
if expected_sha256:
if (
not path.is_file()
or calculate_file_sha256(path).casefold()
!= expected_sha256.casefold()
):
raise FileVersionConflictError(str(path))
if path.exists():
os.chmod(temp_path, path.stat().st_mode)
os.replace(temp_path, path)
finally:
if temp_path.exists():
temp_path.unlink()
+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"] = {
+12
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)",
@@ -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,
+9 -1
View File
@@ -54,7 +54,15 @@ 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,
"season": subscribe.season,
}
)
# 发送事件
+105 -28
View File
@@ -1,4 +1,4 @@
"""文件编辑工具"""
"""文件精确编辑工具"""
from pathlib import Path
from typing import Optional, Type
@@ -7,6 +7,11 @@ from anyio import Path as AsyncPath
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.impl._file_write_utils import (
FileVersionConflictError,
atomic_write_text,
calculate_file_sha256,
)
from app.agent.tools.tags import ToolTag
from app.log import logger
@@ -15,18 +20,44 @@ class EditFileInput(BaseModel):
"""文件编辑工具的输入参数模型。"""
file_path: str = Field(..., description="The absolute path of the file to edit")
old_text: str = Field(..., description="The exact old text to be replaced")
old_text: str = Field(
...,
description=(
"The exact old text to replace. It must be non-empty and uniquely "
"identify one location unless replace_all is true."
),
)
new_text: str = Field(..., description="The new text to replace with")
replace_all: bool = Field(
False,
description=(
"Replace every exact match. Keep false for normal code edits so an "
"ambiguous match fails instead of changing multiple locations."
),
)
expected_sha256: Optional[str] = Field(
None,
pattern=r"^[0-9a-fA-F]{64}$",
description=(
"Optional SHA-256 returned by read_file(include_metadata=true). The "
"edit fails if the file changed after it was read."
),
)
class EditFileTool(MoviePilotTool):
"""使用精确文本匹配安全编辑本地文件。"""
name: str = "edit_file"
tags: list[str] = [
ToolTag.Write,
ToolTag.File,
]
description: str = (
"Edit a local text file by replacing specific old text with new text. "
"Edit an existing local text file using an exact text match. By default "
"the match must occur exactly once; use replace_all only for intentional "
"bulk replacement. old_text cannot be empty, and new files must be "
"created with write_file. Supports an optional SHA-256 conflict check. "
"Non-admin users can only edit files inside the MoviePilot Agent config "
"directory."
)
@@ -38,7 +69,16 @@ class EditFileTool(MoviePilotTool):
file_name = Path(file_path).name if file_path else "未知文件"
return f"编辑文件: {file_name}"
async def run(self, file_path: str, old_text: str, new_text: str, **kwargs) -> str:
async def run(
self,
file_path: str,
old_text: str,
new_text: str,
replace_all: bool = False,
expected_sha256: Optional[str] = None,
**kwargs,
) -> str:
"""校验精确匹配和可选文件版本后,以原子方式写入编辑结果。"""
logger.info(f"执行工具: {self.name}, 参数: file_path={file_path}")
try:
@@ -48,37 +88,74 @@ class EditFileTool(MoviePilotTool):
if access_error:
return access_error
path = AsyncPath(resolved_path)
# 校验逻辑:如果要替换特定文本,文件必须存在且包含该文本
if not await path.exists():
# 如果 old_text 为空,可能用户想直接创建文件,但通常 edit_file 需要匹配旧内容
if old_text:
return f"错误:文件 {resolved_path} 不存在,无法进行内容替换。"
if not old_text:
return "错误:old_text 不能为空;创建或完整写入文件请使用 write_file。"
if await path.exists() and not await path.is_file():
path = AsyncPath(resolved_path)
if not await path.exists():
return f"错误:文件 {resolved_path} 不存在;创建文件请使用 write_file。"
if not await path.is_file():
return f"错误:{resolved_path} 不是一个文件"
if await path.exists():
content = await path.read_text(encoding="utf-8", errors="replace")
if old_text not in content:
logger.warning(f"编辑文件 {resolved_path} 失败:未找到指定的旧文本块")
return f"错误:在文件 {resolved_path} 中未找到指定的旧文本。请确保包含所有的空格、缩进 and 换行符。"
occurrences = content.count(old_text)
new_content = content.replace(old_text, new_text)
else:
# 文件不存在且 old_text 为空的情形(初始化新文件)
new_content = new_text
occurrences = 1
local_path = Path(resolved_path)
current_sha256 = await self.run_blocking(
"default", calculate_file_sha256, local_path
)
if (
expected_sha256
and current_sha256.casefold() != expected_sha256.casefold()
):
return (
f"错误:文件 {resolved_path} 已在读取后发生变化,拒绝覆盖。"
"请重新读取文件并基于最新内容编辑。"
)
# 自动创建父目录
await path.parent.mkdir(parents=True, exist_ok=True)
content = await path.read_text(encoding="utf-8", errors="strict")
occurrences = content.count(old_text)
if occurrences == 0:
logger.warning(f"编辑文件 {resolved_path} 失败:未找到指定的旧文本块")
return (
f"错误:在文件 {resolved_path} 中未找到指定的旧文本。"
"请重新读取文件并确认空格、缩进和换行。"
)
if occurrences > 1 and not replace_all:
return (
f"错误:old_text 在文件 {resolved_path} 中匹配到 {occurrences} 处,"
"为避免误改已拒绝编辑。请提供更多上下文使其唯一,或明确设置 "
"replace_all=true。"
)
# 写入文件
await path.write_text(new_content, encoding="utf-8")
replacement_count = occurrences if replace_all else 1
new_content = content.replace(
old_text,
new_text,
-1 if replace_all else 1,
)
await self.run_blocking(
"default",
atomic_write_text,
local_path,
new_content,
current_sha256,
)
new_sha256 = await self.run_blocking(
"default", calculate_file_sha256, local_path
)
logger.info(f"成功编辑文件 {resolved_path},替换了 {occurrences} 处内容")
return f"成功编辑文件 {resolved_path} (替换了 {occurrences}匹配内容)"
logger.info(
f"成功编辑文件 {resolved_path}替换了 {replacement_count} 处内容"
)
return (
f"成功编辑文件 {resolved_path}(替换了 {replacement_count} 处匹配内容,"
f"sha256={new_sha256}"
)
except FileVersionConflictError:
return (
f"错误:文件 {file_path} 在编辑期间发生变化,拒绝覆盖。"
"请重新读取文件并再次编辑。"
)
except PermissionError:
return f"错误:没有访问/修改 {file_path} 的权限"
except UnicodeDecodeError:
@@ -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
+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)
@@ -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"),
@@ -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)
+38 -3
View File
@@ -1,5 +1,7 @@
"""文件读取工具"""
import hashlib
import json
from pathlib import Path
from typing import Optional, Type
@@ -16,12 +18,22 @@ MAX_READ_SIZE = 50 * 1024
class ReadFileInput(BaseModel):
"""文件读取工具的输入参数模型。"""
file_path: str = Field(..., description="The absolute path of the file to read")
start_line: Optional[int] = Field(None, description="The starting line number (1-based, inclusive). If not provided, reading starts from the beginning of the file.")
end_line: Optional[int] = Field(None, description="The ending line number (1-based, inclusive). If not provided, reading goes until the end of the file.")
include_metadata: bool = Field(
False,
description=(
"Return structured JSON containing content, size, truncation state, "
"and SHA-256. Use before a guarded full-file overwrite."
),
)
class ReadFileTool(MoviePilotTool):
"""按行范围读取本地文本文件,并可返回文件版本元数据。"""
name: str = "read_file"
tags: list[str] = [
ToolTag.Read,
@@ -36,8 +48,15 @@ class ReadFileTool(MoviePilotTool):
file_name = Path(file_path).name if file_path else "未知文件"
return f"读取文件: {file_name}"
async def run(self, file_path: str, start_line: Optional[int] = None,
end_line: Optional[int] = None, **kwargs) -> str:
async def run(
self,
file_path: str,
start_line: Optional[int] = None,
end_line: Optional[int] = None,
include_metadata: bool = False,
**kwargs,
) -> str:
"""读取指定文本范围,必要时附带完整文件的 SHA-256 元数据。"""
logger.info(f"执行工具: {self.name}, 参数: file_path={file_path}, start_line={start_line}, end_line={end_line}")
try:
@@ -55,7 +74,8 @@ class ReadFileTool(MoviePilotTool):
if not await path.is_file():
return f"错误:{resolved_path} 不是一个文件"
content = await path.read_text(encoding="utf-8", errors="replace")
raw_content = await path.read_bytes()
content = raw_content.decode("utf-8", errors="replace")
truncated = False
if start_line is not None or end_line is not None:
@@ -78,6 +98,21 @@ class ReadFileTool(MoviePilotTool):
content = content_bytes[:MAX_READ_SIZE].decode("utf-8", errors="replace")
truncated = True
if include_metadata:
return json.dumps(
{
"file_path": str(resolved_path),
"sha256": hashlib.sha256(raw_content).hexdigest(),
"size_bytes": len(raw_content),
"start_line": start_line,
"end_line": end_line,
"truncated": truncated,
"content": content,
},
ensure_ascii=False,
indent=2,
)
if truncated:
return f"{content}\n\n[警告:文件内容已超过50KB限制,以上内容已被截断。请使用 start_line/end_line 参数分段读取。]"
+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)
+6
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):
@@ -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,
+75 -9
View File
@@ -7,6 +7,11 @@ from anyio import Path as AsyncPath
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.impl._file_write_utils import (
FileVersionConflictError,
atomic_write_text,
calculate_file_sha256,
)
from app.agent.tools.tags import ToolTag
from app.log import logger
@@ -16,17 +21,36 @@ class WriteFileInput(BaseModel):
file_path: str = Field(..., description="The absolute path of the file to write")
content: str = Field(..., description="The content to write into the file")
overwrite: bool = Field(
False,
description=(
"Allow replacing an existing file in full. Keep false when creating a "
"new file; prefer edit_file for localized changes."
),
)
expected_sha256: Optional[str] = Field(
None,
pattern=r"^[0-9a-fA-F]{64}$",
description=(
"Optional SHA-256 returned by read_file(include_metadata=true). When "
"overwriting, fail if the existing file no longer has this hash."
),
)
class WriteFileTool(MoviePilotTool):
"""创建本地文本文件,或在显式允许后完整覆盖已有文件。"""
name: str = "write_file"
tags: list[str] = [
ToolTag.Write,
ToolTag.File,
]
description: str = (
"Write full content to a local text file. Non-admin users can only write "
"inside the MoviePilot Agent config directory."
"Create a local text file with complete content. Existing files are "
"protected unless overwrite=true; localized changes should use edit_file. "
"Supports an optional SHA-256 conflict check and writes atomically. "
"Non-admin users can only write inside the MoviePilot Agent config directory."
)
args_schema: Type[BaseModel] = WriteFileInput
@@ -36,7 +60,15 @@ class WriteFileTool(MoviePilotTool):
file_name = Path(file_path).name if file_path else "未知文件"
return f"写入文件: {file_name}"
async def run(self, file_path: str, content: str, **kwargs) -> str:
async def run(
self,
file_path: str,
content: str,
overwrite: bool = False,
expected_sha256: Optional[str] = None,
**kwargs,
) -> str:
"""创建或显式覆盖文件,并通过可选哈希阻止陈旧写入。"""
logger.info(f"执行工具: {self.name}, 参数: file_path={file_path}")
try:
@@ -48,18 +80,52 @@ class WriteFileTool(MoviePilotTool):
path = AsyncPath(resolved_path)
if await path.exists() and not await path.is_file():
exists = await path.exists()
if exists and not await path.is_file():
return f"错误:{resolved_path} 路径已存在但不是一个文件"
if exists and not overwrite:
return (
f"错误:文件 {resolved_path} 已存在,拒绝完整覆盖。"
"局部修改请使用 edit_file;确需重写时设置 overwrite=true。"
)
if expected_sha256 and not exists:
return (
f"错误:文件 {resolved_path} 不存在,无法校验 expected_sha256。"
"请确认路径和最新文件状态。"
)
# 自动创建父目录
await path.parent.mkdir(parents=True, exist_ok=True)
local_path = Path(resolved_path)
current_sha256 = None
if exists:
current_sha256 = await self.run_blocking(
"default", calculate_file_sha256, local_path
)
if expected_sha256:
if current_sha256.casefold() != expected_sha256.casefold():
return (
f"错误:文件 {resolved_path} 已在读取后发生变化,拒绝覆盖。"
"请重新读取文件并基于最新内容写入。"
)
# 写入文件
await path.write_text(content, encoding="utf-8")
await self.run_blocking(
"default",
atomic_write_text,
local_path,
content,
current_sha256,
)
new_sha256 = await self.run_blocking(
"default", calculate_file_sha256, local_path
)
logger.info(f"成功写入文件 {resolved_path}")
return f"成功写入文件 {resolved_path}"
return f"成功写入文件 {resolved_path}sha256={new_sha256}"
except FileVersionConflictError:
return (
f"错误:文件 {file_path} 在写入期间发生变化,拒绝覆盖。"
"请重新读取文件并再次写入。"
)
except PermissionError:
return f"错误:没有权限写入 {file_path}"
except Exception as e:
+47 -11
View File
@@ -1,9 +1,11 @@
import json
import threading
import uuid
from typing import Any, Dict, List, Optional
from app.agent.tools.base import ToolExecutionTimeoutError, format_tool_result_for_agent
from app.agent.tools.factory import MoviePilotToolFactory
from app.core.plugin import PluginManager
from app.log import logger
@@ -40,27 +42,59 @@ class MoviePilotToolsManager:
self.session_id = session_id
self.is_admin = is_admin
self.tools: List[Any] = []
self._tools_lock = threading.Lock()
self._plugin_agent_tools_revision = -1
self._load_tools()
def _load_tools(self):
def _load_tools(self) -> None:
"""
加载所有MoviePilot工具
"""
try:
# 创建工具实例
self.tools = MoviePilotToolFactory.create_tools(
session_id=self.session_id,
user_id=self.user_id,
channel=None,
source="api",
username="API Client",
stream_handler=None,
agent_context={"is_admin": self.is_admin},
)
plugin_manager = PluginManager()
while True:
plugin_tools_revision = (
plugin_manager.get_plugin_agent_tools_revision()
)
tools = MoviePilotToolFactory.create_tools(
session_id=self.session_id,
user_id=self.user_id,
channel=None,
source="api",
username="API Client",
stream_handler=None,
agent_context={"is_admin": self.is_admin},
)
if (
plugin_tools_revision
== plugin_manager.get_plugin_agent_tools_revision()
):
break
self.tools = tools
self._plugin_agent_tools_revision = plugin_tools_revision
logger.info(f"成功加载 {len(self.tools)} 个工具")
except Exception as e:
logger.error(f"加载工具失败: {e}", exc_info=True)
self.tools = []
self._plugin_agent_tools_revision = -1
def _ensure_tools_current(self) -> None:
"""
在插件工具注册表变化后惰性刷新工具实例。
"""
plugin_manager = PluginManager()
if (
self._plugin_agent_tools_revision
== plugin_manager.get_plugin_agent_tools_revision()
):
return
with self._tools_lock:
if (
self._plugin_agent_tools_revision
== plugin_manager.get_plugin_agent_tools_revision()
):
return
self._load_tools()
def list_tools(self) -> List[ToolDefinition]:
"""
@@ -69,6 +103,7 @@ class MoviePilotToolsManager:
Returns:
工具定义列表
"""
self._ensure_tools_current()
tools_list = []
for tool in self.tools:
if getattr(tool, "_require_admin", False) and not self.is_admin:
@@ -102,6 +137,7 @@ class MoviePilotToolsManager:
Returns:
工具实例,如果未找到返回None
"""
self._ensure_tools_current()
for tool in self.tools:
if tool.name == tool_name:
return tool
+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"])
+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())
+1 -1
View File
@@ -4,7 +4,7 @@ from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from app import schemas
from app.core.auth_bridge import build_token_response, consume_plugin_auth_ticket
from app.core.auth import build_token_response, consume_plugin_auth_ticket
from app.core.plugin import PluginManager
from app.db.models.passkey import PassKey
from app.db.models.user import User
+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()
+7
View File
@@ -4,12 +4,15 @@ from fastapi import APIRouter, Depends
from app import schemas
from app.chain.douban import DoubanChain
from app.core.config import settings
from app.core.context import MediaInfo
from app.core.security import verify_token
from app.db.models.user import User
from app.db.systemconfig_oper import SystemConfigOper
from app.db.user_oper import get_current_active_superuser_async
from app.modules.douban.douban_cache import DoubanCache
from app.schemas import MediaType
from app.schemas.types import SystemConfigKey
router = APIRouter()
@@ -29,6 +32,10 @@ async def douban_recognition_cache(
"count": len(cache_items),
"recognized": recognized_count,
"unrecognized": len(cache_items) - recognized_count,
"shared_recognized": SystemConfigOper().get(
SystemConfigKey.MediaRecognizeShareCount
) or 0,
"shared_recognize_enabled": settings.MEDIA_RECOGNIZE_SHARE,
"data": cache_items,
},
)
+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,
)
+3
View File
@@ -38,6 +38,7 @@ class LlmTestRequest(BaseModel):
user_agent: Optional[str] = None
temperature: Optional[float] = None
use_proxy: Optional[bool] = None
api_protocol: Optional[str] = None
class LlmProviderAuthStartRequest(BaseModel):
@@ -269,6 +270,7 @@ async def llm_test(
base_url_preset=settings.LLM_BASE_URL_PRESET,
user_agent=settings.LLM_USER_AGENT,
use_proxy=settings.LLM_USE_PROXY,
api_protocol=settings.LLM_API_PROTOCOL,
)
if not payload.provider:
@@ -302,6 +304,7 @@ async def llm_test(
"base_url_preset": payload.base_url_preset,
"user_agent": payload.user_agent,
"use_proxy": payload.use_proxy,
"api_protocol": payload.api_protocol,
}
if payload.temperature is not None:
test_kwargs["temperature"] = payload.temperature
+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="用户名或密码错误")
+152 -51
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") or mediainfo.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(
@@ -27,17 +72,25 @@ 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, custom_words=custom_words.split("\n") if custom_words else None
)
mediainfo = await MediaChain().async_recognize_by_meta(metainfo)
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()
@@ -53,25 +106,28 @@ async def recognize2(
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, custom_words)
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()
@@ -83,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])
@@ -98,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]):
@@ -114,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:
@@ -142,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} 刮削完成")
@@ -242,13 +349,26 @@ 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)
# 明确来源的查询不能按标题切换到默认识别源,避免辅助 TMDB 信息替换主身份。
if media_source and source_media_id:
return []
if title:
meta = MetaInfo(title)
if year:
@@ -259,7 +379,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
)
@@ -269,19 +389,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 []
@@ -294,22 +402,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:
# 广播事件解析媒体信息
@@ -323,13 +426,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:
# 使用名称识别兜底
+28 -11
View File
@@ -1,6 +1,6 @@
from typing import Any, List, Dict, Optional
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app import schemas
@@ -16,10 +16,23 @@ 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()
def _require_mediaserver_result(result: Optional[List[Any]]) -> List[Any]:
"""
保留媒体服务器成功空列表并把提供方失败转换为明确的网关错误
"""
if result is None:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="媒体服务器请求失败",
)
return result
@router.get("/play/{itemid:path}", summary="在线播放")
def play_item(
itemid: str, _: schemas.TokenPayload = Depends(verify_token)
@@ -130,7 +143,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()]
@@ -151,11 +165,12 @@ def latest(
"""
获取媒体服务器最新入库条目
"""
return (
return _require_mediaserver_result(
MediaServerChain().latest(
server=server, count=count, username=userinfo.username
server=server,
count=count,
username=userinfo.username,
)
or []
)
@@ -170,11 +185,12 @@ def playing(
"""
获取媒体服务器正在播放条目
"""
return (
return _require_mediaserver_result(
MediaServerChain().playing(
server=server, count=count, username=userinfo.username
server=server,
count=count,
username=userinfo.username,
)
or []
)
@@ -189,11 +205,12 @@ def library(
"""
获取媒体服务器媒体库列表
"""
return (
return _require_mediaserver_result(
MediaServerChain().librarys(
server=server, username=userinfo.username, hidden=hidden
server=server,
username=userinfo.username,
hidden=hidden,
)
or []
)
+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,
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="验证失败")
+66 -1
View File
@@ -1,7 +1,7 @@
import asyncio
import mimetypes
import shutil
from typing import Annotated, Any, List, Optional
from typing import Annotated, Any, Dict, List, Optional
import aiofiles
from anyio import Path as AsyncPath
@@ -476,6 +476,71 @@ async def statistic(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
return await MoviePilotServerHelper.async_get_plugin_statistic()
@router.get(
"/rating",
summary="批量查询插件评分",
response_model=Dict[str, schemas.PluginRating],
)
async def plugin_ratings(
plugin_ids: Optional[str] = None,
_: User = Depends(get_current_active_superuser_async),
) -> Dict[str, schemas.PluginRating]:
"""
批量查询插件平均分评分人数和当前安装实例评分
"""
requested_ids = plugin_ids.split(",") if plugin_ids is not None else None
ratings = await MoviePilotServerHelper.async_get_plugin_ratings(requested_ids)
return {
plugin_id: schemas.PluginRating.model_validate(rating)
for plugin_id, rating in ratings.items()
}
@router.get(
"/rating/{plugin_id}",
summary="查询插件评分",
response_model=schemas.PluginRating,
)
async def plugin_rating(
plugin_id: str,
_: User = Depends(get_current_active_superuser_async),
) -> schemas.PluginRating:
"""
查询单个插件平均分评分人数和当前安装实例评分
"""
rating = await MoviePilotServerHelper.async_get_plugin_rating(plugin_id)
return schemas.PluginRating.model_validate(rating)
@router.post(
"/rating/{plugin_id}",
summary="提交插件评分",
response_model=schemas.Response,
)
async def rate_plugin(
plugin_id: str,
payload: schemas.PluginRatingRequest,
_: User = Depends(get_current_active_superuser_async),
) -> schemas.Response:
"""
为已安装插件新增或更新当前安装实例评分
"""
installed_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
if plugin_id not in installed_plugins:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"插件 {plugin_id} 未安装,无法评分",
)
rating = await MoviePilotServerHelper.async_submit_plugin_rating(
plugin_id,
payload.rating,
)
if rating is None:
return schemas.Response(success=False, message="连接MoviePilot服务器失败")
return schemas.Response(success=True, data=rating)
@router.get(
"/reload/{plugin_id}", summary="重新加载插件", response_model=schemas.Response
)
+43 -23
View File
@@ -1,17 +1,29 @@
from typing import Any, List, Optional
from typing import Any, Awaitable, List, Optional
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException, status
from app import schemas
from app.chain.recommend import RecommendChain
from app.core.event import eventmanager
from app.core.security import verify_token
from app.modules.themoviedb.tmdbv3api.exceptions import TMDbException
from app.schemas import RecommendSourceEventData
from app.schemas.types import ChainEventType
router = APIRouter()
async def _require_tmdb_result(operation: Awaitable[List[Any]]) -> List[Any]:
"""保留 TMDB 成功空列表,并把上游请求异常转换为明确的网关错误。"""
try:
return await operation
except TMDbException as error:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="TMDB请求失败",
) from error
@router.get(
"/source",
summary="获取推荐数据源",
@@ -204,16 +216,19 @@ async def tmdb_movies(
"""
浏览TMDB电影信息
"""
return await RecommendChain().async_tmdb_movies(
sort_by=sort_by,
with_genres=with_genres,
with_original_language=with_original_language,
with_keywords=with_keywords,
with_watch_providers=with_watch_providers,
vote_average=vote_average,
vote_count=vote_count,
release_date=release_date,
page=page,
return await _require_tmdb_result(
RecommendChain().async_tmdb_movies(
sort_by=sort_by,
with_genres=with_genres,
with_original_language=with_original_language,
with_keywords=with_keywords,
with_watch_providers=with_watch_providers,
vote_average=vote_average,
vote_count=vote_count,
release_date=release_date,
page=page,
raise_exception=True,
)
)
@@ -233,16 +248,19 @@ async def tmdb_tvs(
"""
浏览TMDB剧集信息
"""
return await RecommendChain().async_tmdb_tvs(
sort_by=sort_by,
with_genres=with_genres,
with_original_language=with_original_language,
with_keywords=with_keywords,
with_watch_providers=with_watch_providers,
vote_average=vote_average,
vote_count=vote_count,
release_date=release_date,
page=page,
return await _require_tmdb_result(
RecommendChain().async_tmdb_tvs(
sort_by=sort_by,
with_genres=with_genres,
with_original_language=with_original_language,
with_keywords=with_keywords,
with_watch_providers=with_watch_providers,
vote_average=vote_average,
vote_count=vote_count,
release_date=release_date,
page=page,
raise_exception=True,
)
)
@@ -255,4 +273,6 @@ async def tmdb_trending(
"""
TMDB流行趋势
"""
return await RecommendChain().async_tmdb_trending(page=page)
return await _require_tmdb_result(
RecommendChain().async_tmdb_trending(page=page, raise_exception=True)
)
+204 -441
View File
@@ -1,6 +1,8 @@
import asyncio
import json
from typing import List, Any, Optional, AsyncIterator
import time
from typing import Any, AsyncIterator, Iterator, List, Optional
from uuid import uuid4
from fastapi import APIRouter, Depends, Body, Request
from fastapi.responses import StreamingResponse
@@ -16,12 +18,19 @@ 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()
_SSE_APPEND_FLUSH_INTERVAL = 1
_SSE_APPEND_MAX_ITEMS = 48
_SSE_HEARTBEAT_INTERVAL = 15
_SSE_REPLACE_MAX_ITEMS = 48
_SSE_RESPONSE_HEADERS = {
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
}
def _parse_site_list(sites: Optional[str]) -> Optional[List[int]]:
@@ -44,12 +53,63 @@ def _resolve_media_season(
explicit_season: Optional[int],
recognized_season: Optional[int],
) -> Optional[int]:
"""
合并显式季号与识别结果显式值优先且季 0 属于有效业务值
"""
"""合并显式季号与识别结果,显式值优先且季 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事件
@@ -128,11 +188,40 @@ def _merge_append_event(pending_event: Optional[dict], event: dict) -> dict:
return merged_event
def _iter_replace_event_batches(event: dict) -> Iterator[dict]:
"""
将超大的最终替换事件拆成有序批次避免单个 SSE 消息承载全部完整对象
"""
items = event.get("items")
if (
event.get("type") != "replace"
or not isinstance(items, list)
or len(items) <= _SSE_REPLACE_MAX_ITEMS
):
yield event
return
batch_count = (len(items) + _SSE_REPLACE_MAX_ITEMS - 1) // _SSE_REPLACE_MAX_ITEMS
for batch_index in range(batch_count):
start = batch_index * _SSE_REPLACE_MAX_ITEMS
batch_event = dict(event)
batch_event.update(
{
"type": "replace" if batch_index == 0 else "append",
"items": items[start:start + _SSE_REPLACE_MAX_ITEMS],
"replace_batch": True,
"batch_index": batch_index,
"batch_count": batch_count,
}
)
yield batch_event
async def _iter_batched_search_events(
event_source: AsyncIterator[dict],
) -> AsyncIterator[dict]:
"""
对搜索流事件做轻量批处理避免站点结果集中返回时产生过密 SSE
对搜索流事件做轻量批处理并在上游长时间静默时发送心跳
"""
iterator = event_source.__aiter__()
pending_append_event: Optional[dict] = None
@@ -143,13 +232,19 @@ async def _iter_batched_search_events(
if next_event_task is None:
next_event_task = asyncio.create_task(anext(iterator))
timeout = _SSE_APPEND_FLUSH_INTERVAL if pending_append_event else None
timeout = (
_SSE_APPEND_FLUSH_INTERVAL
if pending_append_event
else _SSE_HEARTBEAT_INTERVAL
)
done, _ = await asyncio.wait({next_event_task}, timeout=timeout)
if not done:
if pending_append_event:
yield pending_append_event
pending_append_event = None
else:
yield {"type": "heartbeat"}
continue
try:
@@ -175,7 +270,8 @@ async def _iter_batched_search_events(
yield pending_append_event
pending_append_event = None
yield event
for batched_event in _iter_replace_event_batches(event):
yield batched_event
finally:
if next_event_task and not next_event_task.done():
next_event_task.cancel()
@@ -187,13 +283,29 @@ async def _iter_batched_search_events(
async def _stream_search_events(request: Request, event_source: AsyncIterator[dict]):
"""
输出搜索SSE事件
输出搜索 SSE 事件并记录连接生命周期与传输规模
"""
locale = LocaleHelper.get_locale_from_request(request)
search_id = uuid4().hex[:12]
request_path = getattr(getattr(request, "url", None), "path", "unknown")
started_at = time.monotonic()
event_count = 0
transmitted_bytes = 0
last_event_type = "none"
last_stage = "none"
termination_reason = "source_exhausted"
logger.info(f"渐进式搜索流已建立,搜索ID{search_id},路径:{request_path}")
try:
has_sent_final_replace = False
async for event in _iter_batched_search_events(event_source):
last_event_type = event.get("type") or "unknown"
last_stage = event.get("stage") or last_stage
if await request.is_disconnected():
termination_reason = "client_disconnected"
logger.warning(
f"渐进式搜索客户端已断开,搜索ID{search_id},路径:{request_path}"
f"事件:{last_event_type},阶段:{last_stage}"
)
break
# 精确搜索会先发送 replace,再发送 done。done 再带整包 items 只会重复占用带宽和前端内存。
if event.get("type") == "replace" and event.get("items"):
@@ -205,13 +317,36 @@ async def _stream_search_events(request: Request, event_source: AsyncIterator[di
and event.get("items")
):
event = {key: value for key, value in event.items() if key != "items"}
yield _sse_event(event, locale=locale)
payload = _sse_event(event, locale=locale)
event_count += 1
transmitted_bytes += len(payload.encode("utf-8"))
if event.get("type") == "done":
termination_reason = "completed"
yield payload
except asyncio.CancelledError:
termination_reason = "cancelled"
logger.warning(
f"渐进式搜索流已取消,搜索ID{search_id},路径:{request_path}"
f"事件:{last_event_type},阶段:{last_stage}"
)
raise
except Exception as err:
termination_reason = "error"
logger.error(f"渐进式搜索出错:{err}", exc_info=True)
yield _sse_event(
payload = _sse_event(
{"type": "error", "success": False, "message": str(err)},
locale=locale,
)
event_count += 1
transmitted_bytes += len(payload.encode("utf-8"))
yield payload
finally:
elapsed = time.monotonic() - started_at
logger.info(
f"渐进式搜索流结束,搜索ID{search_id},路径:{request_path}"
f"状态:{termination_reason},事件数:{event_count}"
f"发送字节:{transmitted_bytes},耗时:{elapsed:.2f}"
)
@router.get("/last", summary="查询搜索结果", response_model=List[schemas.Context])
@@ -264,194 +399,35 @@ 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:
media_season = _resolve_media_season(
explicit_season=media_season,
recognized_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 is not None:
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
return StreamingResponse(
_stream_search_events(request, event_source()), media_type="text/event-stream"
_stream_search_events(request, event_source()),
media_type="text/event-stream",
headers=_SSE_RESPONSE_HEADERS,
)
@@ -467,182 +443,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:
media_season = _resolve_media_season(
explicit_season=media_season,
recognized_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 is not None:
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="渐进式模糊搜索资源")
@@ -661,7 +487,9 @@ async def search_by_title_stream(
title=keyword, page=page, sites=_parse_site_list(sites), cache_local=True
)
return StreamingResponse(
_stream_search_events(request, event_source), media_type="text/event-stream"
_stream_search_events(request, event_source),
media_type="text/event-stream",
headers=_SSE_RESPONSE_HEADERS,
)
@@ -706,6 +534,7 @@ async def search_subtitle_by_title_stream(
_iter_signed_subtitle_search_events(event_source),
),
media_type="text/event-stream",
headers=_SSE_RESPONSE_HEADERS,
)
@@ -746,7 +575,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):
@@ -765,82 +593,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媒体信息"
media_season = _resolve_media_season(
explicit_season=media_season,
recognized_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 is not None:
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="渐进式精确搜索字幕")
@@ -856,7 +618,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,
@@ -885,6 +647,7 @@ async def search_subtitle_by_id_stream(
_iter_signed_subtitle_search_events(event_source()),
),
media_type="text/event-stream",
headers=_SSE_RESPONSE_HEADERS,
)
@@ -900,7 +663,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,
+56 -49
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:
@@ -247,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)
@@ -419,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
@@ -632,6 +628,9 @@ 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.media_id = sub.get("media_id")
media.tvdb_id = sub.get("tvdbid")
media.imdb_id = sub.get("imdbid")
media.season = sub.get("season")
@@ -858,6 +857,14 @@ 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"),
"season": subscribe_info.get("season"),
}
)
return schemas.Response(success=True)
+7 -4
View File
@@ -582,23 +582,27 @@ async def fetch_image(
):
return None
content = await ImageHelper().async_fetch_image(
image_result = await ImageHelper().async_fetch_image_with_mime_type(
url=fetch_url,
proxy=proxy,
use_cache=use_cache,
cookies=cookies,
)
if content:
if image_result:
content, media_type = image_result
# 检查 If-None-Match
etag = HashUtils.md5(content)
headers = RequestUtils.generate_cache_headers(etag, max_age=86400 * 7)
headers["Content-Type"] = media_type
headers["X-Content-Type-Options"] = "nosniff"
if if_none_match == etag:
return Response(status_code=304, headers=headers)
# 返回缓存图片
return Response(
content=content,
media_type=UrlUtils.get_mime_type(fetch_url, "image/jpeg"),
media_type=media_type,
headers=headers,
)
return None
@@ -695,7 +699,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
+7 -1
View File
@@ -4,11 +4,13 @@ from fastapi import APIRouter, Depends
from app import schemas
from app.chain.tmdb import TmdbChain
from app.core.config import settings
from app.core.security import verify_token
from app.db.models.user import User
from app.db.systemconfig_oper import SystemConfigOper
from app.db.user_oper import get_current_active_superuser_async
from app.modules.themoviedb.tmdb_cache import TmdbCache
from app.schemas.types import MediaType
from app.schemas.types import MediaType, SystemConfigKey
router = APIRouter()
@@ -28,6 +30,10 @@ async def tmdb_recognition_cache(
"count": len(cache_items),
"recognized": recognized_count,
"unrecognized": len(cache_items) - recognized_count,
"shared_recognized": SystemConfigOper().get(
SystemConfigKey.MediaRecognizeShareCount
) or 0,
"shared_recognize_enabled": settings.MEDIA_RECOGNIZE_SHARE,
"data": cache_items,
},
)
+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:
# 自动重新识别
+57 -1
View File
@@ -240,6 +240,40 @@ def match_manual_transfer_target_path(
)
@router.post(
"/manual/history",
summary="查询手动转移成功历史",
response_model=schemas.Response,
)
def query_manual_transfer_history(
transer_item: ManualTransferItem,
db: Session = Depends(get_db),
_: User = Depends(get_current_active_manage_user),
) -> Any:
"""
查询文件或目录命中的成功整理记录
:param transer_item: 手工整理项
:param db: 数据库
:param _: Token校验
"""
src_fileitems, error_message = _resolve_manual_transfer_source_fileitems(
transer_item=transer_item,
db=db,
)
if error_message:
return schemas.Response(success=False, message=error_message)
histories = TransferChain().get_manual_transfer_histories(
_deduplicate_fileitems(src_fileitems)
)
history_info = schemas.ManualTransferHistoryInfo(
reorganize=bool(histories),
history_count=len(histories),
)
return schemas.Response(success=True, data=history_info.model_dump())
@router.post("/manual", summary="手动转移", response_model=schemas.Response)
def manual_transfer(
transer_item: ManualTransferItem,
@@ -278,7 +312,11 @@ def manual_transfer(
else:
# 源路径
src_fileitems = [FileItem(**history.src_fileitem)]
if history.dest_fileitem and not transer_item.preview:
if (
history.dest_fileitem
and not transer_item.preview
and not transer_item.reorganize
):
cleanup_dest_fileitem = FileItem(**history.dest_fileitem)
# 从历史数据获取信息
@@ -292,6 +330,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
@@ -409,6 +455,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,
@@ -423,6 +473,7 @@ def manual_transfer(
downloader=downloader,
download_hash=download_hash,
preview=transer_item.preview,
reorganize=transer_item.reorganize,
sync_extra_files=False,
cleanup_dest_fileitem=cleanup_dest_fileitem,
)
@@ -491,6 +542,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,
@@ -505,6 +560,7 @@ def manual_transfer(
downloader=downloader,
download_hash=download_hash,
preview=transer_item.preview,
reorganize=transer_item.reorganize,
sync_extra_files=True,
cleanup_dest_fileitem=cleanup_dest_fileitem,
)
+205 -51
View File
@@ -20,6 +20,7 @@ from app.core.meta import MetaBase
from app.core.module import ModuleManager
from app.core.plugin import PluginManager
from app.db.message_oper import MessageOper
from app.db.systemconfig_oper import SystemConfigOper
from app.db.user_oper import UserOper
from app.helper.message import MessageHelper, MessageQueueManager, MessageTemplateHelper
from app.helper.server import MoviePilotServerHelper
@@ -40,6 +41,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 (
@@ -48,6 +50,7 @@ from app.schemas.types import (
MediaImageType,
EventType,
MessageChannel,
SystemConfigKey,
)
from app.utils.object import ObjectUtils
@@ -464,15 +467,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 +489,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 +522,7 @@ class ChainBase(metaclass=ABCMeta):
tmdbid: Optional[int],
doubanid: Optional[str],
bangumiid: Optional[int],
anilistid: Optional[int],
) -> bool:
"""
仅在名称识别场景下使用共享识别显式ID识别不再重复回查
@@ -516,7 +530,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 +574,76 @@ class ChainBase(metaclass=ABCMeta):
mediainfo=mediainfo,
)
@staticmethod
def _record_media_recognize_share_hit() -> None:
"""记录一次共享媒体识别成功命中,统计失败不影响识别结果。"""
try:
SystemConfigOper().increment(SystemConfigKey.MediaRecognizeShareCount)
except Exception as err:
logger.error(f"记录共享媒体识别命中次数失败:{str(err)}")
@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 +653,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 +668,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 +713,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,14 +729,18 @@ 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,
)
if mediainfo:
self._update_local_recognize_cache(shared_cache_meta, mediainfo)
self._record_media_recognize_share_hit()
return mediainfo
return None
@@ -648,9 +748,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 +763,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 +778,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 +823,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,14 +839,18 @@ 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,
)
if mediainfo:
await self._async_update_local_recognize_cache(shared_cache_meta, mediainfo)
await run_in_threadpool(self._record_media_recognize_share_hit)
return mediainfo
return None
@@ -984,49 +1110,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 []
+93 -21
View File
@@ -7,10 +7,11 @@ 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
from app.chain.media import MediaChain
from app.chain.storage import StorageChain
from app.core.cache import FileCache
from app.core.config import settings, global_vars
@@ -30,6 +31,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 +61,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:
"""
@@ -324,17 +365,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: 成功状态提示消息保存文件列表
@@ -345,11 +394,16 @@ 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, "无法识别媒体信息", []
mediainfo = MediaChain().supplement_tmdb_info(mediainfo, metainfo)
storage, target_dir, error_msg = self._resolve_media_download_dir(
media_info=mediainfo,
@@ -479,10 +533,11 @@ 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', '')}"
)
@@ -536,6 +591,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)
@@ -549,6 +605,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,
@@ -657,7 +717,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
@@ -668,7 +732,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,
@@ -678,21 +743,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,
@@ -740,6 +806,10 @@ class DownloadChain(ChainBase):
_meta = context.meta_info
_site_downloader = _torrent.site_downloader
# 下载目录和下载器分类依赖 TMDB 辅助分类,但媒体主身份保持不变。
_media = MediaChain().supplement_tmdb_info(_media, _meta)
context.media_info = _media
# 发送资源下载事件,允许外部拦截下载
event_data = ResourceDownloadEventData(
context=context,
@@ -775,14 +845,6 @@ class DownloadChain(ChainBase):
logger.warn(str(err))
return (None, str(err)) if return_detail else None
# 补充完整的media数据
if not _media.genre_ids:
new_media = self.recognize_media(mtype=_media.type, tmdbid=_media.tmdb_id,
doubanid=_media.douban_id, bangumiid=_media.bangumi_id,
episode_group=_media.episode_group)
if new_media:
_media = new_media
# 实际下载的集数
download_episodes = StringUtils.format_ep(list(episodes)) if episodes else None
if episodes is not None:
@@ -861,6 +923,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,
@@ -870,6 +933,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(),
@@ -1206,7 +1273,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
@@ -1337,7 +1404,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
@@ -1439,7 +1506,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 \
@@ -1517,6 +1584,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):
"""
@@ -1528,7 +1596,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(
@@ -1569,6 +1637,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"媒体信息识别失败!")
+178 -6
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,
)
@@ -607,14 +614,128 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
logger.warn(f"{metainfo.title} 未识别到媒体信息")
return mediainfo
@staticmethod
def _build_tmdb_supplement_meta(
mediainfo: MediaInfo,
metainfo: Optional[MetaBase] = None,
) -> MetaBase:
"""
根据主识别结果构造 TMDB 辅助识别参数
:param mediainfo: 主识别源返回的媒体信息
:param metainfo: 原始标题解析信息
:return: 不携带主识别源身份的 TMDB 查询参数
"""
title = mediainfo.title or getattr(metainfo, "name", None) or ""
tmdb_meta = MetaInfo(title)
if not tmdb_meta.cn_name and getattr(metainfo, "cn_name", None):
tmdb_meta.cn_name = metainfo.cn_name
if not tmdb_meta.en_name:
tmdb_meta.en_name = mediainfo.en_title or (
getattr(metainfo, "en_name", None)
)
tmdb_meta.type = mediainfo.type or (
getattr(metainfo, "type", None) or MediaType.UNKNOWN
)
season = (
mediainfo.season
if mediainfo.season is not None
else getattr(metainfo, "begin_season", None)
)
tmdb_meta.begin_season = season
season_year = None
if season is not None and mediainfo.season_years:
season_year = (
mediainfo.season_years.get(season)
or mediainfo.season_years.get(str(season))
)
tmdb_meta.year = (
season_year
or mediainfo.year
or getattr(metainfo, "year", None)
)
return tmdb_meta
@staticmethod
def _merge_tmdb_auxiliary(
mediainfo: MediaInfo,
tmdb_media: MediaInfo,
) -> MediaInfo:
"""
TMDB 兼容字段合并到主识别结果不改变主数据源身份和展示信息
:param mediainfo: 主识别源返回的媒体信息
:param tmdb_media: TMDB 辅助识别结果
:return: 已补充 TMDB 兼容字段的主媒体信息
"""
if not tmdb_media or tmdb_media.source != "themoviedb" or not tmdb_media.tmdb_id:
return mediainfo
mediainfo.tmdb_id = tmdb_media.tmdb_id
mediainfo.tmdb_info = tmdb_media.tmdb_info or mediainfo.tmdb_info
if not mediainfo.category:
mediainfo.category = tmdb_media.category
if not mediainfo.genre_ids:
mediainfo.genre_ids = list(tmdb_media.genre_ids or [])
for field in ("imdb_id", "tvdb_id", "collection_id"):
if not getattr(mediainfo, field, None):
setattr(mediainfo, field, getattr(tmdb_media, field, None))
return mediainfo
def supplement_tmdb_info(
self,
mediainfo: Optional[MediaInfo],
metainfo: Optional[MetaBase] = None,
) -> Optional[MediaInfo]:
"""
为任意主识别源补充 TMDB 辅助信息同时保留原始媒体身份
:param mediainfo: 主识别源返回的媒体信息
:param metainfo: 原始标题解析信息
:return: 已补充 TMDB 辅助字段的原媒体对象
"""
if not mediainfo:
return None
if mediainfo.tmdb_id and mediainfo.tmdb_info and mediainfo.genre_ids:
return mediainfo
tmdb_meta = self._build_tmdb_supplement_meta(mediainfo, metainfo)
tmdb_module = self.modulemanager.get_running_module("TheMovieDbModule")
if not tmdb_module:
logger.warn("TMDB 模块未启用,无法补充 TMDB 辅助信息")
return mediainfo
try:
tmdb_media = tmdb_module.recognize_media(
meta=tmdb_meta,
mtype=mediainfo.type,
source="themoviedb",
mediaid=str(mediainfo.tmdb_id) if mediainfo.tmdb_id else None,
tmdbid=mediainfo.tmdb_id,
episode_group=mediainfo.episode_group,
cache=True,
)
except Exception as err:
logger.warn(f"{mediainfo.title_year} 补充 TMDB 辅助信息失败:{err}")
return mediainfo
if not tmdb_media:
logger.warn(f"{mediainfo.title_year} 未匹配到 TMDB 辅助信息")
return mediainfo
return self._merge_tmdb_auxiliary(mediainfo, tmdb_media)
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 +743,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,
)
@@ -668,6 +793,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]:
"""
@@ -676,6 +802,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
:param title: 标题
:param org_meta: 原始元数据
:param share_meta: 共享识别查询/上报使用的原始元数据
:param source: 请求级识别数据源
:param episode_group: 剧集组
"""
# 发送请求事件,等待结果
@@ -718,6 +845,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
# 重新识别
return self.recognize_media(
meta=org_meta,
source=source,
share_meta=share_meta,
episode_group=episode_group,
)
@@ -725,11 +853,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)
@@ -737,6 +872,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,
)
@@ -746,11 +882,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: 识别元数据媒体信息列表
"""
# 提取要素
@@ -772,7 +911,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, []
@@ -1553,14 +1692,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,
)
@@ -1571,29 +1718,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,
)
@@ -1618,6 +1776,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]:
"""
@@ -1626,6 +1785,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
:param title: 标题
:param org_meta: 原始元数据
:param share_meta: 共享识别查询/上报使用的原始元数据
:param source: 请求级识别数据源
:param episode_group: 剧集组
"""
# 发送请求事件,等待结果
@@ -1668,6 +1828,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
# 重新识别
return await self.async_recognize_media(
meta=org_meta,
source=source,
share_meta=share_meta,
episode_group=episode_group,
)
@@ -1675,11 +1836,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)
@@ -1687,6 +1855,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,
)
@@ -1697,12 +1866,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: 识别元数据媒体信息列表
"""
# 提取要素
@@ -1724,7 +1894,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, []
+36 -14
View File
@@ -27,11 +27,13 @@ class MediaServerChain(ChainBase):
def _sign_library_images(
self, libraries: Optional[List[MediaServerLibrary]]
) -> List[MediaServerLibrary]:
) -> Optional[List[MediaServerLibrary]]:
"""
给媒体库列表中的封面和封面组添加代理签名
给媒体库列表中的封面和封面组添加代理签名并保留提供方失败状态
"""
for library in libraries or []:
if libraries is None:
return None
for library in libraries:
if library.image:
library.image = self._sign_image_url(library.image)
if library.image_list:
@@ -40,21 +42,23 @@ class MediaServerChain(ChainBase):
for image in library.image_list
if image
]
return libraries or []
return libraries
def _sign_play_item_images(
self, items: Optional[List[MediaServerPlayItem]]
) -> List[MediaServerPlayItem]:
) -> Optional[List[MediaServerPlayItem]]:
"""
给媒体服务器播放条目中的图片 URL 添加代理签名
给媒体服务器播放条目中的图片 URL 添加代理签名并保留提供方失败状态
"""
for item in items or []:
if items is None:
return None
for item in items:
if item.image:
item.image = self._sign_image_url(item.image)
return items or []
return items
def librarys(self, server: str, username: Optional[str] = None,
hidden: bool = False) -> List[MediaServerLibrary]:
hidden: bool = False) -> Optional[List[MediaServerLibrary]]:
"""
获取媒体服务器所有媒体库
"""
@@ -151,7 +155,7 @@ class MediaServerChain(ChainBase):
return self.run_module("mediaserver_tv_episodes", server=server, item_id=item_id)
def playing(self, server: str, count: Optional[int] = 20,
username: Optional[str] = None) -> List[MediaServerPlayItem]:
username: Optional[str] = None) -> Optional[List[MediaServerPlayItem]]:
"""
获取媒体服务器正在播放信息
"""
@@ -165,7 +169,7 @@ class MediaServerChain(ChainBase):
)
def latest(self, server: str, count: Optional[int] = 20,
username: Optional[str] = None) -> List[MediaServerPlayItem]:
username: Optional[str] = None) -> Optional[List[MediaServerPlayItem]]:
"""
获取媒体服务器最新入库条目
"""
@@ -238,11 +242,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()
@@ -257,7 +266,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,
@@ -266,7 +282,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 = {}
+9 -2
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,7 +2090,8 @@ 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 is not None:
episodes = mediainfo.seasons.get(meta.begin_season)
@@ -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 [
+16 -6
View File
@@ -313,7 +313,8 @@ class RecommendChain(ChainBase, metaclass=Singleton):
vote_average: Optional[float] = 0.0,
vote_count: Optional[int] = 0,
release_date: Optional[str] = "",
page: Optional[int] = 1) -> List[dict]:
page: Optional[int] = 1,
raise_exception: bool = False) -> List[dict]:
"""
异步TMDB热门电影
"""
@@ -326,7 +327,8 @@ class RecommendChain(ChainBase, metaclass=Singleton):
vote_average=vote_average,
vote_count=vote_count,
release_date=release_date,
page=page)
page=page,
raise_exception=raise_exception)
return [movie.to_dict() for movie in movies] if movies else []
@log_execution_time(logger=logger)
@@ -339,7 +341,8 @@ class RecommendChain(ChainBase, metaclass=Singleton):
vote_average: Optional[float] = 0.0,
vote_count: Optional[int] = 0,
release_date: Optional[str] = "",
page: Optional[int] = 1) -> List[dict]:
page: Optional[int] = 1,
raise_exception: bool = False) -> List[dict]:
"""
异步TMDB热门电视剧
"""
@@ -352,16 +355,23 @@ class RecommendChain(ChainBase, metaclass=Singleton):
vote_average=vote_average,
vote_count=vote_count,
release_date=release_date,
page=page)
page=page,
raise_exception=raise_exception)
return [tv.to_dict() for tv in tvs] if tvs else []
@log_execution_time(logger=logger)
@cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True)
async def async_tmdb_trending(self, page: Optional[int] = 1) -> List[dict]:
async def async_tmdb_trending(
self, page: Optional[int] = 1, raise_exception: bool = False
) -> List[dict]:
"""
异步TMDB流行趋势
"""
infos = await TmdbChain().async_run_module("async_tmdb_trending", page=page)
infos = await TmdbChain().async_run_module(
"async_tmdb_trending",
page=page,
raise_exception=raise_exception,
)
return [info.to_dict() for info in infos] if infos else []
@log_execution_time(logger=logger)
+190 -61
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:
@@ -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]:
"""
+373 -148
View File
@@ -43,6 +43,12 @@ from app.schemas import (MediaRecognizeConvertEventData, SubscribeEpisodesRefres
SubscribeCompletionCheckEventData)
from app.schemas.types import MediaType, SystemConfigKey, MessageChannel, NotificationType, EventType, ChainEventType, \
ContentType
from app.utils.media import (
build_media_key,
normalize_media_source,
parse_media_key,
resolve_media_identity,
)
subscribe_interaction_manager = SlashInteractionManager()
@@ -55,9 +61,61 @@ def build_subscribe_meta(subscribe: Subscribe) -> MetaBase:
meta.year = subscribe.year
meta.begin_season = subscribe.season
meta.type = MediaType(subscribe.type)
meta.tmdbid = subscribe.tmdbid
meta.doubanid = subscribe.doubanid
meta.bangumiid = subscribe.bangumiid
meta.anilistid = subscribe.anilistid
meta.media_source = subscribe.media_source
meta.media_id = subscribe.media_id
return meta
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,
}
def _subscribe_recognize_kwargs(subscribe: Subscribe) -> dict:
"""从订阅记录构造完整的识别 ID 参数。"""
media_source, media_id = resolve_media_identity(media=subscribe)
return {
"source": media_source,
"mediaid": media_id,
"tmdbid": subscribe.tmdbid,
"doubanid": subscribe.doubanid,
"bangumiid": subscribe.bangumiid,
"anilistid": subscribe.anilistid,
}
def _subscribe_media_key(subscribe: Subscribe) -> Union[str, int, None]:
"""返回订阅缺失集映射使用的稳定媒体键。"""
media_source, media_id = resolve_media_identity(media=subscribe)
return build_media_key(media_source, media_id) or media_id
def _subscribe_media_keys(subscribe: Subscribe) -> List[Union[str, int]]:
"""返回新旧缺失集缓存均可识别的订阅媒体键。"""
media_source, media_id = resolve_media_identity(media=subscribe)
candidates = [
build_media_key(media_source, media_id),
subscribe.mediaid,
subscribe.tmdbid,
subscribe.doubanid,
subscribe.bangumiid,
subscribe.anilistid,
]
return [candidate for candidate in candidates if candidate not in (None, "")]
class SubscribeChain(ChainBase):
"""
订阅管理处理链
@@ -227,8 +285,14 @@ class SubscribeChain(ChainBase):
if not subscribe.best_version:
no_exists = no_exists or {}
mediakey = subscribe.tmdbid or subscribe.doubanid
left_seasons = no_exists.get(mediakey) or {}
left_seasons = next(
(
no_exists.get(media_key)
for media_key in _subscribe_media_keys(subscribe)
if no_exists.get(media_key) is not None
),
{},
)
for season_info in left_seasons.values():
if season_info.season != subscribe.season:
continue
@@ -739,10 +803,12 @@ class SubscribeChain(ChainBase):
if event_data.media_dict:
mediachain = MediaChain()
new_id = event_data.media_dict.get("id")
if event_data.convert_type == "themoviedb":
return mediachain.recognize_media(meta=_meta, tmdbid=new_id)
elif event_data.convert_type == "douban":
return mediachain.recognize_media(meta=_meta, doubanid=new_id)
if new_id is not None and event_data.convert_type:
return mediachain.recognize_media(
meta=_meta,
source=event_data.convert_type,
mediaid=str(new_id),
)
return None
@staticmethod
@@ -761,10 +827,12 @@ class SubscribeChain(ChainBase):
if event_data.media_dict:
mediachain = MediaChain()
new_id = event_data.media_dict.get("id")
if event_data.convert_type == "themoviedb":
return await mediachain.async_recognize_media(meta=_meta, tmdbid=new_id)
elif event_data.convert_type == "douban":
return await mediachain.async_recognize_media(meta=_meta, doubanid=new_id)
if new_id is not None and event_data.convert_type:
return await mediachain.async_recognize_media(
meta=_meta,
source=event_data.convert_type,
mediaid=str(new_id),
)
return None
def __get_default_kwargs(self, mtype: MediaType, **kwargs) -> dict:
@@ -815,6 +883,9 @@ class SubscribeChain(ChainBase):
username: Optional[str] = None,
message: Optional[bool] = True,
exist_ok: Optional[bool] = False,
anilistid: Optional[int] = None,
media_source: Optional[str] = None,
media_id: Optional[str] = None,
**kwargs) -> Tuple[Optional[int], str]:
"""
识别媒体信息并添加订阅
@@ -831,40 +902,45 @@ class SubscribeChain(ChainBase):
if season is not None:
metainfo.type = MediaType.TV
metainfo.begin_season = season
# 识别媒体信息
if settings.RECOGNIZE_SOURCE == "themoviedb":
# TMDB识别模式
if not tmdbid:
if doubanid:
# 将豆瓣信息转换为TMDB信息
tmdbinfo = MediaChain().get_tmdbinfo_by_doubanid(doubanid=doubanid, mtype=mtype)
if tmdbinfo:
mediainfo = MediaInfo(tmdb_info=tmdbinfo)
elif mediaid:
# 未知前缀,广播事件解析媒体信息
mediainfo = self.__get_event_media(mediaid, metainfo)
else:
# 使用TMDBID识别
mediainfo = self.recognize_media(meta=metainfo, mtype=mtype, tmdbid=tmdbid,
episode_group=episode_group, cache=False)
else:
if doubanid:
# 豆瓣识别模式,不使用缓存
mediainfo = self.recognize_media(meta=metainfo, mtype=mtype, doubanid=doubanid, cache=False)
elif mediaid:
# 未知前缀,广播事件解析媒体信息
mediainfo = self.__get_event_media(mediaid, metainfo)
if mediainfo:
# 豆瓣标题处理
meta = MetaInfo(mediainfo.title)
mediainfo.title = meta.name
if season is None:
season = meta.begin_season
if not media_source and not media_id and mediaid:
media_source, media_id = parse_media_key(mediaid)
resolved_source, resolved_media_id = resolve_media_identity(
source=media_source,
media_id=media_id,
tmdbid=tmdbid,
doubanid=doubanid,
bangumiid=bangumiid,
anilistid=anilistid,
)
if resolved_source and resolved_media_id:
media_source, media_id = resolved_source, resolved_media_id
if any((media_id, tmdbid, doubanid, bangumiid, anilistid)):
mediainfo = self.recognize_media(
meta=metainfo,
mtype=mtype,
source=media_source,
mediaid=media_id,
tmdbid=tmdbid,
doubanid=doubanid,
bangumiid=bangumiid,
anilistid=anilistid,
episode_group=episode_group,
cache=False,
)
elif mediaid:
mediainfo = self.__get_event_media(mediaid, metainfo)
# 使用名称识别兜底
if mediainfo and mediainfo.source != "themoviedb":
meta = MetaInfo(mediainfo.title)
mediainfo.title = meta.name
if season is None:
season = meta.begin_season
# 明确来源时只允许在同一来源内按名称兜底,不能切换主识别源。
if not mediainfo:
mediainfo = MediaChain().recognize_by_meta(
metainfo,
source=media_source,
episode_group=episode_group,
obtain_images=False,
)
@@ -883,9 +959,7 @@ class SubscribeChain(ChainBase):
if not mediainfo.seasons or episode_group:
# 补充媒体信息
mediainfo = self.recognize_media(mtype=mediainfo.type,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
bangumiid=mediainfo.bangumi_id,
**_media_recognize_kwargs(mediainfo),
episode_group=episode_group,
cache=False)
if not mediainfo:
@@ -898,7 +972,10 @@ class SubscribeChain(ChainBase):
# 创建场景没有旧订阅事实,仅允许外部补正未知或扩展总集数。
total_episode = self.__apply_episodes_refresh(
current_total_episode, season=season, mediainfo=mediainfo,
tmdbid=mediainfo.tmdb_id, doubanid=mediainfo.douban_id, scene="create")
tmdbid=mediainfo.tmdb_id, doubanid=mediainfo.douban_id,
bangumiid=mediainfo.bangumi_id, anilistid=mediainfo.anilist_id,
media_source=resolve_media_identity(media=mediainfo)[0],
media_id=resolve_media_identity(media=mediainfo)[1], scene="create")
if current_total_episode and total_episode < current_total_episode:
total_episode = current_total_episode
if not total_episode:
@@ -923,6 +1000,13 @@ class SubscribeChain(ChainBase):
mediainfo.douban_id = doubanid
if bangumiid:
mediainfo.bangumi_id = bangumiid
if anilistid:
mediainfo.anilist_id = anilistid
media_source, media_id = resolve_media_identity(
media=mediainfo, source=media_source, media_id=media_id
)
kwargs.update({"media_source": media_source, "media_id": media_id})
# 添加订阅
kwargs.update(self.__get_default_kwargs(mediainfo.type, **kwargs))
@@ -979,7 +1063,10 @@ class SubscribeChain(ChainBase):
"tvdbid": mediainfo.tvdb_id,
"doubanid": mediainfo.douban_id,
"bangumiid": mediainfo.bangumi_id,
"season": metainfo.begin_season,
"anilistid": mediainfo.anilist_id,
"media_source": media_source,
"media_id": media_id,
"season": season,
"poster": mediainfo.get_poster_image(),
"backdrop": mediainfo.get_backdrop_image(),
"vote": mediainfo.vote_average,
@@ -1002,6 +1089,9 @@ class SubscribeChain(ChainBase):
username: Optional[str] = None,
message: Optional[bool] = True,
exist_ok: Optional[bool] = False,
anilistid: Optional[int] = None,
media_source: Optional[str] = None,
media_id: Optional[str] = None,
**kwargs) -> Tuple[Optional[int], str]:
"""
异步识别媒体信息并添加订阅
@@ -1018,40 +1108,45 @@ class SubscribeChain(ChainBase):
if season is not None:
metainfo.type = MediaType.TV
metainfo.begin_season = season
# 识别媒体信息
if settings.RECOGNIZE_SOURCE == "themoviedb":
# TMDB识别模式
if not tmdbid:
if doubanid:
# 将豆瓣信息转换为TMDB信息
tmdbinfo = await MediaChain().async_get_tmdbinfo_by_doubanid(doubanid=doubanid, mtype=mtype)
if tmdbinfo:
mediainfo = MediaInfo(tmdb_info=tmdbinfo)
elif mediaid:
# 未知前缀,广播事件解析媒体信息
mediainfo = await self.__async_get_event_meida(mediaid, metainfo)
else:
# 使用TMDBID识别
mediainfo = await self.async_recognize_media(meta=metainfo, mtype=mtype, tmdbid=tmdbid,
episode_group=episode_group, cache=False)
else:
if doubanid:
# 豆瓣识别模式,不使用缓存
mediainfo = await self.async_recognize_media(meta=metainfo, mtype=mtype, doubanid=doubanid, cache=False)
elif mediaid:
# 未知前缀,广播事件解析媒体信息
mediainfo = await self.__async_get_event_meida(mediaid, metainfo)
if mediainfo:
# 豆瓣标题处理
meta = MetaInfo(mediainfo.title)
mediainfo.title = meta.name
if season is None:
season = meta.begin_season
if not media_source and not media_id and mediaid:
media_source, media_id = parse_media_key(mediaid)
resolved_source, resolved_media_id = resolve_media_identity(
source=media_source,
media_id=media_id,
tmdbid=tmdbid,
doubanid=doubanid,
bangumiid=bangumiid,
anilistid=anilistid,
)
if resolved_source and resolved_media_id:
media_source, media_id = resolved_source, resolved_media_id
if any((media_id, tmdbid, doubanid, bangumiid, anilistid)):
mediainfo = await self.async_recognize_media(
meta=metainfo,
mtype=mtype,
source=media_source,
mediaid=media_id,
tmdbid=tmdbid,
doubanid=doubanid,
bangumiid=bangumiid,
anilistid=anilistid,
episode_group=episode_group,
cache=False,
)
elif mediaid:
mediainfo = await self.__async_get_event_meida(mediaid, metainfo)
# 使用名称识别兜底
if mediainfo and mediainfo.source != "themoviedb":
meta = MetaInfo(mediainfo.title)
mediainfo.title = meta.name
if season is None:
season = meta.begin_season
# 明确来源时只允许在同一来源内按名称兜底,不能切换主识别源。
if not mediainfo:
mediainfo = await MediaChain().async_recognize_by_meta(
metainfo,
source=media_source,
episode_group=episode_group,
obtain_images=False,
)
@@ -1070,9 +1165,7 @@ class SubscribeChain(ChainBase):
if not mediainfo.seasons or episode_group:
# 补充媒体信息
mediainfo = await self.async_recognize_media(mtype=mediainfo.type,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
bangumiid=mediainfo.bangumi_id,
**_media_recognize_kwargs(mediainfo),
episode_group=episode_group,
cache=False)
if not mediainfo:
@@ -1085,7 +1178,10 @@ class SubscribeChain(ChainBase):
# 创建场景没有旧订阅事实,仅允许外部补正未知或扩展总集数。
total_episode = await self.__async_apply_episodes_refresh(
current_total_episode, season=season, mediainfo=mediainfo,
tmdbid=mediainfo.tmdb_id, doubanid=mediainfo.douban_id, scene="create")
tmdbid=mediainfo.tmdb_id, doubanid=mediainfo.douban_id,
bangumiid=mediainfo.bangumi_id, anilistid=mediainfo.anilist_id,
media_source=resolve_media_identity(media=mediainfo)[0],
media_id=resolve_media_identity(media=mediainfo)[1], scene="create")
if current_total_episode and total_episode < current_total_episode:
total_episode = current_total_episode
if not total_episode:
@@ -1110,6 +1206,13 @@ class SubscribeChain(ChainBase):
mediainfo.douban_id = doubanid
if bangumiid:
mediainfo.bangumi_id = bangumiid
if anilistid:
mediainfo.anilist_id = anilistid
media_source, media_id = resolve_media_identity(
media=mediainfo, source=media_source, media_id=media_id
)
kwargs.update({"media_source": media_source, "media_id": media_id})
# 列新默认参数
kwargs.update(self.__get_default_kwargs(mediainfo.type, **kwargs))
@@ -1166,7 +1269,10 @@ class SubscribeChain(ChainBase):
"tvdbid": mediainfo.tvdb_id,
"doubanid": mediainfo.douban_id,
"bangumiid": mediainfo.bangumi_id,
"season": metainfo.begin_season,
"anilistid": mediainfo.anilist_id,
"media_source": media_source,
"media_id": media_id,
"season": season,
"poster": mediainfo.get_poster_image(),
"backdrop": mediainfo.get_backdrop_image(),
"vote": mediainfo.vote_average,
@@ -1180,9 +1286,16 @@ class SubscribeChain(ChainBase):
"""
判断订阅是否已存在
"""
if SubscribeOper().exists(tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=meta.begin_season if meta else None):
media_source, media_id = resolve_media_identity(media=mediainfo)
if SubscribeOper().exists(
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=meta.begin_season if meta else None,
):
return True
return False
@@ -1239,7 +1352,7 @@ class SubscribeChain(ChainBase):
"current": subscribe.id,
},
)
mediakey = subscribe.tmdbid or subscribe.doubanid
mediakey = _subscribe_media_key(subscribe)
custom_word_list = subscribe.custom_words.split("\n") if subscribe.custom_words else None
search_attempted = False
# 校验当前时间减订阅创建时间是否大于1分钟,否则跳过先,留出编辑订阅的时间
@@ -1267,11 +1380,13 @@ class SubscribeChain(ChainBase):
logger.error(f'订阅 {subscribe.name} 类型错误:{subscribe.type}')
continue
# 识别媒体信息
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,
tmdbid=subscribe.tmdbid,
doubanid=subscribe.doubanid,
episode_group=subscribe.episode_group,
cache=False)
mediainfo: MediaInfo = self.recognize_media(
meta=meta,
mtype=meta.type,
**_subscribe_recognize_kwargs(subscribe),
episode_group=subscribe.episode_group,
cache=False,
)
if not mediainfo:
logger.warn(
f'未识别到媒体信息,标题:{subscribe.name}tmdbid{subscribe.tmdbid}doubanid{subscribe.doubanid}')
@@ -1463,9 +1578,9 @@ class SubscribeChain(ChainBase):
"""
判断是否应完成订阅
"""
mediakey = subscribe.tmdbid or subscribe.doubanid
media_keys = _subscribe_media_keys(subscribe)
# 是否有剩余集
no_lefts = not lefts or not lefts.get(mediakey)
no_lefts = not lefts or not any(lefts.get(media_key) for media_key in media_keys)
if downloads and meta.type == MediaType.TV:
self.__record_subscribe_download_facts(subscribe=subscribe, mediainfo=mediainfo, downloads=downloads)
elif downloads:
@@ -1638,8 +1753,10 @@ class SubscribeChain(ChainBase):
if global_vars.is_system_stopped:
break
# 如果种子未识别且失败次数未超过3次,尝试识别
if (not context.media_info or (not context.media_info.tmdb_id
and not context.media_info.douban_id)) and context.media_recognize_fail_count < 3:
if (
not context.media_info
or not resolve_media_identity(media=context.media_info)[1]
) and context.media_recognize_fail_count < 3:
logger.debug(
f'尝试重新识别种子:{context.torrent_info.title},当前失败次数:{context.media_recognize_fail_count}/3')
re_mediainfo = MediaChain().recognize_by_meta(
@@ -1653,7 +1770,7 @@ class SubscribeChain(ChainBase):
context.media_info = re_mediainfo
context.match_source = self.__get_media_id_match_source(re_mediainfo)
context.candidate_recognized = bool(
re_mediainfo.tmdb_id or re_mediainfo.douban_id
resolve_media_identity(media=re_mediainfo)[1]
)
context.media_info_is_target = False
# 重置失败次数
@@ -1698,7 +1815,7 @@ class SubscribeChain(ChainBase):
},
)
logger.info(f'开始匹配订阅,标题:{subscribe.name} ...')
mediakey = subscribe.tmdbid or subscribe.doubanid
mediakey = _subscribe_media_key(subscribe)
try:
meta = build_subscribe_meta(subscribe)
except ValueError:
@@ -1709,11 +1826,13 @@ class SubscribeChain(ChainBase):
if subscribe.sites:
domains = SiteOper().get_domains_by_ids(subscribe.sites)
# 识别媒体信息
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,
tmdbid=subscribe.tmdbid,
doubanid=subscribe.doubanid,
episode_group=subscribe.episode_group,
cache=False)
mediainfo: MediaInfo = self.recognize_media(
meta=meta,
mtype=meta.type,
**_subscribe_recognize_kwargs(subscribe),
episode_group=subscribe.episode_group,
cache=False,
)
if not mediainfo:
logger.warn(
f'未识别到媒体信息,标题:{subscribe.name}tmdbid{subscribe.tmdbid}doubanid{subscribe.doubanid}')
@@ -1787,13 +1906,14 @@ class SubscribeChain(ChainBase):
_context.media_info = torrent_mediainfo
_context.match_source = self.__get_media_id_match_source(torrent_mediainfo)
_context.candidate_recognized = bool(
torrent_mediainfo.tmdb_id or torrent_mediainfo.douban_id
resolve_media_identity(media=torrent_mediainfo)[1]
)
_context.media_info_is_target = False
# 如果仍然没有识别到媒体信息,尝试标题匹配
if not torrent_mediainfo or (
not torrent_mediainfo.tmdb_id and not torrent_mediainfo.douban_id):
if not torrent_mediainfo or not resolve_media_identity(
media=torrent_mediainfo
)[1]:
logger.debug(
f'{torrent_info.site_name} - {torrent_info.title} 重新识别失败,尝试通过标题匹配...')
if TorrentHelper.match_torrent(mediainfo=mediainfo,
@@ -1812,7 +1932,9 @@ class SubscribeChain(ChainBase):
continue
# 直接比对媒体信息
if torrent_mediainfo and (torrent_mediainfo.tmdb_id or torrent_mediainfo.douban_id):
if torrent_mediainfo and resolve_media_identity(
media=torrent_mediainfo
)[1]:
if torrent_mediainfo.type != mediainfo.type:
continue
if torrent_mediainfo.tmdb_id \
@@ -2022,11 +2144,13 @@ class SubscribeChain(ChainBase):
logger.error(f'订阅 {subscribe.name} 类型错误:{subscribe.type}')
continue
# 识别媒体信息
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,
tmdbid=subscribe.tmdbid,
doubanid=subscribe.doubanid,
episode_group=subscribe.episode_group,
cache=False)
mediainfo: MediaInfo = self.recognize_media(
meta=meta,
mtype=meta.type,
**_subscribe_recognize_kwargs(subscribe),
episode_group=subscribe.episode_group,
cache=False,
)
if not mediainfo:
logger.warn(
f'未识别到媒体信息,标题:{subscribe.name}tmdbid{subscribe.tmdbid}doubanid{subscribe.doubanid}')
@@ -2040,6 +2164,10 @@ class SubscribeChain(ChainBase):
total_episode = self.__apply_episodes_refresh(
current_total_episode, season=subscribe.season, mediainfo=mediainfo,
tmdbid=subscribe.tmdbid, doubanid=subscribe.doubanid,
bangumiid=subscribe.bangumiid,
anilistid=subscribe.anilistid,
media_source=subscribe.media_source,
media_id=subscribe.media_id,
subscribe_id=subscribe.id, scene="refresh")
old_total_episode = subscribe.total_episode or 0
if total_episode and total_episode < old_total_episode:
@@ -2048,7 +2176,7 @@ class SubscribeChain(ChainBase):
candidate_total=total_episode,
meta=meta,
mediainfo=mediainfo,
mediakey=subscribe.tmdbid or subscribe.doubanid,
mediakey=_subscribe_media_key(subscribe),
)
if total_episode and total_episode != old_total_episode:
progress_update = self.__prepare_total_episode_change_fields(
@@ -2079,6 +2207,12 @@ class SubscribeChain(ChainBase):
"description": mediainfo.overview,
"imdbid": mediainfo.imdb_id,
"tvdbid": mediainfo.tvdb_id,
"tmdbid": mediainfo.tmdb_id,
"doubanid": mediainfo.douban_id,
"bangumiid": mediainfo.bangumi_id,
"anilistid": mediainfo.anilist_id,
"media_source": resolve_media_identity(media=mediainfo)[0],
"media_id": resolve_media_identity(media=mediainfo)[1],
"total_episode": total_episode,
}
update_data.update(progress_update)
@@ -2103,8 +2237,13 @@ class SubscribeChain(ChainBase):
if not source_keyword:
return None
# 只保留需要的字段动态获取订阅
valid_fields = {k: v for k, v in source_keyword.items()
if k in ["type", "season", "tmdbid", "doubanid", "bangumiid"]}
valid_fields = {
k: v for k, v in source_keyword.items()
if k in [
"type", "season", "tmdbid", "doubanid", "bangumiid",
"anilistid", "media_source", "media_id",
]
}
# 暂时不考虑订阅历史, 若有必要再添加
return SubscribeOper().get_by(**valid_fields)
@@ -2145,11 +2284,19 @@ class SubscribeChain(ChainBase):
# 订阅已存在则跳过
if subscribeoper.exists(tmdbid=share_sub.get("tmdbid"),
doubanid=share_sub.get("doubanid"),
bangumiid=share_sub.get("bangumiid"),
anilistid=share_sub.get("anilistid"),
media_source=share_sub.get("media_source"),
media_id=share_sub.get("media_id"),
season=share_sub.get("season")):
continue
# 已经订阅过跳过
if subscribeoper.exist_history(tmdbid=share_sub.get("tmdbid"),
doubanid=share_sub.get("doubanid"),
bangumiid=share_sub.get("bangumiid"),
anilistid=share_sub.get("anilistid"),
media_source=share_sub.get("media_source"),
media_id=share_sub.get("media_id"),
season=share_sub.get("season")):
continue
# 去除无效属性
@@ -2160,7 +2307,13 @@ class SubscribeChain(ChainBase):
subscribe_in = schemas.Subscribe(**share_sub)
mtype = MediaType(subscribe_in.type)
# 豆瓣标题处理
if subscribe_in.doubanid or subscribe_in.bangumiid:
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:
@@ -2177,6 +2330,9 @@ class SubscribeChain(ChainBase):
season=subscribe_in.season,
doubanid=subscribe_in.doubanid,
bangumiid=subscribe_in.bangumiid,
anilistid=subscribe_in.anilistid,
media_source=subscribe_in.media_source,
media_id=subscribe_in.media_id,
username="订阅分享",
best_version=subscribe_in.best_version,
save_path=subscribe_in.save_path,
@@ -2239,25 +2395,25 @@ class SubscribeChain(ChainBase):
except ValueError:
logger.error(f'订阅 {subscribe.name} 类型错误:{subscribe.type}')
continue
# 识别媒体信息
if mtype == MediaType.MOVIE:
mediainfo: MediaInfo = await self.async_recognize_media(mtype=mtype,
tmdbid=subscribe.tmdbid,
doubanid=subscribe.doubanid,
bangumiid=subscribe.bangumiid,
episode_group=subscribe.episode_group,
cache=False)
if not mediainfo:
logger.warn(
f'未识别到媒体信息,标题{subscribe.name}tmdbid{subscribe.tmdbid}doubanid{subscribe.doubanid}')
continue
else:
episodes = await TmdbChain().async_tmdb_episodes(tmdbid=subscribe.tmdbid,
# 先按订阅的主媒体身份预热对应数据源,再对 TMDB 额外预热分集接口。
mediainfo: MediaInfo = await self.async_recognize_media(
mtype=mtype,
**_subscribe_recognize_kwargs(subscribe),
episode_group=subscribe.episode_group,
cache=False,
)
if not mediainfo:
logger.warn(
f'未识别到媒体信息,标题:{subscribe.name}'
f'媒体源{subscribe.media_source}媒体ID{subscribe.media_id}')
continue
if mtype == MediaType.TV and mediainfo.source == "themoviedb" and mediainfo.tmdb_id:
episodes = await TmdbChain().async_tmdb_episodes(tmdbid=mediainfo.tmdb_id,
season=subscribe.season,
episode_group=subscribe.episode_group)
if not episodes:
logger.warn(
f'未识别到季集信息,标题:{subscribe.name}tmdbid{subscribe.tmdbid},豆瓣ID{subscribe.doubanid},季:{subscribe.season}')
f'未识别到季集信息,标题:{subscribe.name}tmdbid{mediainfo.tmdb_id},季:{subscribe.season}')
continue
if progress_callback:
progress_callback(
@@ -2289,6 +2445,21 @@ class SubscribeChain(ChainBase):
if subscribe.doubanid and mediainfo.douban_id \
and mediainfo.douban_id != subscribe.doubanid:
continue
subscribe_bangumiid = subscribe.bangumiid
media_bangumiid = mediainfo.bangumi_id
if subscribe_bangumiid and media_bangumiid \
and media_bangumiid != subscribe_bangumiid:
continue
subscribe_anilistid = subscribe.anilistid
media_anilistid = mediainfo.anilist_id
if subscribe_anilistid and media_anilistid \
and media_anilistid != subscribe_anilistid:
continue
subscribe_source, subscribe_media_id = resolve_media_identity(media=subscribe)
media_source, media_id = resolve_media_identity(media=mediainfo)
if subscribe_source == media_source and subscribe_media_id and media_id \
and subscribe_media_id != media_id:
continue
items = []
if mediainfo.type == MediaType.TV:
# 电视剧有集数,使用 episode_list
@@ -2419,15 +2590,13 @@ class SubscribeChain(ChainBase):
mediainfo = self.recognize_media(
meta=meta,
mtype=meta.type,
tmdbid=subscribe.tmdbid,
doubanid=subscribe.doubanid,
bangumiid=subscribe.bangumiid,
**_subscribe_recognize_kwargs(subscribe),
episode_group=subscribe.episode_group,
cache=False,
)
if not mediainfo:
return {"scene": scene, "updated": False, "fields": [], "reason": "recognize_failed"}
mediakey = subscribe.tmdbid or subscribe.doubanid
mediakey = _subscribe_media_key(subscribe)
exist_flag, no_exists = self.resolve_subscribe_missing(
subscribe=subscribe,
meta=meta,
@@ -2688,7 +2857,12 @@ class SubscribeChain(ChainBase):
# 统计订阅
MoviePilotServerHelper.sub_done_async({
"tmdbid": mediainfo.tmdb_id,
"doubanid": mediainfo.douban_id
"doubanid": mediainfo.douban_id,
"bangumiid": mediainfo.bangumi_id,
"anilistid": mediainfo.anilist_id,
"media_source": subscribe.media_source,
"media_id": subscribe.media_id,
"season": subscribe.season,
})
def remote_list(
@@ -3343,6 +3517,11 @@ class SubscribeChain(ChainBase):
{
"tmdbid": subscribe.tmdbid,
"doubanid": subscribe.doubanid,
"bangumiid": subscribe.bangumiid,
"anilistid": subscribe.anilistid,
"media_source": subscribe.media_source,
"media_id": subscribe.media_id,
"season": subscribe.season,
}
)
@@ -3390,7 +3569,12 @@ class SubscribeChain(ChainBase):
# 统计订阅
MoviePilotServerHelper.sub_done_async({
"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,
"season": subscribe.season,
})
# 重新发送消息
self.remote_list(channel=channel, userid=userid, source=source)
@@ -3636,7 +3820,14 @@ class SubscribeChain(ChainBase):
# 所有下载记录
downloadhis = DownloadHistoryOper()
download_his = downloadhis.get_by_mediaid(tmdbid=subscribe.tmdbid, doubanid=subscribe.doubanid)
download_his = downloadhis.get_by_mediaid(
tmdbid=subscribe.tmdbid,
doubanid=subscribe.doubanid,
bangumiid=subscribe.bangumiid,
anilistid=subscribe.anilistid,
media_source=subscribe.media_source,
media_id=subscribe.media_id,
)
if download_his:
for his in download_his:
# 查询下载文件
@@ -3669,11 +3860,13 @@ class SubscribeChain(ChainBase):
logger.error(f'订阅 {subscribe.name} 类型错误:{subscribe.type}')
return subscribe_info
# 识别媒体信息
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,
tmdbid=subscribe.tmdbid,
doubanid=subscribe.doubanid,
episode_group=subscribe.episode_group,
cache=False)
mediainfo: MediaInfo = self.recognize_media(
meta=meta,
mtype=meta.type,
**_subscribe_recognize_kwargs(subscribe),
episode_group=subscribe.episode_group,
cache=False,
)
if not mediainfo:
logger.warn(
f'未识别到媒体信息,标题:{subscribe.name}tmdbid{subscribe.tmdbid}doubanid{subscribe.doubanid}')
@@ -3837,7 +4030,7 @@ class SubscribeChain(ChainBase):
priority>0 的目标集视为已满足默认 False 保持主程序洗版完成需 priority==100
的搜索/完成口径
"""
mediakey = mediakey or subscribe.tmdbid or subscribe.doubanid
mediakey = mediakey or _subscribe_media_key(subscribe)
effective_total_episode = self.__resolve_effective_total_episode(subscribe, mediainfo)
if not subscribe.best_version:
@@ -3923,7 +4116,7 @@ class SubscribeChain(ChainBase):
if subscribe.type != MediaType.TV.value or self.__is_full_best_version_enabled(subscribe):
return candidate_total
target_key = mediakey or subscribe.tmdbid or subscribe.doubanid
target_key = mediakey or _subscribe_media_key(subscribe)
target_season = subscribe.season
target_start = subscribe.start_episode or 1
snapshot = copy.copy(subscribe)
@@ -3944,7 +4137,14 @@ class SubscribeChain(ChainBase):
return old_total
if not isinstance(no_exists, dict):
return candidate_total
seasons = no_exists.get(target_key)
seasons = next(
(
no_exists.get(media_key)
for media_key in [target_key, *_subscribe_media_keys(subscribe)]
if no_exists.get(media_key) is not None
),
None,
)
if not isinstance(seasons, dict):
return candidate_total
missing_info = seasons.get(target_season)
@@ -3994,19 +4194,25 @@ class SubscribeChain(ChainBase):
mediainfo: Optional[MediaInfo] = 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,
subscribe_id: Optional[int] = None,
scene: Optional[str] = None) -> int:
"""
发送订阅总集数推算事件允许外部把主程序本次识别到的 TMDB 当前季总集数向上覆盖
发送订阅总集数推算事件允许外部把当前数据源识别到的季总集数向上覆盖
用途插件在"待定集数"等场景经事件注入 total_episode
无监听者或外部未覆盖时返回入参原值保证零行为变更
:param current_total: 主程序本次识别到的 TMDB 当前季总集数
:param current_total: 主程序本次识别到的当前季总集数
:param season: 季号
:return: 最终采用的总集数
"""
event_data = SubscribeEpisodesRefreshEventData(
tmdbid=tmdbid, doubanid=doubanid, season=season, mediainfo=mediainfo,
tmdbid=tmdbid, doubanid=doubanid, bangumiid=bangumiid,
anilistid=anilistid, media_source=media_source, media_id=media_id,
season=season, mediainfo=mediainfo,
current_total_episode=current_total, subscribe_id=subscribe_id, scene=scene)
event = eventmanager.send_event(ChainEventType.SubscribeEpisodesRefresh, event_data)
if event and event.event_data:
@@ -4021,13 +4227,19 @@ class SubscribeChain(ChainBase):
mediainfo: Optional[MediaInfo] = 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,
subscribe_id: Optional[int] = None,
scene: Optional[str] = None) -> int:
"""
__apply_episodes_refresh 的异步版本
"""
event_data = SubscribeEpisodesRefreshEventData(
tmdbid=tmdbid, doubanid=doubanid, season=season, mediainfo=mediainfo,
tmdbid=tmdbid, doubanid=doubanid, bangumiid=bangumiid,
anilistid=anilistid, media_source=media_source, media_id=media_id,
season=season, mediainfo=mediainfo,
current_total_episode=current_total, subscribe_id=subscribe_id, scene=scene)
event = await eventmanager.async_send_event(ChainEventType.SubscribeEpisodesRefresh, event_data)
if event and event.event_data:
@@ -4059,6 +4271,10 @@ class SubscribeChain(ChainBase):
new_total_episode = self.__apply_episodes_refresh(
current_total_episode, season=subscribe.season, mediainfo=mediainfo,
tmdbid=subscribe.tmdbid, doubanid=subscribe.doubanid,
bangumiid=subscribe.bangumiid,
anilistid=subscribe.anilistid,
media_source=subscribe.media_source,
media_id=subscribe.media_id,
subscribe_id=subscribe.id, scene="precheck")
old_total_episode = subscribe.total_episode or 0
if meta is not None and new_total_episode and new_total_episode < old_total_episode:
@@ -4113,6 +4329,12 @@ class SubscribeChain(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"
@staticmethod
@@ -4150,7 +4372,10 @@ class SubscribeChain(ChainBase):
'imdbid': subscribe.imdbid,
'tvdbid': subscribe.tvdbid,
'doubanid': subscribe.doubanid,
'bangumiid': subscribe.bangumiid
'bangumiid': subscribe.bangumiid,
'anilistid': subscribe.anilistid,
'media_source': subscribe.media_source,
'media_id': subscribe.media_id,
}
return f"Subscribe|{json.dumps(source_keyword, ensure_ascii=False)}"
+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):
+293 -29
View File
@@ -55,6 +55,7 @@ from app.schemas.types import (
ContentType,
)
from app.utils.mixins import ConfigReloadMixin
from app.utils.media import normalize_media_source, parse_media_key, resolve_media_identity
from app.utils.singleton import Singleton
from app.utils.string import StringUtils
from app.utils.system import SystemUtils
@@ -141,7 +142,8 @@ class JobManager:
"""
if not media:
return None, season
return media.tmdb_id or media.douban_id, season
source, media_id = resolve_media_identity(media=media)
return (source, media_id), season
@staticmethod
def __get_file_key(fileitem: FileItem) -> Optional[Tuple[str, str]]:
@@ -781,6 +783,23 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
"TRANSFER_THREADS",
}
@staticmethod
def _requires_automatic_category(task: TransferTask) -> bool:
"""
判断当前整理任务是否需要根据媒体识别结果自动创建类别目录
:param task: 整理任务
:return: 是否必须具备自动分类结果
"""
target_directory = task.target_directory
if target_directory and target_directory.media_category:
return False
if task.library_category_folder is not None:
return bool(task.library_category_folder)
return bool(
target_directory and target_directory.library_category_folder
)
def __init__(self):
"""初始化文件整理处理链。"""
super().__init__()
@@ -909,6 +928,36 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
or "/@eaDir" in normalized_path
)
@staticmethod
def __should_delete_empty_source_directories(
task: TransferTask,
delete_mounted_local_disk_empty_dirs: bool,
mounted_filesystem_cache: Dict[Path, bool],
) -> bool:
"""
判断移动整理后是否应删除源空目录
仅在关闭挂载盘空目录清理且源存储为本地时检测文件系统
避免默认流程产生额外系统调用
"""
if delete_mounted_local_disk_empty_dirs:
return True
if task.fileitem.storage != "local":
return True
source_directory = (
Path(task.target_directory.download_path)
if task.target_directory and task.target_directory.download_path
else Path(task.fileitem.path).parent
)
if source_directory not in mounted_filesystem_cache:
mounted_filesystem_cache[source_directory] = (
SystemUtils.is_network_filesystem(
source_directory, include_local_fuse=True
)
)
return not mounted_filesystem_cache[source_directory]
def __default_callback(
self, task: TransferTask, transferinfo: TransferInfo, /
) -> Tuple[bool, str]:
@@ -1170,10 +1219,16 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
tasks = self.jobview.success_tasks(
task.mediainfo, task.meta.begin_season
)
system_config_oper = SystemConfigOper()
# 获取整理屏蔽词
transfer_exclude_words = SystemConfigOper().get(
transfer_exclude_words = system_config_oper.get(
SystemConfigKey.TransferExcludeWords
)
# 挂载盘空目录清理默认开启
delete_mounted_local_disk_empty_dirs = system_config_oper.get(
SystemConfigKey.MountedLocalDiskDeleteEmptyDirs
) is not False
mounted_filesystem_cache: Dict[Path, bool] = {}
processed_hashes = set()
for t in tasks:
if t.download_hash and t.download_hash not in processed_hashes:
@@ -1190,7 +1245,15 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
logger.info(
f"移动模式删除种子成功:{t.download_hash}"
)
if not t.download_hash and t.fileitem:
if (
not t.download_hash
and t.fileitem
and self.__should_delete_empty_source_directories(
t,
delete_mounted_local_disk_empty_dirs,
mounted_filesystem_cache,
)
):
# 删除剩余空目录
StorageChain().delete_media_file(t.fileitem, delete_self=False)
@@ -1546,7 +1609,9 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
logger.info(__end_msg)
self._progress.update(value=100, text=__end_msg)
self._progress.end()
# 重置计数
# 重置计数_total_num 一并归零,否则会作为历史最大值一直
# 累积,令后续批次的「当前共 N 个文件」与进度百分比失真
self._total_num = 0
self._processed_num = 0
self._fail_num = 0
@@ -1579,7 +1644,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
):
# 下载记录中已存在识别信息
@@ -1587,6 +1658,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
@@ -1600,23 +1675,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, "未识别到媒体信息"
@@ -1679,8 +1761,15 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
mediainfo_changed = True
# 如果未开启新增已入库媒体是否跟随TMDB信息变化则根据tmdbid查询之前的title
if not settings.SCRAP_FOLLOW_TMDB:
# TMDB 仅作为辅助信息合并,不能改变原识别源的主身份和标题。
mediainfo = MediaChain().supplement_tmdb_info(mediainfo, task.meta)
task.mediainfo = mediainfo
# 只有 TMDB 主源沿用历史 TMDB 标题,避免辅助 ID 改写其它识别源标题。
if (
not settings.SCRAP_FOLLOW_TMDB
and normalize_media_source(mediainfo.source) == "themoviedb"
):
transfer_history = transferhis.get_by_type_tmdbid(
tmdbid=mediainfo.tmdb_id, mtype=mediainfo.type.value
)
@@ -1697,7 +1786,11 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
return False, f"{task.fileitem.name} 已在整理队列中"
# 获取集数据
if task.mediainfo.type == MediaType.TV and not task.episodes_info:
if (
task.mediainfo.type == MediaType.TV
and task.mediainfo.tmdb_id
and not task.episodes_info
):
# 判断注意season为0的情况
season_num = task.mediainfo.season
if season_num is None and task.meta.season_seq:
@@ -1732,6 +1825,24 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
if not task.target_storage and task.target_directory:
task.target_storage = task.target_directory.library_storage
if self._requires_automatic_category(task) and not task.mediainfo.category:
if task.mediainfo.tmdb_id:
error_message = "TMDB 信息未匹配到媒体分类,无法按媒体类别整理"
else:
error_message = "未识别到 TMDB 辅助信息,无法按媒体类别整理"
logger.error(f"{task.fileitem.name} {error_message}")
if callback:
return callback(
task,
TransferInfo(
success=False,
fileitem=task.fileitem,
transfer_type=task.transfer_type,
message=error_message,
),
)
return False, error_message
# 正在处理
self.jobview.running_task(task)
@@ -2077,6 +2188,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:
@@ -2567,11 +2682,103 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
else None
)
@staticmethod
def _is_successful_move_history(history: Optional[TransferHistory]) -> bool:
"""判断历史记录是否为已成功完成的移动类整理。"""
return bool(
history
and history.status
and history.mode
and "move" in history.mode
)
def _get_manual_transfer_history(
self,
fileitem: FileItem,
transfer_history_oper: TransferHistoryOper,
include_move_dest: bool = False,
) -> Optional[TransferHistory]:
"""查询文件源路径历史,并兼容从成功移动后的目标现址重新整理。"""
history = transfer_history_oper.get_by_src(
fileitem.path,
storage=fileitem.storage,
)
if history or not include_move_dest:
return history
history = transfer_history_oper.get_by_dest(
fileitem.path,
storage=fileitem.storage,
)
return history if self._is_successful_move_history(history) else None
def get_manual_transfer_histories(
self,
fileitems: List[FileItem],
) -> List[TransferHistory]:
"""
查询文件或目录命中的成功整理记录供手动整理界面显示重整状态
:param fileitems: 待查询的文件或目录项
:return: 去重后的成功整理记录
"""
transfer_history_oper = TransferHistoryOper()
histories: Dict[int, TransferHistory] = {}
for fileitem in fileitems or []:
if not fileitem or not fileitem.path:
continue
storage = fileitem.storage or "local"
if fileitem.type == "dir":
matched_histories = transfer_history_oper.list_success_by_src(
fileitem.path,
storage=storage,
recursive=True,
)
matched_histories.extend(
transfer_history_oper.list_success_move_by_dest(
fileitem.path,
storage=storage,
recursive=True,
)
)
else:
history = self._get_manual_transfer_history(
fileitem=fileitem,
transfer_history_oper=transfer_history_oper,
include_move_dest=True,
)
matched_histories = [history] if history and history.status else []
for history in matched_histories:
histories[history.id] = history
return list(histories.values())
@staticmethod
def _delete_manual_transfer_history(
history: TransferHistory,
transfer_history_oper: TransferHistoryOper,
) -> Tuple[bool, str]:
"""删除手动重整历史;非成功移动记录同时清理可能存在的旧目标。"""
if (
history.dest_fileitem
and not TransferChain._is_successful_move_history(history)
):
dest_fileitem = FileItem(**history.dest_fileitem)
storage_chain = StorageChain()
if (
storage_chain.exists(dest_fileitem)
and not storage_chain.delete_media_file(dest_fileitem)
):
return False, f"{dest_fileitem.path} 删除失败"
transfer_history_oper.delete(history.id)
return True, ""
def do_transfer(
self,
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,
@@ -2591,12 +2798,14 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
sync_extra_files: Optional[bool] = False,
cleanup_dest_fileitem: Optional[FileItem] = None,
continue_callback: Callable = None,
reorganize: Optional[bool] = False,
) -> Tuple[bool, Union[str, dict]]:
"""
执行一个复杂目录的整理操作
:param fileitem: 文件项
:param meta: 元数据
:param mediainfo: 媒体信息
:param media_source: 请求级识别与刮削数据源
:param target_directory: 目标目录配置
:param target_storage: 目标存储器
:param target_path: 目标路径
@@ -2613,6 +2822,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
:param background: 是否后台运行
:param manual: 是否手动整理
:param preview: 是否仅预览
:param reorganize: 是否清理已有成功记录后重新整理
:param sync_extra_files: 是否在整理主视频文件时同步整理同媒体附加文件
:param cleanup_dest_fileitem: 确认存在待整理任务后需要清理的旧目标文件
:param continue_callback: 继续处理回调
@@ -3043,11 +3253,33 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
raise OperationInterrupted()
file_path = Path(file_item.path)
# 整理成功的不再处理
if not force and not preview:
transferd = TransferHistoryOper().get_by_src(
file_item.path, storage=file_item.storage
# 自动整理继续按全部历史去重;手动整理可清理失败记录,或按用户确认清理成功记录。
if (not force or reorganize) and not preview:
transfer_history_oper = TransferHistoryOper()
transferd = self._get_manual_transfer_history(
fileitem=file_item,
transfer_history_oper=transfer_history_oper,
include_move_dest=manual and reorganize,
)
if transferd:
should_reorganize = manual and (
reorganize or not transferd.status
)
if should_reorganize:
state, message = self._delete_manual_transfer_history(
history=transferd,
transfer_history_oper=transfer_history_oper,
)
if not state:
all_success = False
logger.error(message)
err_msgs.append(message)
continue
logger.info(
f"{file_item.path} 已清理旧整理记录,继续重新整理。"
)
transferd = None
if transferd:
skipped_history_count += 1
if not transferd.status:
@@ -3115,6 +3347,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,
@@ -3310,7 +3543,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
source: Optional[str] = None,
):
"""
远程重新整理参数 历史记录ID TMDBID|类型
远程重新整理参数 历史记录ID 来源前缀:媒体ID|类型
"""
def args_error():
@@ -3318,7 +3551,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,
@@ -3352,7 +3585,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():
@@ -3412,7 +3645,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)
@@ -3425,12 +3658,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)
@@ -3474,6 +3716,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,
@@ -3490,6 +3734,9 @@ 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,
reorganize: Optional[bool] = False,
) -> Tuple[bool, Union[str, dict]]:
"""
手动整理支持复杂条件带进度显示
@@ -3498,6 +3745,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: 剧集组
@@ -3512,25 +3763,34 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
:param downloader: 下载器名称
:param download_hash: 下载任务哈希
:param preview: 是否仅预览
:param reorganize: 是否清理已有成功记录后重新整理
:param sync_extra_files: 是否同步整理同媒体附加文件
: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)
@@ -3540,6 +3800,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,
@@ -3553,6 +3814,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
downloader=downloader,
download_hash=download_hash,
preview=preview,
reorganize=reorganize,
sync_extra_files=sync_extra_files,
cleanup_dest_fileitem=cleanup_dest_fileitem,
)
@@ -3567,6 +3829,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,
@@ -3580,6 +3843,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
downloader=downloader,
download_hash=download_hash,
preview=preview,
reorganize=reorganize,
sync_extra_files=sync_extra_files,
cleanup_dest_fileitem=cleanup_dest_fileitem,
)
+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:
+13 -7
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
# ==================== 工作流配置 ====================
# 工作流数据共享
@@ -566,6 +569,8 @@ class ConfigModel(BaseModel):
LLM_MODEL: str = "deepseek-chat"
# 思考模式/深度配置:off/auto/minimal/low/medium/high/max/xhigh
LLM_THINKING_LEVEL: Optional[str] = "off"
# OpenAI兼容接口API协议:auto(自动)/ chat_completions / responses
LLM_API_PROTOCOL: str = "auto"
# LLM是否支持图片输入,开启后消息图片会按多模态输入发送给模型
LLM_SUPPORT_IMAGE_INPUT: bool = True
# 是否启用音频输入,开启后用户语音会先转写为文本再进入 Agent
@@ -746,8 +751,9 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
converted = int(value)
return converted, str(converted) != str(original_value)
elif expected_type is float:
if isinstance(value, float):
return value, str(value) != str(original_value)
if isinstance(value, (int, float)) and not isinstance(value, bool):
converted = float(value)
return converted, str(converted) != str(original_value)
if isinstance(value, str):
converted = float(value)
return converted, str(converted) != str(original_value)
+220 -4
View File
@@ -10,6 +10,9 @@ 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
@@ -251,8 +254,12 @@ 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
# 媒体标题
@@ -279,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
# 媒体原语种
@@ -315,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)
# 演员
@@ -380,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
@@ -750,7 +765,7 @@ class MediaInfo:
self.source = "bangumi"
# 本体
self.bangumi_info = info
# 豆瓣ID
# Bangumi ID
self.bangumi_id = info.get("id")
# 类型
if not self.type:
@@ -804,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:
@@ -831,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
@@ -895,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):
@@ -904,6 +1119,7 @@ class MediaInfo:
self.tmdb_info = {}
self.douban_info = {}
self.bangumi_info = {}
self.anilist_info = {}
self.seasons = {}
self.genres = []
self.season_info = []
@@ -934,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
+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
+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)
+44 -28
View File
@@ -58,6 +58,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
# 插件智能体工具注册表缓存,插件启停或配置生效时主动失效。
self._plugin_agent_tools_cache: Dict[str, List[Dict[str, Any]]] = {}
self._plugin_agent_tools_cache_lock = threading.Lock()
self._plugin_agent_tools_revision: int = 0
# 开发者模式监测插件修改
if settings.DEV or settings.PLUGIN_AUTO_RELOAD:
self.__start_monitor()
@@ -143,6 +144,14 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
"""
with self._plugin_agent_tools_cache_lock:
self._plugin_agent_tools_cache.clear()
self._plugin_agent_tools_revision += 1
def get_plugin_agent_tools_revision(self) -> int:
"""
获取插件智能体工具注册表版本号
"""
with self._plugin_agent_tools_cache_lock:
return self._plugin_agent_tools_revision
def stop(self, pid: Optional[str] = None):
"""
@@ -1002,35 +1011,42 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
}]
"""
cache_key = pid or "__all__"
with self._plugin_agent_tools_cache_lock:
cached_tools = self._plugin_agent_tools_cache.get(cache_key)
if cached_tools is not None:
return self._copy_plugin_agent_tools(cached_tools)
while True:
with self._plugin_agent_tools_cache_lock:
cache_revision = self._plugin_agent_tools_revision
cached_tools = self._plugin_agent_tools_cache.get(cache_key)
if cached_tools is not None:
return self._copy_plugin_agent_tools(cached_tools)
ret_tools = []
# 创建字典快照避免并发修改
running_plugins_snapshot = dict(self._running_plugins)
for plugin_id, plugin in running_plugins_snapshot.items():
if pid and pid != plugin_id:
continue
if hasattr(plugin, "get_agent_tools") and ObjectUtils.check_method(plugin.get_agent_tools):
try:
if not plugin.get_state():
continue
tools = plugin.get_agent_tools()
if tools:
ret_tools.append({
"plugin_id": plugin_id,
"plugin_name": plugin.plugin_name,
"tools": tools
})
except Exception as e:
logger.error(f"获取插件 {plugin_id} 智能体工具出错:{str(e)}")
with self._plugin_agent_tools_cache_lock:
self._plugin_agent_tools_cache[cache_key] = self._copy_plugin_agent_tools(
ret_tools
)
return ret_tools
ret_tools = []
# 创建字典快照避免并发修改
running_plugins_snapshot = dict(self._running_plugins)
for plugin_id, plugin in running_plugins_snapshot.items():
if pid and pid != plugin_id:
continue
if hasattr(plugin, "get_agent_tools") and ObjectUtils.check_method(
plugin.get_agent_tools
):
try:
if not plugin.get_state():
continue
tools = plugin.get_agent_tools()
if tools:
ret_tools.append({
"plugin_id": plugin_id,
"plugin_name": plugin.plugin_name,
"tools": tools
})
except Exception as e:
logger.error(f"获取插件 {plugin_id} 智能体工具出错:{str(e)}")
with self._plugin_agent_tools_cache_lock:
if cache_revision != self._plugin_agent_tools_revision:
# 插件状态在注册表构建期间发生变化,重新读取以避免写回过期快照。
continue
self._plugin_agent_tools_cache[cache_key] = self._copy_plugin_agent_tools(
ret_tools
)
return ret_tools
@staticmethod
def get_plugin_remote_entry(plugin_id: str, dist_path: str) -> str:
+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]:
+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()
+117 -4
View File
@@ -1,6 +1,7 @@
import re
import time
from typing import Optional
from pathlib import Path
from typing import List, Optional
from sqlalchemy import Boolean, Column, Index, Integer, JSON, String, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -48,6 +49,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 +78,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
@@ -178,7 +185,17 @@ class TransferHistory(Base):
@classmethod
@db_query
def get_by_src(cls, db: Session, src: str, storage: Optional[str] = None):
def get_by_src(
cls, db: Session, src: str, storage: Optional[str] = None
) -> Optional["TransferHistory"]:
"""
按源路径和存储查询单条整理记录
:param db: 数据库会话
:param src: 源路径
:param storage: 源存储类型
:return: 命中的整理记录未命中时返回 None
"""
if storage:
return db.query(cls).filter(cls.src == src,
cls.src_storage == storage).first()
@@ -187,8 +204,104 @@ class TransferHistory(Base):
@classmethod
@db_query
def get_by_dest(cls, db: Session, dest: str):
return db.query(cls).filter(cls.dest == dest).first()
def get_by_dest(
cls, db: Session, dest: str, storage: Optional[str] = None
) -> Optional["TransferHistory"]:
"""
按目标路径和存储查询单条整理记录
:param db: 数据库会话
:param dest: 目标路径
:param storage: 目标存储类型
:return: 命中的整理记录未命中时返回 None
"""
query = db.query(cls).filter(cls.dest == dest)
if storage:
query = query.filter(cls.dest_storage == storage)
return query.first()
@classmethod
@db_query
def list_success_by_src(
cls,
db: Session,
src: str,
storage: Optional[str] = None,
recursive: bool = False,
) -> List["TransferHistory"]:
"""
按源路径查询成功整理记录目录模式仅匹配其直接或间接子项
:param db: 数据库会话
:param src: 源路径
:param storage: 源存储类型
:param recursive: 是否递归匹配目录子项
:return: 命中的成功整理记录
"""
normalized_src = (
Path(str(src).replace("\\", "/")).as_posix().rstrip("/") or "/"
)
query = db.query(cls).filter(cls.status.is_(True))
if recursive:
escaped_src = (
normalized_src.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_")
)
query = query.filter(
or_(
cls.src == normalized_src,
cls.src.like(f"{escaped_src.rstrip('/')}/%", escape="\\"),
)
)
else:
query = query.filter(cls.src == normalized_src)
if storage:
query = query.filter(cls.src_storage == storage)
return query.all()
@classmethod
@db_query
def list_success_move_by_dest(
cls,
db: Session,
dest: str,
storage: Optional[str] = None,
recursive: bool = False,
) -> List["TransferHistory"]:
"""
按目标路径查询成功移动记录供从媒体库现址发起重新整理时识别历史
:param db: 数据库会话
:param dest: 目标路径
:param storage: 目标存储类型
:param recursive: 是否递归匹配目录子项
:return: 命中的成功移动记录
"""
normalized_dest = (
Path(str(dest).replace("\\", "/")).as_posix().rstrip("/") or "/"
)
query = db.query(cls).filter(
cls.status.is_(True),
cls.mode.contains("move"),
)
if recursive:
escaped_dest = (
normalized_dest.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_")
)
query = query.filter(
or_(
cls.dest == normalized_dest,
cls.dest.like(f"{escaped_dest.rstrip('/')}/%", escape="\\"),
)
)
else:
query = query.filter(cls.dest == normalized_dest)
if storage:
query = query.filter(cls.dest_storage == storage)
return query.all()
@classmethod
@db_query
+18
View File
@@ -24,6 +24,24 @@ class PluginDataOper(DbOper):
else:
PluginData(plugin_id=plugin_id, key=key, value=value).create(self._db)
async def async_save(self, plugin_id: str, key: str, value: Any) -> None:
"""
异步保存插件数据
:param plugin_id: 插件ID
:param key: 数据键
:param value: 数据值
"""
plugin = await PluginData.async_get_plugin_data_by_key(
self._db, plugin_id, key
)
if plugin:
await plugin.async_update(self._db, {"value": value})
else:
await PluginData(
plugin_id=plugin_id, key=key, value=value
).async_create(self._db)
def get_data(self, plugin_id: str, key: Optional[str] = None) -> Any:
"""
获取插件数据
+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,
))
+13
View File
@@ -101,6 +101,19 @@ class SystemConfigOper(DbOper, metaclass=Singleton):
# 避免将__SYSTEMCONF内的值引用出去,会导致set时误判没有变动
return copy.deepcopy(self.__SYSTEMCONF.get(key))
def increment(self, key: SystemConfigKey, step: int = 1) -> int:
"""
原子递增整数系统设置
:param key: 配置键
:param step: 递增步长
:return: 递增后的整数值
"""
with self._rlock:
value = int(self.get(key) or 0) + step
self.set(key, value)
return value
def all(self):
"""
获取所有系统设置
+65 -3
View File
@@ -78,20 +78,68 @@ class TransferHistoryOper(DbOper):
"""
return TransferHistory.list_by_title(self._db, title)
def get_by_src(self, src: str, storage: Optional[str] = None) -> TransferHistory:
def get_by_src(
self, src: str, storage: Optional[str] = None
) -> Optional[TransferHistory]:
"""
按源查询转移记录
:param src: 数据key
:param storage: 存储类型
:return: 命中的整理记录未命中时返回 None
"""
return TransferHistory.get_by_src(self._db, src, storage)
def get_by_dest(self, dest: str) -> TransferHistory:
def get_by_dest(
self, dest: str, storage: Optional[str] = None
) -> Optional[TransferHistory]:
"""
按转移路径查询转移记录
:param dest: 数据key
:param storage: 存储类型
"""
return TransferHistory.get_by_dest(self._db, dest)
return TransferHistory.get_by_dest(self._db, dest, storage)
def list_success_by_src(
self,
src: str,
storage: Optional[str] = None,
recursive: bool = False,
) -> List[TransferHistory]:
"""
按源路径查询成功整理记录
:param src: 源路径
:param storage: 源存储类型
:param recursive: 是否递归匹配目录子项
:return: 命中的成功整理记录
"""
return TransferHistory.list_success_by_src(
self._db,
src=src,
storage=storage,
recursive=recursive,
)
def list_success_move_by_dest(
self,
dest: str,
storage: Optional[str] = None,
recursive: bool = False,
) -> List[TransferHistory]:
"""
按目标路径查询成功移动记录
:param dest: 目标路径
:param storage: 目标存储类型
:param recursive: 是否递归匹配目录子项
:return: 命中的成功移动记录
"""
return TransferHistory.list_success_move_by_dest(
self._db,
dest=dest,
storage=storage,
recursive=recursive,
)
def list_by_hash(self, download_hash: str) -> List[TransferHistory]:
"""
@@ -198,6 +246,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 +281,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 +299,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(),
+75 -16
View File
@@ -188,16 +188,31 @@ class ImageHelper(metaclass=Singleton):
return cache_path.as_posix()
@staticmethod
def _validate_image(content: bytes) -> bool:
"""验证图片"""
def get_image_mime_type(content: bytes, verify: bool = True) -> Optional[str]:
"""
根据图片内容返回 Pillow 识别的图片 MIME 类型
外部响应在写入缓存前需要完整校验已校验的缓存只需读取格式头
非图片或可脚本化的 MIME 类型不作为图片代理响应
"""
if not content:
return False
return None
try:
Image.open(io.BytesIO(content)).verify()
return True
except Exception as e:
logger.warn(f"Invalid image format: {e}")
return False
with Image.open(io.BytesIO(content)) as image:
image_format = (image.format or "").upper()
if verify:
image.verify()
mime_type = Image.MIME.get(image_format)
if (
not mime_type
or not mime_type.startswith("image/")
or mime_type == "image/svg+xml"
):
return None
return mime_type
except Exception as err:
logger.warning(f"Invalid image format: {err}")
return None
@staticmethod
def _get_request_params(url: str, proxy: Optional[bool], cookies: Optional[str | dict]) -> dict:
@@ -224,6 +239,26 @@ class ImageHelper(metaclass=Singleton):
"""
获取图片同步版本
"""
result = self.fetch_image_with_mime_type(
url=url,
proxy=proxy,
use_cache=use_cache,
cookies=cookies,
)
return result[0] if result else None
def fetch_image_with_mime_type(
self,
url: str,
proxy: Optional[bool] = None,
use_cache: bool = True,
cookies: Optional[str | dict] = None,
) -> Optional[tuple[bytes, str]]:
"""
同步获取图片及其内容识别 MIME 类型
网络响应在写入缓存前完整验证一次缓存命中仅重新识别格式头
"""
if not url:
return None
@@ -233,7 +268,9 @@ class ImageHelper(metaclass=Singleton):
if use_cache:
content = self.file_cache.get(cache_path, region="images")
if content:
return content
mime_type = self.get_image_mime_type(content, verify=False)
if mime_type:
return content, mime_type
# 请求远程图片
params = self._get_request_params(url, proxy, cookies)
@@ -243,13 +280,13 @@ class ImageHelper(metaclass=Singleton):
return None
content = response.content
# 验证图片
if not self._validate_image(content):
mime_type = self.get_image_mime_type(content)
if not mime_type:
return None
# 保存缓存
self.file_cache.set(cache_path, content, region="images")
return content
return content, mime_type
async def async_fetch_image(
self,
@@ -260,6 +297,26 @@ class ImageHelper(metaclass=Singleton):
"""
获取图片异步版本
"""
result = await self.async_fetch_image_with_mime_type(
url=url,
proxy=proxy,
use_cache=use_cache,
cookies=cookies,
)
return result[0] if result else None
async def async_fetch_image_with_mime_type(
self,
url: str,
proxy: Optional[bool] = None,
use_cache: bool = True,
cookies: Optional[str | dict] = None,
) -> Optional[tuple[bytes, str]]:
"""
异步获取图片及其内容识别 MIME 类型
网络响应在写入缓存前完整验证一次缓存命中仅重新识别格式头
"""
if not url:
return None
@@ -269,7 +326,9 @@ class ImageHelper(metaclass=Singleton):
if use_cache:
content = await self.async_file_cache.get(cache_path, region="images")
if content:
return content
mime_type = self.get_image_mime_type(content, verify=False)
if mime_type:
return content, mime_type
# 请求远程图片
params = self._get_request_params(url, proxy, cookies)
@@ -279,10 +338,10 @@ class ImageHelper(metaclass=Singleton):
return None
content = response.content
# 验证图片
if not self._validate_image(content):
mime_type = self.get_image_mime_type(content)
if not mime_type:
return None
# 保存缓存
await self.async_file_cache.set(cache_path, content, region="images")
return content
return content, mime_type
+19
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})
+93 -2
View File
@@ -2,9 +2,12 @@
PassKey WebAuthn 辅助工具类
"""
import base64
import json
import binascii
from typing import Optional, Tuple, List, Dict, Any
import json
import secrets
import threading
from dataclasses import dataclass
from typing import Any, Dict, List, Literal, Optional, Tuple
from urllib.parse import urlparse
from webauthn import (
@@ -26,10 +29,93 @@ from webauthn.helpers.structs import (
AuthenticatorSelectionCriteria
)
from webauthn.helpers.cose import COSEAlgorithmIdentifier
from webauthn.helpers.exceptions import InvalidRegistrationResponse
from app.core.cache import TTLCache
from app.core.config import settings
from app.helper.redis import RedisHelper
from app.log import logger
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
class PassKeyRegistrationVerificationError(Exception):
"""Passkey 注册响应未通过 WebAuthn 安全校验。"""
class PassKeyRegistrationOriginMismatchError(PassKeyRegistrationVerificationError):
"""浏览器来源与系统配置的 Passkey 注册来源不一致。"""
class PassKeyHelper:
"""
@@ -269,6 +355,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
+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:
"""
删除缓存
+179
View File
@@ -0,0 +1,179 @@
from pathlib import Path
from typing import Optional
from urllib.parse import urlparse
from xml.dom import minidom
from app.core.context import MediaInfo
from app.schemas.types import MediaType
from app.utils.dom import DomUtils
class MediaScraperHelper:
"""
基于统一媒体信息生成通用 NFO 与图片清单供缺少专用刮削格式的数据源复用
"""
@staticmethod
def _media_identity(mediainfo: MediaInfo) -> tuple[Optional[str], Optional[str]]:
"""
获取媒体信息中的来源与来源原生 ID
:param mediainfo: 统一媒体信息
:return: 数据源名称与原生 ID
"""
source_ids = {
"themoviedb": mediainfo.tmdb_id,
"douban": mediainfo.douban_id,
"bangumi": mediainfo.bangumi_id,
"anilist": mediainfo.anilist_id,
}
media_id = source_ids.get(mediainfo.source)
return mediainfo.source, str(media_id) if media_id is not None else None
@staticmethod
def _image_extension(url: str) -> str:
"""
从图片 URL 中提取可用于本地文件名的扩展名
:param url: 图片地址
:return: 图片扩展名无法确定时返回 .jpg
"""
extension = Path(urlparse(url).path).suffix.lower()
return extension if extension in {".jpg", ".jpeg", ".png", ".webp"} else ".jpg"
@classmethod
def _append_common_nodes(
cls,
mediainfo: MediaInfo,
doc: minidom.Document,
root: minidom.Node,
) -> None:
"""
NFO 根节点写入各媒体类型共享的标准字段
:param mediainfo: 统一媒体信息
:param doc: XML 文档
:param root: NFO 根节点
"""
DomUtils.add_node(doc, root, "title", mediainfo.title or "")
DomUtils.add_node(doc, root, "originaltitle", mediainfo.original_title or "")
DomUtils.add_node(doc, root, "year", mediainfo.year or "")
DomUtils.add_node(doc, root, "premiered", mediainfo.release_date or "")
DomUtils.add_node(doc, root, "rating", mediainfo.vote_average or "0")
plot = DomUtils.add_node(doc, root, "plot")
plot.appendChild(doc.createCDATASection(mediainfo.overview or ""))
outline = DomUtils.add_node(doc, root, "outline")
outline.appendChild(doc.createCDATASection(mediainfo.overview or ""))
source, media_id = cls._media_identity(mediainfo)
if source and media_id:
unique_id = DomUtils.add_node(doc, root, "uniqueid", media_id)
unique_id.setAttribute("type", source)
unique_id.setAttribute("default", "true")
for genre in mediainfo.genres or []:
genre_name = genre.get("name") if isinstance(genre, dict) else str(genre)
if genre_name:
DomUtils.add_node(doc, root, "genre", genre_name)
for company in mediainfo.production_companies or []:
company_name = company.get("name") if isinstance(company, dict) else str(company)
if company_name:
DomUtils.add_node(doc, root, "studio", company_name)
for director in mediainfo.directors or []:
director_name = director.get("name") if isinstance(director, dict) else str(director)
if director_name:
DomUtils.add_node(doc, root, "director", director_name)
for actor in mediainfo.actors or []:
if not isinstance(actor, dict):
continue
actor_node = DomUtils.add_node(doc, root, "actor")
DomUtils.add_node(doc, actor_node, "name", actor.get("name") or "")
DomUtils.add_node(
doc,
actor_node,
"role",
actor.get("character") or actor.get("role") or "",
)
avatar = actor.get("avatar") or actor.get("images") or {}
if isinstance(avatar, dict):
DomUtils.add_node(
doc,
actor_node,
"thumb",
avatar.get("large") or avatar.get("medium") or avatar.get("normal") or "",
)
@classmethod
def get_metadata_nfo(
cls,
mediainfo: MediaInfo,
season: Optional[int] = None,
episode: Optional[int] = None,
) -> Optional[str]:
"""
根据统一媒体信息生成电影剧集季或单集 NFO
:param mediainfo: 统一媒体信息
:param season: 季号
:param episode: 集号
:return: NFO XML 文本
"""
if not mediainfo:
return None
doc = minidom.Document()
if mediainfo.type == MediaType.MOVIE:
root = DomUtils.add_node(doc, doc, "movie")
cls._append_common_nodes(mediainfo, doc, root)
elif season is not None and episode is not None:
root = DomUtils.add_node(doc, doc, "episodedetails")
cls._append_common_nodes(mediainfo, doc, root)
DomUtils.add_node(doc, root, "season", str(season))
DomUtils.add_node(doc, root, "episode", str(episode))
DomUtils.add_node(
doc,
root,
"showtitle",
mediainfo.title or "",
)
elif season is not None:
root = DomUtils.add_node(doc, doc, "season")
cls._append_common_nodes(mediainfo, doc, root)
DomUtils.add_node(doc, root, "seasonnumber", str(season))
else:
root = DomUtils.add_node(doc, doc, "tvshow")
cls._append_common_nodes(mediainfo, doc, root)
DomUtils.add_node(doc, root, "season", "-1")
DomUtils.add_node(doc, root, "episode", "-1")
return doc.toprettyxml(indent=" ", encoding="utf-8")
@classmethod
def get_metadata_img(
cls,
mediainfo: MediaInfo,
season: Optional[int] = None,
episode: Optional[int] = None,
) -> dict:
"""
根据统一媒体信息生成主海报和背景图下载清单
:param mediainfo: 统一媒体信息
:param season: 季号
:param episode: 集号
:return: 图片文件名与下载地址映射
"""
if not mediainfo or season is not None or episode is not None:
return {}
images = {}
if mediainfo.poster_path:
extension = cls._image_extension(mediainfo.poster_path)
images[f"poster{extension}"] = mediainfo.poster_path
if mediainfo.backdrop_path:
extension = cls._image_extension(mediainfo.backdrop_path)
images[f"backdrop{extension}"] = mediainfo.backdrop_path
return images
+105 -8
View File
@@ -15,6 +15,7 @@ from app.db.workflow_oper import WorkflowOper
from app.log import logger
from app.schemas.types import MediaType, SystemConfigKey, media_type_to_agent
from app.utils.http import AsyncRequestUtils, RequestUtils
from app.utils.media import resolve_media_identity
from app.utils.system import SystemUtils
from version import APP_VERSION, FRONTEND_VERSION
@@ -28,6 +29,7 @@ class MoviePilotServerHelper:
_USAGE_REPORT_PATH = "/usage/report"
_USAGE_STATISTIC_PATH = "/usage/statistic"
_PLUGIN_INSTALL_PATH = "/plugin/install"
_PLUGIN_RATING_PATH = "/plugin/rating"
_PLUGIN_STATISTIC_PATH = "/plugin/statistic"
_SUBSCRIBE_ADD_PATH = "/subscribe/add"
_SUBSCRIBE_DONE_PATH = "/subscribe/done"
@@ -397,6 +399,39 @@ class MoviePilotServerHelper:
"""
return await cls._async_get(cls._server_url(cls._PLUGIN_STATISTIC_PATH), timeout=10)
@classmethod
async def async_plugin_ratings(cls, plugin_ids: Optional[List[str]] = None):
"""
异步批量查询中心端插件评分
"""
params = {"plugin_ids": ",".join(plugin_ids)} if plugin_ids is not None else None
return await cls._async_get(
cls._server_url(cls._PLUGIN_RATING_PATH),
params=params,
timeout=10,
)
@classmethod
async def async_plugin_rating(cls, plugin_id: str):
"""
异步查询中心端单个插件评分
"""
return await cls._async_get(
f"{cls._server_url(cls._PLUGIN_RATING_PATH)}/{quote(plugin_id, safe='')}",
timeout=10,
)
@classmethod
async def async_rate_plugin(cls, plugin_id: str, rating: float):
"""
异步提交当前安装实例的插件评分
"""
return await cls._async_post_json(
f"{cls._server_url(cls._PLUGIN_RATING_PATH)}/{quote(plugin_id, safe='')}",
{"rating": rating},
timeout=10,
)
@classmethod
def plugin_install(cls, plugin_id: str, payload: Dict[str, Any]):
"""
@@ -458,6 +493,58 @@ class MoviePilotServerHelper:
return res.json()
return {}
@classmethod
async def async_get_plugin_ratings(
cls,
plugin_ids: Optional[List[str]] = None,
) -> Dict[str, Dict[str, Any]]:
"""
批量获取插件评分中心端不可用时返回空结果
"""
try:
res = await cls.async_plugin_ratings(plugin_ids)
if res is not None and res.status_code == 200:
return res.json()
except Exception as err:
logger.debug(f"批量获取插件评分失败:{str(err)}")
return {}
@classmethod
async def async_get_plugin_rating(cls, plugin_id: str) -> Dict[str, Any]:
"""
获取单个插件评分中心端不可用时返回零评分
"""
empty_rating = {
"plugin_id": plugin_id,
"average_rating": 0.0,
"rating_count": 0,
"user_rating": None,
}
try:
res = await cls.async_plugin_rating(plugin_id)
if res is not None and res.status_code == 200:
return res.json()
except Exception as err:
logger.debug(f"获取插件 {plugin_id} 评分失败:{str(err)}")
return empty_rating
@classmethod
async def async_submit_plugin_rating(
cls,
plugin_id: str,
rating: float,
) -> Optional[Dict[str, Any]]:
"""
提交插件评分成功时返回最新评分结果
"""
try:
res = await cls.async_rate_plugin(plugin_id, rating)
if res is not None and res.status_code == 200:
return res.json()
except Exception as err:
logger.debug(f"提交插件 {plugin_id} 评分失败:{str(err)}")
return None
@classmethod
def install_plugin_reg(cls, plugin_id: str, repo_url: Optional[str] = None) -> bool:
"""
@@ -667,7 +754,7 @@ class MoviePilotServerHelper:
return params
@classmethod
@cached(region="subscribe_share", maxsize=5, ttl=1800, skip_empty=True)
@cached(region="subscribe_share", maxsize=32, ttl=1800, skip_empty=True)
def get_subscribe_statistic(
cls,
stype: str,
@@ -695,7 +782,7 @@ class MoviePilotServerHelper:
return cls._handle_list_response(cls.subscribe_statistic(params))
@classmethod
@cached(region="subscribe_share", maxsize=5, ttl=1800, skip_empty=True)
@cached(region="subscribe_share", maxsize=32, ttl=1800, skip_empty=True)
async def async_get_subscribe_statistic(
cls,
stype: str,
@@ -881,7 +968,7 @@ class MoviePilotServerHelper:
return cls._handle_response(await cls.async_subscribe_fork(share_id))
@classmethod
@cached(region="subscribe_share", maxsize=1, ttl=1800, skip_empty=True)
@cached(region="subscribe_share", maxsize=32, ttl=1800, skip_empty=True)
def get_subscribe_shares(
cls,
name: Optional[str] = None,
@@ -909,7 +996,7 @@ class MoviePilotServerHelper:
return cls._handle_list_response(cls.subscribe_shares(params))
@classmethod
@cached(region="subscribe_share", maxsize=1, ttl=1800, skip_empty=True)
@cached(region="subscribe_share", maxsize=32, ttl=1800, skip_empty=True)
async def async_get_subscribe_shares(
cls,
name: Optional[str] = None,
@@ -937,7 +1024,7 @@ class MoviePilotServerHelper:
return cls._handle_list_response(await cls.async_subscribe_shares(params))
@classmethod
@cached(region="subscribe_share", maxsize=1, ttl=1800, skip_empty=True)
@cached(region="subscribe_share", maxsize=32, ttl=1800, skip_empty=True)
def get_subscribe_share_statistics(cls) -> List[dict]:
"""
获取订阅分享统计数据
@@ -947,7 +1034,7 @@ class MoviePilotServerHelper:
return cls._handle_list_response(cls.subscribe_share_statistics())
@classmethod
@cached(region="subscribe_share", maxsize=1, ttl=1800, skip_empty=True)
@cached(region="subscribe_share", maxsize=32, ttl=1800, skip_empty=True)
async def async_get_subscribe_share_statistics(cls) -> List[dict]:
"""
异步获取订阅分享统计数据
@@ -1332,7 +1419,10 @@ class MoviePilotServerHelper:
tmdbid = item.get("tmdbid")
doubanid = item.get("doubanid")
bangumiid = item.get("bangumiid")
if not any([tmdbid, doubanid, bangumiid]):
anilistid = item.get("anilistid")
media_source = item.get("media_source")
media_id = item.get("media_id")
if not any([tmdbid, doubanid, bangumiid, anilistid, media_id]):
return None
return {
@@ -1340,6 +1430,9 @@ class MoviePilotServerHelper:
"tmdbid": tmdbid,
"doubanid": doubanid,
"bangumiid": bangumiid,
"anilistid": anilistid,
"source": media_source,
"mediaid": media_id,
"season": item.get("season"),
}
@@ -1486,7 +1579,8 @@ class MoviePilotServerHelper:
media_type = cls._extract_media_type(meta=meta, mediainfo=mediainfo)
if not keyword or not media_type:
return None
if not any([mediainfo.tmdb_id, mediainfo.douban_id, mediainfo.bangumi_id]):
media_source, media_id = resolve_media_identity(media=mediainfo)
if not media_id:
return None
return {
@@ -1502,6 +1596,9 @@ class MoviePilotServerHelper:
"tmdbid": mediainfo.tmdb_id,
"doubanid": mediainfo.douban_id,
"bangumiid": mediainfo.bangumi_id,
"anilistid": mediainfo.anilist_id,
"media_source": media_source,
"media_id": media_id,
}
@classmethod
+15 -2
View File
@@ -1,7 +1,10 @@
from typing import Dict, List, Optional, Type, TypeVar, Generic, Iterator
from pydantic import ValidationError
from app.core.module import ModuleManager
from app.db.systemconfig_oper import SystemConfigOper
from app.log import logger
from app.schemas import DownloaderConf, MediaServerConf, NotificationConf, NotificationSwitchConf, ServiceInfo
from app.schemas.types import NotificationType, SystemConfigKey, ModuleType
@@ -25,8 +28,18 @@ class ServiceConfigHelper:
config_data = SystemConfigOper().get(config_key)
if not config_data:
return []
# 直接使用 conf_type 来实例化配置对象
return [conf_type(**conf) for conf in config_data]
configs = []
for conf in config_data:
if not isinstance(conf, dict):
logger.warn(f"{config_key.value} 配置格式不正确,已跳过:{conf}")
continue
try:
# 直接使用 conf_type 来实例化配置对象
configs.append(conf_type(**conf))
except ValidationError as e:
# 单条配置存在非法值时跳过,避免影响其它服务的初始化
logger.error(f"{config_key.value} 配置 {conf.get('name')} 校验失败,已跳过:{e}")
return configs
@staticmethod
def get_downloader_configs() -> List[DownloaderConf]:
+26 -6
View File
@@ -60,16 +60,24 @@ class TorrentHelper:
"""
def __init__(self):
"""初始化种子失败地址缓存"""
self._invalid_torrents = TTLCache(region="invalid_torrents", maxsize=128, ttl=3600 * 24)
def download_torrent(self, url: str,
cookie: Optional[str] = None,
ua: Optional[str] = None,
referer: Optional[str] = None,
proxy: Optional[bool] = False) \
proxy: Optional[bool] = False,
cache_invalid: bool = True) \
-> Tuple[Optional[Path], Optional[Union[str, bytes]], Optional[str], Optional[list], Optional[str]]:
"""
把种子下载到本地
:param url: 种子下载地址
:param cookie: 站点 Cookie
:param ua: 请求 User-Agent
:param referer: 请求来源地址
:param proxy: 是否使用系统代理
:param cache_invalid: 是否缓存失败地址短时凭证地址必须关闭
:return: 种子缓存相对路径用于索引缓存, 种子内容种子主目录种子文件清单错误信息
"""
if url.startswith("magnet:"):
@@ -142,16 +150,16 @@ class TorrentHelper:
# 检查是不是种子文件,如果不是抛出异常
Torrent.from_string(req.content)
# 跳过成功
logger.info(f"触发了站点首次种子下载,已自动跳过{url}")
logger.info("触发了站点首次种子下载,已自动跳过")
skip_flag = True
elif req is not None:
logger.warn(f"触发了站点首次种子下载,且无法自动跳过,"
f"返回码:{req.status_code},错误原因:{req.reason}")
else:
logger.warn(f"触发了站点首次种子下载,且无法自动跳过{url}")
logger.warn("触发了站点首次种子下载,且无法自动跳过")
break
except Exception as err:
logger.warn(f"触发了站点首次种子下载,尝试自动跳过时出现错误:{str(err)},链接:{url}")
logger.warn(f"触发了站点首次种子下载,尝试自动跳过时出现错误:{str(err)}")
if not skip_flag:
return cache_path, None, "", [], "种子数据有误,请确认链接是否正确,如为PT站点则需手工在站点下载一次种子"
# 种子内容
@@ -177,7 +185,8 @@ class TorrentHelper:
return cache_path, None, "", [], "触发站点流控,请稍后重试"
else:
# 把错误的种子记下来,避免重复使用
self.add_invalid(url)
if cache_invalid:
self.add_invalid(url)
return cache_path, None, "", [], f"下载种子出错,状态码:{req.status_code}"
def get_torrent_info(self, torrent_path: Path) -> Tuple[str, List[str]]:
@@ -397,7 +406,10 @@ class TorrentHelper:
:param torrent: 种子信息
"""
# 比对词条指定的tmdbid
if torrent_meta.tmdbid or torrent_meta.doubanid:
if any((
torrent_meta.tmdbid, torrent_meta.doubanid,
torrent_meta.bangumiid, torrent_meta.anilistid,
)):
if torrent_meta.tmdbid and torrent_meta.tmdbid == mediainfo.tmdb_id:
logger.info(
f'{mediainfo.title} 通过词表指定TMDBID匹配到资源:{torrent.site_name} - {torrent.title}')
@@ -406,6 +418,14 @@ class TorrentHelper:
logger.info(
f'{mediainfo.title} 通过词表指定豆瓣ID匹配到资源:{torrent.site_name} - {torrent.title}')
return True
if torrent_meta.bangumiid and torrent_meta.bangumiid == mediainfo.bangumi_id:
logger.info(
f'{mediainfo.title} 通过词表指定 Bangumi ID 匹配到资源:{torrent.site_name} - {torrent.title}')
return True
if torrent_meta.anilistid and torrent_meta.anilistid == mediainfo.anilist_id:
logger.info(
f'{mediainfo.title} 通过词表指定 AniList ID 匹配到资源:{torrent.site_name} - {torrent.title}')
return True
# 要匹配的媒体标题、原标题
media_titles = {
StringUtils.clear_upper(mediainfo.title),
+34 -6
View File
@@ -99,6 +99,8 @@
"messages": {
"模块不支持测试": "Module does not support testing",
"网络请求失败": "Network request failed",
"TMDB请求失败": "TMDB request failed",
"媒体服务器请求失败": "Media server request failed",
"附件保存失败": "Failed to save attachment",
"该选择已失效,请重新发起选择": "This selection has expired. Please start the selection again",
"会话不存在或无权访问": "The conversation does not exist or you do not have access",
@@ -118,6 +120,8 @@
"模型响应为空": "Model response is empty",
"LLM 调用超时": "LLM call timed out",
"刮削路径无效": "Scraping path is invalid",
"指定媒体ID时必须同时指定媒体数据源": "The media source must be specified together with the media ID",
"媒体ID格式无效": "Invalid media ID format",
"刮削失败,无法识别媒体信息": "Scraping failed: unable to recognize media information",
"刮削路径不存在": "Scraping path does not exist",
"保存成功": "Saved successfully",
@@ -128,17 +132,17 @@
"未配置媒体服务器": "Media server is not configured",
"未找到播放地址": "Playback URL not found",
"验证码错误": "Verification code is incorrect",
"您已注册通行密钥,为了防止域名配置变更导致无法登录,请先删除所有通行密钥再关闭 OTP 验证": "You have registered a passkey. To prevent login issues after domain configuration changes, delete all passkeys before disabling OTP verification",
"密码错误": "Incorrect password",
"为了确保在域名配置错误时仍能找回访问权限,请先启用 OTP 验证码再注册通行密钥": "To ensure access can be recovered when domain configuration is incorrect, enable OTP verification before registering a passkey",
"注册请求已失效,请重新发起注册": "The registration request has expired. Start registration again",
"通行密钥注册成功": "Passkey registered successfully",
"访问域名与系统配置不一致,请使用配置的域名重试": "The access domain does not match the system configuration. Retry using the configured domain",
"通行密钥注册验证失败,请重新发起注册后重试": "Passkey registration verification failed. Start registration again and retry",
"通行密钥注册失败,请稍后重试": "Passkey registration failed. Try again later",
"认证失败": "Authentication failed",
"认证请求已失效": "The authentication request has expired",
"通行密钥已删除": "Passkey deleted",
"通行密钥不存在或无权删除": "The passkey does not exist or you do not have permission to delete it",
"验证失败": "Verification failed",
"通行密钥不存在或不属于当前用户": "The passkey does not exist or does not belong to the current user",
"通行密钥验证失败": "Passkey verification failed",
"二次验证成功": "Secondary verification succeeded",
"没有传入仓库地址,无法正确安装插件,请检查配置": "No repository URL was provided, so the plugin cannot be installed. Please check the configuration",
"插件分身创建成功": "Plugin clone created successfully",
"未识别到豆瓣媒体信息": "Unable to recognize Douban media information",
@@ -277,7 +281,7 @@
"用户不存在或已禁用": "The user does not exist or has been disabled",
"用户权限不足": "Insufficient user permissions",
"用户名或密码错误": "Incorrect username or password",
"需要双重验证,请提供验证码或使用通行密钥": "Two-factor verification is required. Provide a verification code or use a passkey",
"需要二次验证": "Two-step verification is required",
"图片读取出错": "Failed to read image",
"授权失败": "Authorization failed",
"报文内容为空": "Request payload is empty",
@@ -418,6 +422,10 @@
"source": "插件 {plugin} 不存在或未安装",
"target": "Plugin {plugin} does not exist or is not installed"
},
{
"source": "插件 {plugin} 未安装,无法评分",
"target": "Plugin {plugin} is not installed and cannot be rated"
},
{
"source": "插件 {plugin} 不存在或未加载",
"target": "Plugin {plugin} does not exist or is not loaded"
@@ -1102,6 +1110,10 @@
"source": "数据表 {name} 清理处理完成",
"target": "Data table {name} cleanup completed"
},
{
"source": "同步媒体服务器 - {name} 开始执行 ...",
"target": "Starting media server sync - {name} ..."
},
{
"source": "{name} 开始执行 ...",
"target": "Starting {name_i18n} ..."
@@ -1122,6 +1134,10 @@
"source": "正在同步媒体服务器({index}/{total}{name} ...",
"target": "Syncing media server ({index}/{total}) {name} ..."
},
{
"source": "媒体服务器 {name} 未启用或不存在",
"target": "Media server {name} is disabled or does not exist"
},
{
"source": "媒体服务器 {name} 无可同步媒体库",
"target": "Media server {name} has no libraries to sync"
@@ -1326,6 +1342,18 @@
"source": "工作流 {name} 执行完成",
"target": "Workflow {name} completed"
},
{
"source": "同步媒体服务器 - {name} 执行完成",
"target": "Media server sync - {name} completed"
},
{
"source": "同步媒体服务器 - {name} 执行失败",
"target": "Media server sync - {name} failed"
},
{
"source": "同步媒体服务器 - {name}",
"target": "Sync Media Server - {name}"
},
{
"source": "{name} 执行完成",
"target": "{name_i18n} completed"
+2
View File
@@ -99,6 +99,8 @@
"messages": {
"模块不支持测试": "模块不支持测试",
"网络请求失败": "网络请求失败",
"TMDB请求失败": "TMDB请求失败",
"媒体服务器请求失败": "媒体服务器请求失败",
"豆瓣网络连接失败": "豆瓣网络连接失败",
"Bangumi网络连接失败": "Bangumi网络连接失败",
"fanart网络连接失败": "fanart网络连接失败",
+34 -6
View File
@@ -99,6 +99,8 @@
"messages": {
"模块不支持测试": "模組不支援測試",
"网络请求失败": "網路請求失敗",
"TMDB请求失败": "TMDB 請求失敗",
"媒体服务器请求失败": "媒體伺服器請求失敗",
"附件保存失败": "附件儲存失敗",
"该选择已失效,请重新发起选择": "此選擇已失效,請重新發起選擇",
"会话不存在或无权访问": "會話不存在或無權存取",
@@ -118,6 +120,8 @@
"模型响应为空": "模型回應為空",
"LLM 调用超时": "LLM 呼叫逾時",
"刮削路径无效": "刮削路徑無效",
"指定媒体ID时必须同时指定媒体数据源": "指定媒體ID時必須同時指定媒體資料源",
"媒体ID格式无效": "媒體ID格式無效",
"刮削失败,无法识别媒体信息": "刮削失敗,無法識別媒體資訊",
"刮削路径不存在": "刮削路徑不存在",
"保存成功": "儲存成功",
@@ -128,17 +132,17 @@
"未配置媒体服务器": "未設定媒體伺服器",
"未找到播放地址": "未找到播放位址",
"验证码错误": "驗證碼錯誤",
"您已注册通行密钥,为了防止域名配置变更导致无法登录,请先删除所有通行密钥再关闭 OTP 验证": "您已註冊通行密鑰,為避免網域設定變更導致無法登入,請先刪除所有通行密鑰再關閉 OTP 驗證",
"密码错误": "密碼錯誤",
"为了确保在域名配置错误时仍能找回访问权限,请先启用 OTP 验证码再注册通行密钥": "為了確保網域設定錯誤時仍可找回存取權限,請先啟用 OTP 驗證碼再註冊通行密鑰",
"注册请求已失效,请重新发起注册": "註冊請求已失效,請重新發起註冊",
"通行密钥注册成功": "通行密鑰註冊成功",
"访问域名与系统配置不一致,请使用配置的域名重试": "訪問域名與系統設定不一致,請使用設定的域名重試",
"通行密钥注册验证失败,请重新发起注册后重试": "通行密鑰註冊驗證失敗,請重新發起註冊後重試",
"通行密钥注册失败,请稍后重试": "通行密鑰註冊失敗,請稍後重試",
"认证失败": "認證失敗",
"认证请求已失效": "認證請求已失效",
"通行密钥已删除": "通行密鑰已刪除",
"通行密钥不存在或无权删除": "通行密鑰不存在或無權刪除",
"验证失败": "驗證失敗",
"通行密钥不存在或不属于当前用户": "通行密鑰不存在或不屬於目前使用者",
"通行密钥验证失败": "通行密鑰驗證失敗",
"二次验证成功": "二次驗證成功",
"没有传入仓库地址,无法正确安装插件,请检查配置": "未傳入倉庫位址,無法正確安裝插件,請檢查設定",
"插件分身创建成功": "插件分身建立成功",
"未识别到豆瓣媒体信息": "未識別到豆瓣媒體資訊",
@@ -277,7 +281,7 @@
"用户不存在或已禁用": "使用者不存在或已停用",
"用户权限不足": "使用者權限不足",
"用户名或密码错误": "使用者名稱或密碼錯誤",
"需要双重验证,请提供验证码或使用通行密钥": "需要雙重驗證,請提供驗證碼或使用通行密鑰",
"需要二次验证": "需要二次驗證",
"图片读取出错": "圖片讀取出錯",
"授权失败": "授權失敗",
"报文内容为空": "報文內容為空",
@@ -418,6 +422,10 @@
"source": "插件 {plugin} 不存在或未安装",
"target": "插件 {plugin} 不存在或未安裝"
},
{
"source": "插件 {plugin} 未安装,无法评分",
"target": "插件 {plugin} 未安裝,無法評分"
},
{
"source": "插件 {plugin} 不存在或未加载",
"target": "插件 {plugin} 不存在或未載入"
@@ -1102,6 +1110,10 @@
"source": "数据表 {name} 清理处理完成",
"target": "資料表 {name} 清理處理完成"
},
{
"source": "同步媒体服务器 - {name} 开始执行 ...",
"target": "開始同步媒體伺服器 - {name} ..."
},
{
"source": "{name} 开始执行 ...",
"target": "{name_i18n} 開始執行 ..."
@@ -1122,6 +1134,10 @@
"source": "正在同步媒体服务器({index}/{total}{name} ...",
"target": "正在同步媒體伺服器({index}/{total}{name} ..."
},
{
"source": "媒体服务器 {name} 未启用或不存在",
"target": "媒體伺服器 {name} 未啟用或不存在"
},
{
"source": "媒体服务器 {name} 无可同步媒体库",
"target": "媒體伺服器 {name} 無可同步媒體庫"
@@ -1326,6 +1342,18 @@
"source": "工作流 {name} 执行完成",
"target": "工作流 {name} 執行完成"
},
{
"source": "同步媒体服务器 - {name} 执行完成",
"target": "同步媒體伺服器 - {name} 執行完成"
},
{
"source": "同步媒体服务器 - {name} 执行失败",
"target": "同步媒體伺服器 - {name} 執行失敗"
},
{
"source": "同步媒体服务器 - {name}",
"target": "同步媒體伺服器 - {name}"
},
{
"source": "{name} 执行完成",
"target": "{name_i18n} 執行完成"
+567
View File
@@ -0,0 +1,567 @@
from typing import List, Optional, Tuple, Union
from app import schemas
from app.core.config import settings
from app.core.context import MediaInfo
from app.core.meta import MetaBase
from app.helper.scraper import MediaScraperHelper
from app.log import logger
from app.modules import _ModuleBase
from app.modules.anilist.anilist import AniListApi
from app.schemas.types import MediaRecognizeType, MediaType, ModuleType
class AniListModule(_ModuleBase):
"""
AniList 动画媒体识别与刮削模块
"""
CONFIG_WATCH = {"PROXY_HOST"}
anilist_api: AniListApi = None
scraper: MediaScraperHelper = None
def init_module(self) -> None:
"""初始化 AniList 客户端与通用刮削器"""
self.anilist_api = AniListApi()
self.scraper = MediaScraperHelper()
def init_setting(self) -> Tuple[str, Union[str, bool]]:
"""AniList 模块无需独立开关"""
return None
def stop(self) -> None:
"""关闭 AniList 模块"""
return None
def test(self) -> Tuple[bool, str]:
"""测试 AniList GraphQL API 连通性"""
result = self.anilist_api.search("Cowboy Bebop", count=1)
return (True, "") if result else (False, "AniList网络连接失败")
@staticmethod
def get_name() -> str:
"""获取模块名称"""
return "AniList"
@staticmethod
def get_type() -> ModuleType:
"""获取模块类型"""
return ModuleType.MediaRecognize
@staticmethod
def get_subtype() -> MediaRecognizeType:
"""获取模块子类型"""
return MediaRecognizeType.AniList
@staticmethod
def get_priority() -> int:
"""获取模块优先级"""
return 4
@staticmethod
def _source_enabled(source: Optional[str]) -> bool:
"""
判断本次识别是否指定 AniList
:param source: 请求级识别数据源
:return: 是否启用 AniList 识别
"""
return (source or settings.RECOGNIZE_SOURCE) == "anilist"
@staticmethod
def _media_type(info: dict) -> MediaType:
"""
AniList 发布格式转换为系统媒体类型
:param info: AniList 媒体信息
:return: 系统媒体类型
"""
return MediaType.MOVIE if info.get("format") == "MOVIE" else MediaType.TV
@classmethod
def _matches_meta(cls, meta: MetaBase, info: dict) -> bool:
"""
判断 AniList 候选项是否符合标题解析出的类型与年份
:param meta: 标题解析元数据
:param info: AniList 候选项
:return: 是否符合筛选条件
"""
if meta.type in {MediaType.MOVIE, MediaType.TV} and cls._media_type(info) != meta.type:
return False
year = info.get("startDate", {}).get("year") or info.get("seasonYear")
return not meta.year or not year or str(year) == str(meta.year)
@staticmethod
def _enrich_people(info: dict) -> dict:
"""
AniList 人物连接转换为统一媒体信息所需的演职员结构
:param info: AniList 媒体详情
:return: 补充演员和导演后的媒体详情
"""
enriched = dict(info)
actors = []
for edge in info.get("characters", {}).get("edges") or []:
character = edge.get("node") or {}
voice_actors = edge.get("voiceActors") or []
actor = voice_actors[0] if voice_actors else {}
actor_name = actor.get("name", {}).get("full")
if not actor_name:
continue
actors.append(
{
"id": actor.get("id"),
"name": actor_name,
"character": character.get("name", {}).get("full")
or character.get("name", {}).get("native"),
"avatar": {"large": actor.get("image", {}).get("large")},
"url": actor.get("siteUrl"),
}
)
enriched["actors"] = actors
directors = []
for edge in info.get("staff", {}).get("edges") or []:
role = edge.get("role") or ""
if "Director" not in role:
continue
staff = edge.get("node") or {}
directors.append(
{
"id": staff.get("id"),
"name": staff.get("name", {}).get("full"),
"job": role,
"avatar": {"large": staff.get("image", {}).get("large")},
"url": staff.get("siteUrl"),
}
)
enriched["directors"] = directors
return enriched
@staticmethod
def _person_name(name_info: dict) -> Optional[str]:
"""
按原语言通用名顺序选择 AniList 人物姓名
:param name_info: AniList 人物姓名字段
:return: 可展示姓名
"""
return name_info.get("native") or name_info.get("full")
@staticmethod
def _person_date(date_info: dict) -> Optional[str]:
"""
AniList 人物模糊日期转换为标准日期文本
:param date_info: AniList FuzzyDate 字段
:return: 日期文本
"""
return MediaInfo._anilist_date(date_info)
@classmethod
def _build_credit_person(cls, edge: dict) -> Optional[schemas.MediaPerson]:
"""
AniList 角色配音关系转换为统一人物信息
:param edge: AniList 角色关系边
:return: 媒体人物信息
"""
actor = next(iter(edge.get("voiceActors") or []), None)
if not actor:
return None
name_info = actor.get("name") or {}
character_name = (edge.get("node") or {}).get("name") or {}
images = actor.get("image") or {}
return schemas.MediaPerson(
source="anilist",
id=actor.get("id"),
name=cls._person_name(name_info),
original_name=name_info.get("full"),
also_known_as=name_info.get("alternative") or [],
character=character_name.get("native") or character_name.get("full"),
images=images,
avatar=images,
url=actor.get("siteUrl"),
)
@classmethod
def _build_person_detail(cls, info: dict) -> schemas.MediaPerson:
"""
AniList 人物详情转换为统一人物信息
:param info: AniList 人物详情
:return: 媒体人物信息
"""
name_info = info.get("name") or {}
images = info.get("image") or {}
return schemas.MediaPerson(
source="anilist",
id=info.get("id"),
name=cls._person_name(name_info),
original_name=name_info.get("full"),
also_known_as=name_info.get("alternative") or [],
images=images,
avatar=images,
biography=info.get("description"),
birthday=cls._person_date(info.get("dateOfBirth") or {}),
deathday=cls._person_date(info.get("dateOfDeath") or {}),
gender=info.get("gender"),
place_of_birth=info.get("homeTown"),
career=info.get("primaryOccupations") or [],
url=info.get("siteUrl"),
)
def recognize_media(
self,
meta: MetaBase = None,
anilistid: Optional[int] = None,
source: Optional[str] = None,
**kwargs,
) -> Optional[MediaInfo]:
"""
AniList ID 或标题识别动画媒体信息
:param meta: 标题解析元数据
:param anilistid: AniList 媒体 ID
:param source: 请求级识别数据源
:return: 统一媒体信息
"""
if not anilistid and (not meta or not self._source_enabled(source)):
return None
info = self.anilist_api.detail(anilistid) if anilistid else self._match_by_meta(meta)
if not info:
return None
mediainfo = MediaInfo(anilist_info=self._enrich_people(info))
if meta and meta.begin_season is not None:
mediainfo.season = meta.begin_season
logger.info(
f"{anilistid or meta.name} AniList识别结果:{mediainfo.type.value} "
f"{mediainfo.title_year}"
)
return mediainfo
async def async_recognize_media(
self,
meta: MetaBase = None,
anilistid: Optional[int] = None,
source: Optional[str] = None,
**kwargs,
) -> Optional[MediaInfo]:
"""
异步按 AniList ID 或标题识别动画媒体信息
:param meta: 标题解析元数据
:param anilistid: AniList 媒体 ID
:param source: 请求级识别数据源
:return: 统一媒体信息
"""
if not anilistid and (not meta or not self._source_enabled(source)):
return None
info = (
await self.anilist_api.async_detail(anilistid)
if anilistid
else await self._async_match_by_meta(meta)
)
if not info:
return None
mediainfo = MediaInfo(anilist_info=self._enrich_people(info))
if meta and meta.begin_season is not None:
mediainfo.season = meta.begin_season
logger.info(
f"{anilistid or meta.name} AniList识别结果:{mediainfo.type.value} "
f"{mediainfo.title_year}"
)
return mediainfo
def _match_by_meta(self, meta: MetaBase) -> Optional[dict]:
"""
同步搜索并筛选最符合标题解析结果的 AniList 条目
:param meta: 标题解析元数据
:return: AniList 媒体详情
"""
for info in self.anilist_api.search(meta.name):
if self._matches_meta(meta, info):
return info
return None
async def _async_match_by_meta(self, meta: MetaBase) -> Optional[dict]:
"""
异步搜索并筛选最符合标题解析结果的 AniList 条目
:param meta: 标题解析元数据
:return: AniList 媒体详情
"""
for info in await self.anilist_api.async_search(meta.name):
if self._matches_meta(meta, info):
return info
return None
def search_medias(
self, meta: MetaBase, source: Optional[str] = None
) -> Optional[List[MediaInfo]]:
"""
搜索 AniList 动画媒体信息
:param meta: 标题解析元数据
:param source: 请求级搜索数据源
:return: 统一媒体信息列表
"""
if source and source != "anilist":
return None
if not source and settings.SEARCH_SOURCE and "anilist" not in settings.SEARCH_SOURCE:
return None
if not meta or not meta.name:
return []
return [
MediaInfo(anilist_info=self._enrich_people(info))
for info in self.anilist_api.search(meta.name)
if self._matches_meta(meta, info)
]
async def async_search_medias(
self, meta: MetaBase, source: Optional[str] = None
) -> Optional[List[MediaInfo]]:
"""
异步搜索 AniList 动画媒体信息
:param meta: 标题解析元数据
:param source: 请求级搜索数据源
:return: 统一媒体信息列表
"""
if source and source != "anilist":
return None
if not source and settings.SEARCH_SOURCE and "anilist" not in settings.SEARCH_SOURCE:
return None
if not meta or not meta.name:
return []
return [
MediaInfo(anilist_info=self._enrich_people(info))
for info in await self.anilist_api.async_search(meta.name)
if self._matches_meta(meta, info)
]
def anilist_info(self, anilist_id: int) -> Optional[dict]:
"""
获取 AniList 动画详情
:param anilist_id: AniList 媒体 ID
:return: AniList 媒体详情
"""
return self.anilist_api.detail(anilist_id) if anilist_id else None
async def async_anilist_info(self, anilist_id: int) -> Optional[dict]:
"""
异步获取 AniList 动画详情
:param anilist_id: AniList 媒体 ID
:return: AniList 媒体详情
"""
return await self.anilist_api.async_detail(anilist_id) if anilist_id else None
def anilist_trending(self, page: int = 1, count: int = 20) -> List[MediaInfo]:
"""
获取 AniList 当前趋势榜
:return: 统一媒体信息列表
"""
return [
MediaInfo(anilist_info=info)
for info in self.anilist_api.trending(page=page, count=count)
]
async def async_anilist_trending(self, page: int = 1, count: int = 20) -> List[MediaInfo]:
"""
异步获取 AniList 当前趋势榜
:return: 统一媒体信息列表
"""
return [
MediaInfo(anilist_info=info)
for info in await self.anilist_api.async_trending(page=page, count=count)
]
def anilist_popular_this_season(self, page: int = 1, count: int = 20) -> List[MediaInfo]:
"""
获取 AniList 本季热门榜
:return: 统一媒体信息列表
"""
return [
MediaInfo(anilist_info=info)
for info in self.anilist_api.popular_this_season(page=page, count=count)
]
async def async_anilist_popular_this_season(
self, page: int = 1, count: int = 20
) -> List[MediaInfo]:
"""
异步获取 AniList 本季热门榜
:return: 统一媒体信息列表
"""
infos = await self.anilist_api.async_popular_this_season(page=page, count=count)
return [MediaInfo(anilist_info=info) for info in infos]
def anilist_discover(self, **kwargs) -> List[MediaInfo]:
"""
按组合条件探索 AniList 动画
:return: 统一媒体信息列表
"""
return [
MediaInfo(anilist_info=info)
for info in self.anilist_api.discover(**kwargs)
]
async def async_anilist_discover(self, **kwargs) -> List[MediaInfo]:
"""
异步按组合条件探索 AniList 动画
:return: 统一媒体信息列表
"""
return [
MediaInfo(anilist_info=info)
for info in await self.anilist_api.async_discover(**kwargs)
]
def anilist_credits(
self, anilist_id: int, page: int = 1, count: int = 20
) -> List[schemas.MediaPerson]:
"""
获取 AniList 动画配音演员
:return: 媒体人物列表
"""
persons = (
self._build_credit_person(edge)
for edge in self.anilist_api.credits(anilist_id, page=page, count=count)
)
return [person for person in persons if person]
async def async_anilist_credits(
self, anilist_id: int, page: int = 1, count: int = 20
) -> List[schemas.MediaPerson]:
"""
异步获取 AniList 动画配音演员
:return: 媒体人物列表
"""
edges = await self.anilist_api.async_credits(anilist_id, page=page, count=count)
persons = (self._build_credit_person(edge) for edge in edges)
return [person for person in persons if person]
def anilist_recommendations(
self, anilist_id: int, page: int = 1, count: int = 20
) -> List[MediaInfo]:
"""
获取 AniList 动画相关推荐
:return: 统一媒体信息列表
"""
infos = self.anilist_api.recommendations(anilist_id, page=page, count=count)
return [MediaInfo(anilist_info=info) for info in infos]
async def async_anilist_recommendations(
self, anilist_id: int, page: int = 1, count: int = 20
) -> List[MediaInfo]:
"""
异步获取 AniList 动画相关推荐
:return: 统一媒体信息列表
"""
infos = await self.anilist_api.async_recommendations(
anilist_id, page=page, count=count
)
return [MediaInfo(anilist_info=info) for info in infos]
def anilist_person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:
"""
获取 AniList 人物详情
:param person_id: AniList 人物 ID
:return: 媒体人物信息
"""
info = self.anilist_api.person_detail(person_id)
return self._build_person_detail(info) if info else None
async def async_anilist_person_detail(
self, person_id: int
) -> Optional[schemas.MediaPerson]:
"""
异步获取 AniList 人物详情
:param person_id: AniList 人物 ID
:return: 媒体人物信息
"""
info = await self.anilist_api.async_person_detail(person_id)
return self._build_person_detail(info) if info else None
def anilist_person_credits(
self, person_id: int, page: int = 1, count: int = 20
) -> List[MediaInfo]:
"""
获取 AniList 人物参与的动画作品
:return: 统一媒体信息列表
"""
infos = self.anilist_api.person_credits(person_id, page=page, count=count)
return [MediaInfo(anilist_info=info) for info in infos]
async def async_anilist_person_credits(
self, person_id: int, page: int = 1, count: int = 20
) -> List[MediaInfo]:
"""
异步获取 AniList 人物参与的动画作品
:return: 统一媒体信息列表
"""
infos = await self.anilist_api.async_person_credits(
person_id, page=page, count=count
)
return [MediaInfo(anilist_info=info) for info in infos]
def metadata_nfo(
self,
mediainfo: MediaInfo,
season: Optional[int] = None,
episode: Optional[int] = None,
**kwargs,
) -> Optional[str]:
"""
生成 AniList 来源的 NFO 内容
:param mediainfo: 统一媒体信息
:param season: 季号
:param episode: 集号
:return: NFO XML 文本
"""
scrape_source = mediainfo.scrape_source or settings.SCRAP_SOURCE
if scrape_source != "anilist":
return None
return self.scraper.get_metadata_nfo(mediainfo, season=season, episode=episode)
def metadata_img(
self,
mediainfo: MediaInfo,
season: Optional[int] = None,
episode: Optional[int] = None,
) -> Optional[dict]:
"""
获取 AniList 来源的刮削图片清单
:param mediainfo: 统一媒体信息
:param season: 季号
:param episode: 集号
:return: 图片文件名与下载地址映射
"""
scrape_source = mediainfo.scrape_source or settings.SCRAP_SOURCE
if scrape_source != "anilist":
return None
return self.scraper.get_metadata_img(mediainfo, season=season, episode=episode)
def clear_cache(self) -> None:
"""清理 AniList 接口缓存"""
self.anilist_api.clear_cache()
+799
View File
@@ -0,0 +1,799 @@
from datetime import date
from typing import Optional
from app.core.cache import cached
from app.core.config import settings
from app.log import logger
from app.utils.http import AsyncRequestUtils, RequestUtils
class AniListApi:
"""
AniList 中文 GraphQL API 客户端
"""
_base_url = "https://trace.moe/anilist/"
_official_url = "https://graphql.anilist.co"
_translations_url = (
"https://raw.githubusercontent.com/soruly/anilist-chinese/"
"master/anilist-chinese.json"
)
_media_summary_fields = """
id
idMal
title { romaji english native }
format
status
description(asHtml: false)
startDate { year month day }
endDate { year month day }
season
seasonYear
episodes
duration
countryOfOrigin
coverImage { extraLarge large }
bannerImage
genres
synonyms
averageScore
popularity
isAdult
siteUrl
studios(isMain: true) { nodes { name } }
"""
_media_fields = f"""
{_media_summary_fields}
staff(perPage: 25, sort: [RELEVANCE]) {{
edges {{ role node {{ id name {{ full native }} image {{ large }} siteUrl }} }}
}}
characters(perPage: 25, sort: [ROLE, RELEVANCE]) {{
edges {{
role
node {{ id name {{ full native }} image {{ large }} siteUrl }}
voiceActors(language: JAPANESE, sort: [RELEVANCE]) {{
id
name {{ full native alternative }}
image {{ large medium }}
siteUrl
}}
}}
}}
externalLinks {{ site url type }}
"""
_page_query = f"""
query (
$page: Int!,
$count: Int!,
$search: String,
$genre: String,
$format: MediaFormat,
$season: MediaSeason,
$seasonYear: Int,
$status: MediaStatus,
$country: CountryCode,
$sort: [MediaSort]
) {{
Page(page: $page, perPage: $count) {{
media(
search: $search,
type: ANIME,
genre: $genre,
format: $format,
season: $season,
seasonYear: $seasonYear,
status: $status,
countryOfOrigin: $country,
isAdult: false,
sort: $sort
) {{ {_media_summary_fields} }}
}}
}}
"""
_media_by_ids_query = f"""
query ($ids: [Int!]!, $count: Int!) {{
Page(page: 1, perPage: $count) {{
media(id_in: $ids, type: ANIME) {{ {_media_summary_fields} }}
}}
}}
"""
def __init__(self) -> None:
"""初始化同步与异步请求客户端"""
headers = {
"User-Agent": settings.NORMAL_USER_AGENT,
"Accept": "application/json",
"Content-Type": "application/json",
}
self._request = RequestUtils(
proxies=settings.PROXY,
headers=headers,
)
self._async_request = AsyncRequestUtils(
proxies=settings.PROXY,
headers=headers,
)
self._proxy_available = True
self._translations: Optional[dict[int, dict]] = None
@staticmethod
def _extract_response(response) -> Optional[dict]:
"""
提取 GraphQL 响应数据并统一处理上游错误
:param response: HTTP 响应对象
:return: GraphQL data 字段
"""
if response is None or response.status_code != 200:
return None
try:
result = response.json()
except Exception as err:
logger.error(f"解析 AniList 响应失败:{str(err)}")
return None
if result.get("errors"):
logger.warning(f"AniList 接口返回错误:{result.get('errors')}")
return None
return result.get("data")
def _invoke(self, query: str, variables: dict) -> Optional[dict]:
"""
执行同步 GraphQL 请求
:param query: GraphQL 查询
:param variables: 查询变量
:return: GraphQL data 字段
"""
payload = {"query": query, "variables": variables}
if self._proxy_available:
response = self._request.post_res(self._base_url, json=payload)
result = self._extract_response(response)
if result is not None:
return self._inject_chinese(result, self._translation_map())
self._disable_proxy(response)
response = self._request.post_res(self._official_url, json=payload)
result = self._extract_response(response)
return self._inject_chinese(result, self._translation_map()) if result else result
async def _async_invoke(self, query: str, variables: dict) -> Optional[dict]:
"""
执行异步 GraphQL 请求
:param query: GraphQL 查询
:param variables: 查询变量
:return: GraphQL data 字段
"""
payload = {"query": query, "variables": variables}
if self._proxy_available:
response = await self._async_request.post_res(self._base_url, json=payload)
result = self._extract_response(response)
if result is not None:
translations = await self._async_translation_map()
return self._inject_chinese(result, translations)
self._disable_proxy(response)
response = await self._async_request.post_res(self._official_url, json=payload)
result = self._extract_response(response)
if not result:
return result
translations = await self._async_translation_map()
return self._inject_chinese(result, translations)
def _disable_proxy(self, response) -> None:
"""
标记中文代理不可用避免当前进程持续请求已失效的上游
:param response: 中文代理响应对象
"""
self._proxy_available = False
status_code = getattr(response, "status_code", None)
logger.warning(
f"anilist-chinese 代理不可用(HTTP {status_code}),"
"改用 AniList 官方接口并合并中文数据集"
)
@staticmethod
def _build_translation_map(items) -> dict[int, dict]:
"""
anilist-chinese 数据集转换为按 AniList ID 索引的字典
:param items: anilist-chinese JSON 数据
:return: 中文标题数据索引
"""
if not isinstance(items, list):
return {}
return {
item.get("id"): item
for item in items
if isinstance(item, dict) and item.get("id")
}
def _translation_map(self) -> dict[int, dict]:
"""
同步加载并复用 anilist-chinese 中文标题数据
:return: 中文标题数据索引
"""
if self._translations is None:
items = self._request.get_json(self._translations_url)
self._translations = self._build_translation_map(items)
if not self._translations:
logger.warning("加载 anilist-chinese 中文数据集失败")
return self._translations
async def _async_translation_map(self) -> dict[int, dict]:
"""
异步加载并复用 anilist-chinese 中文标题数据
:return: 中文标题数据索引
"""
if self._translations is None:
items = await self._async_request.get_json(self._translations_url)
self._translations = self._build_translation_map(items)
if not self._translations:
logger.warning("加载 anilist-chinese 中文数据集失败")
return self._translations
@classmethod
def _inject_chinese(cls, value, translations: dict[int, dict]):
"""
递归合并 anilist-chinese 标题覆盖代理不会处理的嵌套媒体
:param value: AniList GraphQL data 字段或其子节点
:param translations: 中文标题数据索引
:return: 合并中文标题后的原数据结构
"""
if isinstance(value, list):
for item in value:
cls._inject_chinese(item, translations)
return value
if not isinstance(value, dict):
return value
translation = translations.get(value.get("id"))
title = value.get("title")
if translation and isinstance(title, dict):
title["chinese"] = translation.get("title")
synonyms = value.get("synonyms")
if translation and isinstance(synonyms, list):
value["synonyms"] = list(
dict.fromkeys([*synonyms, *(translation.get("synonyms") or [])])
)
for child in value.values():
cls._inject_chinese(child, translations)
return value
@staticmethod
def _page_variables(
page: int,
count: int,
search: Optional[str] = None,
genre: Optional[str] = None,
media_format: Optional[str] = None,
season: Optional[str] = None,
season_year: Optional[int] = None,
status: Optional[str] = None,
country: Optional[str] = None,
sort: Optional[str] = None,
) -> dict:
"""
构造 AniList 分页媒体查询变量
:return: 去除空值后的 GraphQL 变量
"""
variables = {
"page": page,
"count": count,
"search": search,
"genre": genre,
"format": media_format,
"season": season,
"seasonYear": season_year,
"status": status,
"country": country,
"sort": [sort] if sort else ["POPULARITY_DESC"],
}
return {key: value for key, value in variables.items() if value is not None}
@staticmethod
def _page_medias(result: Optional[dict]) -> list[dict]:
"""
从分页响应中提取媒体列表
:param result: GraphQL data 字段
:return: AniList 媒体列表
"""
return result.get("Page", {}).get("media") or [] if result else []
@staticmethod
def _ordered_medias(media_ids: list[int], medias: list[dict]) -> list[dict]:
"""
按上游关系顺序重排批量查询返回的媒体
:param media_ids: 关系查询返回的 AniList 媒体 ID
:param medias: Page.media 批量查询结果
:return: 保持原关系顺序的媒体列表
"""
media_map = {media.get("id"): media for media in medias if media.get("id")}
return [media_map[media_id] for media_id in media_ids if media_id in media_map]
def _medias_by_ids(self, media_ids: list[int]) -> list[dict]:
"""
通过根级 Page.media 批量查询媒体使中文代理能够注入标题
:param media_ids: AniList 媒体 ID 列表
:return: 按输入顺序排列的媒体列表
"""
unique_ids = list(dict.fromkeys(media_id for media_id in media_ids if media_id))
if not unique_ids:
return []
result = self._invoke(
self._media_by_ids_query,
{"ids": unique_ids, "count": len(unique_ids)},
)
return self._ordered_medias(media_ids, self._page_medias(result))
async def _async_medias_by_ids(self, media_ids: list[int]) -> list[dict]:
"""
异步通过根级 Page.media 批量查询媒体使中文代理能够注入标题
:param media_ids: AniList 媒体 ID 列表
:return: 按输入顺序排列的媒体列表
"""
unique_ids = list(dict.fromkeys(media_id for media_id in media_ids if media_id))
if not unique_ids:
return []
result = await self._async_invoke(
self._media_by_ids_query,
{"ids": unique_ids, "count": len(unique_ids)},
)
return self._ordered_medias(media_ids, self._page_medias(result))
@staticmethod
def _current_season(today: Optional[date] = None) -> tuple[str, int]:
"""
根据当前日期计算 AniList 季度与年份
:param today: 用于测试或指定季度的日期
:return: AniList 季度枚举和年份
"""
current = today or date.today()
seasons = ("WINTER", "SPRING", "SUMMER", "FALL")
return seasons[(current.month - 1) // 3], current.year
@cached(
maxsize=settings.CONF.anilist,
ttl=settings.CONF.meta,
skip_empty=True,
shared_key="detail",
)
def detail(self, anilist_id: int) -> Optional[dict]:
"""
根据 AniList ID 获取动画详情
:param anilist_id: AniList 媒体 ID
:return: AniList 媒体详情
"""
query = f"query ($id: Int!) {{ Media(id: $id, type: ANIME) {{ {self._media_fields} }} }}"
result = self._invoke(query, {"id": anilist_id})
return result.get("Media") if result else None
@cached(
maxsize=settings.CONF.anilist,
ttl=settings.CONF.meta,
skip_empty=True,
shared_key="detail",
)
async def async_detail(self, anilist_id: int) -> Optional[dict]:
"""
异步根据 AniList ID 获取动画详情
:param anilist_id: AniList 媒体 ID
:return: AniList 媒体详情
"""
query = f"query ($id: Int!) {{ Media(id: $id, type: ANIME) {{ {self._media_fields} }} }}"
result = await self._async_invoke(query, {"id": anilist_id})
return result.get("Media") if result else None
@cached(
maxsize=settings.CONF.anilist,
ttl=settings.CONF.meta,
skip_empty=True,
shared_key="search",
)
def search(self, name: str, count: int = 20) -> list[dict]:
"""
按标题搜索 AniList 动画
:param name: 动画标题
:param count: 返回条数
:return: AniList 媒体列表
"""
query = f"""
query ($search: String!, $count: Int!) {{
Page(page: 1, perPage: $count) {{
media(search: $search, type: ANIME, sort: SEARCH_MATCH) {{ {self._media_fields} }}
}}
}}
"""
result = self._invoke(query, {"search": name, "count": count})
return self._page_medias(result)
@cached(
maxsize=settings.CONF.anilist,
ttl=settings.CONF.meta,
skip_empty=True,
shared_key="search",
)
async def async_search(self, name: str, count: int = 20) -> list[dict]:
"""
异步按标题搜索 AniList 动画
:param name: 动画标题
:param count: 返回条数
:return: AniList 媒体列表
"""
query = f"""
query ($search: String!, $count: Int!) {{
Page(page: 1, perPage: $count) {{
media(search: $search, type: ANIME, sort: SEARCH_MATCH) {{ {self._media_fields} }}
}}
}}
"""
result = await self._async_invoke(query, {"search": name, "count": count})
return self._page_medias(result)
@cached(
maxsize=settings.CONF.anilist,
ttl=settings.CONF.meta,
skip_empty=True,
shared_key="discover",
)
def discover(
self,
page: int = 1,
count: int = 20,
search: Optional[str] = None,
genre: Optional[str] = None,
media_format: Optional[str] = None,
season: Optional[str] = None,
season_year: Optional[int] = None,
status: Optional[str] = None,
country: Optional[str] = None,
sort: Optional[str] = None,
) -> list[dict]:
"""
按组合条件探索 AniList 动画
:return: AniList 媒体列表
"""
variables = self._page_variables(
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 self._page_medias(self._invoke(self._page_query, variables))
@cached(
maxsize=settings.CONF.anilist,
ttl=settings.CONF.meta,
skip_empty=True,
shared_key="discover",
)
async def async_discover(
self,
page: int = 1,
count: int = 20,
search: Optional[str] = None,
genre: Optional[str] = None,
media_format: Optional[str] = None,
season: Optional[str] = None,
season_year: Optional[int] = None,
status: Optional[str] = None,
country: Optional[str] = None,
sort: Optional[str] = None,
) -> list[dict]:
"""
异步按组合条件探索 AniList 动画
:return: AniList 媒体列表
"""
variables = self._page_variables(
page=page,
count=count,
search=search,
genre=genre,
media_format=media_format,
season=season,
season_year=season_year,
status=status,
country=country,
sort=sort,
)
result = await self._async_invoke(self._page_query, variables)
return self._page_medias(result)
def trending(self, page: int = 1, count: int = 20) -> list[dict]:
"""
获取 AniList 当前趋势榜
:param page: 页码
:param count: 每页条数
:return: AniList 媒体列表
"""
return self.discover(page=page, count=count, sort="TRENDING_DESC")
async def async_trending(self, page: int = 1, count: int = 20) -> list[dict]:
"""
异步获取 AniList 当前趋势榜
:param page: 页码
:param count: 每页条数
:return: AniList 媒体列表
"""
return await self.async_discover(page=page, count=count, sort="TRENDING_DESC")
def popular_this_season(self, page: int = 1, count: int = 20) -> list[dict]:
"""
获取 AniList 本季热门榜
:param page: 页码
:param count: 每页条数
:return: AniList 媒体列表
"""
season, season_year = self._current_season()
return self.discover(
page=page,
count=count,
season=season,
season_year=season_year,
sort="POPULARITY_DESC",
)
async def async_popular_this_season(self, page: int = 1, count: int = 20) -> list[dict]:
"""
异步获取 AniList 本季热门榜
:param page: 页码
:param count: 每页条数
:return: AniList 媒体列表
"""
season, season_year = self._current_season()
return await self.async_discover(
page=page,
count=count,
season=season,
season_year=season_year,
sort="POPULARITY_DESC",
)
@cached(
maxsize=settings.CONF.anilist,
ttl=settings.CONF.meta,
skip_empty=True,
shared_key="credits",
)
def credits(self, anilist_id: int, page: int = 1, count: int = 20) -> list[dict]:
"""
获取 AniList 动画的日语配音演员
:return: AniList 人物边列表
"""
query = """
query ($id: Int!, $page: Int!, $count: Int!) {
Media(id: $id, type: ANIME) {
characters(page: $page, perPage: $count, sort: [ROLE, RELEVANCE]) {
edges {
role
node { id name { full native } }
voiceActors(language: JAPANESE, sort: [RELEVANCE]) {
id name { full native alternative } image { large medium } siteUrl
}
}
}
}
}
"""
result = self._invoke(query, {"id": anilist_id, "page": page, "count": count})
return result.get("Media", {}).get("characters", {}).get("edges") or [] if result else []
@cached(
maxsize=settings.CONF.anilist,
ttl=settings.CONF.meta,
skip_empty=True,
shared_key="credits",
)
async def async_credits(self, anilist_id: int, page: int = 1, count: int = 20) -> list[dict]:
"""
异步获取 AniList 动画的日语配音演员
:return: AniList 人物边列表
"""
query = """
query ($id: Int!, $page: Int!, $count: Int!) {
Media(id: $id, type: ANIME) {
characters(page: $page, perPage: $count, sort: [ROLE, RELEVANCE]) {
edges {
role
node { id name { full native } }
voiceActors(language: JAPANESE, sort: [RELEVANCE]) {
id name { full native alternative } image { large medium } siteUrl
}
}
}
}
}
"""
result = await self._async_invoke(query, {"id": anilist_id, "page": page, "count": count})
return result.get("Media", {}).get("characters", {}).get("edges") or [] if result else []
@cached(
maxsize=settings.CONF.anilist,
ttl=settings.CONF.meta,
skip_empty=True,
shared_key="recommendations",
)
def recommendations(self, anilist_id: int, page: int = 1, count: int = 20) -> list[dict]:
"""
获取 AniList 动画相关推荐
:return: AniList 媒体列表
"""
query = """
query ($id: Int!, $page: Int!, $count: Int!) {
Media(id: $id, type: ANIME) {
recommendations(page: $page, perPage: $count, sort: [RATING_DESC, ID]) {
nodes { mediaRecommendation { id } }
}
}
}
"""
result = self._invoke(query, {"id": anilist_id, "page": page, "count": count})
nodes = result.get("Media", {}).get("recommendations", {}).get("nodes") or [] if result else []
media_ids = [node.get("mediaRecommendation", {}).get("id") for node in nodes]
return self._medias_by_ids(media_ids)
@cached(
maxsize=settings.CONF.anilist,
ttl=settings.CONF.meta,
skip_empty=True,
shared_key="recommendations",
)
async def async_recommendations(self, anilist_id: int, page: int = 1, count: int = 20) -> list[dict]:
"""
异步获取 AniList 动画相关推荐
:return: AniList 媒体列表
"""
query = """
query ($id: Int!, $page: Int!, $count: Int!) {
Media(id: $id, type: ANIME) {
recommendations(page: $page, perPage: $count, sort: [RATING_DESC, ID]) {
nodes { mediaRecommendation { id } }
}
}
}
"""
result = await self._async_invoke(query, {"id": anilist_id, "page": page, "count": count})
nodes = result.get("Media", {}).get("recommendations", {}).get("nodes") or [] if result else []
media_ids = [node.get("mediaRecommendation", {}).get("id") for node in nodes]
return await self._async_medias_by_ids(media_ids)
@cached(
maxsize=settings.CONF.anilist,
ttl=settings.CONF.meta,
skip_empty=True,
shared_key="person_detail",
)
def person_detail(self, person_id: int) -> Optional[dict]:
"""
获取 AniList 演员详情
:param person_id: AniList 人物 ID
:return: AniList 人物详情
"""
query = """
query ($id: Int!) {
Staff(id: $id) {
id name { full native alternative } image { large medium }
description(asHtml: false) dateOfBirth { year month day }
dateOfDeath { year month day } gender homeTown primaryOccupations siteUrl
}
}
"""
result = self._invoke(query, {"id": person_id})
return result.get("Staff") if result else None
@cached(
maxsize=settings.CONF.anilist,
ttl=settings.CONF.meta,
skip_empty=True,
shared_key="person_detail",
)
async def async_person_detail(self, person_id: int) -> Optional[dict]:
"""
异步获取 AniList 演员详情
:param person_id: AniList 人物 ID
:return: AniList 人物详情
"""
query = """
query ($id: Int!) {
Staff(id: $id) {
id name { full native alternative } image { large medium }
description(asHtml: false) dateOfBirth { year month day }
dateOfDeath { year month day } gender homeTown primaryOccupations siteUrl
}
}
"""
result = await self._async_invoke(query, {"id": person_id})
return result.get("Staff") if result else None
@cached(
maxsize=settings.CONF.anilist,
ttl=settings.CONF.meta,
skip_empty=True,
shared_key="person_credits",
)
def person_credits(self, person_id: int, page: int = 1, count: int = 20) -> list[dict]:
"""
获取 AniList 演员参与的动画作品
:return: AniList 媒体列表
"""
query = """
query ($id: Int!, $page: Int!, $count: Int!) {
Staff(id: $id) {
characterMedia(page: $page, perPage: $count, sort: [POPULARITY_DESC]) {
nodes { id }
}
}
}
"""
result = self._invoke(query, {"id": person_id, "page": page, "count": count})
nodes = result.get("Staff", {}).get("characterMedia", {}).get("nodes") or [] if result else []
return self._medias_by_ids([node.get("id") for node in nodes])
@cached(
maxsize=settings.CONF.anilist,
ttl=settings.CONF.meta,
skip_empty=True,
shared_key="person_credits",
)
async def async_person_credits(self, person_id: int, page: int = 1, count: int = 20) -> list[dict]:
"""
异步获取 AniList 演员参与的动画作品
:return: AniList 媒体列表
"""
query = """
query ($id: Int!, $page: Int!, $count: Int!) {
Staff(id: $id) {
characterMedia(page: $page, perPage: $count, sort: [POPULARITY_DESC]) {
nodes { id }
}
}
}
"""
result = await self._async_invoke(query, {"id": person_id, "page": page, "count": count})
nodes = result.get("Staff", {}).get("characterMedia", {}).get("nodes") or [] if result else []
return await self._async_medias_by_ids([node.get("id") for node in nodes])
def clear_cache(self) -> None:
"""清理 AniList 接口缓存"""
for method in (
self.detail,
self.search,
self.discover,
self.credits,
self.recommendations,
self.person_detail,
self.person_credits,
):
method.cache_clear()
+149 -27
View File
@@ -4,10 +4,11 @@ from app import schemas
from app.core.config import settings
from app.core.context import MediaInfo
from app.core.meta import MetaBase
from app.helper.scraper import MediaScraperHelper
from app.log import logger
from app.modules import _ModuleBase
from app.modules.bangumi.bangumi import BangumiApi
from app.schemas.types import ModuleType, MediaRecognizeType
from app.schemas.types import MediaRecognizeType, MediaType, ModuleType
from app.utils.http import RequestUtils
@@ -18,12 +19,14 @@ class BangumiModule(_ModuleBase):
CONFIG_WATCH = {"PROXY_HOST"}
bangumiapi: BangumiApi = None
scraper: MediaScraperHelper = None
def init_module(self) -> None:
"""
初始化Bangumi客户端
"""
self.bangumiapi = BangumiApi()
self.scraper = MediaScraperHelper()
def stop(self) -> None:
"""
@@ -44,7 +47,8 @@ class BangumiModule(_ModuleBase):
return False, "Bangumi网络连接失败"
def init_setting(self) -> Tuple[str, Union[str, bool]]:
pass
"""Bangumi模块无需独立开关"""
return None
@staticmethod
def get_name() -> str:
@@ -74,59 +78,133 @@ class BangumiModule(_ModuleBase):
"""
return 3
def recognize_media(self, bangumiid: int = None,
**kwargs) -> Optional[MediaInfo]:
def recognize_media(
self,
meta: MetaBase = None,
bangumiid: int = None,
source: Optional[str] = None,
**kwargs,
) -> Optional[MediaInfo]:
"""
识别媒体信息
:param meta: 识别的元数据
:param bangumiid: 识别的Bangumi ID
:param source: 请求级识别数据源
:return: 识别的媒体信息包括剧集信息
"""
if not bangumiid:
if not bangumiid and (
not meta or (source or settings.RECOGNIZE_SOURCE) != "bangumi"
):
return None
# 直接查询详情
info = self.bangumi_info(bangumiid=bangumiid)
info = (
self.bangumi_info(bangumiid=bangumiid)
if bangumiid
else self._match_by_meta(meta)
)
if info:
# 赋值TMDB信息并返回
info["actors"] = self.bangumiapi.credits(info.get("id"))
mediainfo = MediaInfo(bangumi_info=info)
logger.info(f"{bangumiid} Bangumi识别结果:{mediainfo.type.value} "
if meta and meta.begin_season is not None:
mediainfo.season = meta.begin_season
logger.info(f"{bangumiid or meta.name} Bangumi识别结果:{mediainfo.type.value} "
f"{mediainfo.title_year}")
return mediainfo
else:
logger.info(f"{bangumiid} 未匹配到Bangumi媒体信息")
logger.info(f"{bangumiid or meta.name} 未匹配到Bangumi媒体信息")
return None
async def async_recognize_media(self, bangumiid: int = None,
**kwargs) -> Optional[MediaInfo]:
async def async_recognize_media(
self,
meta: MetaBase = None,
bangumiid: int = None,
source: Optional[str] = None,
**kwargs,
) -> Optional[MediaInfo]:
"""
识别媒体信息异步版本
:param meta: 识别的元数据
:param bangumiid: 识别的Bangumi ID
:param source: 请求级识别数据源
:return: 识别的媒体信息包括剧集信息
"""
if not bangumiid:
if not bangumiid and (
not meta or (source or settings.RECOGNIZE_SOURCE) != "bangumi"
):
return None
# 直接查询详情
info = await self.async_bangumi_info(bangumiid=bangumiid)
info = (
await self.async_bangumi_info(bangumiid=bangumiid)
if bangumiid
else await self._async_match_by_meta(meta)
)
if info:
# 赋值TMDB信息并返回
info["actors"] = await self.bangumiapi.async_credits(info.get("id"))
mediainfo = MediaInfo(bangumi_info=info)
logger.info(f"{bangumiid} Bangumi识别结果:{mediainfo.type.value} "
if meta and meta.begin_season is not None:
mediainfo.season = meta.begin_season
logger.info(f"{bangumiid or meta.name} Bangumi识别结果:{mediainfo.type.value} "
f"{mediainfo.title_year}")
return mediainfo
else:
logger.info(f"{bangumiid} 未匹配到Bangumi媒体信息")
logger.info(f"{bangumiid or meta.name} 未匹配到Bangumi媒体信息")
return None
def search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]:
@staticmethod
def _matches_meta(meta: MetaBase, info: dict) -> bool:
"""
判断Bangumi候选项是否符合标题解析出的类型与年份
:param meta: 标题解析元数据
:param info: Bangumi候选项详情
:return: 是否符合筛选条件
"""
if (
meta.type in {MediaType.MOVIE, MediaType.TV}
and MediaInfo.get_bangumi_media_type(info) != meta.type
):
return False
release_date = info.get("date") or info.get("air_date") or ""
return not meta.year or not release_date or release_date[:4] == str(meta.year)
def _match_by_meta(self, meta: MetaBase) -> Optional[dict]:
"""
搜索并获取最符合标题解析结果的Bangumi详情
:param meta: 标题解析元数据
:return: Bangumi媒体详情
"""
for item in (self.bangumiapi.search(meta.name) or [])[:10]:
info = self.bangumiapi.detail(item.get("id")) if item.get("id") else None
if info and self._matches_meta(meta, info):
return info
return None
async def _async_match_by_meta(self, meta: MetaBase) -> Optional[dict]:
"""
异步搜索并获取最符合标题解析结果的Bangumi详情
:param meta: 标题解析元数据
:return: Bangumi媒体详情
"""
for item in (await self.bangumiapi.async_search(meta.name) or [])[:10]:
info = await self.bangumiapi.async_detail(item.get("id")) if item.get("id") else None
if info and self._matches_meta(meta, info):
return info
return None
def search_medias(
self, meta: MetaBase, source: Optional[str] = None
) -> Optional[List[MediaInfo]]:
"""
搜索媒体信息
:param meta: 识别的元数据
:reutrn: 媒体信息
:param source: 请求级搜索数据源
:return: 媒体信息
"""
if settings.SEARCH_SOURCE and "bangumi" not in settings.SEARCH_SOURCE:
if source and source != "bangumi":
return None
if not source and settings.SEARCH_SOURCE and "bangumi" not in settings.SEARCH_SOURCE:
return None
if not meta.name:
return []
@@ -137,13 +215,18 @@ class BangumiModule(_ModuleBase):
or meta.name.lower() in str(info.get("name_cn")).lower()]
return []
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: 媒体信息
"""
if settings.SEARCH_SOURCE and "bangumi" not in settings.SEARCH_SOURCE:
if source and source != "bangumi":
return None
if not source and settings.SEARCH_SOURCE and "bangumi" not in settings.SEARCH_SOURCE:
return None
if not meta.name:
return []
@@ -176,6 +259,45 @@ class BangumiModule(_ModuleBase):
logger.info(f"开始获取Bangumi信息:{bangumiid} ...")
return await self.bangumiapi.async_detail(bangumiid)
def metadata_nfo(
self,
mediainfo: MediaInfo,
season: Optional[int] = None,
episode: Optional[int] = None,
**kwargs,
) -> Optional[str]:
"""
生成Bangumi来源的NFO内容
:param mediainfo: 统一媒体信息
:param season: 季号
:param episode: 集号
:return: NFO XML文本
"""
scrape_source = mediainfo.scrape_source or settings.SCRAP_SOURCE
if scrape_source != "bangumi":
return None
return self.scraper.get_metadata_nfo(mediainfo, season=season, episode=episode)
def metadata_img(
self,
mediainfo: MediaInfo,
season: Optional[int] = None,
episode: Optional[int] = None,
) -> Optional[dict]:
"""
获取Bangumi来源的刮削图片清单
:param mediainfo: 统一媒体信息
:param season: 季号
:param episode: 集号
:return: 图片文件名与下载地址映射
"""
scrape_source = mediainfo.scrape_source or settings.SCRAP_SOURCE
if scrape_source != "bangumi":
return None
return self.scraper.get_metadata_img(mediainfo, season=season, episode=episode)
def bangumi_calendar(self) -> Optional[List[MediaInfo]]:
"""
获取Bangumi每日放送
@@ -319,7 +441,7 @@ class BangumiModule(_ModuleBase):
return [MediaInfo(bangumi_info=info) for info in infos]
return []
def clear_cache(self):
def clear_cache(self) -> None:
"""
清除缓存
"""
+47 -17
View File
@@ -127,8 +127,11 @@ class DoubanModule(_ModuleBase):
if not doubanid and not meta:
return None
if meta and not doubanid \
and settings.RECOGNIZE_SOURCE != "douban":
if (
meta
and not doubanid
and (kwargs.get("source") or settings.RECOGNIZE_SOURCE) != "douban"
):
return None
if not meta:
@@ -227,8 +230,11 @@ class DoubanModule(_ModuleBase):
if not doubanid and not meta:
return None
if meta and not doubanid \
and settings.RECOGNIZE_SOURCE != "douban":
if (
meta
and not doubanid
and (kwargs.get("source") or settings.RECOGNIZE_SOURCE) != "douban"
):
return None
if not meta:
@@ -927,13 +933,18 @@ class DoubanModule(_ModuleBase):
return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")]
return []
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: 媒体信息
"""
if settings.SEARCH_SOURCE and "douban" not in settings.SEARCH_SOURCE:
if source and source != "douban":
return None
if not source and settings.SEARCH_SOURCE and "douban" not in settings.SEARCH_SOURCE:
return None
if not meta.name:
return []
@@ -943,13 +954,18 @@ class DoubanModule(_ModuleBase):
# 返回数据
return self._build_search_medias_result(meta, result.get("items"))
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: 媒体信息
"""
if settings.SEARCH_SOURCE and "douban" not in settings.SEARCH_SOURCE:
if source and source != "douban":
return None
if not source and settings.SEARCH_SOURCE and "douban" not in settings.SEARCH_SOURCE:
return None
if not meta.name:
return []
@@ -959,11 +975,18 @@ class DoubanModule(_ModuleBase):
# 返回数据
return self._build_search_medias_result(meta, result.get("items"))
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: 人物信息列表
"""
if settings.SEARCH_SOURCE and "douban" not in settings.SEARCH_SOURCE:
if source and source != "douban":
return None
if not source and settings.SEARCH_SOURCE and "douban" not in settings.SEARCH_SOURCE:
return None
if not name:
return []
@@ -979,11 +1002,18 @@ class DoubanModule(_ModuleBase):
}) for item in result.get('items') if name in item.get('target', {}).get('title')]
return []
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: 人物信息列表
"""
if settings.SEARCH_SOURCE and "douban" not in settings.SEARCH_SOURCE:
if source and source != "douban":
return None
if not source and settings.SEARCH_SOURCE and "douban" not in settings.SEARCH_SOURCE:
return None
if not name:
return []
@@ -1147,7 +1177,7 @@ class DoubanModule(_ModuleBase):
:param mediainfo: 媒体信息
:param season: 季号
"""
if settings.SCRAP_SOURCE != "douban":
if (mediainfo.scrape_source or settings.SCRAP_SOURCE) != "douban":
return None
return self.scraper.get_metadata_nfo(mediainfo=mediainfo, season=season)
@@ -1158,7 +1188,7 @@ class DoubanModule(_ModuleBase):
:param season: 季号
:param episode: 集号
"""
if settings.SCRAP_SOURCE != "douban":
if (mediainfo.scrape_source or settings.SCRAP_SOURCE) != "douban":
return None
return self.scraper.get_metadata_img(mediainfo=mediainfo, season=season, episode=episode)
@@ -1169,7 +1199,7 @@ class DoubanModule(_ModuleBase):
:param mediainfo: 媒体信息
:return: None 表示不处理MediaInfo 表示继续处理
"""
if settings.RECOGNIZE_SOURCE != "douban":
if mediainfo.source != "douban" and settings.RECOGNIZE_SOURCE != "douban":
return None
if not mediainfo.douban_id:
return None
+5 -6
View File
@@ -282,13 +282,13 @@ class EmbyModule(_ModuleBase, _MediaServerBase[Emby]):
) for season, episodes in seasoninfo.items()]
def mediaserver_playing(self, server: str, count: Optional[int] = 20,
username: Optional[str] = None) -> List[schemas.MediaServerPlayItem]:
username: Optional[str] = None) -> Optional[List[schemas.MediaServerPlayItem]]:
"""
获取媒体服务器正在播放信息
"""
server_obj: Emby = self.get_instance(server)
if not server_obj:
return []
return None
return server_obj.get_resume(num=count, username=username)
def mediaserver_play_url(self, server: str, item_id: Union[str, int]) -> Optional[str]:
@@ -316,13 +316,13 @@ class EmbyModule(_ModuleBase, _MediaServerBase[Emby]):
return server_obj.get_season_episode_ids(str(item_id), season)
def mediaserver_latest(self, server: Optional[str] = None, count: Optional[int] = 20,
username: Optional[str] = None) -> List[schemas.MediaServerPlayItem]:
username: Optional[str] = None) -> Optional[List[schemas.MediaServerPlayItem]]:
"""
获取媒体服务器最新入库条目
"""
server_obj: Emby = self.get_instance(server)
if not server_obj:
return []
return None
return server_obj.get_latest(num=count, username=username)
def mediaserver_latest_images(self,
@@ -345,8 +345,7 @@ class EmbyModule(_ModuleBase, _MediaServerBase[Emby]):
return []
links = []
items: List[schemas.MediaServerPlayItem] = self.mediaserver_latest(server=server, count=count,
username=username)
items = self.mediaserver_latest(server=server, count=count, username=username) or []
for item in items:
if item.BackdropImageTags:
image_url = server_obj.get_backdrop_url(item_id=item.id,
+24 -11
View File
@@ -118,38 +118,47 @@ class Emby:
logger.error(f"连接Library/VirtualFolders/Query 出错:" + str(e))
return []
def __get_emby_librarys(self, username: Optional[str] = None) -> List[dict]:
def __get_emby_librarys(self, username: Optional[str] = None) -> Optional[List[dict]]:
"""
获取Emby媒体库列表
"""
if not self._host or not self._apikey:
return []
return None
if username:
user = self.get_user(username)
else:
user = self.user
if not user:
return None
url = f"{self._host}emby/Users/{user}/Views"
params = {"api_key": self._apikey}
try:
res = RequestUtils().get_res(url, params)
if res:
return res.json().get("Items")
items = res.json().get("Items")
return items if isinstance(items, list) else None
else:
logger.error(f"User/Views 未获取到返回数据")
return []
return None
except Exception as e:
logger.error(f"连接User/Views 出错:" + str(e))
return []
return None
def get_librarys(self, username: Optional[str] = None, hidden: Optional[bool] = False) -> List[
schemas.MediaServerLibrary]:
def get_librarys(
self,
username: Optional[str] = None,
hidden: Optional[bool] = False,
) -> Optional[List[schemas.MediaServerLibrary]]:
"""
获取媒体服务器所有媒体库列表
"""
if not self._host or not self._apikey:
return []
return None
source_libraries = self.__get_emby_librarys(username)
if source_libraries is None:
return None
libraries = []
for library in self.__get_emby_librarys(username) or []:
for library in source_libraries:
if hidden and self._sync_libraries and "all" not in self._sync_libraries \
and library.get("Id") not in self._sync_libraries:
continue
@@ -1206,6 +1215,8 @@ class Emby:
user = self.get_user(username)
else:
user = self.user
if not user:
return None
url = f"{self._host}Users/{user}/Items/Resume"
params = {
"Limit": 100,
@@ -1266,7 +1277,7 @@ class Emby:
logger.error(f"Users/Items/Resume 未获取到返回数据")
except Exception as e:
logger.error(f"连接Users/Items/Resume出错:" + str(e))
return []
return None
def get_latest(self, num: Optional[int] = 20, username: Optional[str] = None) -> Optional[
List[schemas.MediaServerPlayItem]]:
@@ -1279,6 +1290,8 @@ class Emby:
user = self.get_user(username)
else:
user = self.user
if not user:
return None
url = f"{self._host}Users/{user}/Items/Latest"
params = {
"Limit": 100,
@@ -1323,7 +1336,7 @@ class Emby:
logger.error(f"Users/Items/Latest 未获取到返回数据")
except Exception as e:
logger.error(f"连接Users/Items/Latest出错:" + str(e))
return []
return None
def get_user_library_folders(self):
"""
@@ -174,6 +174,13 @@ class StorageBase(metaclass=ABCMeta):
"""
pass
def get_item_strict(self, path: Path) -> Optional[schemas.FileItem]:
"""
获取文件或目录确认不存在返回None无法确认状态时抛出 StorageQueryError
默认实现不区分不存在查询失败由具体存储按需覆写
"""
return self.get_item(path)
def get_parent(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:
"""
获取父目录
+39 -15
View File
@@ -13,6 +13,7 @@ from app.core.config import settings, global_vars
from app.log import logger
from app.modules.filemanager import StorageBase
from app.modules.filemanager.storages import transfer_process
from app.schemas.exception import StorageQueryError
from app.schemas.types import StorageSchema
from app.utils.http import RequestUtils
from app.utils.singleton import WeakSingleton
@@ -834,30 +835,53 @@ class AliPan(StorageBase, metaclass=WeakSingleton):
return False
return True
def __get_by_path_item(self, path: Path, drive_id: str = None) -> Optional[schemas.FileItem]:
"""
按路径查询文件/目录项无法确认状态时抛出 StorageQueryError
NotFound 系列错误码表示确认不存在其余错误网络失败限流
权限或未知业务错误均无法确认目标状态
"""
resp = self._request_api(
"POST",
"/adrive/v1.0/openFile/get_by_path",
json={
"drive_id": drive_id or self._default_drive_id,
"file_path": path.as_posix(),
},
no_error_log=True,
)
if resp is None:
raise StorageQueryError(f"【阿里云盘】无法确认文件状态(请求失败): {path}")
code = resp.get("code")
if code:
if "NotFound" in str(code):
# 明确的不存在错误码,确认目标不存在
return None
raise StorageQueryError(
f"【阿里云盘】查询文件信息出错: {path} - {code} {resp.get('message')}")
return self.__get_fileitem(resp, parent=str(path.parent))
def get_item(self, path: Path, drive_id: str = None) -> Optional[schemas.FileItem]:
"""
获取指定路径的文件/目录项
"""
try:
resp = self._request_api(
"POST",
"/adrive/v1.0/openFile/get_by_path",
json={
"drive_id": drive_id or self._default_drive_id,
"file_path": path.as_posix(),
},
no_error_log=True,
)
if not resp:
return None
if resp.get("code"):
logger.debug(f"【阿里云盘】获取文件信息失败: {resp.get('message')}")
return None
return self.__get_fileitem(resp, parent=str(path.parent))
return self.__get_by_path_item(path, drive_id=drive_id)
except Exception as e:
logger.debug(f"【阿里云盘】获取文件信息失败: {str(e)}")
return None
def get_item_strict(self, path: Path) -> Optional[schemas.FileItem]:
"""
获取指定路径的文件/目录项无法确认状态时抛出 StorageQueryError
"""
try:
return self.__get_by_path_item(path)
except StorageQueryError:
raise
except Exception as e:
raise StorageQueryError(f"【阿里云盘】查询文件信息失败: {path} - {e}") from e
def get_folder(self, path: Path) -> Optional[schemas.FileItem]:
"""
获取指定路径的文件夹如不存在则创建
+49 -16
View File
@@ -1,12 +1,14 @@
import os
import shutil
from pathlib import Path
from typing import Optional, List
from app import schemas
from app.core.config import global_vars
from app.core.config import global_vars, settings
from app.helper.directory import DirectoryHelper
from app.log import logger
from app.modules.filemanager.storages import StorageBase, transfer_process
from app.schemas.exception import StorageQueryError
from app.schemas.types import StorageSchema
from app.utils.system import SystemUtils
@@ -147,6 +149,23 @@ class LocalStorage(StorageBase):
return self.__get_fileitem(path)
return self.__get_diritem(path)
def get_item_strict(self, path: Path) -> Optional[schemas.FileItem]:
"""
获取文件或目录无法确认状态时抛出 StorageQueryError
Path.exists() 会把部分 errno EBADF/ELOOP归入不存在
网络/FUSE 挂载抖动时会误判这里用 stat 显式区分
"""
try:
path.stat()
except (FileNotFoundError, NotADirectoryError):
return None
except OSError as e:
raise StorageQueryError(f"【本地】读取文件状态失败: {path} - {e}") from e
try:
return self.get_item(path)
except OSError as e:
raise StorageQueryError(f"【本地】读取文件信息失败: {path} - {e}") from e
def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:
"""
获取文件详情
@@ -195,11 +214,31 @@ class LocalStorage(StorageBase):
"""
return Path(fileitem.path)
def _copy_with_progress(self, src: Path, dest: Path):
@staticmethod
def _copy_with_target_permissions(src: Path, dest: Path) -> Path:
"""
复制文件内容和时间戳并保留目标目录赋予新文件的权限
目标目录的默认权限或继承 ACL 应作为媒体库的访问策略复制完成后不能再用
源文件权限覆盖否则部分文件系统会清除已继承的 ACL
:param src: 源文件路径
:param dest: 目标文件路径
:return: 目标文件路径
"""
src = Path(src)
dest = Path(dest)
src_stat = src.stat()
shutil.copyfile(src, dest)
os.utime(dest, ns=(src_stat.st_atime_ns, src_stat.st_mtime_ns))
return dest
def _copy_with_progress(self, src: Path, dest: Path) -> bool:
"""
分块复制文件并回调进度
"""
total_size = src.stat().st_size
src_stat = src.stat()
total_size = src_stat.st_size
copied_size = 0
progress_callback = transfer_process(src.as_posix())
try:
@@ -217,8 +256,7 @@ class LocalStorage(StorageBase):
if progress_callback:
percent = copied_size / total_size * 100
progress_callback(percent)
# 保留文件时间戳、权限等信息
shutil.copystat(src, dest)
os.utime(dest, ns=(src_stat.st_atime_ns, src_stat.st_mtime_ns))
return True
except Exception as e:
logger.error(f"【本地】复制文件 {src} 失败:{e}")
@@ -273,11 +311,8 @@ class LocalStorage(StorageBase):
if self._copy_with_progress(src, dest):
return True
else:
code, message = SystemUtils.copy(src, dest)
if code == 0:
return True
else:
logger.error(f"【本地】复制文件失败:{message}")
self._copy_with_target_permissions(src, dest)
return True
except Exception as err:
logger.error(f"【本地】复制文件失败:{err}")
return False
@@ -303,11 +338,8 @@ class LocalStorage(StorageBase):
src.unlink()
return True
else:
code, message = SystemUtils.move(src, dest)
if code == 0:
return True
else:
logger.error(f"【本地】移动文件失败:{message}")
shutil.move(src, dest, copy_function=self._copy_with_target_permissions)
return True
except Exception as err:
logger.error(f"【本地】移动文件失败:{err}")
return False
@@ -341,7 +373,8 @@ class LocalStorage(StorageBase):
directory_helper = DirectoryHelper()
total_storage, free_storage = SystemUtils.space_usage(
[Path(d.download_path) for d in directory_helper.get_local_download_dirs() if d.download_path] +
[Path(d.library_path) for d in directory_helper.get_local_library_dirs() if d.library_path]
[Path(d.library_path) for d in directory_helper.get_local_library_dirs() if d.library_path],
btrfs_fsid_dedup=settings.BTRFS_FSID_DEDUP,
)
return schemas.StorageUsage(
total=total_storage,
+46 -23
View File
@@ -17,6 +17,7 @@ from app.core.config import settings, global_vars
from app.log import logger
from app.modules.filemanager import StorageBase
from app.modules.filemanager.storages import transfer_process
from app.schemas.exception import StorageQueryError
from app.schemas.types import StorageSchema
from app.utils.singleton import WeakSingleton
from app.utils.string import StringUtils
@@ -906,38 +907,60 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
return True
return False
def __get_info_item(self, path: Path) -> Optional[schemas.FileItem]:
"""
查询指定路径的文件/目录项无法确认状态时抛出 StorageQueryError
接口业务码 20004记录不存在 0 一样视为确认结果其余错误
网络失败限流重试用尽未知业务错误均无法确认目标状态
"""
resp = self._request_api(
"POST",
"/open/folder/get_info",
data={"path": path.as_posix()},
no_error_log=True,
)
if resp is None:
raise StorageQueryError(f"【115】无法确认文件状态(请求失败或接口错误): {path}")
data = resp.get("data") if isinstance(resp, dict) else None
if not data or not data.get("file_id"):
# code 20004(记录不存在)等场景,确认目标不存在
return None
return schemas.FileItem(
storage=self.schema.value,
fileid=str(data["file_id"]),
path=path.as_posix() + ("/" if data["file_category"] == "0" else ""),
type="file" if data["file_category"] == "1" else "dir",
name=data["file_name"],
basename=Path(data["file_name"]).stem,
extension=Path(data["file_name"]).suffix[1:]
if data["file_category"] == "1"
else None,
pickcode=data["pick_code"],
size=data["size_byte"] if data["file_category"] == "1" else None,
modify_time=data["utime"],
)
def get_item(self, path: Path) -> Optional[schemas.FileItem]:
"""
获取指定路径的文件/目录项
"""
try:
resp = self._request_api(
"POST",
"/open/folder/get_info",
"data",
data={"path": path.as_posix()},
no_error_log=True,
)
if not resp:
return None
return schemas.FileItem(
storage=self.schema.value,
fileid=str(resp["file_id"]),
path=path.as_posix() + ("/" if resp["file_category"] == "0" else ""),
type="file" if resp["file_category"] == "1" else "dir",
name=resp["file_name"],
basename=Path(resp["file_name"]).stem,
extension=Path(resp["file_name"]).suffix[1:]
if resp["file_category"] == "1"
else None,
pickcode=resp["pick_code"],
size=resp["size_byte"] if resp["file_category"] == "1" else None,
modify_time=resp["utime"],
)
return self.__get_info_item(path)
except Exception as e:
logger.debug(f"【115】获取文件信息失败: {str(e)}")
return None
def get_item_strict(self, path: Path) -> Optional[schemas.FileItem]:
"""
获取指定路径的文件/目录项无法确认状态时抛出 StorageQueryError
"""
try:
return self.__get_info_item(path)
except StorageQueryError:
raise
except Exception as e:
raise StorageQueryError(f"【115】查询文件信息失败: {path} - {e}") from e
def get_folder(self, path: Path) -> Optional[schemas.FileItem]:
"""
获取指定路径的文件夹如不存在则创建

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