mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-28 03:27:31 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93761fe7e4 | ||
|
|
593139faac | ||
|
|
6c89f1eb4b | ||
|
|
2310a3a456 | ||
|
|
48852350a0 | ||
|
|
a23fce1491 | ||
|
|
c976741574 | ||
|
|
04facef64d | ||
|
|
33a97eb2c8 | ||
|
|
cf80b551f9 | ||
|
|
e011b20210 | ||
|
|
bdf395f494 | ||
|
|
68686bc23a | ||
|
|
1a528c7803 | ||
|
|
6b4a255f26 | ||
|
|
1a3c1b8b39 | ||
|
|
8788dae34b | ||
|
|
bb00814d7a | ||
|
|
3d55d44457 | ||
|
|
7a5e565b15 | ||
|
|
cae28d8c03 | ||
|
|
3d020c8ceb | ||
|
|
1b065cc08b | ||
|
|
a1a6376adc | ||
|
|
197a09b2a4 | ||
|
|
8d099b9581 | ||
|
|
ff9ba79b60 | ||
|
|
6354a48405 | ||
|
|
f6df6cc093 | ||
|
|
98e69d1b45 | ||
|
|
4b9af5b8c7 | ||
|
|
059a50f7f8 | ||
|
|
14fed2d70b | ||
|
|
875984ad39 | ||
|
|
297cd04fbc | ||
|
|
3dde94be0f | ||
|
|
98ee939236 | ||
|
|
c6611f6210 | ||
|
|
503ee90c0c | ||
|
|
fb32c59713 | ||
|
|
a3c90c64ca | ||
|
|
de97cb3c0a | ||
|
|
3b709b7f2e | ||
|
|
6f8b6cfbc9 | ||
|
|
e3f80af74f |
+18
-17
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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']}",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
@@ -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"] = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
# 发送事件
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 参数分段读取。]"
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
# 检查订阅状态
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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"])
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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: 搜索类型,支持 media、collection、person
|
||||
: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:
|
||||
# 使用名称识别兜底
|
||||
|
||||
@@ -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
@@ -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="验证失败")
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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、豆瓣、Bangumi、AniList 或插件媒体键查询订阅。
|
||||
"""
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
# 自动重新识别
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 = {}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 ID(AniList外部映射)
|
||||
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: YYYY、YYYY-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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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: MetaAnime、MetaVideo
|
||||
"""
|
||||
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
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
))
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
获取所有系统设置
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
删除缓存
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -99,6 +99,8 @@
|
||||
"messages": {
|
||||
"模块不支持测试": "模块不支持测试",
|
||||
"网络请求失败": "网络请求失败",
|
||||
"TMDB请求失败": "TMDB请求失败",
|
||||
"媒体服务器请求失败": "媒体服务器请求失败",
|
||||
"豆瓣网络连接失败": "豆瓣网络连接失败",
|
||||
"Bangumi网络连接失败": "Bangumi网络连接失败",
|
||||
"fanart网络连接失败": "fanart网络连接失败",
|
||||
|
||||
+34
-6
@@ -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} 執行完成"
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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:
|
||||
"""
|
||||
清除缓存
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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]:
|
||||
"""
|
||||
获取父目录
|
||||
|
||||
@@ -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]:
|
||||
"""
|
||||
获取指定路径的文件夹,如不存在则创建
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user