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 | ||
|
|
1d708870c9 | ||
|
|
053e1b7562 | ||
|
|
4ca3e40507 | ||
|
|
318d2ab7d7 | ||
|
|
7c2390908a | ||
|
|
5c2b503a74 | ||
|
|
44fa202778 | ||
|
|
ed92be08af | ||
|
|
9ed0704c5b | ||
|
|
e46b4e5ba0 | ||
|
|
87ad7988b2 | ||
|
|
1382975b18 | ||
|
|
d9a42c672a | ||
|
|
b042086efa | ||
|
|
36fefa14e0 | ||
|
|
1332576c3f | ||
|
|
4300af0e9c | ||
|
|
405350c774 | ||
|
|
d666134ed2 | ||
|
|
5588e37c6d | ||
|
|
2056aa0b2c | ||
|
|
b0ff3ae3c7 | ||
|
|
31544629b4 | ||
|
|
142393f2d3 | ||
|
|
5cf79e0360 | ||
|
|
f152a0381d | ||
|
|
428c19b6ba | ||
|
|
8b5524a321 | ||
|
|
b972b46747 | ||
|
|
0598fbdd75 | ||
|
|
572299a45e | ||
|
|
229824a417 |
+103
-18
@@ -58,6 +58,7 @@ from app.chain import ChainBase
|
||||
from app.core.config import settings
|
||||
from app.core.event import eventmanager
|
||||
from app.db.agentchat_oper import AgentChatOper
|
||||
from app.db.agenttask_oper import AgentTaskOper
|
||||
from app.db.user_oper import UserOper
|
||||
from app.log import logger
|
||||
from app.schemas import AgentLLMProviderEventData, AgentTokensUsageEventData, Notification, NotificationType
|
||||
@@ -67,6 +68,8 @@ from app.utils.identity import SYSTEM_INTERNAL_USER_ID
|
||||
|
||||
|
||||
class AgentChain(ChainBase):
|
||||
"""Agent 业务处理链。"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@@ -712,7 +715,7 @@ class MoviePilotAgent:
|
||||
"""
|
||||
通过链式事件解析本次 Agent 可用的 LLM 运行时配置。
|
||||
|
||||
若没有插件返回 selected_provider_id,则沿用系统配置,保持既有行为。
|
||||
插件未返回有效配置时沿用系统配置,显式返回的配置优先。
|
||||
"""
|
||||
if self._llm_runtime_config is not None:
|
||||
return self._llm_runtime_config
|
||||
@@ -725,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,
|
||||
@@ -760,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")
|
||||
)
|
||||
@@ -788,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
|
||||
|
||||
@@ -1023,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, ...]:
|
||||
@@ -1039,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),
|
||||
@@ -1627,20 +1640,21 @@ class MoviePilotAgent:
|
||||
if not streaming_stopped:
|
||||
await self.stream_handler.stop_streaming()
|
||||
|
||||
async def send_agent_message(self, message: str, title: str = ""):
|
||||
async def send_agent_message(self, message: str, title: str = "") -> None:
|
||||
"""
|
||||
通过原渠道发送消息给用户
|
||||
发送 Agent 消息;后台任务不绑定原渠道,交由通知链广播。
|
||||
"""
|
||||
broadcast = self.is_background
|
||||
self._save_assistant_display_message_once(message)
|
||||
await AgentChain().async_post_message(
|
||||
Notification(
|
||||
channel=self.channel,
|
||||
source=self.source,
|
||||
channel=None if broadcast else self.channel,
|
||||
source=None if broadcast else self.source,
|
||||
mtype=NotificationType.Agent,
|
||||
userid=self.user_id,
|
||||
username=self.username,
|
||||
original_message_id=self.original_message_id,
|
||||
original_chat_id=self.original_chat_id,
|
||||
userid=None if broadcast else self.user_id,
|
||||
username=self.username or (settings.SUPERUSER if broadcast else None),
|
||||
original_message_id=None if broadcast else self.original_message_id,
|
||||
original_chat_id=None if broadcast else self.original_chat_id,
|
||||
title=title,
|
||||
text=message,
|
||||
save_history=False,
|
||||
@@ -2000,12 +2014,11 @@ class AgentManager:
|
||||
else:
|
||||
agent = self.active_agents[session_id]
|
||||
agent.user_id = task.user_id
|
||||
if task.channel:
|
||||
agent.channel = task.channel
|
||||
if task.source:
|
||||
agent.source = task.source
|
||||
if task.username:
|
||||
agent.username = task.username
|
||||
# 每条队列任务都携带完整消息上下文,None 也必须覆盖,避免后台任务
|
||||
# 复用会话 Agent 时继续沿用上一条入站消息的渠道。
|
||||
agent.channel = task.channel
|
||||
agent.source = task.source
|
||||
agent.username = task.username
|
||||
agent.original_message_id = task.original_message_id
|
||||
agent.original_chat_id = task.original_chat_id
|
||||
agent.reply_mode = task.reply_mode
|
||||
@@ -2123,6 +2136,78 @@ class AgentManager:
|
||||
await agent.cleanup()
|
||||
memory_manager.clear_memory(session_id, user_id)
|
||||
|
||||
async def execute_scheduled_task(self, task_id: int) -> tuple[bool, str]:
|
||||
"""
|
||||
按持久化上下文唤醒 Agent 执行自主定时任务并向用户回传结果。
|
||||
|
||||
:param task_id: Agent 定时任务 ID
|
||||
:return: 执行是否成功及结果摘要
|
||||
"""
|
||||
if not settings.AI_AGENT_ENABLE:
|
||||
return False, "AI Agent 未启用"
|
||||
oper = AgentTaskOper()
|
||||
task = oper.get(task_id)
|
||||
if not task or not task.enabled:
|
||||
return False, "Agent 定时任务不存在或已停用"
|
||||
if not oper.mark_running(task_id):
|
||||
return False, "Agent 定时任务当前不可执行"
|
||||
|
||||
task_message = (
|
||||
f"定时任务已按计划触发。请立即完成下面的任务,不要只确认收到,"
|
||||
f"也不要重复创建同一个定时任务。\n\n"
|
||||
f"任务名称:{task.name}\n"
|
||||
f"任务内容:{task.content}\n\n"
|
||||
"完成后请直接向用户发送消息报告本次执行结果;如果无法完成,也需发送消息说明原因。"
|
||||
)
|
||||
success = True
|
||||
result = ""
|
||||
notification_username = task.username or settings.SUPERUSER
|
||||
try:
|
||||
result = await self.process_message(
|
||||
session_id=task.session_id,
|
||||
user_id=task.user_id,
|
||||
message=task_message,
|
||||
channel=None,
|
||||
source=None,
|
||||
username=notification_username,
|
||||
original_chat_id=None,
|
||||
reply_mode=ReplyMode.DISPATCH,
|
||||
allow_message_tools=True,
|
||||
wait_for_completion=True,
|
||||
)
|
||||
result_text = str(result or "").strip()
|
||||
success = not result_text.startswith(
|
||||
(AGENT_EXECUTION_ERROR_PREFIX, "处理消息时发生错误")
|
||||
)
|
||||
except Exception as err:
|
||||
success = False
|
||||
result = f"Agent 定时任务执行失败:{str(err)}"
|
||||
logger.error(f"Agent 定时任务 {task_id} 执行失败: {str(err)}")
|
||||
await AgentChain().async_post_message(
|
||||
Notification(
|
||||
mtype=NotificationType.Agent,
|
||||
username=notification_username,
|
||||
title=f"定时任务执行失败:{task.name}",
|
||||
text=result,
|
||||
save_history=False,
|
||||
)
|
||||
)
|
||||
finally:
|
||||
current_task = oper.get(task_id)
|
||||
oper.finish(
|
||||
task_id=task_id,
|
||||
success=success,
|
||||
result=str(result or ""),
|
||||
disable=bool(
|
||||
current_task
|
||||
and task.trigger_type == "date"
|
||||
and current_task.trigger_type == task.trigger_type
|
||||
and current_task.run_at == task.run_at
|
||||
),
|
||||
)
|
||||
|
||||
return success, str(result or "任务执行完成")
|
||||
|
||||
@staticmethod
|
||||
def _build_heartbeat_prompt() -> str:
|
||||
"""使用程序内置 System Tasks 定义构建心跳任务提示词。"""
|
||||
|
||||
+66
-6
@@ -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)
|
||||
|
||||
@@ -1058,6 +1088,29 @@ class LLMHelper:
|
||||
http_async_client=_build_httpx_client(llm_proxy, async_client=True),
|
||||
**thinking_kwargs,
|
||||
)
|
||||
elif runtime["runtime"] == "bedrock":
|
||||
from langchain_aws import ChatBedrockConverse
|
||||
|
||||
from app.agent.llm.provider import LLMProviderManager
|
||||
|
||||
aws_region = runtime.get("aws_region") or "us-east-1"
|
||||
aws_auth = runtime.get("aws_auth") or {}
|
||||
# Bearer 认证需要跳过 SigV4 签名并注入 Authorization 头,SigV4 认证
|
||||
# 直接以 AK/SK 签名;两种方式统一由 provider 管理器构造 boto3 客户端。
|
||||
bedrock_client = LLMProviderManager().create_bedrock_client(
|
||||
"bedrock-runtime",
|
||||
region=aws_region,
|
||||
credentials=aws_auth,
|
||||
base_url=runtime.get("base_url"),
|
||||
use_proxy=use_proxy,
|
||||
read_timeout=settings.LLM_TOOL_TIMEOUT,
|
||||
)
|
||||
model = ChatBedrockConverse(
|
||||
model_id=model_name,
|
||||
client=bedrock_client,
|
||||
temperature=temperature_value,
|
||||
disable_streaming=not streaming,
|
||||
)
|
||||
elif runtime["runtime"] in {"anthropic_compatible", "copilot_anthropic"}:
|
||||
from langchain_anthropic import ChatAnthropic
|
||||
|
||||
@@ -1107,7 +1160,11 @@ class LLMHelper:
|
||||
# 优先使用 provider / models.dev 目录中的上下文上限,减少用户手填成本。
|
||||
model_profile = getattr(model, "profile", None)
|
||||
if model_profile:
|
||||
logger.debug(f"使用LLM模型: {model.model},Profile: {model.profile}")
|
||||
# ChatBedrockConverse 等模型类没有 model 属性,模型名存放在 model_id。
|
||||
logged_model_name = getattr(model, "model", None) or getattr(
|
||||
model, "model_id", model_name
|
||||
)
|
||||
logger.debug(f"使用LLM模型: {logged_model_name},Profile: {model_profile}")
|
||||
else:
|
||||
model_record = runtime.get("model_record") or {}
|
||||
model_metadata = runtime.get("model_metadata") or {}
|
||||
@@ -1183,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
|
||||
@@ -1202,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
|
||||
|
||||
+488
-1
@@ -7,13 +7,14 @@ import base64
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from urllib.parse import urlencode
|
||||
from urllib.parse import urlencode, urlsplit
|
||||
|
||||
import aiofiles
|
||||
import httpx
|
||||
@@ -106,6 +107,90 @@ class LLMProviderManager(metaclass=Singleton):
|
||||
_MODELS_DEV_BUNDLED_PATH = Path(__file__).with_name("models.json")
|
||||
_MODELS_DEV_CACHE_TTL = 7 * 24 * 60 * 60
|
||||
_AUTH_SESSION_DONE_RETENTION = 300
|
||||
_BEDROCK_DEFAULT_REGION = "us-east-1"
|
||||
_BEDROCK_API_KEY_PREFIX = "bedrock-api-key-"
|
||||
_BEDROCK_GPT_OSS_BASE_REGIONS = (
|
||||
"ap-northeast-1",
|
||||
"ap-south-1",
|
||||
"ap-southeast-2",
|
||||
"eu-central-1",
|
||||
"eu-north-1",
|
||||
"eu-west-1",
|
||||
"eu-west-2",
|
||||
"sa-east-1",
|
||||
"us-east-1",
|
||||
"us-east-2",
|
||||
"us-west-2",
|
||||
)
|
||||
_BEDROCK_GPT_OSS_SAFEGUARD_REGIONS = (
|
||||
"ap-northeast-1",
|
||||
"ap-south-1",
|
||||
"ap-southeast-2",
|
||||
"eu-west-1",
|
||||
"eu-west-2",
|
||||
"sa-east-1",
|
||||
"us-east-1",
|
||||
"us-east-2",
|
||||
"us-west-2",
|
||||
)
|
||||
_BEDROCK_ON_DEMAND_MODEL_REGIONS = {
|
||||
"openai.gpt-oss-120b-1:0": _BEDROCK_GPT_OSS_BASE_REGIONS,
|
||||
"openai.gpt-oss-20b-1:0": _BEDROCK_GPT_OSS_BASE_REGIONS,
|
||||
"openai.gpt-oss-safeguard-120b": _BEDROCK_GPT_OSS_SAFEGUARD_REGIONS,
|
||||
"openai.gpt-oss-safeguard-20b": _BEDROCK_GPT_OSS_SAFEGUARD_REGIONS,
|
||||
"amazon.nova-lite-v1:0": (
|
||||
"ap-northeast-1",
|
||||
"ap-southeast-2",
|
||||
"eu-west-2",
|
||||
"us-east-1",
|
||||
"us-gov-west-1",
|
||||
),
|
||||
"amazon.nova-micro-v1:0": (
|
||||
"ap-southeast-2",
|
||||
"eu-west-2",
|
||||
"us-east-1",
|
||||
"us-gov-west-1",
|
||||
),
|
||||
"amazon.nova-pro-v1:0": (
|
||||
"ap-southeast-2",
|
||||
"eu-west-2",
|
||||
"us-east-1",
|
||||
"us-gov-west-1",
|
||||
),
|
||||
"anthropic.claude-3-5-haiku-20241022-v1:0": (
|
||||
"us-west-2",
|
||||
),
|
||||
"anthropic.claude-3-5-sonnet-20240620-v1:0": (
|
||||
"ap-northeast-1",
|
||||
"ap-northeast-2",
|
||||
"ap-southeast-1",
|
||||
"eu-central-1",
|
||||
"eu-central-2",
|
||||
"us-east-1",
|
||||
"us-gov-west-1",
|
||||
"us-west-2",
|
||||
),
|
||||
"anthropic.claude-3-5-sonnet-20241022-v2:0": (
|
||||
"ap-southeast-2",
|
||||
"us-west-2",
|
||||
),
|
||||
"anthropic.claude-3-7-sonnet-20250219-v1:0": (
|
||||
"eu-west-2",
|
||||
"us-gov-west-1",
|
||||
),
|
||||
"anthropic.claude-3-haiku-20240307-v1:0": (
|
||||
"ap-northeast-1",
|
||||
"ap-northeast-2",
|
||||
"ap-south-1",
|
||||
"ap-southeast-2",
|
||||
"eu-central-1",
|
||||
"eu-west-1",
|
||||
"eu-west-3",
|
||||
"us-east-1",
|
||||
"us-gov-west-1",
|
||||
"us-west-2",
|
||||
),
|
||||
}
|
||||
_CHATGPT_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||
_CHATGPT_ISSUER = "https://auth.openai.com"
|
||||
_CHATGPT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
|
||||
@@ -367,6 +452,50 @@ class LLMProviderManager(metaclass=Singleton):
|
||||
api_key_hint="填写 Anthropic API Key。",
|
||||
description="Anthropic Claude 官方端点。",
|
||||
),
|
||||
ProviderSpec(
|
||||
id="amazon-bedrock",
|
||||
name="Amazon Bedrock",
|
||||
runtime="bedrock",
|
||||
models_dev_provider_id="amazon-bedrock",
|
||||
default_base_url="https://bedrock-runtime.us-east-1.amazonaws.com",
|
||||
base_url_presets=(
|
||||
url_preset(
|
||||
id="bedrock-us-east-1",
|
||||
label="美东(弗吉尼亚北部)us-east-1",
|
||||
value="https://bedrock-runtime.us-east-1.amazonaws.com",
|
||||
),
|
||||
url_preset(
|
||||
id="bedrock-us-west-2",
|
||||
label="美西(俄勒冈)us-west-2",
|
||||
value="https://bedrock-runtime.us-west-2.amazonaws.com",
|
||||
),
|
||||
url_preset(
|
||||
id="bedrock-eu-central-1",
|
||||
label="欧洲(法兰克福)eu-central-1",
|
||||
value="https://bedrock-runtime.eu-central-1.amazonaws.com",
|
||||
),
|
||||
url_preset(
|
||||
id="bedrock-ap-northeast-1",
|
||||
label="亚太(东京)ap-northeast-1",
|
||||
value="https://bedrock-runtime.ap-northeast-1.amazonaws.com",
|
||||
),
|
||||
url_preset(
|
||||
id="bedrock-ap-southeast-1",
|
||||
label="亚太(新加坡)ap-southeast-1",
|
||||
value="https://bedrock-runtime.ap-southeast-1.amazonaws.com",
|
||||
),
|
||||
),
|
||||
base_url_editable=True,
|
||||
api_key_label="Bedrock API Key / AK:SK",
|
||||
api_key_hint=(
|
||||
"支持两种认证方式:填写 Amazon Bedrock API Key(bedrock-api-key- 开头,"
|
||||
"Bearer 认证);或填写 Access Key ID:Secret Access Key(可选追加 :Session Token,"
|
||||
"SigV4 认证)。Base URL 决定 AWS Region。"
|
||||
),
|
||||
model_list_strategy="bedrock",
|
||||
description="Amazon Bedrock 托管模型服务,支持 Bedrock API Key 与 AK/SK 双认证。",
|
||||
sort_order=35,
|
||||
),
|
||||
ProviderSpec(
|
||||
id="deepseek",
|
||||
name="DeepSeek",
|
||||
@@ -1743,6 +1872,112 @@ class LLMProviderManager(metaclass=Singleton):
|
||||
return normalized[:-3]
|
||||
return normalized
|
||||
|
||||
@classmethod
|
||||
def _extract_bedrock_region(cls, base_url: Optional[str]) -> str:
|
||||
"""
|
||||
从 Bedrock 运行时端点 URL 中提取 AWS Region
|
||||
|
||||
兼容标准端点、FIPS 端点与 PrivateLink(VPCE)端点等主机名形态,
|
||||
从中识别 Region 段。
|
||||
|
||||
:param base_url: 形如 https://bedrock-runtime.us-east-1.amazonaws.com 的端点地址
|
||||
:return: 提取到的 Region,无法识别时回退 us-east-1
|
||||
"""
|
||||
hostname = urlsplit((base_url or "").strip().lower()).hostname or ""
|
||||
match = re.search(
|
||||
r"(?:^|\.)(?:bedrock(?:-runtime)?(?:-fips)?)"
|
||||
r"\.([a-z0-9-]+-\d+)(?:\.|$)",
|
||||
hostname,
|
||||
)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return cls._BEDROCK_DEFAULT_REGION
|
||||
|
||||
# Inference Profile 的地理前缀与可用 Region 的对应关系,用于降级目录按
|
||||
# 当前 Region 过滤掉不可调用的 Profile 条目。
|
||||
_BEDROCK_GEO_PREFIXES: dict[str, tuple[str, ...]] = {
|
||||
"us": ("us-east-", "us-west-"),
|
||||
"eu": ("eu-",),
|
||||
"apac": ("ap-",),
|
||||
"au": ("ap-southeast-2", "ap-southeast-4"),
|
||||
"jp": ("ap-northeast-1", "ap-northeast-3"),
|
||||
"ca": ("ca-",),
|
||||
}
|
||||
_BEDROCK_NON_COMMERCIAL_REGION_PREFIXES = (
|
||||
"cn-",
|
||||
"eu-isoe-",
|
||||
"us-gov-",
|
||||
"us-iso-",
|
||||
"us-isob-",
|
||||
"us-isof-",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _bedrock_model_matches_region(cls, model_id: str, region: str) -> bool:
|
||||
"""
|
||||
判断目录中的模型 ID 在指定 Region 是否可调用
|
||||
|
||||
models.dev 目录同时收录裸模型 ID(直连调用)与带地理前缀的
|
||||
Inference Profile ID(us./eu./apac./global. 等)。带前缀的条目只在
|
||||
对应地理分区和 AWS 分区的 Region 可用;global Profile 仅允许商业
|
||||
AWS 分区。裸 ID 仅在明确记录的 ON_DEMAND Region 可用,未知条目
|
||||
按不可直连处理。
|
||||
|
||||
:param model_id: 目录中的模型 ID
|
||||
:param region: 当前 Base URL 对应的 AWS Region
|
||||
:return: 该模型在当前 Region 可调用时返回 True
|
||||
"""
|
||||
prefix = model_id.split(".", 1)[0]
|
||||
if prefix == "global":
|
||||
return not region.startswith(cls._BEDROCK_NON_COMMERCIAL_REGION_PREFIXES)
|
||||
region_prefixes = cls._BEDROCK_GEO_PREFIXES.get(prefix)
|
||||
if region_prefixes is not None:
|
||||
return (
|
||||
not region.startswith(cls._BEDROCK_NON_COMMERCIAL_REGION_PREFIXES)
|
||||
and region.startswith(region_prefixes)
|
||||
)
|
||||
on_demand_regions = cls._BEDROCK_ON_DEMAND_MODEL_REGIONS.get(model_id)
|
||||
return on_demand_regions is not None and region in on_demand_regions
|
||||
|
||||
@classmethod
|
||||
def _parse_bedrock_credentials(cls, api_key: Optional[str]) -> dict[str, Any]:
|
||||
"""
|
||||
解析 Bedrock 凭证字符串,识别 Bearer 与 SigV4 两种认证方式
|
||||
|
||||
- Bedrock API Key(bedrock-api-key- 开头的长期 Key,或控制台生成的短期
|
||||
Token)走 Bearer 认证;
|
||||
- `AccessKeyId:SecretAccessKey` 或 `AccessKeyId:SecretAccessKey:SessionToken`
|
||||
走 SigV4 认证,AWS Access Key ID 均以 "AKIA"/"ASIA" 开头。
|
||||
|
||||
:param api_key: 用户在 API Key 输入框填写的凭证内容
|
||||
:return: 含 auth_scheme 及对应凭证字段的字典
|
||||
"""
|
||||
normalized = str(api_key or "").strip()
|
||||
if not normalized:
|
||||
raise LLMProviderAuthError(
|
||||
"Amazon Bedrock 需要填写 Bedrock API Key 或 Access Key ID:Secret Access Key"
|
||||
)
|
||||
|
||||
if not normalized.startswith(cls._BEDROCK_API_KEY_PREFIX):
|
||||
parts = [part.strip() for part in normalized.split(":")]
|
||||
if len(parts) in {2, 3} and all(parts):
|
||||
credentials = {
|
||||
"auth_scheme": "sigv4",
|
||||
"access_key_id": parts[0],
|
||||
"secret_access_key": parts[1],
|
||||
}
|
||||
if len(parts) == 3:
|
||||
credentials["session_token"] = parts[2]
|
||||
return credentials
|
||||
if ":" in normalized:
|
||||
raise LLMProviderAuthError(
|
||||
"Amazon Bedrock AK/SK 凭证格式不正确,"
|
||||
"请按 AccessKeyId:SecretAccessKey 或 "
|
||||
"AccessKeyId:SecretAccessKey:SessionToken 填写"
|
||||
)
|
||||
|
||||
return {"auth_scheme": "bearer", "bearer_token": normalized}
|
||||
|
||||
async def _list_models_from_google(
|
||||
self,
|
||||
api_key: str,
|
||||
@@ -1857,6 +2092,235 @@ class LLMProviderManager(metaclass=Singleton):
|
||||
)
|
||||
return sorted(results, key=lambda item: item["name"].lower())
|
||||
|
||||
def _build_bedrock_boto3_config(
|
||||
self,
|
||||
use_proxy: Optional[bool] = None,
|
||||
) -> Any:
|
||||
"""
|
||||
构造 Bedrock boto3 客户端配置,统一超时、重试与代理策略
|
||||
|
||||
:param use_proxy: 是否使用系统代理,None 时读取 LLM_USE_PROXY 配置
|
||||
:return: botocore Config 实例
|
||||
"""
|
||||
from botocore.config import Config
|
||||
|
||||
should_use_proxy = settings.LLM_USE_PROXY if use_proxy is None else use_proxy
|
||||
proxies = None
|
||||
if should_use_proxy and settings.PROXY_HOST:
|
||||
proxies = {"http": settings.PROXY_HOST, "https": settings.PROXY_HOST}
|
||||
return Config(
|
||||
connect_timeout=10,
|
||||
read_timeout=60,
|
||||
retries={"max_attempts": 3, "mode": "standard"},
|
||||
proxies=proxies,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _bedrock_endpoint_url(
|
||||
service_name: str, base_url: Optional[str]
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
解析应传给 boto3 客户端的自定义端点 URL
|
||||
|
||||
标准公有端点交由 boto3 按 Region 自行推导;用户填写 PrivateLink、
|
||||
FIPS 等非标准端点时才显式透传,保证所选网络路径实际生效。
|
||||
|
||||
:param service_name: boto3 服务名(bedrock 或 bedrock-runtime)
|
||||
:param base_url: 用户配置的 Base URL
|
||||
:return: 需要显式指定端点时返回 URL,否则返回 None
|
||||
"""
|
||||
normalized = (base_url or "").strip().rstrip("/")
|
||||
if not normalized:
|
||||
return None
|
||||
if re.fullmatch(
|
||||
rf"https://{service_name}\.[a-z0-9-]+\.amazonaws\.com",
|
||||
normalized,
|
||||
):
|
||||
return None
|
||||
return normalized
|
||||
|
||||
def create_bedrock_client(
|
||||
self,
|
||||
service_name: str,
|
||||
region: str,
|
||||
credentials: dict[str, Any],
|
||||
base_url: Optional[str] = None,
|
||||
use_proxy: Optional[bool] = None,
|
||||
read_timeout: Optional[int] = None,
|
||||
) -> Any:
|
||||
"""
|
||||
按解析后的凭证创建 Bedrock boto3 客户端,Bearer 方式注入 Authorization 头
|
||||
|
||||
:param service_name: boto3 服务名(bedrock 或 bedrock-runtime)
|
||||
:param region: AWS Region
|
||||
:param credentials: `_parse_bedrock_credentials` 的解析结果
|
||||
:param base_url: 用户配置的 Base URL,非标准端点(PrivateLink/FIPS 等)时透传给 boto3
|
||||
:param use_proxy: 是否使用系统代理
|
||||
:param read_timeout: 读取超时秒数,None 时使用默认值
|
||||
:return: boto3 客户端实例
|
||||
"""
|
||||
import boto3
|
||||
from botocore import UNSIGNED
|
||||
|
||||
config = self._build_bedrock_boto3_config(use_proxy)
|
||||
if read_timeout:
|
||||
config = config.merge(type(config)(read_timeout=read_timeout))
|
||||
endpoint_kwargs: dict[str, Any] = {}
|
||||
endpoint_url = self._bedrock_endpoint_url(service_name, base_url)
|
||||
if endpoint_url:
|
||||
endpoint_kwargs["endpoint_url"] = endpoint_url
|
||||
|
||||
if credentials["auth_scheme"] == "sigv4":
|
||||
return boto3.client(
|
||||
service_name,
|
||||
region_name=region,
|
||||
aws_access_key_id=credentials["access_key_id"],
|
||||
aws_secret_access_key=credentials["secret_access_key"],
|
||||
aws_session_token=credentials.get("session_token"),
|
||||
config=config,
|
||||
**endpoint_kwargs,
|
||||
)
|
||||
|
||||
# Bearer 认证:以 UNSIGNED 跳过 SigV4 签名,再把 API Key 注入 Authorization 头。
|
||||
bearer_token = credentials["bearer_token"]
|
||||
config = config.merge(type(config)(signature_version=UNSIGNED))
|
||||
client = boto3.client(
|
||||
service_name,
|
||||
region_name=region,
|
||||
aws_access_key_id="unsigned",
|
||||
aws_secret_access_key="unsigned",
|
||||
config=config,
|
||||
**endpoint_kwargs,
|
||||
)
|
||||
|
||||
def _inject_bearer(request: Any, **_kwargs: Any) -> None:
|
||||
request.headers["Authorization"] = f"Bearer {bearer_token}"
|
||||
|
||||
client.meta.events.register(
|
||||
f"request-created.{service_name}",
|
||||
_inject_bearer,
|
||||
)
|
||||
return client
|
||||
|
||||
async def _list_models_from_bedrock_fallback(
|
||||
self,
|
||||
region: str,
|
||||
use_proxy: Optional[bool] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
从 models.dev 目录筛选当前 Region 可调用的 Bedrock 模型
|
||||
|
||||
:param region: 当前 Base URL 对应的 AWS Region
|
||||
:param use_proxy: 是否使用系统代理
|
||||
:return: 过滤后的标准化模型记录列表
|
||||
"""
|
||||
models = await self._list_models_from_models_dev_only(
|
||||
provider_id="amazon-bedrock",
|
||||
use_proxy=use_proxy,
|
||||
)
|
||||
return [
|
||||
model
|
||||
for model in models
|
||||
if self._bedrock_model_matches_region(model["id"], region)
|
||||
]
|
||||
|
||||
async def _list_models_from_bedrock(
|
||||
self,
|
||||
api_key: str,
|
||||
base_url: Optional[str],
|
||||
use_proxy: Optional[bool] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
从 Bedrock 控制面拉取模型目录,聚合跨区 Inference Profile 与直连模型
|
||||
|
||||
Bedrock 多数新模型仅允许通过 Inference Profile(us./eu./apac./global. 前缀)
|
||||
调用,因此优先列出 Profile,再补充支持 ON_DEMAND 直连的基础模型。
|
||||
|
||||
:param api_key: 用户填写的凭证内容(Bedrock API Key 或 AK/SK)
|
||||
:param base_url: Bedrock 运行时端点,决定 Region
|
||||
:param use_proxy: 是否使用系统代理
|
||||
:return: 标准化后的模型记录列表
|
||||
"""
|
||||
credentials = self._parse_bedrock_credentials(api_key)
|
||||
region = self._extract_bedrock_region(base_url)
|
||||
# runtime VPCE 无法安全推导对应的控制面 VPCE;FIPS 端点也不能绕回
|
||||
# 公有非 FIPS 控制面,因此直接使用本地目录。
|
||||
if self._bedrock_endpoint_url("bedrock-runtime", base_url):
|
||||
return await self._list_models_from_bedrock_fallback(region, use_proxy)
|
||||
client = self.create_bedrock_client(
|
||||
"bedrock",
|
||||
region=region,
|
||||
credentials=credentials,
|
||||
use_proxy=use_proxy,
|
||||
)
|
||||
|
||||
def _fetch() -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
profiles: list[dict[str, Any]] = []
|
||||
paginator = client.get_paginator("list_inference_profiles")
|
||||
for page in paginator.paginate(typeEquals="SYSTEM_DEFINED"):
|
||||
profiles.extend(page.get("inferenceProfileSummaries") or [])
|
||||
foundation = client.list_foundation_models(
|
||||
byOutputModality="TEXT",
|
||||
byInferenceType="ON_DEMAND",
|
||||
).get("modelSummaries") or []
|
||||
return profiles, foundation
|
||||
|
||||
try:
|
||||
profile_summaries, foundation_summaries = await asyncio.to_thread(_fetch)
|
||||
except Exception as err:
|
||||
# 部分 Bedrock API Key 的授权范围仅覆盖 bedrock-runtime 推理接口,
|
||||
# 控制面查询被拒时降级到 models.dev 目录,保证仍能选择模型。
|
||||
logger.warning(
|
||||
f"获取 Amazon Bedrock 控制面模型列表失败,降级 models.dev 目录: {err}"
|
||||
)
|
||||
return await self._list_models_from_bedrock_fallback(region, use_proxy)
|
||||
finally:
|
||||
await asyncio.to_thread(client.close)
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
seen_ids: set[str] = set()
|
||||
|
||||
def _append_record(model_id: str, display_name: Optional[str]) -> None:
|
||||
if not model_id or model_id in seen_ids:
|
||||
return
|
||||
seen_ids.add(model_id)
|
||||
# Inference Profile 带区域前缀,models.dev 目录按基础模型 ID 收录,
|
||||
# 去掉首个前缀段再查一次元数据。
|
||||
metadata = self._cached_models_dev_model("amazon-bedrock", model_id)
|
||||
if not metadata and "." in model_id:
|
||||
metadata = self._cached_models_dev_model(
|
||||
"amazon-bedrock",
|
||||
model_id.split(".", 1)[1],
|
||||
)
|
||||
results.append(
|
||||
self._normalize_model_record(
|
||||
model_id=model_id,
|
||||
display_name=display_name or (metadata or {}).get("name") or model_id,
|
||||
metadata=metadata or {},
|
||||
source="provider",
|
||||
)
|
||||
)
|
||||
|
||||
for profile in profile_summaries:
|
||||
if (profile.get("status") or "ACTIVE") != "ACTIVE":
|
||||
continue
|
||||
_append_record(
|
||||
str(profile.get("inferenceProfileId") or "").strip(),
|
||||
profile.get("inferenceProfileName"),
|
||||
)
|
||||
# 控制面已按当前 Region 和 ON_DEMAND 筛选,不能复用仅面向
|
||||
# models.dev 降级目录的静态白名单,否则 AWS 新增模型会被遗漏。
|
||||
for summary in foundation_summaries:
|
||||
lifecycle = (summary.get("modelLifecycle") or {}).get("status") or "ACTIVE"
|
||||
if lifecycle != "ACTIVE":
|
||||
continue
|
||||
_append_record(
|
||||
str(summary.get("modelId") or "").strip(),
|
||||
summary.get("modelName"),
|
||||
)
|
||||
|
||||
return sorted(results, key=lambda item: item["name"].lower())
|
||||
|
||||
@staticmethod
|
||||
def _copilot_headers(
|
||||
token: Optional[str] = None, include_auth: bool = True
|
||||
@@ -2064,6 +2528,13 @@ class LLMProviderManager(metaclass=Singleton):
|
||||
use_proxy=use_proxy,
|
||||
)
|
||||
|
||||
if resolved_model_list_strategy == "bedrock":
|
||||
return await self._list_models_from_bedrock(
|
||||
api_key=runtime["api_key"],
|
||||
base_url=runtime.get("base_url"),
|
||||
use_proxy=use_proxy,
|
||||
)
|
||||
|
||||
if resolved_model_list_strategy == "anthropic_compatible":
|
||||
return await self._list_models_from_models_dev_only(
|
||||
provider_id=provider_id,
|
||||
@@ -2731,6 +3202,22 @@ class LLMProviderManager(metaclass=Singleton):
|
||||
)
|
||||
return result
|
||||
|
||||
if resolved_runtime == "bedrock":
|
||||
effective_base_url = normalized_base_url or self._default_base_url_for_provider(
|
||||
spec
|
||||
)
|
||||
credentials = self._parse_bedrock_credentials(normalized_api_key)
|
||||
result.update(
|
||||
{
|
||||
"api_key": normalized_api_key,
|
||||
"base_url": effective_base_url,
|
||||
"aws_region": self._extract_bedrock_region(effective_base_url),
|
||||
"aws_auth": credentials,
|
||||
"auth_mode": "api_key",
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
if resolved_runtime == "anthropic_compatible":
|
||||
effective_base_url = normalized_base_url or self._default_base_url_for_provider(
|
||||
spec
|
||||
|
||||
@@ -204,9 +204,14 @@ You have a scheduled jobs system for user-requested delayed or recurring work.
|
||||
{jobs_list}
|
||||
|
||||
Rules:
|
||||
- Create jobs only when the user asks for delayed, recurring, reminder, or monitoring behavior.
|
||||
- Do not create jobs for immediate one-time work or work already handled by MoviePilot schedulers.
|
||||
- Each job lives in its own directory with a `JOB.md`; read the listed file before executing or updating an active job.
|
||||
- For new delayed, recurring, reminder, or monitoring work, use the dedicated
|
||||
`create_agent_task`, `query_agent_tasks`, `update_agent_task`, `run_agent_task`,
|
||||
and `delete_agent_task` tools. These tools use integer task IDs. Do not create
|
||||
or edit JOB.md files for new tasks.
|
||||
- Use `query_schedulers` and `run_scheduler` only for MoviePilot system, plugin,
|
||||
or workflow runtime services; never pass their string job IDs to Agent task tools.
|
||||
- Do not create tasks for immediate one-time work or work already handled by MoviePilot schedulers.
|
||||
- Entries listed above are legacy JOB.md tasks. Read their files only when a heartbeat asks you to execute them.
|
||||
- During heartbeat checks, act only on `pending` or `in_progress` jobs, update status/last_run/logs, and leave recurring jobs `pending` after each run.
|
||||
</jobs_system>
|
||||
"""
|
||||
@@ -230,7 +235,7 @@ class JobsMiddleware(AgentMiddleware[JobsState, ContextT, ResponseT]): # noqa
|
||||
def _format_jobs_list(jobs: list[JobMetadata]) -> str:
|
||||
"""格式化任务元数据列表用于系统提示词。"""
|
||||
if not jobs:
|
||||
return "(No active jobs. You can create jobs when users request periodic or scheduled tasks.)"
|
||||
return "(No active legacy JOB.md tasks. Use create_agent_task for new scheduled work.)"
|
||||
|
||||
lines = []
|
||||
for job in jobs:
|
||||
|
||||
@@ -17,13 +17,13 @@ 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>
|
||||
- Do not stop for approval on read-only operations.
|
||||
- If the user has not explicitly requested an operation that changes system behavior, ask for confirmation before proceeding. This includes modifying system settings, updating plugin configuration, reloading plugins, running restart/stop/start commands, or triggering slash commands such as `/restart`.
|
||||
- Always get explicit consent before destructive or high-impact actions such as starting downloads, deleting subscriptions, deleting download tasks or files, removing history, installing/uninstalling plugins, changing site authentication, changing scheduler or workflow execution state, restarting services, or stopping services.
|
||||
- When the user explicitly asks for delayed, recurring, reminder, or monitoring work, use `create_agent_task` instead of promising to remember it or writing a JOB.md file. Use a `date` trigger with `delay_minutes` for requests such as "in 30 minutes", an exact `date` trigger for other single future runs, and a five-field `cron` trigger for recurring work. Manage existing autonomous tasks with `query_agent_tasks`, `update_agent_task`, `run_agent_task`, and `delete_agent_task`; these tools use integer `task_id` values. Use `query_schedulers` and `run_scheduler` only for MoviePilot system, plugin, or workflow runtime services, whose string `job_id` values must never be passed to autonomous-task tools.
|
||||
- If the user explicitly requested the exact write action, perform the smallest correct change and then validate the result.
|
||||
- If a requested action is ambiguous between read-only inspection and state change, inspect first and ask a short confirmation question before the state-changing step.
|
||||
</confirmation_policy>
|
||||
@@ -65,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']}",
|
||||
]
|
||||
)
|
||||
|
||||
+16
-1
@@ -620,7 +620,8 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
发送工具通知消息。
|
||||
|
||||
WebAgent 渠道没有后端模块实例,前端流式面板通过 Agent 上下文中的
|
||||
回调直接接收通知;其它渠道继续走统一消息链。
|
||||
回调直接接收通知;无渠道的后台任务清空渠道侧定位信息后交由消息链广播,
|
||||
其它渠道继续走统一消息链。
|
||||
"""
|
||||
callback = self._agent_context.get("notification_callback")
|
||||
if (
|
||||
@@ -630,6 +631,20 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
callback(notification)
|
||||
return
|
||||
|
||||
if not self._channel or not self._source:
|
||||
notification = notification.model_copy(
|
||||
update={
|
||||
"channel": None,
|
||||
"source": None,
|
||||
"userid": None,
|
||||
"username": notification.username
|
||||
or self._username
|
||||
or settings.SUPERUSER,
|
||||
"original_message_id": None,
|
||||
"original_chat_id": None,
|
||||
}
|
||||
)
|
||||
|
||||
await ToolChain().async_post_message(notification)
|
||||
|
||||
async def send_tool_message(
|
||||
|
||||
@@ -42,8 +42,13 @@ from app.agent.tools.impl.send_message import SendMessageTool
|
||||
from app.agent.tools.impl.ask_user_choice import AskUserChoiceTool
|
||||
from app.agent.tools.impl.send_local_file import SendLocalFileTool
|
||||
from app.agent.tools.impl.send_voice_message import SendVoiceMessageTool
|
||||
from app.agent.tools.impl.create_agent_task import CreateAgentTaskTool
|
||||
from app.agent.tools.impl.delete_agent_task import DeleteAgentTaskTool
|
||||
from app.agent.tools.impl.query_agent_tasks import QueryAgentTasksTool
|
||||
from app.agent.tools.impl.query_schedulers import QuerySchedulersTool
|
||||
from app.agent.tools.impl.run_agent_task import RunAgentTaskTool
|
||||
from app.agent.tools.impl.run_scheduler import RunSchedulerTool
|
||||
from app.agent.tools.impl.update_agent_task import UpdateAgentTaskTool
|
||||
from app.agent.tools.impl.query_workflows import QueryWorkflowsTool
|
||||
from app.agent.tools.impl.run_workflow import RunWorkflowTool
|
||||
from app.agent.tools.impl.query_personas import QueryPersonasTool
|
||||
@@ -141,6 +146,11 @@ class MoviePilotToolFactory:
|
||||
QueryTransferHistoryTool,
|
||||
TransferFileTool,
|
||||
SendMessageTool,
|
||||
CreateAgentTaskTool,
|
||||
QueryAgentTasksTool,
|
||||
UpdateAgentTaskTool,
|
||||
RunAgentTaskTool,
|
||||
DeleteAgentTaskTool,
|
||||
QuerySchedulersTool,
|
||||
RunSchedulerTool,
|
||||
QueryWorkflowsTool,
|
||||
@@ -181,6 +191,8 @@ class MoviePilotToolFactory:
|
||||
"edit_file",
|
||||
"execute_command",
|
||||
"ask_user_choice",
|
||||
"create_agent_task",
|
||||
"query_agent_tasks",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -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)",
|
||||
@@ -97,7 +101,7 @@ class AddSubscribeTool(MoviePilotTool):
|
||||
message += f" ({year})"
|
||||
if media_type:
|
||||
message += f" [{media_type}]"
|
||||
if season:
|
||||
if season is not None:
|
||||
message += f" 第{season}季"
|
||||
elif media_type == "tv":
|
||||
message += " 第1季(默认)"
|
||||
@@ -144,6 +148,10 @@ class AddSubscribeTool(MoviePilotTool):
|
||||
season: Optional[int] = None,
|
||||
tmdb_id: Optional[int] = None,
|
||||
douban_id: Optional[str] = None,
|
||||
bangumi_id: Optional[int] = None,
|
||||
anilist_id: Optional[int] = None,
|
||||
media_source: Optional[str] = None,
|
||||
media_id: Optional[str] = None,
|
||||
start_episode: Optional[int] = None,
|
||||
total_episode: Optional[int] = None,
|
||||
quality: Optional[str] = None,
|
||||
@@ -197,6 +205,10 @@ class AddSubscribeTool(MoviePilotTool):
|
||||
year=year,
|
||||
tmdbid=tmdb_id,
|
||||
doubanid=douban_id,
|
||||
bangumiid=bangumi_id,
|
||||
anilistid=anilist_id,
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
season=season,
|
||||
username=subscribe_username,
|
||||
**subscribe_kwargs,
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Literal, Optional, Type
|
||||
|
||||
import pytz
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.core.config import settings
|
||||
from app.db.agentchat_oper import AgentChatOper
|
||||
from app.db.agenttask_oper import AgentTaskOper
|
||||
from app.utils.timer import TimerUtils
|
||||
|
||||
|
||||
class CreateAgentTaskInput(BaseModel):
|
||||
"""创建 Agent 自主定时任务的输入参数。"""
|
||||
|
||||
name: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=100,
|
||||
description="Short task name shown in task management and execution reports.",
|
||||
)
|
||||
content: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=10000,
|
||||
description="Complete instructions that the agent must execute when the task fires.",
|
||||
)
|
||||
trigger_type: Literal["date", "cron"] = Field(
|
||||
...,
|
||||
description="Use 'date' for one exact future run or 'cron' for recurring work.",
|
||||
)
|
||||
trigger: Optional[str] = Field(
|
||||
None,
|
||||
min_length=1,
|
||||
max_length=200,
|
||||
description=(
|
||||
"For date, an ISO 8601 local or timezone-aware time such as "
|
||||
"2026-07-19 20:30:00; for cron, a standard five-field expression "
|
||||
"(minute hour day month weekday). The MoviePilot system timezone is used."
|
||||
),
|
||||
)
|
||||
delay_minutes: Optional[int] = Field(
|
||||
None,
|
||||
ge=1,
|
||||
le=525600,
|
||||
description=(
|
||||
"For a one-time date task expressed as 'in N minutes', provide this instead "
|
||||
"of trigger. MoviePilot calculates and persists the exact future run time."
|
||||
),
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_trigger(self) -> "CreateAgentTaskInput":
|
||||
"""校验任务触发配置并统一格式。"""
|
||||
self.name = self.name.strip()
|
||||
self.content = self.content.strip()
|
||||
if not self.name or not self.content:
|
||||
raise ValueError("name 和 content 不能只包含空白字符")
|
||||
if self.trigger_type == "date":
|
||||
if self.delay_minutes is not None:
|
||||
# LangChain 会在 run() 前后各校验一次,延迟时间在持久化前统一计算。
|
||||
self.trigger = None
|
||||
return self
|
||||
if self.trigger is None:
|
||||
raise ValueError("date 任务必须提供 trigger 或 delay_minutes")
|
||||
elif self.trigger is None or self.delay_minutes is not None:
|
||||
raise ValueError("cron 任务必须提供 trigger,且不能提供 delay_minutes")
|
||||
self.trigger_type, self.trigger = TimerUtils.normalize_schedule_trigger(
|
||||
trigger_type=self.trigger_type,
|
||||
trigger_value=self.trigger,
|
||||
timezone_name=settings.TZ,
|
||||
require_future=True,
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class CreateAgentTaskTool(MoviePilotTool):
|
||||
"""创建可精确唤醒当前 Agent 会话的自主定时任务。"""
|
||||
|
||||
name: str = "create_agent_task"
|
||||
tags: list[str] = [ToolTag.Write, ToolTag.AgentTask, ToolTag.Admin]
|
||||
description: str = (
|
||||
"Create a persistent autonomous agent task only when the user explicitly asks "
|
||||
"for delayed, scheduled, recurring, reminder, or monitoring work. Use trigger_type "
|
||||
"'date' with delay_minutes for requests such as 'check in 30 minutes', an exact "
|
||||
"trigger time for other one-time work, and 'cron' for recurring schedules. When "
|
||||
"fired, MoviePilot wakes the agent in this conversation, executes content, and "
|
||||
"broadcasts user-facing messages through the configured notification channels."
|
||||
)
|
||||
args_schema: Type[BaseModel] = CreateAgentTaskInput
|
||||
require_admin: bool = True
|
||||
|
||||
def get_tool_message(self, **kwargs: object) -> Optional[str]:
|
||||
"""生成创建定时任务的提示消息。"""
|
||||
return f"创建自主定时任务:{kwargs.get('name', '')}"
|
||||
|
||||
def _create_task(self, payload: CreateAgentTaskInput) -> dict:
|
||||
"""持久化任务并立即注册到运行时调度器。"""
|
||||
from app.scheduler import Scheduler
|
||||
|
||||
trigger_value = payload.trigger
|
||||
if payload.trigger_type == "date" and payload.delay_minutes is not None:
|
||||
timezone = pytz.timezone(settings.TZ)
|
||||
trigger_value = (
|
||||
datetime.now(timezone) + timedelta(minutes=payload.delay_minutes)
|
||||
).isoformat(timespec="seconds")
|
||||
_, trigger_value = TimerUtils.normalize_schedule_trigger(
|
||||
trigger_type=payload.trigger_type,
|
||||
trigger_value=trigger_value,
|
||||
timezone_name=settings.TZ,
|
||||
require_future=True,
|
||||
)
|
||||
chat = AgentChatOper().get(
|
||||
session_id=self._session_id,
|
||||
user_id=self._user_id,
|
||||
)
|
||||
task = AgentTaskOper().add(
|
||||
name=payload.name.strip(),
|
||||
content=payload.content.strip(),
|
||||
trigger_type=payload.trigger_type,
|
||||
cron_expression=trigger_value if payload.trigger_type == "cron" else None,
|
||||
run_at=trigger_value if payload.trigger_type == "date" else None,
|
||||
user_id=str(self._user_id),
|
||||
username=self._username or (chat.username if chat else None),
|
||||
session_id=str(self._session_id),
|
||||
channel=self._channel or (chat.channel if chat else None),
|
||||
source=self._source or (chat.source if chat else None),
|
||||
original_chat_id=chat.original_chat_id if chat else None,
|
||||
)
|
||||
scheduler = Scheduler()
|
||||
next_run_at = scheduler.update_agent_task_job(task.id)
|
||||
return AgentTaskOper.to_dict(
|
||||
task,
|
||||
next_run_at=next_run_at,
|
||||
timezone=settings.TZ,
|
||||
)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
name: str,
|
||||
content: str,
|
||||
trigger_type: str,
|
||||
trigger: Optional[str] = None,
|
||||
delay_minutes: Optional[int] = None,
|
||||
**kwargs: object,
|
||||
) -> str:
|
||||
"""创建 Agent 自主定时任务。"""
|
||||
if not settings.AI_AGENT_ENABLE:
|
||||
return "AI Agent 未启用,无法创建自主定时任务"
|
||||
payload = CreateAgentTaskInput(
|
||||
name=name,
|
||||
content=content,
|
||||
trigger_type=trigger_type,
|
||||
trigger=trigger,
|
||||
delay_minutes=delay_minutes,
|
||||
)
|
||||
task = await self.run_blocking("db", self._create_task, payload)
|
||||
return json.dumps(task, ensure_ascii=False, indent=2)
|
||||
@@ -0,0 +1,50 @@
|
||||
from typing import Optional, Type
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.db.agenttask_oper import AgentTaskOper
|
||||
|
||||
|
||||
class DeleteAgentTaskInput(BaseModel):
|
||||
"""删除 Agent 自主定时任务的输入参数。"""
|
||||
|
||||
task_id: int = Field(..., ge=1, description="ID of the task to permanently delete.")
|
||||
|
||||
|
||||
class DeleteAgentTaskTool(MoviePilotTool):
|
||||
"""永久删除 Agent 自主定时任务。"""
|
||||
|
||||
name: str = "delete_agent_task"
|
||||
tags: list[str] = [ToolTag.Write, ToolTag.AgentTask, ToolTag.Admin]
|
||||
description: str = (
|
||||
"Permanently delete an autonomous agent task and remove its runtime schedule. "
|
||||
"Use update_agent_task with enabled=false when the user only wants to pause it."
|
||||
)
|
||||
args_schema: Type[BaseModel] = DeleteAgentTaskInput
|
||||
require_admin: bool = True
|
||||
|
||||
def get_tool_message(self, **kwargs: object) -> Optional[str]:
|
||||
"""生成删除定时任务的提示消息。"""
|
||||
return f"删除自主定时任务:{kwargs.get('task_id', '')}"
|
||||
|
||||
def _delete_task(self, task_id: int) -> bool:
|
||||
"""删除当前用户的任务并移除运行时调度。"""
|
||||
from app.scheduler import Scheduler
|
||||
|
||||
deleted = AgentTaskOper().delete(
|
||||
task_id=task_id,
|
||||
user_id=str(self._user_id),
|
||||
)
|
||||
if deleted:
|
||||
Scheduler().remove_agent_task_job(task_id)
|
||||
return deleted
|
||||
|
||||
async def run(self, task_id: int, **kwargs: object) -> str:
|
||||
"""删除 Agent 自主定时任务。"""
|
||||
payload = DeleteAgentTaskInput(task_id=task_id)
|
||||
deleted = await self.run_blocking("db", self._delete_task, payload.task_id)
|
||||
if not deleted:
|
||||
return f"Agent 定时任务 {task_id} 不存在或不属于当前用户"
|
||||
return f"Agent 定时任务 {task_id} 已删除"
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import json
|
||||
from typing import Optional, Type
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.core.config import settings
|
||||
from app.db.agenttask_oper import AgentTaskOper
|
||||
|
||||
|
||||
class QueryAgentTasksInput(BaseModel):
|
||||
"""查询 Agent 自主定时任务的输入参数。"""
|
||||
|
||||
task_id: Optional[int] = Field(
|
||||
None,
|
||||
ge=1,
|
||||
description="Optional task ID. Omit it to list tasks owned by the current user.",
|
||||
)
|
||||
enabled: Optional[bool] = Field(
|
||||
None,
|
||||
description="Optional enabled-state filter used when listing tasks.",
|
||||
)
|
||||
|
||||
|
||||
class QueryAgentTasksTool(MoviePilotTool):
|
||||
"""查询当前用户创建的 Agent 自主定时任务。"""
|
||||
|
||||
name: str = "query_agent_tasks"
|
||||
tags: list[str] = [ToolTag.Read, ToolTag.AgentTask, ToolTag.Admin]
|
||||
description: str = (
|
||||
"Query persistent autonomous agent tasks owned by the current user, including "
|
||||
"reminders, monitoring tasks, and recurring agent work. Returns the integer "
|
||||
"task_id, instructions, trigger, enabled state, next run time, and latest result. "
|
||||
"Do not use this for MoviePilot system, plugin, or workflow scheduler services."
|
||||
)
|
||||
args_schema: Type[BaseModel] = QueryAgentTasksInput
|
||||
require_admin: bool = True
|
||||
|
||||
def get_tool_message(self, **kwargs: object) -> Optional[str]:
|
||||
"""生成查询定时任务的提示消息。"""
|
||||
task_id = kwargs.get("task_id")
|
||||
return f"查询自主定时任务:{task_id}" if task_id else "查询自主定时任务"
|
||||
|
||||
def _query_tasks(
|
||||
self,
|
||||
task_id: Optional[int],
|
||||
enabled: Optional[bool],
|
||||
) -> list[dict]:
|
||||
"""读取当前用户的任务及运行时下一次触发时间。"""
|
||||
from app.scheduler import Scheduler
|
||||
|
||||
oper = AgentTaskOper()
|
||||
if task_id:
|
||||
task = oper.get(task_id=task_id, user_id=str(self._user_id))
|
||||
tasks = [task] if task else []
|
||||
else:
|
||||
tasks = oper.list(user_id=str(self._user_id), enabled=enabled)
|
||||
scheduler = Scheduler()
|
||||
result = []
|
||||
for task in tasks:
|
||||
data = oper.to_dict(
|
||||
task,
|
||||
next_run_at=scheduler.get_agent_task_next_run(task.id),
|
||||
timezone=settings.TZ,
|
||||
)
|
||||
result.append(data)
|
||||
return result
|
||||
|
||||
async def run(
|
||||
self,
|
||||
task_id: Optional[int] = None,
|
||||
enabled: Optional[bool] = None,
|
||||
**kwargs: object,
|
||||
) -> str:
|
||||
"""查询 Agent 自主定时任务。"""
|
||||
payload = QueryAgentTasksInput(task_id=task_id, enabled=enabled)
|
||||
tasks = await self.run_blocking(
|
||||
"db",
|
||||
self._query_tasks,
|
||||
payload.task_id,
|
||||
payload.enabled,
|
||||
)
|
||||
return json.dumps(
|
||||
{"total": len(tasks), "tasks": tasks},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
@@ -44,7 +44,9 @@ class QueryDoctorReportTool(MoviePilotTool):
|
||||
description: str = (
|
||||
"Run MoviePilot Doctor in read-only mode and return a structured diagnostic report for troubleshooting. "
|
||||
"Use this tool when analyzing startup failures, Docker/runtime issues, port conflicts, dependency problems, "
|
||||
"database health, frontend assets, safe mode, or recent log error clues. This tool never applies fixes."
|
||||
"database health, frontend assets, safe mode, or recent log error clues. Plugin-only log findings remain "
|
||||
"visible with affects_report_status=false and do not downgrade the overall status. This tool never applies "
|
||||
"fixes."
|
||||
)
|
||||
require_admin: bool = True
|
||||
args_schema: Type[BaseModel] = QueryDoctorReportInput
|
||||
@@ -73,6 +75,7 @@ class QueryDoctorReportTool(MoviePilotTool):
|
||||
"title": item.get("title"),
|
||||
"fixable": item.get("fixable"),
|
||||
"fixed": item.get("fixed"),
|
||||
"affects_report_status": item.get("affects_report_status", True),
|
||||
}
|
||||
for item in report.get("findings") or []
|
||||
if isinstance(item, dict)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -118,7 +118,7 @@ class QueryPopularSubscribesTool(MoviePilotTool):
|
||||
# 处理标题
|
||||
title = sub.get("name")
|
||||
season = sub.get("season")
|
||||
if season and int(season) > 1 and media.tmdb_id:
|
||||
if season not in (None, "") and int(season) != 1 and media.tmdb_id:
|
||||
# 小写数据转大写
|
||||
season_str = cn2an.an2cn(season, "low")
|
||||
title = f"{title} 第{season_str}季"
|
||||
@@ -126,6 +126,8 @@ class QueryPopularSubscribesTool(MoviePilotTool):
|
||||
media.year = sub.get("year")
|
||||
media.douban_id = sub.get("doubanid")
|
||||
media.bangumi_id = sub.get("bangumiid")
|
||||
media.anilist_id = sub.get("anilistid")
|
||||
media.source = sub.get("media_source")
|
||||
media.tvdb_id = sub.get("tvdbid")
|
||||
media.imdb_id = sub.get("imdbid")
|
||||
media.season = sub.get("season")
|
||||
@@ -149,6 +151,9 @@ class QueryPopularSubscribesTool(MoviePilotTool):
|
||||
"tmdb_id": media_dict.get("tmdb_id"),
|
||||
"douban_id": media_dict.get("douban_id"),
|
||||
"bangumi_id": media_dict.get("bangumi_id"),
|
||||
"anilist_id": media_dict.get("anilist_id"),
|
||||
"media_source": media_dict.get("source"),
|
||||
"media_id": media_dict.get("media_id"),
|
||||
"tvdb_id": media_dict.get("tvdb_id"),
|
||||
"imdb_id": media_dict.get("imdb_id"),
|
||||
"season": media_dict.get("season"),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import json
|
||||
from typing import Optional, Type
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
@@ -11,47 +11,69 @@ from app.log import logger
|
||||
|
||||
|
||||
class QuerySchedulersInput(BaseModel):
|
||||
"""查询定时服务工具的输入参数模型"""
|
||||
"""查询运行时定时服务的输入参数模型。"""
|
||||
|
||||
|
||||
class QuerySchedulersTool(MoviePilotTool):
|
||||
"""查询系统、插件和工作流注册的运行时定时服务。"""
|
||||
|
||||
name: str = "query_schedulers"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Scheduler,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = "Query scheduled tasks and list all available scheduler jobs. Shows job status, next run time, and provider information."
|
||||
description: str = (
|
||||
"Query runtime scheduler services registered by MoviePilot system components, "
|
||||
"plugins, and workflows. It excludes user-created autonomous agent tasks; use "
|
||||
"query_agent_tasks for reminders, monitoring tasks, and other agent schedules."
|
||||
)
|
||||
args_schema: Type[BaseModel] = QuerySchedulersInput
|
||||
require_admin: bool = True
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""生成友好的提示消息"""
|
||||
return "查询定时服务"
|
||||
def get_tool_message(self, **kwargs: object) -> Optional[str]:
|
||||
"""生成查询运行时定时服务的提示消息。"""
|
||||
return "查询系统定时服务"
|
||||
|
||||
async def run(self, **kwargs) -> str:
|
||||
async def run(self, **kwargs: object) -> str:
|
||||
"""查询非 Agent 自主任务的运行时定时服务。"""
|
||||
logger.info(f"执行工具: {self.name}")
|
||||
try:
|
||||
from app.scheduler import Scheduler
|
||||
from app.scheduler import AGENT_TASK_JOB_PREFIX, Scheduler
|
||||
|
||||
scheduler = Scheduler()
|
||||
schedulers = scheduler.list()
|
||||
agent_task_prefix = f"{AGENT_TASK_JOB_PREFIX}-"
|
||||
schedulers = [
|
||||
scheduler_item
|
||||
for scheduler_item in scheduler.list()
|
||||
if not str(scheduler_item.id or "").startswith(agent_task_prefix)
|
||||
]
|
||||
if schedulers:
|
||||
# 转换为字典列表以便JSON序列化
|
||||
schedulers_list = []
|
||||
for s in schedulers:
|
||||
schedulers_list.append({
|
||||
"id": s.id,
|
||||
"name": s.name,
|
||||
"provider": s.provider,
|
||||
"status": s.status,
|
||||
"next_run": s.next_run
|
||||
})
|
||||
schedulers_list = [
|
||||
{
|
||||
"id": scheduler_item.id,
|
||||
"name": scheduler_item.name,
|
||||
"provider": scheduler_item.provider,
|
||||
"status": scheduler_item.status,
|
||||
"next_run": scheduler_item.next_run,
|
||||
}
|
||||
for scheduler_item in schedulers
|
||||
]
|
||||
result_json = json.dumps(schedulers_list, ensure_ascii=False, indent=2)
|
||||
# 限制最多30条结果
|
||||
total_count = len(schedulers_list)
|
||||
if total_count > 30:
|
||||
limited_schedulers = schedulers_list[:30]
|
||||
limited_json = json.dumps(limited_schedulers, ensure_ascii=False, indent=2)
|
||||
return f"注意:查询结果共找到 {total_count} 条,为节省上下文空间,仅显示前 30 条结果。\n\n{limited_json}"
|
||||
limited_json = json.dumps(
|
||||
limited_schedulers,
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
return (
|
||||
f"注意:查询结果共找到 {total_count} 条,为节省上下文空间,"
|
||||
f"仅显示前 30 条结果。\n\n{limited_json}"
|
||||
)
|
||||
return result_json
|
||||
return "未找到定时服务"
|
||||
return "未找到系统、插件或工作流定时服务"
|
||||
except Exception as e:
|
||||
logger.error(f"查询定时服务失败: {e}", exc_info=True)
|
||||
return f"查询定时服务时发生错误: {str(e)}"
|
||||
|
||||
@@ -170,6 +170,9 @@ class QuerySubscribeHistoryTool(MoviePilotTool):
|
||||
"tmdbid": record.tmdbid,
|
||||
"doubanid": record.doubanid,
|
||||
"bangumiid": record.bangumiid,
|
||||
"anilistid": record.anilistid,
|
||||
"media_source": record.media_source,
|
||||
"media_id": record.media_id,
|
||||
"poster": record.poster,
|
||||
"vote": record.vote,
|
||||
"total_episode": record.total_episode,
|
||||
|
||||
@@ -97,6 +97,9 @@ class QuerySubscribeSharesTool(MoviePilotTool):
|
||||
"tmdbid": share.get("tmdbid"),
|
||||
"doubanid": share.get("doubanid"),
|
||||
"bangumiid": share.get("bangumiid"),
|
||||
"anilistid": share.get("anilistid"),
|
||||
"media_source": share.get("media_source"),
|
||||
"media_id": share.get("media_id"),
|
||||
"poster": share.get("poster"),
|
||||
"vote": share.get("vote"),
|
||||
"share_title": share.get("share_title"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""立即执行 Agent 自主定时任务工具。"""
|
||||
|
||||
from typing import Optional, Type
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.db.agenttask_oper import AgentTaskOper
|
||||
|
||||
|
||||
class RunAgentTaskInput(BaseModel):
|
||||
"""立即执行 Agent 自主定时任务的输入参数。"""
|
||||
|
||||
task_id: int = Field(
|
||||
...,
|
||||
ge=1,
|
||||
description=(
|
||||
"Integer autonomous task ID returned by query_agent_tasks. Do not pass a "
|
||||
"runtime scheduler job_id such as agent-task-12."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class RunAgentTaskTool(MoviePilotTool):
|
||||
"""将当前用户的 Agent 自主定时任务提交为立即执行。"""
|
||||
|
||||
name: str = "run_agent_task"
|
||||
tags: list[str] = [ToolTag.Write, ToolTag.AgentTask, ToolTag.Admin]
|
||||
description: str = (
|
||||
"Queue an enabled autonomous agent task owned by the current user for immediate "
|
||||
"execution. Use the integer task_id returned by query_agent_tasks. The task runs "
|
||||
"after the current agent turn can finish and broadcasts its result through the "
|
||||
"configured notification channels."
|
||||
)
|
||||
args_schema: Type[BaseModel] = RunAgentTaskInput
|
||||
require_admin: bool = True
|
||||
|
||||
def get_tool_message(self, **kwargs: object) -> Optional[str]:
|
||||
"""生成立即执行 Agent 任务的提示消息。"""
|
||||
return f"立即执行自主定时任务:{kwargs.get('task_id', '')}"
|
||||
|
||||
def _get_task_state(self, task_id: int) -> tuple[str, Optional[str]]:
|
||||
"""校验任务归属和状态,返回可执行性及任务名称。"""
|
||||
task = AgentTaskOper().get(
|
||||
task_id=task_id,
|
||||
user_id=str(self._user_id),
|
||||
)
|
||||
if not task:
|
||||
return "not_found", None
|
||||
if not task.enabled:
|
||||
return "disabled", task.name
|
||||
if task.last_status == "running":
|
||||
return "running", task.name
|
||||
return "ready", task.name
|
||||
|
||||
async def run(self, task_id: int, **kwargs: object) -> str:
|
||||
"""立即执行当前用户拥有且已启用的 Agent 自主定时任务。"""
|
||||
from app.scheduler import Scheduler
|
||||
|
||||
payload = RunAgentTaskInput(task_id=task_id)
|
||||
status, task_name = await self.run_blocking(
|
||||
"db",
|
||||
self._get_task_state,
|
||||
payload.task_id,
|
||||
)
|
||||
if status == "not_found":
|
||||
return f"Agent 定时任务 {task_id} 不存在或不属于当前用户"
|
||||
if status == "disabled":
|
||||
return f"Agent 定时任务 {task_id} 已暂停,请先恢复后再执行"
|
||||
if status == "running":
|
||||
return f"Agent 定时任务 {task_id} 正在执行,请勿重复触发"
|
||||
if not Scheduler().start_agent_task(payload.task_id):
|
||||
return f"Agent 定时任务 {task_id} 尚未注册到运行时调度器,无法立即执行"
|
||||
return (
|
||||
f"Agent 定时任务 {task_id} 已提交立即执行:{task_name}。"
|
||||
"执行完成后将通过已配置的通知渠道广播结果"
|
||||
)
|
||||
@@ -14,23 +14,32 @@ class RunSchedulerInput(BaseModel):
|
||||
|
||||
job_id: str = Field(
|
||||
...,
|
||||
description="The ID of the scheduled job to run (can be obtained from query_schedulers tool)",
|
||||
description=(
|
||||
"Runtime scheduler job ID returned by query_schedulers. Do not pass an "
|
||||
"autonomous agent task ID or an agent-task-* runtime ID."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class RunSchedulerTool(MoviePilotTool):
|
||||
"""立即运行系统、插件或工作流注册的定时服务。"""
|
||||
|
||||
name: str = "run_scheduler"
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
ToolTag.Scheduler,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = "Manually trigger a scheduled task to run immediately. This will execute the specified scheduler job by its ID."
|
||||
description: str = (
|
||||
"Manually trigger a MoviePilot system, plugin, or workflow scheduler service by "
|
||||
"the runtime job_id returned from query_schedulers. This tool does not run "
|
||||
"user-created autonomous agent tasks; use run_agent_task with an integer task_id."
|
||||
)
|
||||
args_schema: Type[BaseModel] = RunSchedulerInput
|
||||
require_admin: bool = True
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""根据运行参数生成友好的提示消息"""
|
||||
def get_tool_message(self, **kwargs: object) -> Optional[str]:
|
||||
"""根据运行参数生成友好的提示消息。"""
|
||||
job_id = kwargs.get("job_id", "")
|
||||
return f"运行定时服务 (ID: {job_id})"
|
||||
|
||||
@@ -46,10 +55,18 @@ class RunSchedulerTool(MoviePilotTool):
|
||||
return True, scheduler_item.name
|
||||
return False, ""
|
||||
|
||||
async def run(self, job_id: str, **kwargs) -> str:
|
||||
async def run(self, job_id: str, **kwargs: object) -> str:
|
||||
"""立即运行非 Agent 自主任务的运行时定时服务。"""
|
||||
logger.info(f"执行工具: {self.name}, 参数: job_id={job_id}")
|
||||
|
||||
try:
|
||||
from app.scheduler import AGENT_TASK_JOB_PREFIX
|
||||
|
||||
if job_id.startswith(f"{AGENT_TASK_JOB_PREFIX}-"):
|
||||
return (
|
||||
"Agent 自主定时任务不能通过 run_scheduler 运行,"
|
||||
"请使用 query_agent_tasks 查询整数 task_id 后调用 run_agent_task"
|
||||
)
|
||||
job_exists, job_name = await self.run_blocking(
|
||||
"workflow", self._run_scheduler_sync, job_id
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.agent.tools.tags import ToolTag
|
||||
from app.chain.media import MediaChain
|
||||
from app.log import logger
|
||||
from app.schemas.types import MediaType, media_type_to_agent
|
||||
from app.utils.media import resolve_media_identity
|
||||
|
||||
|
||||
class SearchMediaInput(BaseModel):
|
||||
@@ -43,7 +44,7 @@ class SearchMediaTool(MoviePilotTool):
|
||||
message += f" ({year})"
|
||||
if media_type:
|
||||
message += f" [{media_type}]"
|
||||
if season:
|
||||
if season is not None:
|
||||
message += f" 第{season}季"
|
||||
|
||||
return message
|
||||
@@ -83,6 +84,7 @@ class SearchMediaTool(MoviePilotTool):
|
||||
# 精简字段,只保留关键信息
|
||||
simplified_results = []
|
||||
for r in limited_results:
|
||||
media_source, media_id = resolve_media_identity(media=r)
|
||||
simplified = {
|
||||
"title": r.title,
|
||||
"en_title": r.en_title,
|
||||
@@ -92,6 +94,10 @@ class SearchMediaTool(MoviePilotTool):
|
||||
"tmdb_id": r.tmdb_id,
|
||||
"imdb_id": r.imdb_id,
|
||||
"douban_id": r.douban_id,
|
||||
"bangumi_id": r.bangumi_id,
|
||||
"anilist_id": r.anilist_id,
|
||||
"media_source": media_source,
|
||||
"media_id": media_id,
|
||||
"overview": r.overview[:200] + "..." if r.overview and len(r.overview) > 200 else r.overview,
|
||||
"vote_average": r.vote_average,
|
||||
"poster_path": r.poster_path,
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.chain.douban import DoubanChain
|
||||
from app.chain.tmdb import TmdbChain
|
||||
from app.chain.bangumi import BangumiChain
|
||||
from app.log import logger
|
||||
from app.utils.media import resolve_media_identity
|
||||
|
||||
|
||||
class SearchPersonCreditsInput(BaseModel):
|
||||
@@ -59,6 +60,7 @@ class SearchPersonCreditsTool(MoviePilotTool):
|
||||
# 精简字段,只保留关键信息
|
||||
simplified_results = []
|
||||
for media in limited_medias:
|
||||
media_source, media_id = resolve_media_identity(media=media)
|
||||
simplified = {
|
||||
"title": media.title,
|
||||
"en_title": media.en_title,
|
||||
@@ -68,6 +70,10 @@ class SearchPersonCreditsTool(MoviePilotTool):
|
||||
"tmdb_id": media.tmdb_id,
|
||||
"imdb_id": media.imdb_id,
|
||||
"douban_id": media.douban_id,
|
||||
"bangumi_id": media.bangumi_id,
|
||||
"anilist_id": media.anilist_id,
|
||||
"media_source": media_source,
|
||||
"media_id": media_id,
|
||||
"overview": media.overview[:200] + "..." if media.overview and len(media.overview) > 200 else media.overview,
|
||||
"vote_average": media.vote_average,
|
||||
"poster_path": media.poster_path,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Literal, Optional, Type
|
||||
|
||||
import pytz
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.core.config import settings
|
||||
from app.db.agenttask_oper import AgentTaskOper
|
||||
from app.utils.timer import TimerUtils
|
||||
|
||||
|
||||
class UpdateAgentTaskInput(BaseModel):
|
||||
"""更新 Agent 自主定时任务的输入参数。"""
|
||||
|
||||
task_id: int = Field(..., ge=1, description="ID of the task to update.")
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
content: Optional[str] = Field(None, min_length=1, max_length=10000)
|
||||
trigger_type: Optional[Literal["date", "cron"]] = Field(
|
||||
None,
|
||||
description="New trigger type. Must be provided together with trigger.",
|
||||
)
|
||||
trigger: Optional[str] = Field(
|
||||
None,
|
||||
min_length=1,
|
||||
max_length=200,
|
||||
description="New ISO 8601 date or five-field cron expression.",
|
||||
)
|
||||
delay_minutes: Optional[int] = Field(
|
||||
None,
|
||||
ge=1,
|
||||
le=525600,
|
||||
description=(
|
||||
"For a one-time date task expressed as 'in N minutes', provide this instead "
|
||||
"of trigger together with trigger_type='date'."
|
||||
),
|
||||
)
|
||||
enabled: Optional[bool] = Field(
|
||||
None,
|
||||
description="Set false to pause the task or true to resume it.",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_update(self) -> "UpdateAgentTaskInput":
|
||||
"""校验更新内容和触发参数组合。"""
|
||||
if self.name is not None:
|
||||
self.name = self.name.strip()
|
||||
if not self.name:
|
||||
raise ValueError("name 不能只包含空白字符")
|
||||
if self.content is not None:
|
||||
self.content = self.content.strip()
|
||||
if not self.content:
|
||||
raise ValueError("content 不能只包含空白字符")
|
||||
has_schedule_update = any(
|
||||
value is not None
|
||||
for value in (self.trigger_type, self.trigger, self.delay_minutes)
|
||||
)
|
||||
if has_schedule_update:
|
||||
if self.trigger_type is None:
|
||||
raise ValueError("修改触发配置时必须提供 trigger_type")
|
||||
if self.trigger_type == "date":
|
||||
if self.delay_minutes is not None:
|
||||
# 保持校验幂等,具体绝对时间在更新调度前只计算一次。
|
||||
self.trigger = None
|
||||
elif self.trigger is None:
|
||||
raise ValueError("date 任务必须提供 trigger 或 delay_minutes")
|
||||
elif self.trigger is None or self.delay_minutes is not None:
|
||||
raise ValueError("cron 任务必须提供 trigger,且不能提供 delay_minutes")
|
||||
if all(
|
||||
value is None
|
||||
for value in (
|
||||
self.name,
|
||||
self.content,
|
||||
self.trigger_type,
|
||||
self.enabled,
|
||||
)
|
||||
):
|
||||
raise ValueError("至少需要提供一个要更新的字段")
|
||||
return self
|
||||
|
||||
|
||||
class UpdateAgentTaskTool(MoviePilotTool):
|
||||
"""修改、暂停或恢复 Agent 自主定时任务。"""
|
||||
|
||||
name: str = "update_agent_task"
|
||||
tags: list[str] = [ToolTag.Write, ToolTag.AgentTask, ToolTag.Admin]
|
||||
description: str = (
|
||||
"Update an autonomous agent task's name, instructions, exact date or cron "
|
||||
"trigger, relative delay_minutes, or enabled state. Use enabled=false to pause "
|
||||
"and enabled=true to resume."
|
||||
)
|
||||
args_schema: Type[BaseModel] = UpdateAgentTaskInput
|
||||
require_admin: bool = True
|
||||
|
||||
def get_tool_message(self, **kwargs: object) -> Optional[str]:
|
||||
"""生成更新定时任务的提示消息。"""
|
||||
return f"更新自主定时任务:{kwargs.get('task_id', '')}"
|
||||
|
||||
def _update_task(self, payload: UpdateAgentTaskInput) -> Optional[dict]:
|
||||
"""更新当前用户的任务并刷新运行时调度。"""
|
||||
from app.scheduler import Scheduler
|
||||
|
||||
oper = AgentTaskOper()
|
||||
task = oper.get(task_id=payload.task_id, user_id=str(self._user_id))
|
||||
if not task:
|
||||
return None
|
||||
if task.last_status == "running":
|
||||
return {"error": f"Agent 定时任务 {payload.task_id} 正在执行,请稍后再修改"}
|
||||
|
||||
trigger_type = payload.trigger_type or task.trigger_type
|
||||
trigger_value = payload.trigger
|
||||
if trigger_type == "date" and payload.delay_minutes is not None:
|
||||
timezone = pytz.timezone(settings.TZ)
|
||||
trigger_value = (
|
||||
datetime.now(timezone) + timedelta(minutes=payload.delay_minutes)
|
||||
).isoformat(timespec="seconds")
|
||||
if trigger_value is None:
|
||||
trigger_value = (
|
||||
task.cron_expression if trigger_type == "cron" else task.run_at
|
||||
)
|
||||
enabled = task.enabled if payload.enabled is None else payload.enabled
|
||||
normalized_type, normalized_trigger = TimerUtils.normalize_schedule_trigger(
|
||||
trigger_type=trigger_type,
|
||||
trigger_value=trigger_value,
|
||||
timezone_name=settings.TZ,
|
||||
require_future=bool(enabled and trigger_type == "date"),
|
||||
)
|
||||
|
||||
update_payload = {}
|
||||
if payload.name is not None:
|
||||
update_payload["name"] = payload.name.strip()
|
||||
if payload.content is not None:
|
||||
update_payload["content"] = payload.content.strip()
|
||||
if payload.trigger_type is not None:
|
||||
update_payload.update(
|
||||
{
|
||||
"trigger_type": normalized_type,
|
||||
"cron_expression": (
|
||||
normalized_trigger if normalized_type == "cron" else None
|
||||
),
|
||||
"run_at": normalized_trigger if normalized_type == "date" else None,
|
||||
"last_status": "waiting",
|
||||
"last_result": None,
|
||||
}
|
||||
)
|
||||
if payload.enabled is not None:
|
||||
update_payload["enabled"] = payload.enabled
|
||||
if payload.enabled:
|
||||
update_payload["last_status"] = "waiting"
|
||||
|
||||
oper.update(
|
||||
task_id=payload.task_id,
|
||||
payload=update_payload,
|
||||
user_id=str(self._user_id),
|
||||
)
|
||||
scheduler = Scheduler()
|
||||
next_run_at = scheduler.update_agent_task_job(payload.task_id)
|
||||
updated_task = oper.get(task_id=payload.task_id, user_id=str(self._user_id))
|
||||
return oper.to_dict(
|
||||
updated_task,
|
||||
next_run_at=next_run_at,
|
||||
timezone=settings.TZ,
|
||||
)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
task_id: int,
|
||||
name: Optional[str] = None,
|
||||
content: Optional[str] = None,
|
||||
trigger_type: Optional[str] = None,
|
||||
trigger: Optional[str] = None,
|
||||
delay_minutes: Optional[int] = None,
|
||||
enabled: Optional[bool] = None,
|
||||
**kwargs: object,
|
||||
) -> str:
|
||||
"""更新 Agent 自主定时任务。"""
|
||||
payload = UpdateAgentTaskInput(
|
||||
task_id=task_id,
|
||||
name=name,
|
||||
content=content,
|
||||
trigger_type=trigger_type,
|
||||
trigger=trigger,
|
||||
delay_minutes=delay_minutes,
|
||||
enabled=enabled,
|
||||
)
|
||||
task = await self.run_blocking("db", self._update_task, payload)
|
||||
if not task:
|
||||
return f"Agent 定时任务 {task_id} 不存在或不属于当前用户"
|
||||
if task.get("error"):
|
||||
return task["error"]
|
||||
return json.dumps(task, ensure_ascii=False, indent=2)
|
||||
@@ -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
|
||||
|
||||
@@ -25,6 +25,7 @@ class ToolTag(str, Enum):
|
||||
Plugin = "plugin"
|
||||
Workflow = "workflow"
|
||||
Scheduler = "scheduler"
|
||||
AgentTask = "agent_task"
|
||||
File = "file"
|
||||
Directory = "directory"
|
||||
Web = "web"
|
||||
|
||||
+2
-1
@@ -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"])
|
||||
|
||||
@@ -219,7 +219,9 @@ class _WebAgentMoviePilotAgent(MoviePilotAgent):
|
||||
self.stream_handler = _WebAgentStreamingHandler(self._emit_output)
|
||||
|
||||
def _should_stream(self) -> bool:
|
||||
"""Web 面板需要实时输出,即使 Web 渠道本身不支持消息编辑。"""
|
||||
"""Web 对话实时输出,复用会话执行后台任务时改用非流式广播。"""
|
||||
if self.is_background:
|
||||
return False
|
||||
return True
|
||||
|
||||
def set_notification_callback(
|
||||
|
||||
@@ -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="用户名或密码错误")
|
||||
|
||||
+159
-53
@@ -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(
|
||||
@@ -26,14 +71,26 @@ router = APIRouter()
|
||||
async def recognize(
|
||||
title: str,
|
||||
subtitle: Optional[str] = None,
|
||||
custom_words: Optional[str] = None,
|
||||
source: Optional[MediaSource] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token),
|
||||
) -> Any:
|
||||
"""
|
||||
根据标题、副标题识别媒体信息
|
||||
:param title: 标题
|
||||
:param subtitle: 副标题
|
||||
:param custom_words: 临时识别词(每行一条规则),传入时仅在本次识别中生效,不会保存到系统配置
|
||||
:param source: 请求级识别数据源
|
||||
:param _:
|
||||
"""
|
||||
# 识别媒体信息
|
||||
metainfo = MetaInfo(title, subtitle)
|
||||
mediainfo = await MediaChain().async_recognize_by_meta(metainfo)
|
||||
# 识别媒体信息,传入临时识别词时优先于系统配置的识别词生效
|
||||
metainfo = MetaInfo(
|
||||
title, subtitle, custom_words=custom_words.split("\n") if custom_words else None
|
||||
)
|
||||
mediainfo = await MediaChain().async_recognize_by_meta(
|
||||
metainfo,
|
||||
source=source,
|
||||
)
|
||||
if mediainfo:
|
||||
return Context(meta_info=metainfo, media_info=mediainfo).to_dict()
|
||||
return schemas.Context()
|
||||
@@ -48,25 +105,29 @@ async def recognize2(
|
||||
_: Annotated[str, Depends(verify_apitoken)],
|
||||
title: str,
|
||||
subtitle: Optional[str] = None,
|
||||
custom_words: Optional[str] = None,
|
||||
source: Optional[MediaSource] = None,
|
||||
) -> Any:
|
||||
"""
|
||||
根据标题、副标题识别媒体信息 API_TOKEN认证(?token=xxx)
|
||||
"""
|
||||
# 识别媒体信息
|
||||
return await recognize(title, subtitle)
|
||||
return await recognize(title, subtitle, custom_words, source)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/recognize_file", summary="识别媒体信息(文件)", response_model=schemas.Context
|
||||
)
|
||||
async def recognize_file(
|
||||
path: str, _: schemas.TokenPayload = Depends(verify_token)
|
||||
path: str,
|
||||
source: Optional[MediaSource] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token),
|
||||
) -> Any:
|
||||
"""
|
||||
根据文件路径识别媒体信息
|
||||
"""
|
||||
# 识别媒体信息
|
||||
context = await MediaChain().async_recognize_by_path(path)
|
||||
context = await MediaChain().async_recognize_by_path(path, source=source)
|
||||
if context:
|
||||
return context.to_dict()
|
||||
return schemas.Context()
|
||||
@@ -78,13 +139,15 @@ async def recognize_file(
|
||||
response_model=schemas.Context,
|
||||
)
|
||||
async def recognize_file2(
|
||||
path: str, _: Annotated[str, Depends(verify_apitoken)]
|
||||
path: str,
|
||||
_: Annotated[str, Depends(verify_apitoken)],
|
||||
source: Optional[MediaSource] = None,
|
||||
) -> Any:
|
||||
"""
|
||||
根据文件路径识别媒体信息 API_TOKEN认证(?token=xxx)
|
||||
"""
|
||||
# 识别媒体信息
|
||||
return await recognize_file(path)
|
||||
return await recognize_file(path, source)
|
||||
|
||||
|
||||
@router.get("/search", summary="搜索媒体/人物信息", response_model=List[dict])
|
||||
@@ -93,10 +156,19 @@ async def search(
|
||||
type: Optional[str] = "media",
|
||||
page: int = 1,
|
||||
count: int = 8,
|
||||
source: Optional[MediaSource] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token),
|
||||
) -> Any:
|
||||
"""
|
||||
模糊搜索媒体/人物信息列表 media:媒体信息,person:人物信息
|
||||
模糊搜索媒体、合集或人物信息列表。
|
||||
|
||||
:param title: 搜索关键词
|
||||
:param type: 搜索类型,支持 media、collection、person
|
||||
:param page: 页码
|
||||
:param count: 每页数量
|
||||
:param source: 请求级搜索数据源
|
||||
:param _: Token校验
|
||||
:return: 搜索结果列表
|
||||
"""
|
||||
|
||||
def __get_source(obj: Union[schemas.MediaInfo, schemas.MediaPerson, dict]):
|
||||
@@ -109,15 +181,17 @@ async def search(
|
||||
|
||||
media_chain = MediaChain()
|
||||
if type == "media":
|
||||
_, medias = await media_chain.async_search(title=title)
|
||||
_, medias = await media_chain.async_search(title=title, source=source)
|
||||
result = [media.to_dict() for media in medias] if medias else []
|
||||
elif type == "collection":
|
||||
collections = await media_chain.async_search_collections(name=title)
|
||||
collections = await media_chain.async_search_collections(
|
||||
name=title, source=source
|
||||
)
|
||||
result = (
|
||||
[collection.to_dict() for collection in collections] if collections else []
|
||||
)
|
||||
else: # person
|
||||
persons = await media_chain.async_search_persons(name=title)
|
||||
persons = await media_chain.async_search_persons(name=title, source=source)
|
||||
result = [person.model_dump() for person in persons] if persons else []
|
||||
|
||||
if not result:
|
||||
@@ -137,26 +211,64 @@ async def search(
|
||||
def scrape(
|
||||
fileitem: schemas.FileItem,
|
||||
storage: Optional[str] = "local",
|
||||
media_source: Optional[MediaSource] = None,
|
||||
media_id: Optional[str] = None,
|
||||
type_name: Optional[MediaType] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token),
|
||||
) -> Any:
|
||||
"""
|
||||
刮削媒体信息
|
||||
刮削媒体信息,可按请求指定媒体数据源及其原生ID
|
||||
|
||||
:param fileitem: 待刮削文件项
|
||||
:param storage: 文件所在存储
|
||||
:param media_source: 请求级媒体数据源
|
||||
:param media_id: 数据源原生ID
|
||||
:param type_name: 媒体类型
|
||||
:param _: Token校验
|
||||
"""
|
||||
if not fileitem or not fileitem.path:
|
||||
return schemas.Response(success=False, message="刮削路径无效")
|
||||
normalized_media_id = media_id.strip() if media_id else None
|
||||
if normalized_media_id and not media_source:
|
||||
return schemas.Response(
|
||||
success=False, message="指定媒体ID时必须同时指定媒体数据源"
|
||||
)
|
||||
if normalized_media_id and not normalized_media_id.isdigit():
|
||||
return schemas.Response(success=False, message="媒体ID格式无效")
|
||||
|
||||
chain = MediaChain()
|
||||
# 识别媒体信息
|
||||
context = chain.recognize_by_path(fileitem.path, obtain_images=True)
|
||||
if not context or not context.media_info:
|
||||
if normalized_media_id:
|
||||
meta_info = MetaInfoPath(Path(fileitem.path))
|
||||
media_info = chain.recognize_media(
|
||||
meta=meta_info,
|
||||
mtype=type_name,
|
||||
source=media_source,
|
||||
mediaid=normalized_media_id,
|
||||
)
|
||||
if media_info:
|
||||
media_info.scrape_source = media_source
|
||||
chain.obtain_images(mediainfo=media_info)
|
||||
else:
|
||||
context = chain.recognize_by_path(
|
||||
fileitem.path,
|
||||
source=media_source,
|
||||
obtain_images=True,
|
||||
)
|
||||
meta_info = context.meta_info if context else None
|
||||
media_info = context.media_info if context else None
|
||||
|
||||
if not media_info:
|
||||
return schemas.Response(success=False, message="刮削失败,无法识别媒体信息")
|
||||
if media_source:
|
||||
media_info.scrape_source = media_source
|
||||
if storage == "local":
|
||||
if not Path(fileitem.path).exists():
|
||||
return schemas.Response(success=False, message="刮削路径不存在")
|
||||
# 手动刮削 (暂时使用同步版本,可以后续优化为异步)
|
||||
chain.scrape_metadata(
|
||||
fileitem=fileitem,
|
||||
meta=context.meta_info,
|
||||
mediainfo=context.media_info,
|
||||
meta=meta_info,
|
||||
mediainfo=media_info,
|
||||
overwrite=True,
|
||||
)
|
||||
return schemas.Response(success=True, message=f"{fileitem.path} 刮削完成")
|
||||
@@ -237,13 +349,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:
|
||||
@@ -254,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
|
||||
)
|
||||
@@ -264,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 []
|
||||
|
||||
|
||||
@@ -289,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:
|
||||
# 广播事件解析媒体信息
|
||||
@@ -318,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)
|
||||
)
|
||||
|
||||
+211
-432
@@ -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]]:
|
||||
@@ -40,6 +49,67 @@ def _parse_media_type(mtype: Optional[str]) -> Optional[MediaType]:
|
||||
return MediaType.from_agent(mtype) or MediaType(mtype)
|
||||
|
||||
|
||||
def _resolve_media_season(
|
||||
explicit_season: Optional[int],
|
||||
recognized_season: Optional[int],
|
||||
) -> Optional[int]:
|
||||
"""合并显式季号与识别结果,显式值优先且季 0 属于有效业务值。"""
|
||||
return explicit_season if explicit_season is not None else recognized_season
|
||||
|
||||
|
||||
async def _resolve_media_search_params(
|
||||
mediaid: str,
|
||||
media_type: Optional[MediaType] = None,
|
||||
title: Optional[str] = None,
|
||||
year: Optional[str] = None,
|
||||
media_season: Optional[int] = None,
|
||||
) -> tuple[Optional[dict], str]:
|
||||
"""将任意来源媒体键解析为 SearchChain 可直接使用的识别参数。"""
|
||||
source, source_media_id = parse_media_key(mediaid)
|
||||
if source and source_media_id:
|
||||
if source in {"themoviedb", "bangumi", "anilist"} \
|
||||
and not source_media_id.isdigit():
|
||||
return None, "媒体ID格式错误"
|
||||
return {"source": source, "mediaid": source_media_id}, ""
|
||||
|
||||
event_data = MediaRecognizeConvertEventData(
|
||||
mediaid=mediaid, convert_type=settings.RECOGNIZE_SOURCE
|
||||
)
|
||||
event = await eventmanager.async_send_event(
|
||||
ChainEventType.MediaRecognizeConvert, event_data
|
||||
)
|
||||
if event and event.event_data and event.event_data.media_dict:
|
||||
event_data = event.event_data
|
||||
search_id = event_data.media_dict.get("id")
|
||||
if search_id is not None:
|
||||
return {
|
||||
"source": event_data.convert_type,
|
||||
"mediaid": str(search_id),
|
||||
}, ""
|
||||
|
||||
if not title:
|
||||
return None, "未知的媒体ID"
|
||||
|
||||
meta = MetaInfo(title)
|
||||
if year:
|
||||
meta.year = year
|
||||
if media_type:
|
||||
meta.type = media_type
|
||||
if media_season is not None:
|
||||
meta.type = MediaType.TV
|
||||
meta.begin_season = media_season
|
||||
mediainfo = await MediaChain().async_recognize_by_meta(
|
||||
meta,
|
||||
obtain_images=False,
|
||||
)
|
||||
if not mediainfo:
|
||||
return None, "未识别到媒体信息"
|
||||
source, source_media_id = resolve_media_identity(media=mediainfo)
|
||||
if not source or not source_media_id:
|
||||
return None, "媒体信息缺少有效ID"
|
||||
return {"source": source, "mediaid": source_media_id}, ""
|
||||
|
||||
|
||||
def _sse_event(data: dict, locale: Optional[str] = None) -> str:
|
||||
"""
|
||||
转换为SSE事件
|
||||
@@ -118,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
|
||||
@@ -133,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:
|
||||
@@ -165,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()
|
||||
@@ -177,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"):
|
||||
@@ -195,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])
|
||||
@@ -254,192 +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:
|
||||
if tmdbinfo.get("season") and not media_season:
|
||||
media_season = tmdbinfo.get("season")
|
||||
torrents = search_chain.async_search_by_id_stream(
|
||||
tmdbid=tmdbinfo.get("id"),
|
||||
mtype=media_type,
|
||||
area=area,
|
||||
season=media_season,
|
||||
sites=site_list,
|
||||
cache_local=True,
|
||||
)
|
||||
else:
|
||||
yield {
|
||||
"type": "error",
|
||||
"success": False,
|
||||
"message": "未识别到TMDB媒体信息",
|
||||
}
|
||||
return
|
||||
else:
|
||||
torrents = search_chain.async_search_by_id_stream(
|
||||
doubanid=doubanid,
|
||||
mtype=media_type,
|
||||
area=area,
|
||||
season=media_season,
|
||||
sites=site_list,
|
||||
cache_local=True,
|
||||
)
|
||||
elif mediaid.startswith("bangumi:"):
|
||||
bangumiid = int(mediaid.replace("bangumi:", ""))
|
||||
if settings.RECOGNIZE_SOURCE == "themoviedb":
|
||||
tmdbinfo = await media_chain.async_get_tmdbinfo_by_bangumiid(
|
||||
bangumiid=bangumiid
|
||||
)
|
||||
if tmdbinfo:
|
||||
torrents = search_chain.async_search_by_id_stream(
|
||||
tmdbid=tmdbinfo.get("id"),
|
||||
mtype=media_type,
|
||||
area=area,
|
||||
season=media_season,
|
||||
sites=site_list,
|
||||
cache_local=True,
|
||||
)
|
||||
else:
|
||||
yield {
|
||||
"type": "error",
|
||||
"success": False,
|
||||
"message": "未识别到TMDB媒体信息",
|
||||
}
|
||||
return
|
||||
else:
|
||||
doubaninfo = await media_chain.async_get_doubaninfo_by_bangumiid(
|
||||
bangumiid=bangumiid
|
||||
)
|
||||
if doubaninfo:
|
||||
torrents = search_chain.async_search_by_id_stream(
|
||||
doubanid=doubaninfo.get("id"),
|
||||
mtype=media_type,
|
||||
area=area,
|
||||
season=media_season,
|
||||
sites=site_list,
|
||||
cache_local=True,
|
||||
)
|
||||
else:
|
||||
yield {
|
||||
"type": "error",
|
||||
"success": False,
|
||||
"message": "未识别到豆瓣媒体信息",
|
||||
}
|
||||
return
|
||||
else:
|
||||
event_data = MediaRecognizeConvertEventData(
|
||||
mediaid=mediaid, convert_type=settings.RECOGNIZE_SOURCE
|
||||
)
|
||||
event = await eventmanager.async_send_event(
|
||||
ChainEventType.MediaRecognizeConvert, event_data
|
||||
)
|
||||
if event and event.event_data:
|
||||
event_data = event.event_data
|
||||
if event_data.media_dict:
|
||||
search_id = event_data.media_dict.get("id")
|
||||
if event_data.convert_type == "themoviedb":
|
||||
torrents = search_chain.async_search_by_id_stream(
|
||||
tmdbid=search_id,
|
||||
mtype=media_type,
|
||||
area=area,
|
||||
season=media_season,
|
||||
sites=site_list,
|
||||
cache_local=True,
|
||||
)
|
||||
elif event_data.convert_type == "douban":
|
||||
torrents = search_chain.async_search_by_id_stream(
|
||||
doubanid=search_id,
|
||||
mtype=media_type,
|
||||
area=area,
|
||||
season=media_season,
|
||||
sites=site_list,
|
||||
cache_local=True,
|
||||
)
|
||||
else:
|
||||
if not title:
|
||||
yield {"type": "error", "success": False, "message": "未知的媒体ID"}
|
||||
return
|
||||
meta = MetaInfo(title)
|
||||
if year:
|
||||
meta.year = year
|
||||
if media_type:
|
||||
meta.type = media_type
|
||||
if media_season:
|
||||
meta.type = MediaType.TV
|
||||
meta.begin_season = media_season
|
||||
mediainfo = await media_chain.async_recognize_by_meta(
|
||||
meta,
|
||||
obtain_images=False,
|
||||
)
|
||||
if mediainfo:
|
||||
if settings.RECOGNIZE_SOURCE == "themoviedb":
|
||||
torrents = search_chain.async_search_by_id_stream(
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
mtype=media_type,
|
||||
area=area,
|
||||
season=media_season,
|
||||
sites=site_list,
|
||||
cache_local=True,
|
||||
)
|
||||
else:
|
||||
torrents = search_chain.async_search_by_id_stream(
|
||||
doubanid=mediainfo.douban_id,
|
||||
mtype=media_type,
|
||||
area=area,
|
||||
season=media_season,
|
||||
sites=site_list,
|
||||
cache_local=True,
|
||||
)
|
||||
|
||||
if not torrents:
|
||||
yield {"type": "error", "success": False, "message": "未搜索到任何资源"}
|
||||
"""解析媒体身份并输出精确搜索流事件。"""
|
||||
search_params, message = await _resolve_media_search_params(
|
||||
mediaid=mediaid,
|
||||
media_type=media_type,
|
||||
title=title,
|
||||
year=year,
|
||||
media_season=media_season,
|
||||
)
|
||||
if not search_params:
|
||||
yield {"type": "error", "success": False, "message": message}
|
||||
return
|
||||
|
||||
torrents = search_chain.async_search_by_id_stream(
|
||||
**search_params,
|
||||
mtype=media_type,
|
||||
area=area,
|
||||
season=media_season,
|
||||
sites=site_list,
|
||||
cache_local=True,
|
||||
)
|
||||
async for event in torrents:
|
||||
yield event
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -455,180 +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:
|
||||
if tmdbinfo.get("season") and not media_season:
|
||||
media_season = tmdbinfo.get("season")
|
||||
torrents = await search_chain.async_search_by_id(
|
||||
tmdbid=tmdbinfo.get("id"),
|
||||
mtype=media_type,
|
||||
area=area,
|
||||
season=media_season,
|
||||
sites=site_list,
|
||||
cache_local=True,
|
||||
)
|
||||
else:
|
||||
return schemas.Response(success=False, message="未识别到TMDB媒体信息")
|
||||
else:
|
||||
torrents = await search_chain.async_search_by_id(
|
||||
doubanid=doubanid,
|
||||
mtype=media_type,
|
||||
area=area,
|
||||
season=media_season,
|
||||
sites=site_list,
|
||||
cache_local=True,
|
||||
)
|
||||
elif mediaid.startswith("bangumi:"):
|
||||
bangumiid = int(mediaid.replace("bangumi:", ""))
|
||||
if settings.RECOGNIZE_SOURCE == "themoviedb":
|
||||
# 通过BangumiID识别TMDBID
|
||||
tmdbinfo = await media_chain.async_get_tmdbinfo_by_bangumiid(
|
||||
bangumiid=bangumiid
|
||||
)
|
||||
if tmdbinfo:
|
||||
torrents = await search_chain.async_search_by_id(
|
||||
tmdbid=tmdbinfo.get("id"),
|
||||
mtype=media_type,
|
||||
area=area,
|
||||
season=media_season,
|
||||
sites=site_list,
|
||||
cache_local=True,
|
||||
)
|
||||
else:
|
||||
return schemas.Response(success=False, message="未识别到TMDB媒体信息")
|
||||
else:
|
||||
# 通过BangumiID识别豆瓣ID
|
||||
doubaninfo = await media_chain.async_get_doubaninfo_by_bangumiid(
|
||||
bangumiid=bangumiid
|
||||
)
|
||||
if doubaninfo:
|
||||
torrents = await search_chain.async_search_by_id(
|
||||
doubanid=doubaninfo.get("id"),
|
||||
mtype=media_type,
|
||||
area=area,
|
||||
season=media_season,
|
||||
sites=site_list,
|
||||
cache_local=True,
|
||||
)
|
||||
else:
|
||||
return schemas.Response(success=False, message="未识别到豆瓣媒体信息")
|
||||
else:
|
||||
# 未知前缀,广播事件解析媒体信息
|
||||
event_data = MediaRecognizeConvertEventData(
|
||||
mediaid=mediaid, convert_type=settings.RECOGNIZE_SOURCE
|
||||
)
|
||||
event = await eventmanager.async_send_event(
|
||||
ChainEventType.MediaRecognizeConvert, event_data
|
||||
)
|
||||
# 使用事件返回的上下文数据
|
||||
if event and event.event_data:
|
||||
event_data: MediaRecognizeConvertEventData = event.event_data
|
||||
if event_data.media_dict:
|
||||
search_id = event_data.media_dict.get("id")
|
||||
if event_data.convert_type == "themoviedb":
|
||||
torrents = await search_chain.async_search_by_id(
|
||||
tmdbid=search_id,
|
||||
mtype=media_type,
|
||||
area=area,
|
||||
season=media_season,
|
||||
cache_local=True,
|
||||
)
|
||||
elif event_data.convert_type == "douban":
|
||||
torrents = await search_chain.async_search_by_id(
|
||||
doubanid=search_id,
|
||||
mtype=media_type,
|
||||
area=area,
|
||||
season=media_season,
|
||||
cache_local=True,
|
||||
)
|
||||
else:
|
||||
if not title:
|
||||
return schemas.Response(success=False, message="未知的媒体ID")
|
||||
# 使用名称识别兜底
|
||||
meta = MetaInfo(title)
|
||||
if year:
|
||||
meta.year = year
|
||||
if media_type:
|
||||
meta.type = media_type
|
||||
if media_season:
|
||||
meta.type = MediaType.TV
|
||||
meta.begin_season = media_season
|
||||
mediainfo = await media_chain.async_recognize_by_meta(
|
||||
meta,
|
||||
obtain_images=False,
|
||||
)
|
||||
if mediainfo:
|
||||
if settings.RECOGNIZE_SOURCE == "themoviedb":
|
||||
torrents = await search_chain.async_search_by_id(
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
mtype=media_type,
|
||||
area=area,
|
||||
season=media_season,
|
||||
cache_local=True,
|
||||
)
|
||||
else:
|
||||
torrents = await search_chain.async_search_by_id(
|
||||
doubanid=mediainfo.douban_id,
|
||||
mtype=media_type,
|
||||
area=area,
|
||||
season=media_season,
|
||||
cache_local=True,
|
||||
)
|
||||
# 返回搜索结果
|
||||
media_season = int(season) if season else None
|
||||
search_params, message = await _resolve_media_search_params(
|
||||
mediaid=mediaid,
|
||||
media_type=media_type,
|
||||
title=title,
|
||||
year=year,
|
||||
media_season=media_season,
|
||||
)
|
||||
if not search_params:
|
||||
return schemas.Response(success=False, message=message)
|
||||
torrents = await SearchChain().async_search_by_id(
|
||||
**search_params,
|
||||
mtype=media_type,
|
||||
area=area,
|
||||
season=media_season,
|
||||
sites=_parse_site_list(sites),
|
||||
cache_local=True,
|
||||
)
|
||||
if not torrents:
|
||||
return schemas.Response(success=False, message="未搜索到任何资源")
|
||||
else:
|
||||
return schemas.Response(
|
||||
success=True, data=[torrent.to_dict() for torrent in torrents]
|
||||
)
|
||||
return schemas.Response(
|
||||
success=True, data=[torrent.to_dict() for torrent in torrents]
|
||||
)
|
||||
|
||||
|
||||
@router.get("/title/stream", summary="渐进式模糊搜索资源")
|
||||
@@ -647,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,
|
||||
)
|
||||
|
||||
|
||||
@@ -692,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,
|
||||
)
|
||||
|
||||
|
||||
@@ -732,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):
|
||||
@@ -751,80 +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媒体信息"
|
||||
if tmdbinfo.get("season") and not media_season:
|
||||
media_season = tmdbinfo.get("season")
|
||||
return call_search(tmdbid=tmdbinfo.get("id")), ""
|
||||
return call_search(doubanid=doubanid), ""
|
||||
|
||||
if mediaid.startswith("bangumi:"):
|
||||
bangumiid = int(mediaid.replace("bangumi:", ""))
|
||||
if settings.RECOGNIZE_SOURCE == "themoviedb":
|
||||
tmdbinfo = await media_chain.async_get_tmdbinfo_by_bangumiid(
|
||||
bangumiid=bangumiid
|
||||
)
|
||||
if not tmdbinfo:
|
||||
return None, "未识别到TMDB媒体信息"
|
||||
return call_search(tmdbid=tmdbinfo.get("id")), ""
|
||||
doubaninfo = await media_chain.async_get_doubaninfo_by_bangumiid(
|
||||
bangumiid=bangumiid
|
||||
)
|
||||
if not doubaninfo:
|
||||
return None, "未识别到豆瓣媒体信息"
|
||||
return call_search(doubanid=doubaninfo.get("id")), ""
|
||||
|
||||
event_data = MediaRecognizeConvertEventData(
|
||||
mediaid=mediaid, convert_type=settings.RECOGNIZE_SOURCE
|
||||
search_params, message = await _resolve_media_search_params(
|
||||
mediaid=mediaid,
|
||||
media_type=media_type,
|
||||
title=title,
|
||||
year=year,
|
||||
media_season=media_season,
|
||||
)
|
||||
event = await eventmanager.async_send_event(
|
||||
ChainEventType.MediaRecognizeConvert, event_data
|
||||
)
|
||||
if event and event.event_data and event.event_data.media_dict:
|
||||
event_data = event.event_data
|
||||
search_id = event_data.media_dict.get("id")
|
||||
if event_data.convert_type == "themoviedb":
|
||||
return call_search(tmdbid=search_id), ""
|
||||
if event_data.convert_type == "douban":
|
||||
return call_search(doubanid=search_id), ""
|
||||
|
||||
if not title:
|
||||
return None, "未知的媒体ID"
|
||||
|
||||
meta = MetaInfo(title)
|
||||
if year:
|
||||
meta.year = year
|
||||
if media_type:
|
||||
meta.type = media_type
|
||||
if media_season:
|
||||
meta.type = MediaType.TV
|
||||
meta.begin_season = media_season
|
||||
mediainfo = await media_chain.async_recognize_by_meta(
|
||||
meta,
|
||||
obtain_images=False,
|
||||
)
|
||||
if not mediainfo:
|
||||
return None, "未识别到媒体信息"
|
||||
if settings.RECOGNIZE_SOURCE == "themoviedb":
|
||||
return call_search(tmdbid=mediainfo.tmdb_id), ""
|
||||
return call_search(doubanid=mediainfo.douban_id), ""
|
||||
if not search_params:
|
||||
return None, message
|
||||
return call_search(**search_params), ""
|
||||
|
||||
|
||||
@router.get("/subtitle/media/{mediaid}/stream", summary="渐进式精确搜索字幕")
|
||||
@@ -840,7 +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,
|
||||
@@ -869,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,
|
||||
)
|
||||
|
||||
|
||||
@@ -884,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:
|
||||
@@ -152,14 +187,8 @@ async def create_subscribe(
|
||||
title = subscribe_in.name
|
||||
else:
|
||||
title = None
|
||||
# 订阅用户
|
||||
subscribe_in.username = current_user.name
|
||||
# 转化为字典
|
||||
subscribe_dict = subscribe_in.model_dump()
|
||||
if subscribe_in.id:
|
||||
subscribe_dict.pop("id", None)
|
||||
# completed_episode 是响应派生字段,禁止写入持久层
|
||||
subscribe_dict.pop("completed_episode", None)
|
||||
subscribe_dict = subscribe_in.to_public_write_payload()
|
||||
subscribe_dict["username"] = current_user.name
|
||||
sid, message = await SubscribeChain().async_add(
|
||||
mtype=mtype,
|
||||
title=title,
|
||||
@@ -183,23 +212,14 @@ async def update_subscribe(
|
||||
subscribe = await get_accessible_subscribe(db, subscribe_in.id, current_user)
|
||||
if not subscribe:
|
||||
return schemas.Response(success=False, message="订阅不存在")
|
||||
# 避免更新缺失集数
|
||||
old_subscribe_dict = subscribe.to_dict()
|
||||
subscribe_dict = subscribe_in.model_dump()
|
||||
subscribe_dict = subscribe_in.to_public_write_payload()
|
||||
subscribe_dict["username"] = subscribe.username
|
||||
if subscribe_in.episode_priority is None:
|
||||
subscribe_dict.pop("episode_priority", None)
|
||||
# completed_episode 是响应派生字段,禁止写入持久层
|
||||
subscribe_dict.pop("completed_episode", None)
|
||||
if not subscribe_in.lack_episode:
|
||||
# 没有缺失集数时,缺失集数清空,避免更新为0
|
||||
subscribe_dict.pop("lack_episode")
|
||||
elif subscribe_in.total_episode:
|
||||
# 总集数增加时,缺失集数也要增加
|
||||
if subscribe_in.total_episode > (subscribe.total_episode or 0):
|
||||
subscribe_dict["lack_episode"] = subscribe.lack_episode + (
|
||||
subscribe_in.total_episode - (subscribe.total_episode or 0)
|
||||
)
|
||||
if subscribe_in.total_episode and subscribe_in.total_episode > (subscribe.total_episode or 0):
|
||||
# 扩大目标范围时,新增加的集数尚无下载事实,应同步计入缺失集数。
|
||||
subscribe_dict["lack_episode"] = (subscribe.lack_episode or 0) + (
|
||||
subscribe_in.total_episode - (subscribe.total_episode or 0)
|
||||
)
|
||||
# 是否手动修改过总集数
|
||||
if subscribe_in.total_episode != subscribe.total_episode:
|
||||
subscribe_dict["manual_total_episode"] = 1
|
||||
@@ -262,36 +282,12 @@ async def subscribe_mediaid(
|
||||
current_user: User = Depends(get_current_active_user_async),
|
||||
) -> Any:
|
||||
"""
|
||||
根据 TMDBID/豆瓣ID/BangumiId 查询订阅 tmdb:/douban:
|
||||
根据 TMDB、豆瓣、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)
|
||||
@@ -339,6 +335,8 @@ async def reset_subscribes(
|
||||
"lack_episode": subscribe.total_episode,
|
||||
"current_priority": None,
|
||||
"episode_priority": {},
|
||||
# 重置代表放弃手动总集数,后续订阅检查重新按 TMDB 集数更新。
|
||||
"manual_total_episode": 0,
|
||||
"state": "R",
|
||||
},
|
||||
)
|
||||
@@ -432,24 +430,9 @@ async def delete_subscribe_by_mediaid(
|
||||
current_user: User = Depends(get_current_active_user_async),
|
||||
) -> Any:
|
||||
"""
|
||||
根据TMDBID或豆瓣ID删除订阅 tmdb:/douban:
|
||||
根据任意媒体数据源 ID 删除订阅。
|
||||
"""
|
||||
delete_subscribes = []
|
||||
if mediaid.startswith("tmdb:"):
|
||||
tmdbid = mediaid[5:]
|
||||
if not tmdbid or not str(tmdbid).isdigit():
|
||||
return schemas.Response(success=False)
|
||||
subscribes = await Subscribe.async_get_by_tmdbid(db, int(tmdbid), season)
|
||||
delete_subscribes.extend(subscribes)
|
||||
elif mediaid.startswith("douban:"):
|
||||
doubanid = mediaid[7:]
|
||||
if not doubanid:
|
||||
return schemas.Response(success=False)
|
||||
subscribes = await Subscribe.async_list_by_doubanid(db, doubanid)
|
||||
delete_subscribes.extend(subscribes)
|
||||
else:
|
||||
subscribes = await Subscribe.async_list_by_mediaid(db, mediaid)
|
||||
delete_subscribes.extend(subscribes)
|
||||
delete_subscribes = await list_subscribes_by_media_key(db, mediaid, season)
|
||||
delete_events = []
|
||||
for subscribe in [
|
||||
subscribe
|
||||
@@ -637,7 +620,7 @@ async def popular_subscribes(
|
||||
# 处理标题
|
||||
title = sub.get("name")
|
||||
season = sub.get("season")
|
||||
if season and int(season) > 1 and media.tmdb_id:
|
||||
if season not in (None, "") and int(season) != 1 and media.tmdb_id:
|
||||
# 小写数据转大写
|
||||
season_str = cn2an.an2cn(season, "low")
|
||||
title = f"{title} 第{season_str}季"
|
||||
@@ -645,6 +628,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")
|
||||
@@ -871,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,
|
||||
@@ -269,15 +303,20 @@ def manual_transfer(
|
||||
)
|
||||
# 强制转移
|
||||
force = True
|
||||
downloader = history.downloader
|
||||
download_hash = history.download_hash
|
||||
# 下载器与 Hash 是同一组下载上下文,重新识别时由当前文件路径重新匹配。
|
||||
downloader = history.downloader if transer_item.from_history else None
|
||||
download_hash = history.download_hash if transer_item.from_history else None
|
||||
if history.status and ("move" in history.mode):
|
||||
# 重新整理成功的转移,则使用成功的 dest 做 in_path
|
||||
src_fileitems = [FileItem(**history.dest_fileitem)]
|
||||
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)
|
||||
|
||||
# 从历史数据获取信息
|
||||
@@ -291,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
|
||||
@@ -408,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,
|
||||
@@ -422,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,
|
||||
)
|
||||
@@ -490,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,
|
||||
@@ -504,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 []
|
||||
+148
-60
@@ -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:
|
||||
"""
|
||||
@@ -137,9 +178,23 @@ class DownloadChain(ChainBase):
|
||||
logger.warn(str(err))
|
||||
return None, None, str(err)
|
||||
if re.match(r"^[A-Za-z]:/", validated_save_path):
|
||||
return storage, Path(validated_save_path), ""
|
||||
file_uri = FileURI.from_uri(validated_save_path)
|
||||
return file_uri.storage or storage, Path(file_uri.path), ""
|
||||
target_dir = Path(validated_save_path)
|
||||
else:
|
||||
file_uri = FileURI.from_uri(validated_save_path)
|
||||
storage = file_uri.storage or storage
|
||||
target_dir = Path(file_uri.path)
|
||||
|
||||
dir_info = DirectoryHelper().get_download_dir_by_save_path(
|
||||
media=media_info,
|
||||
save_path=validated_save_path,
|
||||
)
|
||||
if dir_info:
|
||||
target_dir = DownloadChain._append_download_classification(
|
||||
root_path=target_dir,
|
||||
dir_info=dir_info,
|
||||
media_info=media_info,
|
||||
)
|
||||
return storage, target_dir, ""
|
||||
|
||||
dir_info = DirectoryHelper().get_dir(media_info, include_unsorted=True)
|
||||
storage = dir_info.storage if dir_info else storage
|
||||
@@ -147,15 +202,33 @@ class DownloadChain(ChainBase):
|
||||
logger.error(f"未找到下载目录:{media_info.type.value} {media_info.title_year}")
|
||||
return None, None, "未找到下载目录"
|
||||
|
||||
if not dir_info.media_type and dir_info.download_type_folder:
|
||||
download_dir = Path(dir_info.download_path) / media_info.type.value
|
||||
else:
|
||||
download_dir = Path(dir_info.download_path)
|
||||
download_dir = DownloadChain._append_download_classification(
|
||||
root_path=Path(dir_info.download_path),
|
||||
dir_info=dir_info,
|
||||
media_info=media_info,
|
||||
)
|
||||
return storage, download_dir, ""
|
||||
|
||||
@staticmethod
|
||||
def _append_download_classification(
|
||||
root_path: Path,
|
||||
dir_info: schemas.TransferDirectoryConf,
|
||||
media_info: MediaInfo,
|
||||
) -> Path:
|
||||
"""
|
||||
按下载目录配置拼装媒体类型和类别子目录。
|
||||
|
||||
:param root_path: 下载根目录
|
||||
:param dir_info: 下载目录配置
|
||||
:param media_info: 媒体信息
|
||||
:return: 应传给存储或下载器的媒体下载目录
|
||||
"""
|
||||
download_dir = root_path
|
||||
if not dir_info.media_type and dir_info.download_type_folder:
|
||||
download_dir = download_dir / media_info.type.value
|
||||
if not dir_info.media_category and dir_info.download_category_folder and media_info.category:
|
||||
download_dir = download_dir / media_info.category
|
||||
|
||||
return storage, download_dir, ""
|
||||
return download_dir
|
||||
|
||||
@staticmethod
|
||||
def _upload_subtitle_file(
|
||||
@@ -292,17 +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: 成功状态、提示消息、保存文件列表
|
||||
@@ -313,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,
|
||||
@@ -447,19 +533,23 @@ class DownloadChain(ChainBase):
|
||||
return None
|
||||
|
||||
media_type = getattr(getattr(media, "type", None), "value", getattr(media, "type", None))
|
||||
media_source, media_id = resolve_media_identity(media=media)
|
||||
media_key = (
|
||||
getattr(media, "tmdb_id", None)
|
||||
or getattr(media, "douban_id", None)
|
||||
or getattr(media, "imdb_id", None)
|
||||
f"{media_source}:{media_id}"
|
||||
if media_source and media_id
|
||||
else getattr(media, "imdb_id", None)
|
||||
or getattr(media, "tvdb_id", None)
|
||||
or f"{getattr(media, 'title', '')}:{getattr(media, 'year', '')}"
|
||||
)
|
||||
meta = getattr(context, "meta_info", None)
|
||||
site = getattr(torrent, "site", None) or getattr(torrent, "site_name", None)
|
||||
meta_season = getattr(meta, "season", None)
|
||||
media_season = getattr(media, "season", None)
|
||||
season = meta_season if meta_season is not None else media_season
|
||||
payload = {
|
||||
"media_type": str(media_type or ""),
|
||||
"media_key": str(media_key or ""),
|
||||
"season": str(getattr(meta, "season", None) or getattr(media, "season", None) or ""),
|
||||
"season": str(season) if season is not None else "",
|
||||
"episodes": cls._format_failure_episodes(meta) or "",
|
||||
"site": str(site or ""),
|
||||
"resource": cls._torrent_resource_key(torrent),
|
||||
@@ -501,6 +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)
|
||||
@@ -514,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,
|
||||
@@ -622,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
|
||||
|
||||
@@ -633,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,
|
||||
@@ -643,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,
|
||||
@@ -705,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,
|
||||
@@ -740,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:
|
||||
@@ -785,36 +882,17 @@ class DownloadChain(ChainBase):
|
||||
# 获取种子文件的文件夹名和文件清单
|
||||
_folder_name, _file_list = TorrentHelper().get_fileinfo_from_torrent_content(torrent_content)
|
||||
|
||||
storage = 'local'
|
||||
# 下载目录
|
||||
if save_path is not None:
|
||||
download_dir = Path(save_path)
|
||||
else:
|
||||
# 根据媒体信息查询下载目录配置
|
||||
dir_info = DirectoryHelper().get_dir(_media, include_unsorted=True)
|
||||
storage = dir_info.storage if dir_info else storage
|
||||
# 拼装子目录
|
||||
if dir_info:
|
||||
# 一级目录
|
||||
if not dir_info.media_type and dir_info.download_type_folder:
|
||||
# 一级自动分类
|
||||
download_dir = Path(dir_info.download_path) / _media.type.value
|
||||
else:
|
||||
# 一级不分类
|
||||
download_dir = Path(dir_info.download_path)
|
||||
|
||||
# 二级目录
|
||||
if not dir_info.media_category and dir_info.download_category_folder and _media and _media.category:
|
||||
# 二级自动分类
|
||||
download_dir = download_dir / _media.category
|
||||
else:
|
||||
# 未找到下载目录,且没有自定义下载目录
|
||||
logger.error(f"未找到下载目录:{_media.type.value} {_media.title_year}")
|
||||
storage, download_dir, error_msg = self._resolve_media_download_dir(
|
||||
media_info=_media,
|
||||
save_path=save_path,
|
||||
)
|
||||
if not download_dir:
|
||||
if error_msg == "未找到下载目录":
|
||||
self.messagehelper.put(f"{_media.type.value} {_media.title_year} 未找到下载目录!",
|
||||
title="下载失败", role="system")
|
||||
return (None, "未找到下载目录") if return_detail else None
|
||||
fileURI = FileURI(storage=storage, path=download_dir.as_posix())
|
||||
download_dir = Path(fileURI.uri)
|
||||
return (None, error_msg or "未找到下载目录") if return_detail else None
|
||||
file_uri = FileURI(storage=storage, path=download_dir.as_posix())
|
||||
download_dir = Path(file_uri.uri)
|
||||
|
||||
# 添加下载
|
||||
result: Optional[tuple] = self.download(content=torrent_content,
|
||||
@@ -845,6 +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,
|
||||
@@ -854,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(),
|
||||
@@ -1164,7 +1247,7 @@ class DownloadChain(ChainBase):
|
||||
if not tv.episodes:
|
||||
if not need_seasons.get(need_mid):
|
||||
need_seasons[need_mid] = []
|
||||
need_seasons[need_mid].append(tv.season or 1)
|
||||
need_seasons[need_mid].append(tv.season if tv.season is not None else 1)
|
||||
logger.info(f"缺失整季:{need_seasons}")
|
||||
# 查找整季包含的种子,只处理整季没集的种子或者是集数超过季的种子
|
||||
for need_mid, need_season in need_seasons.items():
|
||||
@@ -1190,7 +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
|
||||
@@ -1321,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
|
||||
@@ -1423,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 \
|
||||
@@ -1501,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):
|
||||
"""
|
||||
@@ -1512,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(
|
||||
@@ -1553,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"媒体信息识别失败!")
|
||||
|
||||
+203
-19
@@ -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,
|
||||
)
|
||||
|
||||
@@ -653,11 +778,22 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
self.obtain_images(mediainfo=mediainfo)
|
||||
return mediainfo
|
||||
|
||||
@staticmethod
|
||||
def _parse_recognize_event_number(value) -> Optional[int]:
|
||||
"""
|
||||
解析辅助识别返回的季集号,兼容整数和数字字符串并保留数值 0。
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return int(text) if text.isdigit() else None
|
||||
|
||||
def recognize_help(
|
||||
self,
|
||||
title: str,
|
||||
org_meta: MetaBase,
|
||||
share_meta: MetaBase = None,
|
||||
source: Optional[str] = None,
|
||||
episode_group: Optional[str] = None,
|
||||
) -> Optional[MediaInfo]:
|
||||
"""
|
||||
@@ -666,6 +802,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
:param title: 标题
|
||||
:param org_meta: 原始元数据
|
||||
:param share_meta: 共享识别查询/上报使用的原始元数据
|
||||
:param source: 请求级识别数据源
|
||||
:param episode_group: 剧集组
|
||||
"""
|
||||
# 发送请求事件,等待结果
|
||||
@@ -686,10 +823,8 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
title = str(event_data["name"]).split("/")[0].strip().replace(".", " ")
|
||||
if event_data.get("year"):
|
||||
year = str(event_data["year"]).split("/")[0].strip()
|
||||
if event_data.get("season") and str(event_data["season"]).isdigit():
|
||||
season_number = int(event_data["season"])
|
||||
if event_data.get("episode") and str(event_data["episode"]).isdigit():
|
||||
episode_number = int(event_data["episode"])
|
||||
season_number = self._parse_recognize_event_number(event_data.get("season"))
|
||||
episode_number = self._parse_recognize_event_number(event_data.get("episode"))
|
||||
if not title:
|
||||
return None
|
||||
if title == "Unknown":
|
||||
@@ -710,6 +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,
|
||||
)
|
||||
@@ -717,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)
|
||||
@@ -729,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,
|
||||
)
|
||||
@@ -738,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: 识别元数据,媒体信息列表
|
||||
"""
|
||||
# 提取要素
|
||||
@@ -764,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, []
|
||||
@@ -837,7 +984,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
tmdbinfo = self._match_tmdb_with_names(
|
||||
meta_names=meta_names,
|
||||
year=year,
|
||||
mtype=MediaType.TV,
|
||||
mtype=MediaInfo.get_bangumi_media_type(bangumiinfo),
|
||||
season=meta.begin_season,
|
||||
)
|
||||
return tmdbinfo
|
||||
@@ -877,7 +1024,10 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
year = self._extract_year_from_bangumi(bangumiinfo)
|
||||
# 使用名称识别豆瓣媒体信息
|
||||
return self.match_doubaninfo(
|
||||
name=meta.name, year=year, mtype=MediaType.TV, season=meta.begin_season
|
||||
name=meta.name,
|
||||
year=year,
|
||||
mtype=MediaInfo.get_bangumi_media_type(bangumiinfo),
|
||||
season=meta.begin_season,
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -1542,14 +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,
|
||||
)
|
||||
@@ -1560,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,
|
||||
)
|
||||
|
||||
@@ -1607,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]:
|
||||
"""
|
||||
@@ -1615,6 +1785,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
:param title: 标题
|
||||
:param org_meta: 原始元数据
|
||||
:param share_meta: 共享识别查询/上报使用的原始元数据
|
||||
:param source: 请求级识别数据源
|
||||
:param episode_group: 剧集组
|
||||
"""
|
||||
# 发送请求事件,等待结果
|
||||
@@ -1635,10 +1806,8 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
title = str(event_data["name"]).split("/")[0].strip().replace(".", " ")
|
||||
if event_data.get("year"):
|
||||
year = str(event_data["year"]).split("/")[0].strip()
|
||||
if event_data.get("season") and str(event_data["season"]).isdigit():
|
||||
season_number = int(event_data["season"])
|
||||
if event_data.get("episode") and str(event_data["episode"]).isdigit():
|
||||
episode_number = int(event_data["episode"])
|
||||
season_number = self._parse_recognize_event_number(event_data.get("season"))
|
||||
episode_number = self._parse_recognize_event_number(event_data.get("episode"))
|
||||
if not title:
|
||||
return None
|
||||
if title == "Unknown":
|
||||
@@ -1654,11 +1823,12 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
org_meta.year = year
|
||||
org_meta.begin_season = season_number
|
||||
org_meta.begin_episode = episode_number
|
||||
if org_meta.begin_season or org_meta.begin_episode:
|
||||
if org_meta.begin_season is not None or org_meta.begin_episode is not None:
|
||||
org_meta.type = MediaType.TV
|
||||
# 重新识别
|
||||
return await self.async_recognize_media(
|
||||
meta=org_meta,
|
||||
source=source,
|
||||
share_meta=share_meta,
|
||||
episode_group=episode_group,
|
||||
)
|
||||
@@ -1666,11 +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)
|
||||
@@ -1678,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,
|
||||
)
|
||||
@@ -1688,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: 识别元数据,媒体信息列表
|
||||
"""
|
||||
# 提取要素
|
||||
@@ -1715,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, []
|
||||
@@ -1855,7 +2036,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
tmdbinfo = await self._async_match_tmdb_with_names(
|
||||
meta_names=meta_names,
|
||||
year=year,
|
||||
mtype=MediaType.TV,
|
||||
mtype=MediaInfo.get_bangumi_media_type(bangumiinfo),
|
||||
season=meta.begin_season,
|
||||
)
|
||||
return tmdbinfo
|
||||
@@ -1895,6 +2076,9 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
year = self._extract_year_from_bangumi(bangumiinfo)
|
||||
# 使用名称识别豆瓣媒体信息
|
||||
return await self.async_match_doubaninfo(
|
||||
name=meta.name, year=year, mtype=MediaType.TV, season=meta.begin_season
|
||||
name=meta.name,
|
||||
year=year,
|
||||
mtype=MediaInfo.get_bangumi_media_type(bangumiinfo),
|
||||
season=meta.begin_season,
|
||||
)
|
||||
return None
|
||||
|
||||
+55
-15
@@ -1,6 +1,6 @@
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from typing import Callable, List, Union, Optional, Generator, Any
|
||||
from typing import Callable, Dict, List, Union, Optional, Generator, Any
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.core.config import global_vars
|
||||
@@ -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]]:
|
||||
"""
|
||||
获取媒体服务器最新入库条目
|
||||
"""
|
||||
@@ -210,6 +214,24 @@ class MediaServerChain(ChainBase):
|
||||
"""
|
||||
return self.run_module("mediaserver_play_url", server=server, item_id=item_id)
|
||||
|
||||
def get_season_episode_ids(self, server: str, item_id: Union[str, int],
|
||||
season: int) -> Dict[int, str]:
|
||||
"""
|
||||
获取指定季的集号到媒体服务器条目 ID 映射
|
||||
|
||||
:param server: 媒体服务器名称
|
||||
:param item_id: 剧集在媒体服务器中的条目 ID
|
||||
:param season: 季号
|
||||
:return: 集号到条目 ID 的映射,无数据时返回空字典
|
||||
"""
|
||||
result = self.run_module(
|
||||
"mediaserver_season_episode_ids",
|
||||
server=server,
|
||||
item_id=item_id,
|
||||
season=season,
|
||||
)
|
||||
return result or {}
|
||||
|
||||
def get_image_cookies(
|
||||
self, server: Optional[str], image_url: str
|
||||
) -> Optional[str | dict]:
|
||||
@@ -220,11 +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()
|
||||
@@ -239,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,
|
||||
@@ -248,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 = {}
|
||||
|
||||
+10
-3
@@ -42,6 +42,7 @@ from app.schemas.message import ChannelCapabilityManager, ChannelCapability
|
||||
from app.schemas.system import TransferDirectoryConf
|
||||
from app.schemas.types import EventType, MessageChannel, MediaType
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.media import build_media_key, resolve_media_identity
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
@@ -2071,6 +2072,10 @@ class MediaInteractionChain(ChainBase):
|
||||
mtype=mediainfo.type,
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
doubanid=mediainfo.douban_id,
|
||||
bangumiid=mediainfo.bangumi_id,
|
||||
anilistid=mediainfo.anilist_id,
|
||||
source=resolve_media_identity(media=mediainfo)[0],
|
||||
mediaid=resolve_media_identity(media=mediainfo)[1],
|
||||
cache=False,
|
||||
)
|
||||
if not mediainfo:
|
||||
@@ -2085,9 +2090,10 @@ class MediaInteractionChain(ChainBase):
|
||||
)
|
||||
return {}
|
||||
|
||||
mediakey = mediainfo.tmdb_id or mediainfo.douban_id
|
||||
media_source, media_id = resolve_media_identity(media=mediainfo)
|
||||
mediakey = build_media_key(media_source, media_id)
|
||||
no_exists = {mediakey: {}}
|
||||
if meta.begin_season:
|
||||
if meta.begin_season is not None:
|
||||
episodes = mediainfo.seasons.get(meta.begin_season)
|
||||
if not episodes:
|
||||
return {}
|
||||
@@ -3528,7 +3534,8 @@ class MediaInteractionChain(ChainBase):
|
||||
"""
|
||||
if not no_exists:
|
||||
return []
|
||||
mediakey = mediainfo.tmdb_id or mediainfo.douban_id
|
||||
media_source, media_id = resolve_media_identity(media=mediainfo)
|
||||
mediakey = build_media_key(media_source, media_id)
|
||||
season_map = no_exists.get(mediakey) or {}
|
||||
if show_missing_only:
|
||||
return [
|
||||
|
||||
+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)
|
||||
|
||||
+191
-62
@@ -24,6 +24,7 @@ from app.helper.torrent import TorrentHelper
|
||||
from app.log import logger
|
||||
from app.schemas import NotExistMediaInfo
|
||||
from app.schemas.types import MediaType, ProgressKey, SystemConfigKey, EventType
|
||||
from app.utils.media import build_media_key, resolve_media_identity
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
@@ -171,16 +172,38 @@ class SearchChain(ChainBase):
|
||||
|
||||
@staticmethod
|
||||
def _build_search_keyword(
|
||||
tmdbid: Optional[int] = None, doubanid: Optional[str] = None
|
||||
source: Optional[str] = None,
|
||||
mediaid: Optional[str] = None,
|
||||
tmdbid: Optional[int] = None,
|
||||
doubanid: Optional[str] = None,
|
||||
bangumiid: Optional[int] = None,
|
||||
anilistid: Optional[int] = None,
|
||||
) -> str:
|
||||
"""
|
||||
根据媒体ID生成可重放的搜索关键字。
|
||||
"""
|
||||
if tmdbid is not None:
|
||||
return f"tmdb:{tmdbid}"
|
||||
if doubanid:
|
||||
return f"douban:{doubanid}"
|
||||
return ""
|
||||
media_source, media_id = resolve_media_identity(
|
||||
source=source,
|
||||
media_id=mediaid,
|
||||
tmdbid=tmdbid,
|
||||
doubanid=doubanid,
|
||||
bangumiid=bangumiid,
|
||||
anilistid=anilistid,
|
||||
)
|
||||
return build_media_key(media_source, media_id)
|
||||
|
||||
@staticmethod
|
||||
def _media_recognize_kwargs(mediainfo: MediaInfo) -> dict:
|
||||
"""从统一媒体信息构造完整的识别 ID 参数。"""
|
||||
media_source, media_id = resolve_media_identity(media=mediainfo)
|
||||
return {
|
||||
"source": media_source,
|
||||
"mediaid": media_id,
|
||||
"tmdbid": mediainfo.tmdb_id,
|
||||
"doubanid": mediainfo.douban_id,
|
||||
"bangumiid": mediainfo.bangumi_id,
|
||||
"anilistid": mediainfo.anilist_id,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _stringify_sites(sites: Optional[List[int]]) -> str:
|
||||
@@ -203,7 +226,7 @@ class SearchChain(ChainBase):
|
||||
"area": str(params.get("area") or ""),
|
||||
"title": str(params.get("title") or ""),
|
||||
"year": str(params.get("year") or ""),
|
||||
"season": str(params.get("season") or ""),
|
||||
"season": str(params["season"]) if params.get("season") is not None else "",
|
||||
"episode": str(params.get("episode") or ""),
|
||||
"sites": str(params.get("sites") or ""),
|
||||
"result_type": str(params.get("result_type") or "torrent"),
|
||||
@@ -488,13 +511,22 @@ class SearchChain(ChainBase):
|
||||
|
||||
state._ai_recommend_task = asyncio.create_task(run_recommend())
|
||||
|
||||
def search_by_id(self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
|
||||
mtype: MediaType = None, area: Optional[str] = "title", season: Optional[int] = None,
|
||||
sites: List[int] = None, cache_local: bool = False) -> List[Context]:
|
||||
def search_by_id(
|
||||
self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
|
||||
mtype: MediaType = None, area: Optional[str] = "title",
|
||||
season: Optional[int] = None, sites: List[int] = None,
|
||||
cache_local: bool = False,
|
||||
bangumiid: Optional[int] = None, anilistid: Optional[int] = None,
|
||||
source: Optional[str] = None, mediaid: Optional[str] = None,
|
||||
) -> List[Context]:
|
||||
"""
|
||||
根据TMDBID/豆瓣ID搜索资源,精确匹配,不过滤本地存在的资源
|
||||
根据数据源媒体 ID 搜索资源,精确匹配,不过滤本地存在的资源
|
||||
:param tmdbid: TMDB ID
|
||||
:param doubanid: 豆瓣 ID
|
||||
:param bangumiid: Bangumi ID
|
||||
:param anilistid: AniList ID
|
||||
:param source: 媒体数据源
|
||||
:param mediaid: 数据源原生 ID
|
||||
:param mtype: 媒体,电影 or 电视剧
|
||||
:param area: 搜索范围,title or imdbid
|
||||
:param season: 季数
|
||||
@@ -504,20 +536,26 @@ class SearchChain(ChainBase):
|
||||
if cache_local:
|
||||
self.cancel_ai_recommend()
|
||||
self.save_last_search_params(
|
||||
keyword=self._build_search_keyword(tmdbid=tmdbid, doubanid=doubanid),
|
||||
keyword=self._build_search_keyword(
|
||||
source, mediaid, tmdbid, doubanid, bangumiid, anilistid
|
||||
),
|
||||
mtype=mtype,
|
||||
area=area,
|
||||
season=season,
|
||||
sites=sites,
|
||||
)
|
||||
mediainfo = self.recognize_media(tmdbid=tmdbid, doubanid=doubanid, mtype=mtype)
|
||||
mediainfo = self.recognize_media(
|
||||
source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid,
|
||||
bangumiid=bangumiid, anilistid=anilistid, mtype=mtype,
|
||||
)
|
||||
if not mediainfo:
|
||||
logger.error(f'{tmdbid} 媒体信息识别失败!')
|
||||
logger.error(f'{self._build_search_keyword(source, mediaid, tmdbid, doubanid, bangumiid, anilistid)} 媒体信息识别失败!')
|
||||
return []
|
||||
no_exists = None
|
||||
if season is not None:
|
||||
media_source, media_id = resolve_media_identity(media=mediainfo)
|
||||
no_exists = {
|
||||
tmdbid or doubanid: {
|
||||
build_media_key(media_source, media_id): {
|
||||
season: NotExistMediaInfo(episodes=[])
|
||||
}
|
||||
}
|
||||
@@ -658,14 +696,22 @@ class SearchChain(ChainBase):
|
||||
"total_items": len(subtitles)
|
||||
}
|
||||
|
||||
async def async_search_subtitles_by_id(self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
|
||||
mtype: MediaType = None, season: Optional[int] = None,
|
||||
episode: Optional[int] = None, sites: List[int] = None,
|
||||
cache_local: bool = False) -> List[SubtitleInfo]:
|
||||
async def async_search_subtitles_by_id(
|
||||
self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
|
||||
mtype: MediaType = None, season: Optional[int] = None,
|
||||
episode: Optional[int] = None, sites: List[int] = None,
|
||||
cache_local: bool = False,
|
||||
bangumiid: Optional[int] = None, anilistid: Optional[int] = None,
|
||||
source: Optional[str] = None, mediaid: Optional[str] = None,
|
||||
) -> List[SubtitleInfo]:
|
||||
"""
|
||||
根据TMDBID/豆瓣ID异步精确搜索字幕,不应用过滤规则。
|
||||
根据数据源媒体 ID 异步精确搜索字幕,不应用过滤规则。
|
||||
:param tmdbid: TMDB ID
|
||||
:param doubanid: 豆瓣 ID
|
||||
:param bangumiid: Bangumi ID
|
||||
:param anilistid: AniList ID
|
||||
:param source: 媒体数据源
|
||||
:param mediaid: 数据源原生 ID
|
||||
:param mtype: 媒体,电影 or 电视剧
|
||||
:param season: 季数
|
||||
:param episode: 集数
|
||||
@@ -675,7 +721,9 @@ class SearchChain(ChainBase):
|
||||
if cache_local:
|
||||
self.cancel_ai_recommend()
|
||||
await self.async_save_last_search_params(
|
||||
keyword=self._build_search_keyword(tmdbid=tmdbid, doubanid=doubanid),
|
||||
keyword=self._build_search_keyword(
|
||||
source, mediaid, tmdbid, doubanid, bangumiid, anilistid
|
||||
),
|
||||
mtype=mtype,
|
||||
area="title",
|
||||
season=season,
|
||||
@@ -683,14 +731,24 @@ class SearchChain(ChainBase):
|
||||
sites=sites,
|
||||
result_type="subtitle",
|
||||
)
|
||||
mediainfo = await self.async_recognize_media(tmdbid=tmdbid, doubanid=doubanid, mtype=mtype)
|
||||
mediainfo = await self.async_recognize_media(
|
||||
source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid,
|
||||
bangumiid=bangumiid, anilistid=anilistid, mtype=mtype,
|
||||
)
|
||||
if not mediainfo:
|
||||
logger.error(f'{tmdbid} 媒体信息识别失败!')
|
||||
logger.error(
|
||||
f'{self._build_search_keyword(source, mediaid, tmdbid, doubanid, bangumiid, anilistid)} '
|
||||
'媒体信息识别失败!'
|
||||
)
|
||||
return []
|
||||
subtitles = await self.__async_search_subtitles_for_media(
|
||||
mediainfo=mediainfo,
|
||||
tmdbid=tmdbid,
|
||||
doubanid=doubanid,
|
||||
bangumiid=bangumiid,
|
||||
anilistid=anilistid,
|
||||
source=source,
|
||||
mediaid=mediaid,
|
||||
season=season,
|
||||
episode=episode,
|
||||
sites=sites,
|
||||
@@ -708,14 +766,20 @@ class SearchChain(ChainBase):
|
||||
episode: Optional[int] = None,
|
||||
sites: List[int] = None,
|
||||
cache_local: bool = False,
|
||||
bangumiid: Optional[int] = None,
|
||||
anilistid: Optional[int] = None,
|
||||
source: Optional[str] = None,
|
||||
mediaid: Optional[str] = None,
|
||||
) -> AsyncIterator[dict]:
|
||||
"""
|
||||
根据TMDBID/豆瓣ID渐进式精确搜索字幕,先返回站点候选,再返回标题和剧集匹配后的结果。
|
||||
根据数据源媒体 ID 渐进式精确搜索字幕,先返回站点候选,再返回标题和剧集匹配后的结果。
|
||||
"""
|
||||
if cache_local:
|
||||
self.cancel_ai_recommend()
|
||||
await self.async_save_last_search_params(
|
||||
keyword=self._build_search_keyword(tmdbid=tmdbid, doubanid=doubanid),
|
||||
keyword=self._build_search_keyword(
|
||||
source, mediaid, tmdbid, doubanid, bangumiid, anilistid
|
||||
),
|
||||
mtype=mtype,
|
||||
area="title",
|
||||
season=season,
|
||||
@@ -723,9 +787,15 @@ class SearchChain(ChainBase):
|
||||
sites=sites,
|
||||
result_type="subtitle",
|
||||
)
|
||||
mediainfo = await self.async_recognize_media(tmdbid=tmdbid, doubanid=doubanid, mtype=mtype)
|
||||
mediainfo = await self.async_recognize_media(
|
||||
source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid,
|
||||
bangumiid=bangumiid, anilistid=anilistid, mtype=mtype,
|
||||
)
|
||||
if not mediainfo:
|
||||
logger.error(f'{tmdbid} 媒体信息识别失败!')
|
||||
logger.error(
|
||||
f'{self._build_search_keyword(source, mediaid, tmdbid, doubanid, bangumiid, anilistid)} '
|
||||
'媒体信息识别失败!'
|
||||
)
|
||||
yield {
|
||||
"type": "error",
|
||||
"success": False,
|
||||
@@ -738,6 +808,10 @@ class SearchChain(ChainBase):
|
||||
mediainfo=mediainfo,
|
||||
tmdbid=tmdbid,
|
||||
doubanid=doubanid,
|
||||
bangumiid=bangumiid,
|
||||
anilistid=anilistid,
|
||||
source=source,
|
||||
mediaid=mediaid,
|
||||
season=season,
|
||||
episode=episode,
|
||||
sites=sites):
|
||||
@@ -753,13 +827,22 @@ class SearchChain(ChainBase):
|
||||
if cache_local:
|
||||
await self.async_save_cache(subtitles, self.__subtitle_result_temp_file)
|
||||
|
||||
async def async_search_by_id(self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
|
||||
mtype: MediaType = None, area: Optional[str] = "title", season: Optional[int] = None,
|
||||
sites: List[int] = None, cache_local: bool = False) -> List[Context]:
|
||||
async def async_search_by_id(
|
||||
self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
|
||||
mtype: MediaType = None, area: Optional[str] = "title",
|
||||
season: Optional[int] = None, sites: List[int] = None,
|
||||
cache_local: bool = False,
|
||||
bangumiid: Optional[int] = None, anilistid: Optional[int] = None,
|
||||
source: Optional[str] = None, mediaid: Optional[str] = None,
|
||||
) -> List[Context]:
|
||||
"""
|
||||
根据TMDBID/豆瓣ID异步搜索资源,精确匹配,不过滤本地存在的资源
|
||||
根据数据源媒体 ID 异步搜索资源,精确匹配,不过滤本地存在的资源
|
||||
:param tmdbid: TMDB ID
|
||||
:param doubanid: 豆瓣 ID
|
||||
:param bangumiid: Bangumi ID
|
||||
:param anilistid: AniList ID
|
||||
:param source: 媒体数据源
|
||||
:param mediaid: 数据源原生 ID
|
||||
:param mtype: 媒体,电影 or 电视剧
|
||||
:param area: 搜索范围,title or imdbid
|
||||
:param season: 季数
|
||||
@@ -769,20 +852,29 @@ class SearchChain(ChainBase):
|
||||
if cache_local:
|
||||
self.cancel_ai_recommend()
|
||||
await self.async_save_last_search_params(
|
||||
keyword=self._build_search_keyword(tmdbid=tmdbid, doubanid=doubanid),
|
||||
keyword=self._build_search_keyword(
|
||||
source, mediaid, tmdbid, doubanid, bangumiid, anilistid
|
||||
),
|
||||
mtype=mtype,
|
||||
area=area,
|
||||
season=season,
|
||||
sites=sites,
|
||||
)
|
||||
mediainfo = await self.async_recognize_media(tmdbid=tmdbid, doubanid=doubanid, mtype=mtype)
|
||||
mediainfo = await self.async_recognize_media(
|
||||
source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid,
|
||||
bangumiid=bangumiid, anilistid=anilistid, mtype=mtype,
|
||||
)
|
||||
if not mediainfo:
|
||||
logger.error(f'{tmdbid} 媒体信息识别失败!')
|
||||
logger.error(
|
||||
f'{self._build_search_keyword(source, mediaid, tmdbid, doubanid, bangumiid, anilistid)} '
|
||||
'媒体信息识别失败!'
|
||||
)
|
||||
return []
|
||||
no_exists = None
|
||||
if season is not None:
|
||||
media_source, media_id = resolve_media_identity(media=mediainfo)
|
||||
no_exists = {
|
||||
tmdbid or doubanid: {
|
||||
build_media_key(media_source, media_id): {
|
||||
season: NotExistMediaInfo(episodes=[])
|
||||
}
|
||||
}
|
||||
@@ -913,25 +1005,37 @@ class SearchChain(ChainBase):
|
||||
logger.info(f'标题搜索过滤完成,剩余 {len(filtered_torrents)} 个资源')
|
||||
return filtered_torrents
|
||||
|
||||
async def async_search_by_id_stream(self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
|
||||
mtype: MediaType = None, area: Optional[str] = "title",
|
||||
season: Optional[int] = None, sites: List[int] = None,
|
||||
cache_local: bool = False) -> AsyncIterator[dict]:
|
||||
async def async_search_by_id_stream(
|
||||
self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
|
||||
mtype: MediaType = None, area: Optional[str] = "title",
|
||||
season: Optional[int] = None, sites: List[int] = None,
|
||||
cache_local: bool = False,
|
||||
bangumiid: Optional[int] = None, anilistid: Optional[int] = None,
|
||||
source: Optional[str] = None, mediaid: Optional[str] = None,
|
||||
) -> AsyncIterator[dict]:
|
||||
"""
|
||||
根据TMDBID/豆瓣ID渐进式搜索资源,先返回站点原始候选,再返回过滤匹配后的最终结果
|
||||
根据数据源媒体 ID 渐进式搜索资源,先返回站点原始候选,再返回过滤匹配后的最终结果
|
||||
"""
|
||||
if cache_local:
|
||||
self.cancel_ai_recommend()
|
||||
await self.async_save_last_search_params(
|
||||
keyword=self._build_search_keyword(tmdbid=tmdbid, doubanid=doubanid),
|
||||
keyword=self._build_search_keyword(
|
||||
source, mediaid, tmdbid, doubanid, bangumiid, anilistid
|
||||
),
|
||||
mtype=mtype,
|
||||
area=area,
|
||||
season=season,
|
||||
sites=sites,
|
||||
)
|
||||
mediainfo = await self.async_recognize_media(tmdbid=tmdbid, doubanid=doubanid, mtype=mtype)
|
||||
mediainfo = await self.async_recognize_media(
|
||||
source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid,
|
||||
bangumiid=bangumiid, anilistid=anilistid, mtype=mtype,
|
||||
)
|
||||
if not mediainfo:
|
||||
logger.error(f'{tmdbid} 媒体信息识别失败!')
|
||||
logger.error(
|
||||
f'{self._build_search_keyword(source, mediaid, tmdbid, doubanid, bangumiid, anilistid)} '
|
||||
'媒体信息识别失败!'
|
||||
)
|
||||
yield {
|
||||
"type": "error",
|
||||
"success": False,
|
||||
@@ -941,8 +1045,9 @@ class SearchChain(ChainBase):
|
||||
|
||||
no_exists = None
|
||||
if season is not None:
|
||||
media_source, media_id = resolve_media_identity(media=mediainfo)
|
||||
no_exists = {
|
||||
tmdbid or doubanid: {
|
||||
build_media_key(media_source, media_id): {
|
||||
season: NotExistMediaInfo(episodes=[])
|
||||
}
|
||||
}
|
||||
@@ -970,7 +1075,8 @@ class SearchChain(ChainBase):
|
||||
准备搜索参数
|
||||
"""
|
||||
# 缺失的季集
|
||||
mediakey = mediainfo.tmdb_id or mediainfo.douban_id
|
||||
media_source, media_id = resolve_media_identity(media=mediainfo)
|
||||
mediakey = build_media_key(media_source, media_id)
|
||||
if no_exists and no_exists.get(mediakey):
|
||||
# 过滤剧集
|
||||
season_episodes = {sea: info.episodes
|
||||
@@ -1230,9 +1336,10 @@ class SearchChain(ChainBase):
|
||||
|
||||
# 补充媒体信息
|
||||
if not mediainfo.names:
|
||||
mediainfo: MediaInfo = self.recognize_media(mtype=mediainfo.type,
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
doubanid=mediainfo.douban_id)
|
||||
mediainfo: MediaInfo = self.recognize_media(
|
||||
mtype=mediainfo.type,
|
||||
**self._media_recognize_kwargs(mediainfo),
|
||||
)
|
||||
if not mediainfo:
|
||||
logger.error(f'媒体信息识别失败!')
|
||||
return []
|
||||
@@ -1313,9 +1420,10 @@ class SearchChain(ChainBase):
|
||||
|
||||
# 补充媒体信息
|
||||
if not mediainfo.names:
|
||||
mediainfo: MediaInfo = await self.async_recognize_media(mtype=mediainfo.type,
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
doubanid=mediainfo.douban_id)
|
||||
mediainfo: MediaInfo = await self.async_recognize_media(
|
||||
mtype=mediainfo.type,
|
||||
**self._media_recognize_kwargs(mediainfo),
|
||||
)
|
||||
if not mediainfo:
|
||||
logger.error(f'媒体信息识别失败!')
|
||||
return []
|
||||
@@ -1385,9 +1493,10 @@ class SearchChain(ChainBase):
|
||||
|
||||
# 补充媒体信息
|
||||
if not mediainfo.names:
|
||||
mediainfo = await self.async_recognize_media(mtype=mediainfo.type,
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
doubanid=mediainfo.douban_id)
|
||||
mediainfo = await self.async_recognize_media(
|
||||
mtype=mediainfo.type,
|
||||
**self._media_recognize_kwargs(mediainfo),
|
||||
)
|
||||
if not mediainfo:
|
||||
logger.error(f'媒体信息识别失败!')
|
||||
yield {
|
||||
@@ -1619,6 +1728,10 @@ class SearchChain(ChainBase):
|
||||
mediainfo: MediaInfo,
|
||||
tmdbid: Optional[int] = None,
|
||||
doubanid: Optional[str] = None,
|
||||
bangumiid: Optional[int] = None,
|
||||
anilistid: Optional[int] = None,
|
||||
source: Optional[str] = None,
|
||||
mediaid: Optional[str] = None,
|
||||
season: Optional[int] = None,
|
||||
episode: Optional[int] = None,
|
||||
sites: List[int] = None,
|
||||
@@ -1633,17 +1746,23 @@ class SearchChain(ChainBase):
|
||||
logger.info(f'开始精确搜索字幕,关键词:{mediainfo.title} ...')
|
||||
|
||||
if not mediainfo.names:
|
||||
mediainfo = await self.async_recognize_media(mtype=mediainfo.type,
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
doubanid=mediainfo.douban_id)
|
||||
mediainfo = await self.async_recognize_media(
|
||||
mtype=mediainfo.type,
|
||||
**self._media_recognize_kwargs(mediainfo),
|
||||
)
|
||||
if not mediainfo:
|
||||
logger.error('媒体信息识别失败!')
|
||||
return []
|
||||
|
||||
no_exists = None
|
||||
if season is not None:
|
||||
media_source, media_id = resolve_media_identity(
|
||||
media=mediainfo, source=source, media_id=mediaid,
|
||||
tmdbid=tmdbid, doubanid=doubanid,
|
||||
bangumiid=bangumiid, anilistid=anilistid,
|
||||
)
|
||||
no_exists = {
|
||||
tmdbid or doubanid: {
|
||||
build_media_key(media_source, media_id): {
|
||||
season: NotExistMediaInfo(episodes=[episode] if episode is not None else [])
|
||||
}
|
||||
}
|
||||
@@ -1689,6 +1808,10 @@ class SearchChain(ChainBase):
|
||||
mediainfo: MediaInfo,
|
||||
tmdbid: Optional[int] = None,
|
||||
doubanid: Optional[str] = None,
|
||||
bangumiid: Optional[int] = None,
|
||||
anilistid: Optional[int] = None,
|
||||
source: Optional[str] = None,
|
||||
mediaid: Optional[str] = None,
|
||||
season: Optional[int] = None,
|
||||
episode: Optional[int] = None,
|
||||
sites: List[int] = None,
|
||||
@@ -1704,9 +1827,10 @@ class SearchChain(ChainBase):
|
||||
logger.info(f'开始渐进式精确搜索字幕,关键词:{mediainfo.title} ...')
|
||||
|
||||
if not mediainfo.names:
|
||||
mediainfo = await self.async_recognize_media(mtype=mediainfo.type,
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
doubanid=mediainfo.douban_id)
|
||||
mediainfo = await self.async_recognize_media(
|
||||
mtype=mediainfo.type,
|
||||
**self._media_recognize_kwargs(mediainfo),
|
||||
)
|
||||
if not mediainfo:
|
||||
logger.error('媒体信息识别失败!')
|
||||
yield {
|
||||
@@ -1718,8 +1842,13 @@ class SearchChain(ChainBase):
|
||||
|
||||
no_exists = None
|
||||
if season is not None:
|
||||
media_source, media_id = resolve_media_identity(
|
||||
media=mediainfo, source=source, media_id=mediaid,
|
||||
tmdbid=tmdbid, doubanid=doubanid,
|
||||
bangumiid=bangumiid, anilistid=anilistid,
|
||||
)
|
||||
no_exists = {
|
||||
tmdbid or doubanid: {
|
||||
build_media_key(media_source, media_id): {
|
||||
season: NotExistMediaInfo(episodes=[episode] if episode is not None else [])
|
||||
}
|
||||
}
|
||||
|
||||
+70
-17
@@ -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]:
|
||||
"""
|
||||
|
||||
+570
-209
File diff suppressed because it is too large
Load Diff
+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):
|
||||
|
||||
+324
-31
@@ -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,7 +783,25 @@ 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__()
|
||||
# 主要媒体文件后缀
|
||||
self._media_exts = settings.RMT_MEDIAEXT
|
||||
@@ -841,6 +861,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
logger.info("文件整理线程已停止")
|
||||
|
||||
def on_config_changed(self):
|
||||
"""配置变更时重启文件整理线程。"""
|
||||
self.__stop()
|
||||
self.__init()
|
||||
|
||||
@@ -907,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]:
|
||||
@@ -1168,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:
|
||||
@@ -1188,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)
|
||||
|
||||
@@ -1544,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
|
||||
|
||||
@@ -1577,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
|
||||
):
|
||||
# 下载记录中已存在识别信息
|
||||
@@ -1585,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
|
||||
@@ -1598,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, "未识别到媒体信息"
|
||||
@@ -1677,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
|
||||
)
|
||||
@@ -1695,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:
|
||||
@@ -1730,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)
|
||||
|
||||
@@ -2075,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:
|
||||
@@ -2213,6 +2330,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
"""
|
||||
shared_roots: set[str] = set()
|
||||
media_type_dirs = {mtype.value for mtype in MediaType}
|
||||
media_categories = None
|
||||
|
||||
for dir_info in DirectoryHelper().get_download_dirs():
|
||||
if not dir_info.download_path:
|
||||
@@ -2226,6 +2344,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
relative_parts = file_path.relative_to(download_root).parts
|
||||
current_root = download_root
|
||||
part_index = 0
|
||||
media_type = dir_info.media_type
|
||||
|
||||
if (
|
||||
not dir_info.media_type
|
||||
@@ -2235,6 +2354,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
):
|
||||
current_root = current_root / relative_parts[part_index]
|
||||
shared_roots.add(current_root.as_posix())
|
||||
media_type = relative_parts[part_index]
|
||||
part_index += 1
|
||||
|
||||
if (
|
||||
@@ -2242,8 +2362,32 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
and dir_info.download_category_folder
|
||||
and len(relative_parts) > part_index
|
||||
):
|
||||
current_root = current_root / relative_parts[part_index]
|
||||
shared_roots.add(current_root.as_posix())
|
||||
category_root = current_root / relative_parts[part_index]
|
||||
shared_roots.add(category_root.as_posix())
|
||||
if media_categories is None:
|
||||
media_categories = MediaChain().media_category() or {}
|
||||
if media_type:
|
||||
category_names = media_categories.get(media_type, [])
|
||||
else:
|
||||
category_names = {
|
||||
category
|
||||
for categories in media_categories.values()
|
||||
for category in categories
|
||||
}
|
||||
category_paths = sorted(
|
||||
(Path(category).parts for category in category_names if category),
|
||||
key=len,
|
||||
)
|
||||
for category_parts in category_paths:
|
||||
relative_category_parts = tuple(
|
||||
relative_parts[part_index:part_index + len(category_parts)]
|
||||
)
|
||||
if relative_category_parts != category_parts:
|
||||
continue
|
||||
category_root = current_root
|
||||
for category_part in category_parts:
|
||||
category_root = category_root / category_part
|
||||
shared_roots.add(category_root.as_posix())
|
||||
|
||||
return shared_roots
|
||||
|
||||
@@ -2538,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,
|
||||
@@ -2562,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: 目标路径
|
||||
@@ -2584,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: 继续处理回调
|
||||
@@ -3014,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:
|
||||
@@ -3086,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,
|
||||
@@ -3281,7 +3543,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
source: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
远程重新整理,参数 历史记录ID TMDBID|类型
|
||||
远程重新整理,参数 历史记录ID 来源前缀:媒体ID|类型
|
||||
"""
|
||||
|
||||
def args_error():
|
||||
@@ -3289,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,
|
||||
@@ -3323,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():
|
||||
@@ -3383,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)
|
||||
@@ -3396,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)
|
||||
@@ -3445,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,
|
||||
@@ -3461,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]]:
|
||||
"""
|
||||
手动整理,支持复杂条件,带进度显示
|
||||
@@ -3469,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: 剧集组
|
||||
@@ -3483,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)
|
||||
|
||||
@@ -3511,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,
|
||||
@@ -3524,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,
|
||||
)
|
||||
@@ -3538,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,
|
||||
@@ -3551,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:
|
||||
|
||||
+1
-1
@@ -988,7 +988,7 @@ def logs(lines: int, follow: bool, stdio: bool, frontend_log: bool) -> None:
|
||||
@click.option("--fix", is_flag=True, help="执行白名单安全修复")
|
||||
@click.option("--deep", is_flag=True, help="执行可能较慢的深度检查")
|
||||
def doctor(json_output: bool, fix: bool, deep: bool) -> None:
|
||||
"""离线诊断本地 MoviePilot 运行环境"""
|
||||
"""离线诊断本地 MoviePilot 运行环境,插件日志告警不影响整体状态"""
|
||||
from app.doctor import run_doctor
|
||||
from app.doctor.formatters import format_json_report, format_text_report
|
||||
|
||||
|
||||
+88
-38
@@ -13,7 +13,7 @@ import aiofiles
|
||||
import aioshutil
|
||||
from anyio import Path as AsyncPath
|
||||
from cachetools import LRUCache as MemoryLRUCache
|
||||
from cachetools import TTLCache as MemoryTTLCache
|
||||
from cachetools import TLRUCache as MemoryTLRUCache
|
||||
from cachetools.keys import hashkey
|
||||
|
||||
from app.core.config import settings
|
||||
@@ -357,15 +357,52 @@ class AsyncCacheBackend(CacheBackend):
|
||||
pass
|
||||
|
||||
|
||||
class _MemoryTLRUCache(MemoryTLRUCache):
|
||||
"""
|
||||
支持为每个 key 设置独立 TTL 的内存缓存
|
||||
"""
|
||||
|
||||
def __init__(self, maxsize: int, ttl: int):
|
||||
self.__ttl = ttl
|
||||
self.__setting_ttls: Dict[str, int] = {}
|
||||
super().__init__(maxsize=maxsize, ttu=self._get_expiration)
|
||||
|
||||
def _get_expiration(self, key: str, _value: Any, now: float) -> float:
|
||||
return now + self.__setting_ttls.get(key, self.__ttl)
|
||||
|
||||
@property
|
||||
def ttl(self) -> int:
|
||||
"""
|
||||
默认缓存存活时间,单位秒
|
||||
"""
|
||||
return self.__ttl
|
||||
|
||||
def set(self, key: str, value: Any, ttl: int) -> None:
|
||||
"""
|
||||
使用指定 TTL 设置缓存值
|
||||
"""
|
||||
if ttl <= 0:
|
||||
try:
|
||||
del self[key]
|
||||
except KeyError:
|
||||
pass
|
||||
return
|
||||
self.__setting_ttls[key] = ttl
|
||||
try:
|
||||
super().__setitem__(key, value)
|
||||
finally:
|
||||
self.__setting_ttls.pop(key, None)
|
||||
|
||||
|
||||
class MemoryBackend(CacheBackend):
|
||||
"""
|
||||
基于 `cachetools.TTLCache` 实现的缓存后端
|
||||
基于 `cachetools.TLRUCache` 实现的缓存后端
|
||||
"""
|
||||
|
||||
# 类变量 _region_caches 的互斥锁
|
||||
_lock = threading.Lock()
|
||||
# 存储各个 region 的缓存实例,region -> TTLCache
|
||||
_region_caches: Dict[str, Union[MemoryTTLCache, MemoryLRUCache]] = {}
|
||||
# 存储各个 region 的缓存实例,region -> TLRUCache/LRUCache
|
||||
_region_caches: Dict[str, Union[_MemoryTLRUCache, MemoryLRUCache]] = {}
|
||||
|
||||
def __init__(self, cache_type: Literal['ttl', 'lru'] = 'ttl',
|
||||
maxsize: Optional[int] = None, ttl: Optional[int] = None):
|
||||
@@ -378,9 +415,9 @@ class MemoryBackend(CacheBackend):
|
||||
"""
|
||||
self.cache_type = cache_type
|
||||
self.maxsize = maxsize or DEFAULT_CACHE_SIZE
|
||||
self.ttl = ttl or DEFAULT_CACHE_TTL
|
||||
self.ttl = DEFAULT_CACHE_TTL if ttl is None else ttl
|
||||
|
||||
def __get_region_cache(self, region: str) -> Optional[Union[MemoryTTLCache, MemoryLRUCache]]:
|
||||
def __get_region_cache(self, region: str) -> Optional[Union[_MemoryTLRUCache, MemoryLRUCache]]:
|
||||
"""
|
||||
获取指定区域的缓存实例,如果不存在则返回 None
|
||||
"""
|
||||
@@ -394,21 +431,29 @@ class MemoryBackend(CacheBackend):
|
||||
|
||||
:param key: 缓存的键
|
||||
:param value: 缓存的值
|
||||
:param ttl: 缓存的存活时间,不传入为永久缓存,单位秒
|
||||
:param ttl: 缓存的存活时间,未传入则使用 backend 默认值,单位秒
|
||||
:param region: 缓存的区
|
||||
"""
|
||||
ttl = ttl or self.ttl
|
||||
maxsize = kwargs.get("maxsize", self.maxsize)
|
||||
ttl = self.ttl if ttl is None else ttl
|
||||
maxsize = kwargs.get("maxsize") or self.maxsize
|
||||
region = self.get_region(region)
|
||||
# 设置缓存值
|
||||
with self._lock:
|
||||
# 如果该 key 尚未有缓存实例,则创建一个新的 TTLCache 实例
|
||||
region_cache = self._region_caches.setdefault(
|
||||
region,
|
||||
MemoryTTLCache(maxsize=maxsize, ttl=ttl) if self.cache_type == 'ttl'
|
||||
else MemoryLRUCache(maxsize=maxsize)
|
||||
)
|
||||
region_cache[key] = value
|
||||
region_cache = self._region_caches.get(region)
|
||||
if region_cache is None:
|
||||
region_cache = (
|
||||
_MemoryTLRUCache(maxsize=maxsize, ttl=ttl) if self.cache_type == 'ttl'
|
||||
else MemoryLRUCache(maxsize=maxsize)
|
||||
)
|
||||
self._region_caches[region] = region_cache
|
||||
elif isinstance(region_cache, _MemoryTLRUCache) != (self.cache_type == 'ttl'):
|
||||
raise ValueError(
|
||||
f"Cache region {region!r} already uses a different cache type"
|
||||
)
|
||||
if isinstance(region_cache, _MemoryTLRUCache):
|
||||
region_cache.set(key, value, ttl=ttl)
|
||||
else:
|
||||
region_cache[key] = value
|
||||
|
||||
def exists(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> bool:
|
||||
"""
|
||||
@@ -458,19 +503,18 @@ class MemoryBackend(CacheBackend):
|
||||
|
||||
:param region: 缓存的区,为None时清空所有区缓存
|
||||
"""
|
||||
if region:
|
||||
# 清理指定缓存区
|
||||
region_cache = self.__get_region_cache(region)
|
||||
if region_cache:
|
||||
with self._lock:
|
||||
with self._lock:
|
||||
if region:
|
||||
# 清理指定缓存区
|
||||
region_cache = self.__get_region_cache(region)
|
||||
if region_cache is not None:
|
||||
region_cache.clear()
|
||||
logger.debug(f"Cleared cache for region: {region}")
|
||||
else:
|
||||
# 清除所有区域的缓存
|
||||
for region_cache in self._region_caches.values():
|
||||
with self._lock:
|
||||
logger.debug(f"Cleared cache for region: {region}")
|
||||
else:
|
||||
# 清除所有区域的缓存
|
||||
for region_cache in self._region_caches.values():
|
||||
region_cache.clear()
|
||||
logger.info("Cleared all cache")
|
||||
logger.info("Cleared all cache")
|
||||
|
||||
def items(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> Generator[Tuple[str, Any], None, None]:
|
||||
"""
|
||||
@@ -520,7 +564,7 @@ class AsyncMemoryBackend(AsyncCacheBackend):
|
||||
|
||||
:param key: 缓存的键
|
||||
:param value: 缓存的值
|
||||
:param ttl: 缓存的存活时间,不传入为永久缓存,单位秒
|
||||
:param ttl: 缓存的存活时间,未传入则使用 backend 默认值,单位秒
|
||||
:param region: 缓存的区
|
||||
"""
|
||||
return self._backend.set(key=key, value=value, ttl=ttl, region=region, **kwargs)
|
||||
@@ -600,11 +644,14 @@ class RedisBackend(CacheBackend):
|
||||
|
||||
:param key: 缓存的键
|
||||
:param value: 缓存的值
|
||||
:param ttl: 缓存的存活时间,未传入则为永久缓存,单位秒
|
||||
:param ttl: 缓存的存活时间,未传入则使用 backend 默认值,单位秒
|
||||
:param region: 缓存的区
|
||||
:param kwargs: kwargs
|
||||
"""
|
||||
ttl = ttl or self.ttl
|
||||
ttl = self.ttl if ttl is None else ttl
|
||||
if ttl is not None and ttl <= 0:
|
||||
self.redis_helper.delete(key, region=region)
|
||||
return
|
||||
self.redis_helper.set(key, value, ttl=ttl, region=region, **kwargs)
|
||||
|
||||
def exists(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> bool:
|
||||
@@ -681,11 +728,14 @@ class AsyncRedisBackend(AsyncCacheBackend):
|
||||
|
||||
:param key: 缓存的键
|
||||
:param value: 缓存的值
|
||||
:param ttl: 缓存的存活时间,未传入则为永久缓存,单位秒
|
||||
:param ttl: 缓存的存活时间,未传入则使用 backend 默认值,单位秒
|
||||
:param region: 缓存的区
|
||||
:param kwargs: kwargs
|
||||
"""
|
||||
ttl = ttl or self.ttl
|
||||
ttl = self.ttl if ttl is None else ttl
|
||||
if ttl is not None and ttl <= 0:
|
||||
await self.redis_helper.delete(key, region=region)
|
||||
return
|
||||
await self.redis_helper.set(key, value, ttl=ttl, region=region, **kwargs)
|
||||
|
||||
async def exists(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> bool:
|
||||
@@ -1018,7 +1068,7 @@ def FileCache(base: Path = settings.TEMP_PATH, ttl: Optional[int] = None) -> Cac
|
||||
"""
|
||||
if settings.CACHE_BACKEND_TYPE == "redis":
|
||||
# 如果使用 Redis,则设置缓存的存活时间为配置的天数转换为秒
|
||||
return RedisBackend(ttl=ttl or settings.TEMP_FILE_DAYS * 24 * 3600)
|
||||
return RedisBackend(ttl=ttl if ttl is not None else settings.TEMP_FILE_DAYS * 24 * 3600)
|
||||
else:
|
||||
# 如果使用文件系统,在停止服务时会自动清理过期文件
|
||||
return FileBackend(base=base)
|
||||
@@ -1030,7 +1080,7 @@ def AsyncFileCache(base: Path = settings.TEMP_PATH, ttl: Optional[int] = None) -
|
||||
"""
|
||||
if settings.CACHE_BACKEND_TYPE == "redis":
|
||||
# 如果使用 Redis,则设置缓存的存活时间为配置的天数转换为秒
|
||||
return AsyncRedisBackend(ttl=ttl or settings.TEMP_FILE_DAYS * 24 * 3600)
|
||||
return AsyncRedisBackend(ttl=ttl if ttl is not None else settings.TEMP_FILE_DAYS * 24 * 3600)
|
||||
else:
|
||||
# 如果使用文件系统,在停止服务时会自动清理过期文件
|
||||
return AsyncFileBackend(base=base)
|
||||
@@ -1075,11 +1125,11 @@ def AsyncCache(cache_type: Literal['ttl', 'lru'] = 'ttl',
|
||||
def cached(region: Optional[str] = None, maxsize: Optional[int] = 1024, ttl: Optional[int] = None,
|
||||
skip_none: Optional[bool] = True, skip_empty: Optional[bool] = False, shared_key: Optional[str] = None):
|
||||
"""
|
||||
自定义缓存装饰器,支持为每个 key 动态传递 maxsize 和 ttl
|
||||
自定义缓存装饰器,支持配置缓存区域的 maxsize 和每个 key 的 ttl
|
||||
|
||||
:param region: 缓存区域的标识符,默认根据模块名、函数名等自动生成标识
|
||||
:param maxsize: 缓存区内的最大条目数
|
||||
:param ttl: 缓存的存活时间,单位秒,未传入则为永久缓存,单位秒
|
||||
:param ttl: 缓存的存活时间,单位秒;未传入时使用 LRU 缓存
|
||||
:param skip_none: 跳过 None 缓存,默认为 True
|
||||
:param skip_empty: 跳过空值缓存(如 None, [], {}, "", set()),默认为 False
|
||||
:param shared_key: 同步/异步函数共享缓存的键,默认使用函数名(异步函数名会标准化为同步格式,如移除 `async_` 前缀)
|
||||
@@ -1186,7 +1236,7 @@ def cached(region: Optional[str] = None, maxsize: Optional[int] = 1024, ttl: Opt
|
||||
|
||||
if is_async:
|
||||
# 异步函数使用异步缓存后端
|
||||
cache_backend = AsyncCache(cache_type="ttl" if ttl else "lru", maxsize=maxsize, ttl=ttl)
|
||||
cache_backend = AsyncCache(cache_type="ttl" if ttl is not None else "lru", maxsize=maxsize, ttl=ttl)
|
||||
# 异步函数的缓存装饰器
|
||||
@wraps(func)
|
||||
async def async_wrapper(*args, **kwargs):
|
||||
@@ -1230,7 +1280,7 @@ def cached(region: Optional[str] = None, maxsize: Optional[int] = 1024, ttl: Opt
|
||||
return async_wrapper
|
||||
else:
|
||||
# 同步函数使用同步缓存后端
|
||||
cache_backend = Cache(cache_type="ttl" if ttl else "lru", maxsize=maxsize, ttl=ttl)
|
||||
cache_backend = Cache(cache_type="ttl" if ttl is not None else "lru", maxsize=maxsize, ttl=ttl)
|
||||
# 同步函数的缓存装饰器
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
|
||||
+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)
|
||||
|
||||
+241
-6
@@ -9,6 +9,11 @@ from app.core.metainfo import MetaInfo
|
||||
from app.schemas.types import MediaType
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
BANGUMI_MOVIE_PLATFORMS = frozenset({"movie", "电影", "剧场版"})
|
||||
ANILIST_MOVIE_FORMATS = frozenset({"MOVIE"})
|
||||
ANILIST_CHINESE_TITLE_PATTERN = re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff]")
|
||||
ANILIST_JAPANESE_KANA_PATTERN = re.compile(r"[\u3040-\u30ff]")
|
||||
|
||||
|
||||
@dataclass
|
||||
class TorrentInfo:
|
||||
@@ -243,10 +248,18 @@ class SubtitleInfo:
|
||||
|
||||
@dataclass
|
||||
class MediaInfo:
|
||||
"""
|
||||
统一媒体信息,负责聚合各元数据源的标准字段
|
||||
"""
|
||||
|
||||
# 内部标记:是否命中本地识别缓存,不参与序列化
|
||||
recognize_cache_hit = False
|
||||
# 来源:themoviedb、douban、bangumi
|
||||
# 来源:themoviedb、douban、bangumi、anilist
|
||||
source: str = None
|
||||
# 当前数据源原生ID,主要用于保留插件自定义数据源身份
|
||||
media_id: str = None
|
||||
# 请求级刮削来源;为空时使用系统设置
|
||||
scrape_source: str = None
|
||||
# 类型 电影、电视剧
|
||||
type: MediaType = None
|
||||
# 媒体标题
|
||||
@@ -273,6 +286,10 @@ class MediaInfo:
|
||||
douban_id: str = None
|
||||
# Bangumi ID
|
||||
bangumi_id: int = None
|
||||
# AniList ID
|
||||
anilist_id: int = None
|
||||
# AniDB ID(AniList外部映射)
|
||||
anidb_id: int = None
|
||||
# 合集ID
|
||||
collection_id: int = None
|
||||
# 媒体原语种
|
||||
@@ -309,6 +326,8 @@ class MediaInfo:
|
||||
douban_info: dict = field(default_factory=dict)
|
||||
# Bangumi INFO
|
||||
bangumi_info: dict = field(default_factory=dict)
|
||||
# AniList INFO
|
||||
anilist_info: dict = field(default_factory=dict)
|
||||
# 导演
|
||||
directors: List[dict] = field(default_factory=list)
|
||||
# 演员
|
||||
@@ -374,6 +393,8 @@ class MediaInfo:
|
||||
self.set_douban_info(self.douban_info)
|
||||
if self.bangumi_info:
|
||||
self.set_bangumi_info(self.bangumi_info)
|
||||
if self.anilist_info:
|
||||
self.set_anilist_info(self.anilist_info)
|
||||
|
||||
def __setattr__(self, name: str, value: Any):
|
||||
self.__dict__[name] = value
|
||||
@@ -721,7 +742,20 @@ class MediaInfo:
|
||||
elif type(current_value) is type(value):
|
||||
setattr(self, key, value)
|
||||
|
||||
def set_bangumi_info(self, info: dict):
|
||||
@staticmethod
|
||||
def get_bangumi_media_type(info: dict) -> MediaType:
|
||||
"""
|
||||
根据Bangumi媒介平台获取标准媒体类型,未知平台兼容回退为电视剧
|
||||
|
||||
:param info: Bangumi条目信息
|
||||
:return: 标准媒体类型
|
||||
"""
|
||||
platform = str(info.get("platform") or "").strip().casefold()
|
||||
if platform in BANGUMI_MOVIE_PLATFORMS:
|
||||
return MediaType.MOVIE
|
||||
return MediaType.TV
|
||||
|
||||
def set_bangumi_info(self, info: dict) -> None:
|
||||
"""
|
||||
初始化Bangumi信息
|
||||
"""
|
||||
@@ -731,11 +765,11 @@ class MediaInfo:
|
||||
self.source = "bangumi"
|
||||
# 本体
|
||||
self.bangumi_info = info
|
||||
# 豆瓣ID
|
||||
# Bangumi ID
|
||||
self.bangumi_id = info.get("id")
|
||||
# 类型
|
||||
if not self.type:
|
||||
self.type = MediaType.TV
|
||||
self.type = self.get_bangumi_media_type(info)
|
||||
# 标题
|
||||
if not self.title:
|
||||
self.title = info.get("name_cn") or info.get("name")
|
||||
@@ -785,13 +819,196 @@ class MediaInfo:
|
||||
if self.type == MediaType.TV and not self.seasons:
|
||||
meta = MetaInfo(self.title)
|
||||
season = meta.begin_season if meta.begin_season is not None else 1
|
||||
episodes_count = info.get("total_episodes")
|
||||
episodes_count = info.get("total_episodes") or info.get("eps")
|
||||
if episodes_count:
|
||||
self.seasons[season] = list(range(1, episodes_count + 1))
|
||||
self.number_of_episodes = episodes_count
|
||||
self.number_of_seasons = 1
|
||||
# 风格
|
||||
if not self.genres:
|
||||
self.genres = [
|
||||
{"id": tag.get("name"), "name": tag.get("name")}
|
||||
for tag in info.get("tags") or []
|
||||
if tag.get("name")
|
||||
]
|
||||
# 制作公司与导演
|
||||
if info.get("infobox"):
|
||||
companies = []
|
||||
directors = []
|
||||
for item in info.get("infobox"):
|
||||
values = item.get("value")
|
||||
if not isinstance(values, list):
|
||||
values = [values]
|
||||
normalized_values = [
|
||||
value.get("v") if isinstance(value, dict) else value
|
||||
for value in values
|
||||
if value
|
||||
]
|
||||
if item.get("key") in {"动画制作", "制作"}:
|
||||
companies.extend({"name": value} for value in normalized_values)
|
||||
elif item.get("key") == "导演":
|
||||
directors.extend({"name": value} for value in normalized_values)
|
||||
if companies and not self.production_companies:
|
||||
self.production_companies = companies
|
||||
if directors and not self.directors:
|
||||
self.directors = directors
|
||||
# 演员
|
||||
if not self.actors:
|
||||
self.actors = info.get("actors") or []
|
||||
|
||||
@staticmethod
|
||||
def get_anilist_media_type(info: dict) -> MediaType:
|
||||
"""
|
||||
根据 AniList 发布格式获取标准媒体类型。
|
||||
|
||||
:param info: AniList 媒体信息
|
||||
:return: 标准媒体类型
|
||||
"""
|
||||
return (
|
||||
MediaType.MOVIE
|
||||
if str(info.get("format") or "").upper() in ANILIST_MOVIE_FORMATS
|
||||
else MediaType.TV
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _anilist_date(date_info: dict) -> Optional[str]:
|
||||
"""
|
||||
将 AniList 模糊日期转换为标准日期文本。
|
||||
|
||||
:param date_info: AniList FuzzyDate 字段
|
||||
:return: 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:
|
||||
@@ -812,6 +1029,8 @@ class MediaInfo:
|
||||
return "https://movie.douban.com/subject/%s" % self.douban_id
|
||||
elif self.bangumi_id:
|
||||
return "http://bgm.tv/subject/%s" % self.bangumi_id
|
||||
elif self.anilist_id:
|
||||
return "https://anilist.co/anime/%s" % self.anilist_id
|
||||
return ""
|
||||
|
||||
@property
|
||||
@@ -876,6 +1095,21 @@ class MediaInfo:
|
||||
dicts["tmdb_info"] = None
|
||||
dicts["douban_info"] = None
|
||||
dicts["bangumi_info"] = None
|
||||
dicts["anilist_info"] = None
|
||||
source_ids = {
|
||||
"themoviedb": self.tmdb_id,
|
||||
"douban": self.douban_id,
|
||||
"bangumi": self.bangumi_id,
|
||||
"anilist": self.anilist_id,
|
||||
}
|
||||
media_source = self.source or next(
|
||||
(source for source, media_id in source_ids.items() if media_id is not None),
|
||||
None,
|
||||
)
|
||||
dicts["source"] = media_source
|
||||
dicts["mediaid_prefix"] = media_source
|
||||
media_id = self.media_id or source_ids.get(media_source)
|
||||
dicts["media_id"] = str(media_id) if media_id is not None else None
|
||||
return dicts
|
||||
|
||||
def clear(self):
|
||||
@@ -885,6 +1119,7 @@ class MediaInfo:
|
||||
self.tmdb_info = {}
|
||||
self.douban_info = {}
|
||||
self.bangumi_info = {}
|
||||
self.anilist_info = {}
|
||||
self.seasons = {}
|
||||
self.genres = []
|
||||
self.season_info = []
|
||||
@@ -915,7 +1150,7 @@ class Context:
|
||||
media_recognize_fail_count: int = 0
|
||||
# 候选资源来源:rss、spider、search、unknown。
|
||||
resource_source: str = "unknown"
|
||||
# 候选匹配来源:tmdbid、doubanid、imdbid、title、plugin、unknown。
|
||||
# 候选匹配来源:tmdbid、doubanid、bangumiid、anilistid、imdbid、title、plugin、unknown。
|
||||
match_source: str = "unknown"
|
||||
# 候选自身是否已经识别出有效媒体 ID。
|
||||
candidate_recognized: bool = False
|
||||
|
||||
@@ -23,7 +23,10 @@ def should_use_parent_title_for_file_stem(
|
||||
"""
|
||||
if not file_meta.isfile or not stem or not parent_dir_name:
|
||||
return False
|
||||
if file_meta.tmdbid or file_meta.doubanid:
|
||||
if any((
|
||||
file_meta.tmdbid, file_meta.doubanid,
|
||||
file_meta.bangumiid, file_meta.anilistid, file_meta.media_id,
|
||||
)):
|
||||
return False
|
||||
if not PARENT_LATIN_TITLE_RE.search(parent_dir_name):
|
||||
return False
|
||||
|
||||
+19
-14
@@ -38,6 +38,14 @@ class MetaAnime(MetaBase):
|
||||
_name_nostring_pattern = re.compile(_name_nostring_re, re.IGNORECASE)
|
||||
_fps_pattern = re.compile(r"(%s)" % _fps_re, re.IGNORECASE)
|
||||
|
||||
@staticmethod
|
||||
def _parse_season_number(value):
|
||||
"""解析第三方动漫季号,仅接受整数或纯数字字符串并保留数值 0。"""
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return int(text) if text.isdigit() else None
|
||||
|
||||
def __init__(self, title: str, subtitle: str = None, isfile: bool = False):
|
||||
super().__init__(title, subtitle, isfile)
|
||||
if not title:
|
||||
@@ -111,22 +119,19 @@ class MetaAnime(MetaBase):
|
||||
# 季号
|
||||
anime_season = anitopy_info.get("anime_season")
|
||||
if isinstance(anime_season, list):
|
||||
if len(anime_season) == 1:
|
||||
begin_season = anime_season[0]
|
||||
end_season = None
|
||||
else:
|
||||
begin_season = anime_season[0]
|
||||
end_season = anime_season[-1]
|
||||
elif anime_season:
|
||||
begin_season = anime_season
|
||||
end_season = None
|
||||
seasons = [
|
||||
season for item in anime_season
|
||||
if (season := self._parse_season_number(item)) is not None
|
||||
]
|
||||
begin_season = seasons[0] if seasons else None
|
||||
end_season = seasons[-1] if len(seasons) > 1 else None
|
||||
else:
|
||||
begin_season = None
|
||||
begin_season = self._parse_season_number(anime_season)
|
||||
end_season = None
|
||||
if begin_season:
|
||||
self.begin_season = int(begin_season)
|
||||
if end_season and int(end_season) != self.begin_season:
|
||||
self.end_season = int(end_season)
|
||||
if begin_season is not None:
|
||||
self.begin_season = begin_season
|
||||
if end_season is not None and end_season != self.begin_season:
|
||||
self.end_season = end_season
|
||||
self.total_season = (self.end_season - self.begin_season) + 1
|
||||
else:
|
||||
self.total_season = 1
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -251,7 +251,7 @@ class MetaVideo(MetaBase):
|
||||
if name.isdecimal() \
|
||||
and int(name) < 1800 \
|
||||
and not self.year \
|
||||
and not self.begin_season \
|
||||
and self.begin_season is None \
|
||||
and not self.resource_pix \
|
||||
and not self.resource_type \
|
||||
and not self.audio_encode \
|
||||
@@ -259,7 +259,7 @@ class MetaVideo(MetaBase):
|
||||
if self.begin_episode is None:
|
||||
self.begin_episode = int(name)
|
||||
name = None
|
||||
elif self.is_in_episode(int(name)) and not self.begin_season:
|
||||
elif self.is_in_episode(int(name)) and self.begin_season is None:
|
||||
name = None
|
||||
return name
|
||||
|
||||
@@ -366,7 +366,7 @@ class MetaVideo(MetaBase):
|
||||
if not self.name:
|
||||
return
|
||||
if not self.year \
|
||||
and not self.begin_season \
|
||||
and self.begin_season is None \
|
||||
and not self.begin_episode \
|
||||
and not self.resource_pix \
|
||||
and not self.resource_type:
|
||||
@@ -690,7 +690,7 @@ class MetaVideo(MetaBase):
|
||||
if not self.year \
|
||||
and not self.resource_pix \
|
||||
and not self.resource_type \
|
||||
and not self.begin_season \
|
||||
and self.begin_season is None \
|
||||
and not self.begin_episode:
|
||||
return
|
||||
re_res = self._video_encode_pattern.search(token)
|
||||
@@ -738,7 +738,7 @@ class MetaVideo(MetaBase):
|
||||
if not self.year \
|
||||
and not self.resource_pix \
|
||||
and not self.resource_type \
|
||||
and not self.begin_season \
|
||||
and self.begin_season is None \
|
||||
and not self.begin_episode:
|
||||
return
|
||||
video_bit = self.extract_video_bit(token)
|
||||
@@ -759,7 +759,7 @@ class MetaVideo(MetaBase):
|
||||
if not self.year \
|
||||
and not self.resource_pix \
|
||||
and not self.resource_type \
|
||||
and not self.begin_season \
|
||||
and self.begin_season is None \
|
||||
and not self.begin_episode:
|
||||
return
|
||||
re_res = self._audio_encode_pattern.search(token)
|
||||
|
||||
+108
-7
@@ -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:
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from app.db import DbOper
|
||||
from app.db.models.agenttask import AgentTask
|
||||
|
||||
|
||||
class AgentTaskOper(DbOper):
|
||||
"""
|
||||
Agent 自主定时任务管理。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _now() -> str:
|
||||
"""生成当前数据库时间字符串。"""
|
||||
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
def add(self, **kwargs: object) -> AgentTask:
|
||||
"""
|
||||
新增 Agent 定时任务。
|
||||
"""
|
||||
now = self._now()
|
||||
task_id = AgentTask.add_task(
|
||||
self._db,
|
||||
**kwargs,
|
||||
enabled=True,
|
||||
last_status="waiting",
|
||||
run_count=0,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
return self.get(task_id)
|
||||
|
||||
def get(
|
||||
self,
|
||||
task_id: int,
|
||||
user_id: Optional[str] = None,
|
||||
) -> Optional[AgentTask]:
|
||||
"""
|
||||
查询单个 Agent 定时任务。
|
||||
"""
|
||||
return AgentTask.get_for_user(self._db, task_id=task_id, user_id=user_id)
|
||||
|
||||
def list(
|
||||
self,
|
||||
user_id: Optional[str] = None,
|
||||
enabled: Optional[bool] = None,
|
||||
) -> list[AgentTask]:
|
||||
"""
|
||||
查询 Agent 定时任务列表。
|
||||
"""
|
||||
return AgentTask.list_for_user(self._db, user_id=user_id, enabled=enabled)
|
||||
|
||||
def update(
|
||||
self,
|
||||
task_id: int,
|
||||
payload: dict,
|
||||
user_id: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
更新 Agent 定时任务。
|
||||
"""
|
||||
normalized_payload = {
|
||||
key: value
|
||||
for key, value in payload.items()
|
||||
if key in {
|
||||
"name",
|
||||
"content",
|
||||
"trigger_type",
|
||||
"cron_expression",
|
||||
"run_at",
|
||||
"enabled",
|
||||
"last_status",
|
||||
"last_result",
|
||||
}
|
||||
}
|
||||
if not normalized_payload:
|
||||
return False
|
||||
normalized_payload["updated_at"] = self._now()
|
||||
return AgentTask.update_task(
|
||||
self._db,
|
||||
task_id=task_id,
|
||||
payload=normalized_payload,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
def delete(self, task_id: int, user_id: Optional[str] = None) -> bool:
|
||||
"""
|
||||
删除 Agent 定时任务。
|
||||
"""
|
||||
return AgentTask.delete_task(
|
||||
self._db,
|
||||
task_id=task_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
def mark_running(self, task_id: int) -> bool:
|
||||
"""
|
||||
将 Agent 定时任务标记为运行中。
|
||||
"""
|
||||
return AgentTask.mark_running(
|
||||
self._db,
|
||||
task_id=task_id,
|
||||
run_at=self._now(),
|
||||
)
|
||||
|
||||
def finish(
|
||||
self,
|
||||
task_id: int,
|
||||
success: bool,
|
||||
result: str,
|
||||
disable: bool = False,
|
||||
) -> bool:
|
||||
"""
|
||||
记录 Agent 定时任务执行结果。
|
||||
"""
|
||||
return AgentTask.finish_task(
|
||||
self._db,
|
||||
task_id=task_id,
|
||||
success=success,
|
||||
result=(result or "")[:20000],
|
||||
disable=disable,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def to_dict(
|
||||
task: AgentTask,
|
||||
next_run_at: Optional[str] = None,
|
||||
timezone: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
将 Agent 定时任务转换为工具可返回的结构。
|
||||
"""
|
||||
return {
|
||||
"id": task.id,
|
||||
"name": task.name,
|
||||
"content": task.content,
|
||||
"trigger_type": task.trigger_type,
|
||||
"cron_expression": task.cron_expression,
|
||||
"run_at": task.run_at,
|
||||
"timezone": timezone,
|
||||
"enabled": bool(task.enabled),
|
||||
"last_status": task.last_status,
|
||||
"last_run_at": task.last_run_at,
|
||||
"last_result": task.last_result,
|
||||
"run_count": task.run_count or 0,
|
||||
"next_run_at": next_run_at,
|
||||
"created_at": task.created_at,
|
||||
"updated_at": task.updated_at,
|
||||
}
|
||||
@@ -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]:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from .agentchat import AgentChat
|
||||
from .agenttask import AgentTask
|
||||
from .downloadfailure import DownloadFailure
|
||||
from .downloadhistory import DownloadHistory, DownloadFiles
|
||||
from .mediaserver import MediaServerItem
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Boolean, Column, Index, Integer, String, Text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import Base, db_query, db_update, get_id_column
|
||||
|
||||
|
||||
class AgentTask(Base):
|
||||
"""
|
||||
Agent 自主定时任务表。
|
||||
"""
|
||||
|
||||
id = get_id_column()
|
||||
# 任务名称
|
||||
name = Column(String, nullable=False)
|
||||
# 交给 Agent 执行的完整任务内容
|
||||
content = Column(Text, nullable=False)
|
||||
# 触发类型:date-单次触发,cron-周期触发
|
||||
trigger_type = Column(String, nullable=False)
|
||||
# 标准五段 cron 表达式
|
||||
cron_expression = Column(String)
|
||||
# 单次触发时间,使用带时区的 ISO 8601 格式
|
||||
run_at = Column(String)
|
||||
# 是否继续接受调度
|
||||
enabled = Column(Boolean, nullable=False, default=True)
|
||||
# 创建任务的用户与会话上下文
|
||||
user_id = Column(String, nullable=False)
|
||||
username = Column(String)
|
||||
session_id = Column(String, nullable=False)
|
||||
channel = Column(String)
|
||||
source = Column(String)
|
||||
original_chat_id = Column(String)
|
||||
# 最近一次执行状态与结果
|
||||
last_status = Column(String, nullable=False, default="waiting")
|
||||
last_run_at = Column(String)
|
||||
last_result = Column(Text)
|
||||
run_count = Column(Integer, nullable=False, default=0)
|
||||
created_at = Column(String, nullable=False)
|
||||
updated_at = Column(String, nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_agenttask_enabled", "enabled"),
|
||||
Index("ix_agenttask_user_created", "user_id", "created_at", "id"),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@db_update
|
||||
def add_task(cls, db: Session, **kwargs: object) -> int:
|
||||
"""
|
||||
新增 Agent 定时任务并返回任务 ID。
|
||||
"""
|
||||
task = cls(**kwargs)
|
||||
db.add(task)
|
||||
db.flush()
|
||||
return task.id
|
||||
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_for_user(
|
||||
cls,
|
||||
db: Session,
|
||||
task_id: int,
|
||||
user_id: Optional[str] = None,
|
||||
) -> Optional["AgentTask"]:
|
||||
"""
|
||||
按任务 ID 和可选用户 ID 查询 Agent 定时任务。
|
||||
"""
|
||||
query = db.query(cls).filter(cls.id == task_id)
|
||||
if user_id is not None:
|
||||
query = query.filter(cls.user_id == user_id)
|
||||
return query.first()
|
||||
|
||||
@classmethod
|
||||
@db_query
|
||||
def list_for_user(
|
||||
cls,
|
||||
db: Session,
|
||||
user_id: Optional[str] = None,
|
||||
enabled: Optional[bool] = None,
|
||||
) -> list["AgentTask"]:
|
||||
"""
|
||||
按用户和启用状态查询 Agent 定时任务。
|
||||
"""
|
||||
query = db.query(cls)
|
||||
if user_id is not None:
|
||||
query = query.filter(cls.user_id == user_id)
|
||||
if enabled is not None:
|
||||
query = query.filter(cls.enabled.is_(enabled))
|
||||
return query.order_by(cls.created_at.desc(), cls.id.desc()).all()
|
||||
|
||||
@classmethod
|
||||
@db_update
|
||||
def update_task(
|
||||
cls,
|
||||
db: Session,
|
||||
task_id: int,
|
||||
payload: dict,
|
||||
user_id: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
按任务 ID 和可选用户 ID 更新 Agent 定时任务。
|
||||
"""
|
||||
query = db.query(cls).filter(cls.id == task_id)
|
||||
if user_id is not None:
|
||||
query = query.filter(cls.user_id == user_id)
|
||||
return bool(query.update(payload))
|
||||
|
||||
@classmethod
|
||||
@db_update
|
||||
def delete_task(
|
||||
cls,
|
||||
db: Session,
|
||||
task_id: int,
|
||||
user_id: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
按任务 ID 和可选用户 ID 删除 Agent 定时任务。
|
||||
"""
|
||||
query = db.query(cls).filter(cls.id == task_id)
|
||||
if user_id is not None:
|
||||
query = query.filter(cls.user_id == user_id)
|
||||
return bool(query.delete())
|
||||
|
||||
@classmethod
|
||||
@db_update
|
||||
def mark_running(cls, db: Session, task_id: int, run_at: str) -> bool:
|
||||
"""
|
||||
将可执行任务标记为运行中。
|
||||
"""
|
||||
updated_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
return bool(
|
||||
db.query(cls)
|
||||
.filter(
|
||||
cls.id == task_id,
|
||||
cls.enabled.is_(True),
|
||||
cls.last_status != "running",
|
||||
)
|
||||
.update(
|
||||
{
|
||||
"last_status": "running",
|
||||
"last_run_at": run_at,
|
||||
"updated_at": updated_at,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@db_update
|
||||
def finish_task(
|
||||
cls,
|
||||
db: Session,
|
||||
task_id: int,
|
||||
success: bool,
|
||||
result: str,
|
||||
disable: bool = False,
|
||||
) -> bool:
|
||||
"""
|
||||
记录 Agent 定时任务执行结果,并按需关闭单次任务。
|
||||
"""
|
||||
payload = {
|
||||
"last_status": "success" if success else "failed",
|
||||
"last_result": result,
|
||||
"run_count": cls.run_count + 1,
|
||||
"updated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
if disable:
|
||||
payload["enabled"] = False
|
||||
return bool(db.query(cls).filter(cls.id == task_id).update(payload))
|
||||
@@ -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(),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user