mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-06-15 07:26:44 +00:00
Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75fca971d4 | ||
|
|
22f3244bf5 | ||
|
|
aafc4b3a39 | ||
|
|
18906e5ab2 | ||
|
|
9675d199f9 | ||
|
|
78e8faa203 | ||
|
|
d5ed9bc654 | ||
|
|
770065d9ed | ||
|
|
abc4154e2c | ||
|
|
fd6c9d5d34 | ||
|
|
dc428e7de0 | ||
|
|
0c51d79be7 | ||
|
|
1b489ba581 | ||
|
|
4d9f17b083 | ||
|
|
3c7cd2186f | ||
|
|
5acfd683b9 | ||
|
|
6b01901a4a | ||
|
|
1ca54afd6c | ||
|
|
9c75c2d22e | ||
|
|
79ec3ed2c3 | ||
|
|
7072d2cfe8 | ||
|
|
c0c08b0b84 | ||
|
|
01329195ee | ||
|
|
ad40b99313 | ||
|
|
1e338e48ab | ||
|
|
ac9c9598f4 | ||
|
|
02cb5dfc31 | ||
|
|
8109ffb445 | ||
|
|
0ecbcb89fa | ||
|
|
8f38c06424 | ||
|
|
902394f86e | ||
|
|
9fefd807f9 | ||
|
|
a8fb4a6d84 | ||
|
|
7806267e92 | ||
|
|
eb5e17a115 | ||
|
|
2ae98d628d | ||
|
|
8b9dc0e77f | ||
|
|
2f151cea64 | ||
|
|
b777e8cab1 | ||
|
|
663e37bd03 | ||
|
|
8960620883 | ||
|
|
5b892b3a63 | ||
|
|
974d5f2f49 | ||
|
|
f70881bb4f | ||
|
|
376c65335f | ||
|
|
d7a5c32b08 | ||
|
|
4cda182ccd | ||
|
|
60ac901c6c | ||
|
|
388afa8d3c |
@@ -30,24 +30,99 @@ from app.core.config import settings
|
||||
from app.helper.llm import LLMHelper
|
||||
from app.log import logger
|
||||
from app.schemas import Notification, NotificationType
|
||||
from app.schemas.message import ChannelCapabilityManager, ChannelCapability
|
||||
from app.schemas.types import MessageChannel
|
||||
|
||||
|
||||
class AgentChain(ChainBase):
|
||||
pass
|
||||
|
||||
|
||||
class _ThinkTagStripper:
|
||||
"""
|
||||
流式剥离 <think>...</think> 标签的辅助类。
|
||||
维护内部缓冲区,处理标签跨 token 边界被截断的情况。
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.buffer = ""
|
||||
self.in_think_tag = False
|
||||
|
||||
def reset(self):
|
||||
"""重置状态"""
|
||||
self.buffer = ""
|
||||
self.in_think_tag = False
|
||||
|
||||
def process(self, text: str, on_output: Callable[[str], None]):
|
||||
"""
|
||||
将新文本送入处理,剥离 <think> 标签后通过 on_output 回调输出。
|
||||
:param text: 新增的文本片段
|
||||
:param on_output: 输出回调,接收过滤后的文本
|
||||
:return: 本次调用是否通过 on_output 输出了内容
|
||||
"""
|
||||
self.buffer += text
|
||||
emitted = False
|
||||
while self.buffer:
|
||||
if not self.in_think_tag:
|
||||
start_idx = self.buffer.find("<think>")
|
||||
if start_idx != -1:
|
||||
if start_idx > 0:
|
||||
on_output(self.buffer[:start_idx])
|
||||
emitted = True
|
||||
self.in_think_tag = True
|
||||
self.buffer = self.buffer[start_idx + 7 :]
|
||||
else:
|
||||
# 检查是否以 <think> 的不完整前缀结尾
|
||||
partial_match = False
|
||||
for i in range(6, 0, -1):
|
||||
if self.buffer.endswith("<think>"[:i]):
|
||||
if len(self.buffer) > i:
|
||||
on_output(self.buffer[:-i])
|
||||
emitted = True
|
||||
self.buffer = self.buffer[-i:]
|
||||
partial_match = True
|
||||
break
|
||||
if not partial_match:
|
||||
on_output(self.buffer)
|
||||
emitted = True
|
||||
self.buffer = ""
|
||||
else:
|
||||
end_idx = self.buffer.find("</think>")
|
||||
if end_idx != -1:
|
||||
self.in_think_tag = False
|
||||
self.buffer = self.buffer[end_idx + 8 :]
|
||||
else:
|
||||
# 检查是否以 </think> 的不完整前缀结尾
|
||||
partial_match = False
|
||||
for i in range(7, 0, -1):
|
||||
if self.buffer.endswith("</think>"[:i]):
|
||||
self.buffer = self.buffer[-i:]
|
||||
partial_match = True
|
||||
break
|
||||
if not partial_match:
|
||||
self.buffer = ""
|
||||
break
|
||||
return emitted
|
||||
|
||||
def flush(self, on_output: Callable[[str], None]):
|
||||
"""流式结束时,输出缓冲区中剩余的非思考内容"""
|
||||
if self.buffer and not self.in_think_tag:
|
||||
on_output(self.buffer)
|
||||
self.buffer = ""
|
||||
|
||||
|
||||
class MoviePilotAgent:
|
||||
"""
|
||||
MoviePilot AI智能体(基于 LangChain v1 + LangGraph)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_id: str,
|
||||
user_id: str = None,
|
||||
channel: str = None,
|
||||
source: str = None,
|
||||
username: str = None,
|
||||
self,
|
||||
session_id: str,
|
||||
user_id: str = None,
|
||||
channel: str = None,
|
||||
source: str = None,
|
||||
username: str = None,
|
||||
):
|
||||
self.session_id = session_id
|
||||
self.user_id = user_id
|
||||
@@ -63,14 +138,37 @@ class MoviePilotAgent:
|
||||
"""
|
||||
是否为后台任务模式(无渠道信息,如定时唤醒)
|
||||
"""
|
||||
return not self.channel and not self.source
|
||||
return not self.channel or not self.source
|
||||
|
||||
def _should_stream(self) -> bool:
|
||||
"""
|
||||
判断是否应启用流式输出:
|
||||
- 后台模式不启用流式输出
|
||||
- 渠道支持消息编辑:启用流式输出(实时推送 token)
|
||||
- 渠道不支持消息编辑但开启了啰嗦模式:也需要启用流式输出,
|
||||
以便在工具调用前捕获 Agent 的中间文字并随工具消息一起发送
|
||||
- 其他情况不启用流式输出
|
||||
"""
|
||||
if self.is_background:
|
||||
return False
|
||||
# 啰嗦模式下始终需要流式输出来捕获工具调用前的 Agent 文字
|
||||
if settings.AI_AGENT_VERBOSE:
|
||||
return True
|
||||
try:
|
||||
channel_enum = MessageChannel(self.channel)
|
||||
return ChannelCapabilityManager.supports_capability(
|
||||
channel_enum, ChannelCapability.MESSAGE_EDITING
|
||||
)
|
||||
except (ValueError, KeyError):
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _initialize_llm():
|
||||
def _initialize_llm(streaming: bool = False):
|
||||
"""
|
||||
初始化 LLM(带流式回调)
|
||||
初始化 LLM
|
||||
:param streaming: 是否启用流式输出
|
||||
"""
|
||||
return LLMHelper.get_llm(streaming=True)
|
||||
return LLMHelper.get_llm(streaming=streaming)
|
||||
|
||||
@staticmethod
|
||||
def _extract_text_content(content) -> str:
|
||||
@@ -92,10 +190,10 @@ class MoviePilotAgent:
|
||||
if block.get("thought"):
|
||||
continue
|
||||
if block.get("type") in (
|
||||
"thinking",
|
||||
"reasoning_content",
|
||||
"reasoning",
|
||||
"thought",
|
||||
"thinking",
|
||||
"reasoning_content",
|
||||
"reasoning",
|
||||
"thought",
|
||||
):
|
||||
continue
|
||||
if block.get("type") == "text":
|
||||
@@ -118,16 +216,17 @@ class MoviePilotAgent:
|
||||
stream_handler=self.stream_handler,
|
||||
)
|
||||
|
||||
def _create_agent(self):
|
||||
def _create_agent(self, streaming: bool = False):
|
||||
"""
|
||||
创建 LangGraph Agent(使用 create_agent + SummarizationMiddleware)
|
||||
:param streaming: 是否启用流式输出
|
||||
"""
|
||||
try:
|
||||
# 系统提示词
|
||||
system_prompt = prompt_manager.get_agent_prompt(channel=self.channel)
|
||||
|
||||
# LLM 模型(用于 agent 执行)
|
||||
llm = self._initialize_llm()
|
||||
llm = self._initialize_llm(streaming=streaming)
|
||||
|
||||
# 工具列表
|
||||
tools = self._initialize_tools()
|
||||
@@ -209,7 +308,7 @@ class MoviePilotAgent:
|
||||
return error_message
|
||||
|
||||
async def _stream_agent_tokens(
|
||||
self, agent, messages: dict, config: dict, on_token: Callable[[str], None]
|
||||
self, agent, messages: dict, config: dict, on_token: Callable[[str], None]
|
||||
):
|
||||
"""
|
||||
流式运行智能体,过滤工具调用token和思考内容,将模型生成的内容通过回调输出。
|
||||
@@ -218,77 +317,64 @@ class MoviePilotAgent:
|
||||
:param config: Agent 运行配置
|
||||
:param on_token: 收到有效 token 时的回调
|
||||
"""
|
||||
in_think_tag = False
|
||||
buffer = ""
|
||||
stripper = _ThinkTagStripper()
|
||||
# 非VERBOSE模式下,跟踪当前langgraph_step以检测中间步骤的模型输出
|
||||
# 当模型在工具调用之前输出的"计划/思考"文本,会在检测到tool_call时被清除
|
||||
current_model_step = -1
|
||||
has_emitted_in_step = False
|
||||
|
||||
async for chunk in agent.astream(
|
||||
messages,
|
||||
stream_mode="messages",
|
||||
config=config,
|
||||
subgraphs=False,
|
||||
version="v2",
|
||||
messages,
|
||||
stream_mode="messages",
|
||||
config=config,
|
||||
subgraphs=False,
|
||||
version="v2",
|
||||
):
|
||||
if chunk["type"] == "messages":
|
||||
token, metadata = chunk["data"]
|
||||
if (
|
||||
token
|
||||
and hasattr(token, "tool_call_chunks")
|
||||
and not token.tool_call_chunks
|
||||
):
|
||||
# 跳过模型思考/推理内容(如 DeepSeek R1 的 reasoning_content)
|
||||
additional = getattr(token, "additional_kwargs", None)
|
||||
if additional and additional.get("reasoning_content"):
|
||||
continue
|
||||
if token.content:
|
||||
# content 可能是字符串或内容块列表,过滤掉思考类型的块
|
||||
content = self._extract_text_content(token.content)
|
||||
if content:
|
||||
buffer += content
|
||||
while buffer:
|
||||
if not in_think_tag:
|
||||
start_idx = buffer.find("<think>")
|
||||
if start_idx != -1:
|
||||
if start_idx > 0:
|
||||
on_token(buffer[:start_idx])
|
||||
in_think_tag = True
|
||||
buffer = buffer[start_idx + 7:]
|
||||
else:
|
||||
# 检查是否以 <think> 的前缀结尾
|
||||
partial_match = False
|
||||
for i in range(6, 0, -1):
|
||||
if buffer.endswith("<think>"[:i]):
|
||||
if len(buffer) > i:
|
||||
on_token(buffer[:-i])
|
||||
buffer = buffer[-i:]
|
||||
partial_match = True
|
||||
break
|
||||
if not partial_match:
|
||||
on_token(buffer)
|
||||
buffer = ""
|
||||
else:
|
||||
end_idx = buffer.find("</think>")
|
||||
if end_idx != -1:
|
||||
in_think_tag = False
|
||||
buffer = buffer[end_idx + 8:]
|
||||
else:
|
||||
# 检查是否以 </think> 的前缀结尾
|
||||
partial_match = False
|
||||
for i in range(7, 0, -1):
|
||||
if buffer.endswith("</think>"[:i]):
|
||||
buffer = buffer[-i:]
|
||||
partial_match = True
|
||||
break
|
||||
if not partial_match:
|
||||
buffer = ""
|
||||
if not token or not hasattr(token, "tool_call_chunks"):
|
||||
continue
|
||||
|
||||
if buffer and not in_think_tag:
|
||||
on_token(buffer)
|
||||
# 获取当前步骤信息
|
||||
step = metadata.get("langgraph_step", -1) if metadata else -1
|
||||
|
||||
if token.tool_call_chunks:
|
||||
# 检测到工具调用token:说明当前步骤是中间步骤
|
||||
# 非VERBOSE模式下,清除该步骤之前输出的"计划/思考"文本
|
||||
if not settings.AI_AGENT_VERBOSE and has_emitted_in_step:
|
||||
self.stream_handler.reset()
|
||||
stripper.reset()
|
||||
has_emitted_in_step = False
|
||||
continue
|
||||
|
||||
# 以下处理纯文本token(tool_call_chunks为空)
|
||||
|
||||
# 检测步骤变化,重置步骤内emit跟踪
|
||||
if step != current_model_step:
|
||||
current_model_step = step
|
||||
has_emitted_in_step = False
|
||||
|
||||
# 跳过模型思考/推理内容(如 DeepSeek R1 的 reasoning_content)
|
||||
additional = getattr(token, "additional_kwargs", None)
|
||||
if additional and additional.get("reasoning_content"):
|
||||
continue
|
||||
|
||||
if token.content:
|
||||
# content 可能是字符串或内容块列表,过滤掉思考类型的块
|
||||
content = self._extract_text_content(token.content)
|
||||
if content:
|
||||
if stripper.process(content, on_token):
|
||||
has_emitted_in_step = True
|
||||
|
||||
stripper.flush(on_token)
|
||||
|
||||
async def _execute_agent(self, messages: List[BaseMessage]):
|
||||
"""
|
||||
调用 LangGraph Agent,通过 astream 流式获取 token。
|
||||
支持流式输出:在支持消息编辑的渠道上实时推送 token。
|
||||
后台任务模式(无渠道信息):不进行流式输出,仅广播最终结果。
|
||||
调用 LangGraph Agent 执行推理。
|
||||
根据运行环境选择不同的执行模式:
|
||||
- 后台任务模式(无渠道信息):非流式 LLM + ainvoke,仅广播最终结果
|
||||
- 渠道不支持消息编辑:非流式 LLM + ainvoke,完成后发送最终回复
|
||||
- 渠道支持消息编辑:流式 LLM + astream,实时推送 token
|
||||
"""
|
||||
try:
|
||||
# Agent运行配置
|
||||
@@ -298,39 +384,14 @@ class MoviePilotAgent:
|
||||
}
|
||||
}
|
||||
|
||||
# 创建智能体
|
||||
agent = self._create_agent()
|
||||
# 判断是否启用流式输出
|
||||
use_streaming = self._should_stream()
|
||||
|
||||
if self.is_background:
|
||||
# 后台任务模式:非流式执行,等待完成后只取最后一条AI回复
|
||||
await agent.ainvoke(
|
||||
{"messages": messages},
|
||||
config=agent_config,
|
||||
)
|
||||
# 创建智能体(根据是否流式传入不同 LLM)
|
||||
agent = self._create_agent(streaming=use_streaming)
|
||||
|
||||
# 从最终状态中提取最后一条AI回复内容
|
||||
final_messages = agent.get_state(agent_config).values.get(
|
||||
"messages", []
|
||||
)
|
||||
final_text = ""
|
||||
for msg in reversed(final_messages):
|
||||
if hasattr(msg, "type") and msg.type == "ai" and msg.content:
|
||||
# 过滤掉思考/推理内容,只提取纯文本
|
||||
text = self._extract_text_content(msg.content)
|
||||
if text:
|
||||
# 过滤掉包含在 <think> 标签中的内容
|
||||
text = re.sub(
|
||||
r"<think>.*?(?:</think>|$)", "", text, flags=re.DOTALL
|
||||
)
|
||||
final_text = text.strip()
|
||||
break
|
||||
|
||||
# 后台任务仅广播最终回复,带标题
|
||||
if final_text:
|
||||
await self.send_agent_message(final_text, title="MoviePilot助手")
|
||||
|
||||
else:
|
||||
# 正常渠道模式:启动流式输出
|
||||
if use_streaming:
|
||||
# 流式模式:渠道支持消息编辑,启动流式输出实时推送 token
|
||||
await self.stream_handler.start_streaming(
|
||||
channel=self.channel,
|
||||
source=self.source,
|
||||
@@ -353,7 +414,7 @@ class MoviePilotAgent:
|
||||
) = await self.stream_handler.stop_streaming()
|
||||
|
||||
if not all_sent_via_stream:
|
||||
# 流式输出未能发送全部内容(渠道不支持编辑,或发送失败)
|
||||
# 流式输出未能发送全部内容(发送失败等)
|
||||
# 通过常规方式发送剩余内容
|
||||
remaining_text = await self.stream_handler.take()
|
||||
if remaining_text:
|
||||
@@ -362,6 +423,40 @@ class MoviePilotAgent:
|
||||
# 流式输出已发送全部内容,但未记录到数据库,补充保存消息记录
|
||||
await self._save_agent_message_to_db(streamed_text)
|
||||
|
||||
else:
|
||||
# 非流式模式:后台任务或渠道不支持消息编辑
|
||||
await agent.ainvoke(
|
||||
{"messages": messages},
|
||||
config=agent_config,
|
||||
)
|
||||
|
||||
# 从最终状态中提取最后一条AI回复内容
|
||||
final_messages = agent.get_state(agent_config).values.get(
|
||||
"messages", []
|
||||
)
|
||||
final_text = ""
|
||||
for msg in reversed(final_messages):
|
||||
if hasattr(msg, "type") and msg.type == "ai" and msg.content:
|
||||
# 过滤掉思考/推理内容,只提取纯文本
|
||||
text = self._extract_text_content(msg.content)
|
||||
if text:
|
||||
# 过滤掉包含在 <think> 标签中的内容
|
||||
text = re.sub(
|
||||
r"<think>.*?(?:</think>|$)", "", text, flags=re.DOTALL
|
||||
)
|
||||
final_text = text.strip()
|
||||
break
|
||||
|
||||
if final_text:
|
||||
if self.is_background:
|
||||
# 后台任务仅广播最终回复,带标题
|
||||
await self.send_agent_message(
|
||||
final_text, title="MoviePilot助手"
|
||||
)
|
||||
else:
|
||||
# 非流式渠道:发送最终回复
|
||||
await self.send_agent_message(final_text)
|
||||
|
||||
# 保存消息
|
||||
memory_manager.save_agent_messages(
|
||||
session_id=self.session_id,
|
||||
@@ -377,8 +472,7 @@ class MoviePilotAgent:
|
||||
return str(e), {}
|
||||
finally:
|
||||
# 确保停止流式输出
|
||||
if not self.is_background:
|
||||
await self.stream_handler.stop_streaming()
|
||||
await self.stream_handler.stop_streaming()
|
||||
|
||||
async def send_agent_message(self, message: str, title: str = ""):
|
||||
"""
|
||||
@@ -448,12 +542,21 @@ class AgentManager:
|
||||
同一会话的消息按顺序排队处理,不同会话之间互不影响。
|
||||
"""
|
||||
|
||||
# 批量重试整理的等待时间(秒),同一批次内的失败记录会合并为一次agent调用
|
||||
RETRY_TRANSFER_DEBOUNCE_SECONDS = 300
|
||||
|
||||
def __init__(self):
|
||||
self.active_agents: Dict[str, MoviePilotAgent] = {}
|
||||
# 每个会话的消息队列
|
||||
self._session_queues: Dict[str, asyncio.Queue] = {}
|
||||
# 每个会话的worker任务
|
||||
self._session_workers: Dict[str, asyncio.Task] = {}
|
||||
# 重试整理的 debounce 缓冲区: group_key -> List[history_id]
|
||||
self._retry_transfer_buffer: Dict[str, List[int]] = {}
|
||||
# 重试整理的 debounce 定时器: group_key -> asyncio.TimerHandle
|
||||
self._retry_transfer_timers: Dict[str, asyncio.TimerHandle] = {}
|
||||
# 重试整理缓冲区锁
|
||||
self._retry_transfer_lock = asyncio.Lock()
|
||||
|
||||
@staticmethod
|
||||
async def initialize():
|
||||
@@ -467,6 +570,11 @@ class AgentManager:
|
||||
关闭管理器
|
||||
"""
|
||||
await memory_manager.close()
|
||||
# 取消所有重试整理的延迟定时器
|
||||
for timer in self._retry_transfer_timers.values():
|
||||
timer.cancel()
|
||||
self._retry_transfer_timers.clear()
|
||||
self._retry_transfer_buffer.clear()
|
||||
# 取消所有会话worker
|
||||
for task in self._session_workers.values():
|
||||
task.cancel()
|
||||
@@ -483,14 +591,14 @@ class AgentManager:
|
||||
self.active_agents.clear()
|
||||
|
||||
async def process_message(
|
||||
self,
|
||||
session_id: str,
|
||||
user_id: str,
|
||||
message: str,
|
||||
images: List[str] = None,
|
||||
channel: str = None,
|
||||
source: str = None,
|
||||
username: str = None,
|
||||
self,
|
||||
session_id: str,
|
||||
user_id: str,
|
||||
message: str,
|
||||
images: List[str] = None,
|
||||
channel: str = None,
|
||||
source: str = None,
|
||||
username: str = None,
|
||||
) -> str:
|
||||
"""
|
||||
处理用户消息:将消息放入会话队列,按顺序依次处理。
|
||||
@@ -515,8 +623,8 @@ class AgentManager:
|
||||
|
||||
# 如果队列中已有等待的消息,通知用户消息已排队
|
||||
if queue_size > 0 or (
|
||||
session_id in self._session_workers
|
||||
and not self._session_workers[session_id].done()
|
||||
session_id in self._session_workers
|
||||
and not self._session_workers[session_id].done()
|
||||
):
|
||||
logger.info(
|
||||
f"会话 {session_id} 有任务正在处理,消息已排队等待 "
|
||||
@@ -528,8 +636,8 @@ class AgentManager:
|
||||
|
||||
# 确保该会话有一个worker在运行
|
||||
if (
|
||||
session_id not in self._session_workers
|
||||
or self._session_workers[session_id].done()
|
||||
session_id not in self._session_workers
|
||||
or self._session_workers[session_id].done()
|
||||
):
|
||||
self._session_workers[session_id] = asyncio.create_task(
|
||||
self._session_worker(session_id)
|
||||
@@ -570,8 +678,8 @@ class AgentManager:
|
||||
self._session_workers.pop(session_id, None) # noqa
|
||||
# 如果队列为空,清理队列
|
||||
if (
|
||||
session_id in self._session_queues
|
||||
and self._session_queues[session_id].empty()
|
||||
session_id in self._session_queues
|
||||
and self._session_queues[session_id].empty()
|
||||
):
|
||||
self._session_queues.pop(session_id, None)
|
||||
|
||||
@@ -604,6 +712,43 @@ class AgentManager:
|
||||
|
||||
return await agent.process(task.message, images=task.images)
|
||||
|
||||
async def stop_current_task(self, session_id: str):
|
||||
"""
|
||||
应急停止当前正在执行的Agent推理任务,但保留会话和记忆。
|
||||
与 clear_session 不同,此方法不会销毁Agent实例或清除记忆,
|
||||
用户可以在停止后继续对话。
|
||||
"""
|
||||
stopped = False
|
||||
|
||||
# 取消该会话的worker(会触发 _execute_agent 中的 CancelledError)
|
||||
if session_id in self._session_workers:
|
||||
self._session_workers[session_id].cancel()
|
||||
try:
|
||||
await self._session_workers[session_id]
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._session_workers.pop(session_id, None) # noqa
|
||||
stopped = True
|
||||
|
||||
# 清空队列中待处理的消息
|
||||
if session_id in self._session_queues:
|
||||
queue = self._session_queues[session_id]
|
||||
while not queue.empty():
|
||||
try:
|
||||
queue.get_nowait()
|
||||
queue.task_done()
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
self._session_queues.pop(session_id, None)
|
||||
stopped = True
|
||||
|
||||
if stopped:
|
||||
logger.info(f"会话 {session_id} 的Agent推理已应急停止")
|
||||
else:
|
||||
logger.debug(f"会话 {session_id} 没有正在执行的Agent任务")
|
||||
|
||||
return stopped
|
||||
|
||||
async def clear_session(self, session_id: str, user_id: str):
|
||||
"""
|
||||
清空会话
|
||||
@@ -684,6 +829,144 @@ class AgentManager:
|
||||
except Exception as e:
|
||||
logger.error(f"智能体心跳唤醒失败: {e}")
|
||||
|
||||
async def retry_failed_transfer(self, history_id: int, group_key: str = ""):
|
||||
"""
|
||||
触发智能体重新整理失败的历史记录。
|
||||
由文件整理模块在检测到整理失败后调用。
|
||||
同一 group_key 的失败记录会在缓冲期内合并为一次agent调用,避免重复浪费token。
|
||||
:param history_id: 失败的整理历史记录ID
|
||||
:param group_key: 分组键,相同key的记录会被合并处理(如download_hash、源目录等)
|
||||
"""
|
||||
if not group_key:
|
||||
group_key = f"_default_{history_id}"
|
||||
|
||||
async with self._retry_transfer_lock:
|
||||
# 将 history_id 加入缓冲区
|
||||
if group_key not in self._retry_transfer_buffer:
|
||||
self._retry_transfer_buffer[group_key] = []
|
||||
if history_id not in self._retry_transfer_buffer[group_key]:
|
||||
self._retry_transfer_buffer[group_key].append(history_id)
|
||||
logger.info(
|
||||
f"智能体重试整理:记录 ID={history_id} 已加入缓冲区 "
|
||||
f"(group={group_key}, 当前{len(self._retry_transfer_buffer[group_key])}条)"
|
||||
)
|
||||
|
||||
# 取消该分组的旧定时器
|
||||
if group_key in self._retry_transfer_timers:
|
||||
self._retry_transfer_timers[group_key].cancel()
|
||||
|
||||
# 设置新的延迟定时器
|
||||
loop = asyncio.get_running_loop()
|
||||
self._retry_transfer_timers[group_key] = loop.call_later(
|
||||
self.RETRY_TRANSFER_DEBOUNCE_SECONDS,
|
||||
lambda gk=group_key: asyncio.ensure_future(
|
||||
self._flush_retry_transfer(gk)
|
||||
),
|
||||
)
|
||||
|
||||
async def _flush_retry_transfer(self, group_key: str):
|
||||
"""
|
||||
延迟定时器到期后,取出该分组的所有 history_id 并合并为一次agent调用。
|
||||
"""
|
||||
async with self._retry_transfer_lock:
|
||||
history_ids = self._retry_transfer_buffer.pop(group_key, [])
|
||||
self._retry_transfer_timers.pop(group_key, None)
|
||||
|
||||
if not history_ids:
|
||||
return
|
||||
|
||||
try:
|
||||
session_id = f"__agent_retry_transfer_batch_{uuid.uuid4().hex[:8]}__"
|
||||
user_id = "system"
|
||||
|
||||
ids_str = ", ".join(str(i) for i in history_ids)
|
||||
logger.info(
|
||||
f"智能体重试整理:开始批量处理失败记录 IDs=[{ids_str}] (group={group_key})"
|
||||
)
|
||||
|
||||
if len(history_ids) == 1:
|
||||
# 单条记录,使用原有逻辑
|
||||
retry_message = (
|
||||
f"[System Task - Transfer Failed Retry] A file transfer/organization has failed. "
|
||||
f"Please use the 'transfer-failed-retry' skill to retry the failed transfer.\n\n"
|
||||
f"Failed transfer history record ID: {history_ids[0]}\n\n"
|
||||
f"Follow these steps:\n"
|
||||
f"1. Use `query_transfer_history` with status='failed' to find the record with id={history_ids[0]} "
|
||||
f"and understand the failure details (source path, error message, media info)\n"
|
||||
f"2. Analyze the error message to determine the best retry strategy\n"
|
||||
f"3. If the source file no longer exists, skip this retry and report that the file is missing\n"
|
||||
f"4. Delete the failed history record using `delete_transfer_history` with history_id={history_ids[0]}\n"
|
||||
f"5. Re-identify the media using `recognize_media` with the source file path\n"
|
||||
f"6. If recognition fails, try `search_media` with keywords from the filename\n"
|
||||
f"7. Re-transfer using `transfer_file` with the source path and any identified media info (tmdbid, media_type)\n"
|
||||
f"8. Report the final result\n\n"
|
||||
f"IMPORTANT: This is a background system task, NOT a user conversation. "
|
||||
f"Your final response will be broadcast as a notification. "
|
||||
f"Only output a brief result summary. "
|
||||
f"Do NOT include greetings, explanations, or conversational text. "
|
||||
f"Respond in Chinese (中文)."
|
||||
)
|
||||
else:
|
||||
# 多条记录,使用批量处理逻辑
|
||||
retry_message = (
|
||||
f"[System Task - Batch Transfer Failed Retry] Multiple file transfers from the same source "
|
||||
f"have failed. These files likely belong to the SAME media (e.g., multiple episodes of the same TV show). "
|
||||
f"Please use the 'transfer-failed-retry' skill to retry them efficiently.\n\n"
|
||||
f"Failed transfer history record IDs: {ids_str}\n"
|
||||
f"Total failed records: {len(history_ids)}\n\n"
|
||||
f"Follow these steps:\n"
|
||||
f"1. Use `query_transfer_history` with status='failed' to find ALL records with these IDs "
|
||||
f"and understand the failure details\n"
|
||||
f"2. Since these files are likely from the same media, analyze the FIRST record to determine "
|
||||
f"the media identity and the best retry strategy. The root cause is usually the same for all files.\n"
|
||||
f"3. If the error is about media recognition (e.g., '未识别到媒体信息'), identify the media ONCE "
|
||||
f"using `recognize_media` or `search_media`, then reuse that result (tmdbid, media_type) for all files\n"
|
||||
f"4. For EACH failed record:\n"
|
||||
f" a. Delete the failed history record using `delete_transfer_history`\n"
|
||||
f" b. Re-transfer using `transfer_file` with the source path and the identified media info\n"
|
||||
f"5. Report a summary of results (how many succeeded, how many failed)\n\n"
|
||||
f"IMPORTANT OPTIMIZATION: These files share the same media identity. "
|
||||
f"Do NOT call `recognize_media` or `search_media` repeatedly for each file. "
|
||||
f"Identify the media ONCE, then apply to all files.\n\n"
|
||||
f"IMPORTANT: This is a background system task, NOT a user conversation. "
|
||||
f"Your final response will be broadcast as a notification. "
|
||||
f"Only output a brief result summary. "
|
||||
f"Do NOT include greetings, explanations, or conversational text. "
|
||||
f"Respond in Chinese (中文)."
|
||||
)
|
||||
|
||||
await self.process_message(
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
message=retry_message,
|
||||
channel=None,
|
||||
source=None,
|
||||
username=settings.SUPERUSER,
|
||||
)
|
||||
|
||||
# 等待消息队列处理完成
|
||||
if session_id in self._session_queues:
|
||||
await self._session_queues[session_id].join()
|
||||
|
||||
# 等待worker结束
|
||||
if session_id in self._session_workers:
|
||||
try:
|
||||
await self._session_workers[session_id]
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
logger.info(
|
||||
f"智能体重试整理:批量处理完成 IDs=[{ids_str}] (group={group_key})"
|
||||
)
|
||||
|
||||
# 用完即弃,清理资源
|
||||
await self.clear_session(session_id, user_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"智能体重试整理失败 (IDs=[{ids_str}], group={group_key}): {e}"
|
||||
)
|
||||
|
||||
|
||||
# 全局智能体管理器实例
|
||||
agent_manager = AgentManager()
|
||||
|
||||
@@ -38,7 +38,7 @@ class StreamingHandler:
|
||||
"""
|
||||
|
||||
# 流式输出的刷新间隔(秒)
|
||||
FLUSH_INTERVAL = 1.0
|
||||
FLUSH_INTERVAL = 0.3
|
||||
|
||||
def __init__(self):
|
||||
self._lock = threading.Lock()
|
||||
@@ -120,7 +120,9 @@ class StreamingHandler:
|
||||
title: str = "",
|
||||
):
|
||||
"""
|
||||
启动流式输出。检查渠道是否支持消息编辑,如果支持则启动定时刷新任务。
|
||||
启动流式输出。
|
||||
始终标记为流式状态(用于 buffer 收集 token),
|
||||
但只有渠道支持消息编辑时才启动定时刷新任务(实时推送给用户)。
|
||||
:param channel: 消息渠道
|
||||
:param source: 消息来源
|
||||
:param user_id: 用户ID
|
||||
@@ -133,16 +135,16 @@ class StreamingHandler:
|
||||
self._username = username
|
||||
self._title = title
|
||||
|
||||
# 检查渠道是否支持消息编辑
|
||||
if not self._can_stream():
|
||||
logger.debug(f"渠道 {channel} 不支持消息编辑,不启用流式输出")
|
||||
return
|
||||
|
||||
self._streaming_enabled = True
|
||||
self._sent_text = ""
|
||||
self._message_response = None
|
||||
self._msg_start_offset = 0
|
||||
|
||||
# 检查渠道是否支持消息编辑,不支持则仅收集 token 到 buffer,不实时推送
|
||||
if not self._can_stream():
|
||||
logger.debug(f"渠道 {channel} 不支持消息编辑,仅启用 buffer 收集模式")
|
||||
return
|
||||
|
||||
# 从渠道能力中获取单条消息最大长度
|
||||
try:
|
||||
channel_enum = MessageChannel(self._channel)
|
||||
@@ -345,6 +347,13 @@ class StreamingHandler:
|
||||
"""
|
||||
return self._streaming_enabled
|
||||
|
||||
@property
|
||||
def is_auto_flushing(self) -> bool:
|
||||
"""
|
||||
是否正在定时刷新(渠道支持消息编辑时自动推送 buffer 内容)
|
||||
"""
|
||||
return self._flush_task is not None
|
||||
|
||||
@property
|
||||
def has_sent_message(self) -> bool:
|
||||
"""
|
||||
|
||||
@@ -47,6 +47,11 @@ class SkillMetadata(TypedDict):
|
||||
约束: Skill中文描述。
|
||||
"""
|
||||
|
||||
version: int
|
||||
"""Skill 版本号。
|
||||
用于内置技能的版本管理,同步时比较版本号决定是否覆盖用户目录中的旧版本。
|
||||
"""
|
||||
|
||||
description: str
|
||||
"""Skill 功能描述。
|
||||
约束: 1-1024 字符,应说明功能及适用场景。
|
||||
@@ -154,9 +159,23 @@ def _parse_skill_metadata( # noqa: C901
|
||||
)
|
||||
compatibility_str = compatibility_str[:MAX_SKILL_COMPATIBILITY_LENGTH]
|
||||
|
||||
# 版本号,默认为 0(表示未设置版本)
|
||||
raw_version = frontmatter_data.get("version")
|
||||
version = 0
|
||||
if raw_version is not None:
|
||||
try:
|
||||
version = int(raw_version)
|
||||
except (ValueError, TypeError):
|
||||
logger.warning(
|
||||
"Invalid 'version' in %s (got %r), defaulting to 0",
|
||||
skill_path,
|
||||
raw_version,
|
||||
)
|
||||
|
||||
return SkillMetadata(
|
||||
id=skill_id,
|
||||
name=name,
|
||||
version=version,
|
||||
description=description_str,
|
||||
path=skill_path,
|
||||
metadata=_validate_metadata(frontmatter_data.get("metadata", {}), skill_path),
|
||||
@@ -287,10 +306,38 @@ Remember: Skills make you more capable and consistent. When in doubt, check if a
|
||||
"""
|
||||
|
||||
|
||||
def _extract_version(skill_md: Path) -> int:
|
||||
"""从 SKILL.md 文件中快速提取 version 字段,无法提取时返回 0。"""
|
||||
try:
|
||||
content = skill_md.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
return 0
|
||||
match = re.match(r"^---\s*\n(.*?)\n---\s*\n", content, re.DOTALL)
|
||||
if not match:
|
||||
return 0
|
||||
try:
|
||||
frontmatter = yaml.safe_load(match.group(1))
|
||||
except yaml.YAMLError:
|
||||
return 0
|
||||
if not isinstance(frontmatter, dict):
|
||||
return 0
|
||||
raw = frontmatter.get("version")
|
||||
if raw is None:
|
||||
return 0
|
||||
try:
|
||||
return int(raw)
|
||||
except (ValueError, TypeError):
|
||||
return 0
|
||||
|
||||
|
||||
def _sync_bundled_skills(bundled_dir: Path, target_dir: Path) -> None:
|
||||
"""将项目自带的技能同步到用户目录。
|
||||
|
||||
仅当目标目录中不存在对应技能子目录时才复制,已存在则跳过(不覆盖用户修改)。
|
||||
- 目标目录中不存在对应技能子目录时,直接复制。
|
||||
- 目标目录中已存在时,比较内置与用户目录中 SKILL.md 的 version 字段:
|
||||
- 内置版本更高时,直接覆盖用户目录中的旧版本。
|
||||
- 版本相同或用户版本更高时,跳过。
|
||||
- 内置 SKILL.md 无 version 字段(视为 0)时,不覆盖。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
@@ -312,15 +359,43 @@ def _sync_bundled_skills(bundled_dir: Path, target_dir: Path) -> None:
|
||||
continue
|
||||
|
||||
skill_dst = target_dir / skill_src.name
|
||||
if skill_dst.exists():
|
||||
# 目标已存在,跳过(不覆盖用户自定义修改)
|
||||
|
||||
if not skill_dst.exists():
|
||||
# 目标不存在,直接复制
|
||||
try:
|
||||
shutil.copytree(str(skill_src), str(skill_dst))
|
||||
logger.info(
|
||||
"已自动复制内置技能 '%s' -> '%s'", skill_src.name, skill_dst
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("复制内置技能 '%s' 失败: %s", skill_src.name, e)
|
||||
continue
|
||||
|
||||
# 目标已存在,比较版本号
|
||||
bundled_version = _extract_version(skill_md)
|
||||
if bundled_version <= 0:
|
||||
# 内置技能无版本号,保持旧逻辑不覆盖
|
||||
continue
|
||||
|
||||
user_skill_md = skill_dst / "SKILL.md"
|
||||
user_version = _extract_version(user_skill_md) if user_skill_md.is_file() else 0
|
||||
|
||||
if bundled_version <= user_version:
|
||||
# 用户版本 >= 内置版本,跳过
|
||||
continue
|
||||
|
||||
# 内置版本更高,删除旧版本后覆盖
|
||||
try:
|
||||
shutil.rmtree(str(skill_dst))
|
||||
shutil.copytree(str(skill_src), str(skill_dst))
|
||||
logger.info("已自动复制内置技能 '%s' -> '%s'", skill_src.name, skill_dst)
|
||||
logger.info(
|
||||
"已更新内置技能 '%s' (v%d -> v%d)",
|
||||
skill_src.name,
|
||||
user_version,
|
||||
bundled_version,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("复制内置技能 '%s' 失败: %s", skill_src.name, e)
|
||||
logger.warning("更新内置技能 '%s' 失败: %s", skill_src.name, e)
|
||||
|
||||
|
||||
class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # noqa
|
||||
|
||||
@@ -65,29 +65,27 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
# 发送工具执行过程消息
|
||||
if self._stream_handler and self._stream_handler.is_streaming:
|
||||
if settings.AI_AGENT_VERBOSE:
|
||||
# VERBOSE:工具消息直接追加到 buffer 中,与 Agent 文字合并为同一条流式消息
|
||||
if tool_message:
|
||||
self._stream_handler.emit(f"\n\n⚙️ => {tool_message}\n\n")
|
||||
if self._stream_handler.is_auto_flushing:
|
||||
# 渠道支持编辑:工具消息追加到 buffer,由定时刷新推送
|
||||
if tool_message:
|
||||
self._stream_handler.emit(f"\n\n⚙️ => {tool_message}\n\n")
|
||||
else:
|
||||
# 渠道不支持编辑:取出 Agent 文字 + 工具消息合并独立发送
|
||||
agent_message = await self._stream_handler.take()
|
||||
messages = []
|
||||
if agent_message:
|
||||
messages.append(agent_message)
|
||||
if tool_message:
|
||||
messages.append(f"⚙️ => {tool_message}")
|
||||
if messages:
|
||||
merged_message = "\n\n".join(messages)
|
||||
await self.send_tool_message(merged_message)
|
||||
else:
|
||||
# 非VERBOSE,重置缓冲区从头更新,保持消息编辑能力
|
||||
self._stream_handler.reset()
|
||||
else:
|
||||
# 后台模式(无渠道信息)不发送工具调用消息
|
||||
if self._channel:
|
||||
# 非流式渠道:保持原有行为,取出 Agent 文字 + 工具消息合并独立发送
|
||||
agent_message = (
|
||||
await self._stream_handler.take() if self._stream_handler else ""
|
||||
)
|
||||
|
||||
messages = []
|
||||
if agent_message:
|
||||
messages.append(agent_message)
|
||||
if tool_message:
|
||||
messages.append(f"⚙️ => {tool_message}")
|
||||
|
||||
if messages:
|
||||
merged_message = "\n\n".join(messages)
|
||||
await self.send_tool_message(merged_message)
|
||||
# 未启用流式传输,不发送任何工具消息内容
|
||||
pass
|
||||
|
||||
logger.debug(f"Executing tool {self.name} with args: {kwargs}")
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ from app.agent.tools.impl.run_workflow import RunWorkflowTool
|
||||
from app.agent.tools.impl.update_site_cookie import UpdateSiteCookieTool
|
||||
from app.agent.tools.impl.delete_download import DeleteDownloadTool
|
||||
from app.agent.tools.impl.delete_download_history import DeleteDownloadHistoryTool
|
||||
from app.agent.tools.impl.delete_transfer_history import DeleteTransferHistoryTool
|
||||
from app.agent.tools.impl.modify_download import ModifyDownloadTool
|
||||
from app.agent.tools.impl.query_directory_settings import QueryDirectorySettingsTool
|
||||
from app.agent.tools.impl.list_directory import ListDirectoryTool
|
||||
@@ -49,7 +50,10 @@ from app.agent.tools.impl.read_file import ReadFileTool
|
||||
from app.agent.tools.impl.browse_webpage import BrowseWebpageTool
|
||||
from app.agent.tools.impl.query_installed_plugins import QueryInstalledPluginsTool
|
||||
from app.agent.tools.impl.query_plugin_capabilities import QueryPluginCapabilitiesTool
|
||||
from app.agent.tools.impl.run_plugin_command import RunPluginCommandTool
|
||||
from app.agent.tools.impl.run_slash_command import RunSlashCommandTool
|
||||
from app.agent.tools.impl.list_slash_commands import ListSlashCommandsTool
|
||||
from app.agent.tools.impl.query_custom_identifiers import QueryCustomIdentifiersTool
|
||||
from app.agent.tools.impl.update_custom_identifiers import UpdateCustomIdentifiersTool
|
||||
from app.core.plugin import PluginManager
|
||||
from app.log import logger
|
||||
from .base import MoviePilotTool
|
||||
@@ -97,6 +101,7 @@ class MoviePilotToolFactory:
|
||||
QueryDownloadTasksTool,
|
||||
DeleteDownloadTool,
|
||||
DeleteDownloadHistoryTool,
|
||||
DeleteTransferHistoryTool,
|
||||
ModifyDownloadTool,
|
||||
QueryDownloadersTool,
|
||||
QuerySitesTool,
|
||||
@@ -123,7 +128,10 @@ class MoviePilotToolFactory:
|
||||
BrowseWebpageTool,
|
||||
QueryInstalledPluginsTool,
|
||||
QueryPluginCapabilitiesTool,
|
||||
RunPluginCommandTool,
|
||||
RunSlashCommandTool,
|
||||
ListSlashCommandsTool,
|
||||
QueryCustomIdentifiersTool,
|
||||
UpdateCustomIdentifiersTool,
|
||||
]
|
||||
# 创建内置工具
|
||||
for ToolClass in tool_definitions:
|
||||
|
||||
57
app/agent/tools/impl/delete_transfer_history.py
Normal file
57
app/agent/tools/impl/delete_transfer_history.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""删除整理历史记录工具"""
|
||||
|
||||
from typing import Optional, Type
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.db.transferhistory_oper import TransferHistoryOper
|
||||
from app.log import logger
|
||||
|
||||
|
||||
class DeleteTransferHistoryInput(BaseModel):
|
||||
"""删除整理历史记录工具的输入参数模型"""
|
||||
|
||||
explanation: str = Field(
|
||||
...,
|
||||
description="Clear explanation of why this tool is being used in the current context",
|
||||
)
|
||||
history_id: int = Field(
|
||||
..., description="The ID of the transfer history record to delete"
|
||||
)
|
||||
|
||||
|
||||
class DeleteTransferHistoryTool(MoviePilotTool):
|
||||
name: str = "delete_transfer_history"
|
||||
description: str = "Delete a specific transfer history record by its ID. This is useful when you need to remove a failed transfer record before retrying the transfer, as the system skips files that already have transfer history."
|
||||
args_schema: Type[BaseModel] = DeleteTransferHistoryInput
|
||||
require_admin: bool = True
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""根据参数生成友好的提示消息"""
|
||||
history_id = kwargs.get("history_id")
|
||||
return f"正在删除整理历史记录: ID={history_id}"
|
||||
|
||||
async def run(self, history_id: int, **kwargs) -> str:
|
||||
logger.info(f"执行工具: {self.name}, 参数: history_id={history_id}")
|
||||
|
||||
try:
|
||||
transferhis = TransferHistoryOper()
|
||||
|
||||
# 查询历史记录是否存在
|
||||
history = transferhis.get(history_id)
|
||||
if not history:
|
||||
return f"错误:整理历史记录不存在,ID={history_id}"
|
||||
|
||||
# 保存信息用于返回
|
||||
title = history.title or "未知"
|
||||
src = history.src or "未知"
|
||||
status = "成功" if history.status else "失败"
|
||||
|
||||
# 删除记录
|
||||
transferhis.delete(history_id)
|
||||
|
||||
return f"已删除整理历史记录:ID={history_id},标题={title},源路径={src},状态={status}"
|
||||
except Exception as e:
|
||||
logger.error(f"删除整理历史记录失败: {e}", exc_info=True)
|
||||
return f"删除整理历史记录时发生错误: {str(e)}"
|
||||
@@ -13,40 +13,48 @@ from app.schemas.types import MediaType, media_type_to_agent
|
||||
|
||||
class GetRecommendationsInput(BaseModel):
|
||||
"""获取推荐工具的输入参数模型"""
|
||||
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
|
||||
source: Optional[str] = Field("tmdb_trending",
|
||||
description="Recommendation source: "
|
||||
"'tmdb_trending' for TMDB trending content, "
|
||||
"'tmdb_movies' for TMDB popular movies, "
|
||||
"'tmdb_tvs' for TMDB popular TV shows, "
|
||||
"'douban_hot' for Douban popular content, "
|
||||
"'douban_movie_hot' for Douban hot movies, "
|
||||
"'douban_tv_hot' for Douban hot TV shows, "
|
||||
"'douban_movie_showing' for Douban movies currently showing, "
|
||||
"'douban_movies' for Douban latest movies, "
|
||||
"'douban_tvs' for Douban latest TV shows, "
|
||||
"'douban_movie_top250' for Douban movie TOP250, "
|
||||
"'douban_tv_weekly_chinese' for Douban Chinese TV weekly chart, "
|
||||
"'douban_tv_weekly_global' for Douban global TV weekly chart, "
|
||||
"'douban_tv_animation' for Douban popular animation, "
|
||||
"'bangumi_calendar' for Bangumi anime calendar")
|
||||
media_type: Optional[str] = Field("all",
|
||||
description="Allowed values: movie, tv, all")
|
||||
limit: Optional[int] = Field(20,
|
||||
description="Maximum number of recommendations to return (default: 20, maximum: 100)")
|
||||
|
||||
explanation: str = Field(
|
||||
...,
|
||||
description="Clear explanation of why this tool is being used in the current context",
|
||||
)
|
||||
source: Optional[str] = Field(
|
||||
"tmdb_trending",
|
||||
description="Recommendation source: "
|
||||
"'tmdb_trending' for TMDB trending content, "
|
||||
"'tmdb_movies' for TMDB popular movies, "
|
||||
"'tmdb_tvs' for TMDB popular TV shows, "
|
||||
"'douban_hot' for Douban popular content, "
|
||||
"'douban_movie_hot' for Douban hot movies, "
|
||||
"'douban_tv_hot' for Douban hot TV shows, "
|
||||
"'douban_movie_showing' for Douban movies currently showing, "
|
||||
"'douban_movies' for Douban latest movies, "
|
||||
"'douban_tvs' for Douban latest TV shows, "
|
||||
"'douban_movie_top250' for Douban movie TOP250, "
|
||||
"'douban_tv_weekly_chinese' for Douban Chinese TV weekly chart, "
|
||||
"'douban_tv_weekly_global' for Douban global TV weekly chart, "
|
||||
"'douban_tv_animation' for Douban popular animation, "
|
||||
"'bangumi_calendar' for Bangumi anime calendar",
|
||||
)
|
||||
media_type: Optional[str] = Field(
|
||||
"all", description="Allowed values: movie, tv, all"
|
||||
)
|
||||
page: Optional[int] = Field(
|
||||
1, description="Page number for pagination (default: 1, 20 items per page)"
|
||||
)
|
||||
|
||||
|
||||
class GetRecommendationsTool(MoviePilotTool):
|
||||
name: str = "get_recommendations"
|
||||
description: str = "Get trending and popular media recommendations from various sources. Returns curated lists of popular movies, TV shows, and anime based on different criteria like trending, ratings, or calendar schedules."
|
||||
description: str = "Get trending and popular media recommendations from various sources. Returns curated lists of popular movies, TV shows, and anime based on different criteria like trending, ratings, or calendar schedules. Supports pagination with 20 items per page."
|
||||
args_schema: Type[BaseModel] = GetRecommendationsInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""根据推荐参数生成友好的提示消息"""
|
||||
source = kwargs.get("source", "tmdb_trending")
|
||||
media_type = kwargs.get("media_type", "all")
|
||||
limit = kwargs.get("limit", 20)
|
||||
|
||||
page = kwargs.get("page", 1)
|
||||
|
||||
source_map = {
|
||||
"tmdb_trending": "TMDB流行趋势",
|
||||
"tmdb_movies": "TMDB热门电影",
|
||||
@@ -61,20 +69,29 @@ class GetRecommendationsTool(MoviePilotTool):
|
||||
"douban_tv_weekly_chinese": "豆瓣国产剧集榜",
|
||||
"douban_tv_weekly_global": "豆瓣全球剧集榜",
|
||||
"douban_tv_animation": "豆瓣热门动漫",
|
||||
"bangumi_calendar": "番组计划"
|
||||
"bangumi_calendar": "番组计划",
|
||||
}
|
||||
source_desc = source_map.get(source, source)
|
||||
|
||||
|
||||
message = f"正在获取推荐: {source_desc}"
|
||||
if media_type != "all":
|
||||
message += f" [{media_type}]"
|
||||
message += f" (限制: {limit}条)"
|
||||
|
||||
message += f" (第{page}页)"
|
||||
|
||||
return message
|
||||
|
||||
async def run(self, source: Optional[str] = "tmdb_trending",
|
||||
media_type: Optional[str] = "all", limit: Optional[int] = 20, **kwargs) -> str:
|
||||
logger.info(f"执行工具: {self.name}, 参数: source={source}, media_type={media_type}, limit={limit}")
|
||||
async def run(
|
||||
self,
|
||||
source: Optional[str] = "tmdb_trending",
|
||||
media_type: Optional[str] = "all",
|
||||
page: Optional[int] = 1,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
page = max(1, page or 1)
|
||||
page_size = 20
|
||||
logger.info(
|
||||
f"执行工具: {self.name}, 参数: source={source}, media_type={media_type}, page={page}"
|
||||
)
|
||||
try:
|
||||
if media_type != "all":
|
||||
media_type_enum = MediaType.from_agent(media_type)
|
||||
@@ -85,73 +102,103 @@ class GetRecommendationsTool(MoviePilotTool):
|
||||
recommend_chain = RecommendChain()
|
||||
results = []
|
||||
if source == "tmdb_trending":
|
||||
# async_tmdb_trending 只接受 page 参数,返回固定数量的结果
|
||||
# 如果需要限制数量,需要在返回后截取
|
||||
results = await recommend_chain.async_tmdb_trending(page=1)
|
||||
if limit and limit > 0:
|
||||
results = results[:limit]
|
||||
results = await recommend_chain.async_tmdb_trending(page=page)
|
||||
elif source == "tmdb_movies":
|
||||
# async_tmdb_movies 接受 page 参数,返回固定数量的结果
|
||||
results = await recommend_chain.async_tmdb_movies(page=1)
|
||||
if limit and limit > 0:
|
||||
results = results[:limit]
|
||||
results = await recommend_chain.async_tmdb_movies(page=page)
|
||||
elif source == "tmdb_tvs":
|
||||
# async_tmdb_tvs 接受 page 参数,返回固定数量的结果
|
||||
results = await recommend_chain.async_tmdb_tvs(page=1)
|
||||
if limit and limit > 0:
|
||||
results = results[:limit]
|
||||
results = await recommend_chain.async_tmdb_tvs(page=page)
|
||||
elif source == "douban_hot":
|
||||
if media_type == "movie":
|
||||
results = await recommend_chain.async_douban_movie_hot(page=1, count=limit)
|
||||
results = await recommend_chain.async_douban_movie_hot(
|
||||
page=page, count=page_size
|
||||
)
|
||||
elif media_type == "tv":
|
||||
results = await recommend_chain.async_douban_tv_hot(page=1, count=limit)
|
||||
results = await recommend_chain.async_douban_tv_hot(
|
||||
page=page, count=page_size
|
||||
)
|
||||
else: # all
|
||||
results.extend(await recommend_chain.async_douban_movie_hot(page=1, count=limit))
|
||||
results.extend(await recommend_chain.async_douban_tv_hot(page=1, count=limit))
|
||||
results.extend(
|
||||
await recommend_chain.async_douban_movie_hot(
|
||||
page=page, count=page_size
|
||||
)
|
||||
)
|
||||
results.extend(
|
||||
await recommend_chain.async_douban_tv_hot(
|
||||
page=page, count=page_size
|
||||
)
|
||||
)
|
||||
elif source == "douban_movie_hot":
|
||||
results = await recommend_chain.async_douban_movie_hot(page=1, count=limit)
|
||||
results = await recommend_chain.async_douban_movie_hot(
|
||||
page=page, count=page_size
|
||||
)
|
||||
elif source == "douban_tv_hot":
|
||||
results = await recommend_chain.async_douban_tv_hot(page=1, count=limit)
|
||||
results = await recommend_chain.async_douban_tv_hot(
|
||||
page=page, count=page_size
|
||||
)
|
||||
elif source == "douban_movie_showing":
|
||||
results = await recommend_chain.async_douban_movie_showing(page=1, count=limit)
|
||||
results = await recommend_chain.async_douban_movie_showing(
|
||||
page=page, count=page_size
|
||||
)
|
||||
elif source == "douban_movies":
|
||||
results = await recommend_chain.async_douban_movies(page=1, count=limit)
|
||||
results = await recommend_chain.async_douban_movies(
|
||||
page=page, count=page_size
|
||||
)
|
||||
elif source == "douban_tvs":
|
||||
results = await recommend_chain.async_douban_tvs(page=1, count=limit)
|
||||
results = await recommend_chain.async_douban_tvs(
|
||||
page=page, count=page_size
|
||||
)
|
||||
elif source == "douban_movie_top250":
|
||||
results = await recommend_chain.async_douban_movie_top250(page=1, count=limit)
|
||||
results = await recommend_chain.async_douban_movie_top250(
|
||||
page=page, count=page_size
|
||||
)
|
||||
elif source == "douban_tv_weekly_chinese":
|
||||
results = await recommend_chain.async_douban_tv_weekly_chinese(page=1, count=limit)
|
||||
results = await recommend_chain.async_douban_tv_weekly_chinese(
|
||||
page=page, count=page_size
|
||||
)
|
||||
elif source == "douban_tv_weekly_global":
|
||||
results = await recommend_chain.async_douban_tv_weekly_global(page=1, count=limit)
|
||||
results = await recommend_chain.async_douban_tv_weekly_global(
|
||||
page=page, count=page_size
|
||||
)
|
||||
elif source == "douban_tv_animation":
|
||||
results = await recommend_chain.async_douban_tv_animation(page=1, count=limit)
|
||||
results = await recommend_chain.async_douban_tv_animation(
|
||||
page=page, count=page_size
|
||||
)
|
||||
elif source == "bangumi_calendar":
|
||||
results = await recommend_chain.async_bangumi_calendar(page=1, count=limit)
|
||||
results = await recommend_chain.async_bangumi_calendar(
|
||||
page=page, count=page_size
|
||||
)
|
||||
else:
|
||||
# 不支持的推荐来源
|
||||
supported_sources = [
|
||||
"tmdb_trending", "tmdb_movies", "tmdb_tvs",
|
||||
"douban_hot", "douban_movie_hot", "douban_tv_hot",
|
||||
"douban_movie_showing", "douban_movies", "douban_tvs",
|
||||
"douban_movie_top250", "douban_tv_weekly_chinese",
|
||||
"douban_tv_weekly_global", "douban_tv_animation",
|
||||
"bangumi_calendar"
|
||||
"tmdb_trending",
|
||||
"tmdb_movies",
|
||||
"tmdb_tvs",
|
||||
"douban_hot",
|
||||
"douban_movie_hot",
|
||||
"douban_tv_hot",
|
||||
"douban_movie_showing",
|
||||
"douban_movies",
|
||||
"douban_tvs",
|
||||
"douban_movie_top250",
|
||||
"douban_tv_weekly_chinese",
|
||||
"douban_tv_weekly_global",
|
||||
"douban_tv_animation",
|
||||
"bangumi_calendar",
|
||||
]
|
||||
return f"不支持的推荐来源: {source}。支持的来源包括: {', '.join(supported_sources)}"
|
||||
|
||||
if results:
|
||||
# 限制最多20条结果
|
||||
# 对于TMDB来源,API自身按页返回,取前page_size条
|
||||
total_count = len(results)
|
||||
limited_results = results[:20]
|
||||
page_results = results[:page_size]
|
||||
# 精简字段,只保留关键信息
|
||||
simplified_results = []
|
||||
for r in limited_results:
|
||||
for r in page_results:
|
||||
# r 应该是字典格式(to_dict的结果),但为了安全起见进行检查
|
||||
if not isinstance(r, dict):
|
||||
logger.warning(f"推荐结果格式异常,跳过: {type(r)}")
|
||||
continue
|
||||
|
||||
|
||||
simplified = {
|
||||
"title": r.get("title"),
|
||||
"en_title": r.get("en_title"),
|
||||
@@ -163,14 +210,19 @@ class GetRecommendationsTool(MoviePilotTool):
|
||||
"douban_id": r.get("douban_id"),
|
||||
"vote_average": r.get("vote_average"),
|
||||
"poster_path": r.get("poster_path"),
|
||||
"detail_link": r.get("detail_link")
|
||||
"detail_link": r.get("detail_link"),
|
||||
}
|
||||
simplified_results.append(simplified)
|
||||
result_json = json.dumps(simplified_results, ensure_ascii=False, indent=2)
|
||||
# 如果结果被裁剪,添加提示信息
|
||||
if total_count > 20:
|
||||
return f"注意:推荐结果共找到 {total_count} 条,为节省上下文空间,仅显示前 20 条结果。\n\n{result_json}"
|
||||
return result_json
|
||||
result_json = json.dumps(
|
||||
simplified_results, ensure_ascii=False, indent=2
|
||||
)
|
||||
has_more = total_count > page_size
|
||||
payload_msg = f"第 {page} 页,当前页 {len(simplified_results)} 条结果。"
|
||||
if has_more:
|
||||
payload_msg += (
|
||||
f" 可能有更多数据,可使用 page={page + 1} 获取下一页。"
|
||||
)
|
||||
return f"{payload_msg}\n\n{result_json}"
|
||||
return "未找到推荐内容。"
|
||||
except Exception as e:
|
||||
logger.error(f"获取推荐失败: {e}", exc_info=True)
|
||||
|
||||
@@ -19,33 +19,60 @@ from ._torrent_search_utils import (
|
||||
|
||||
class GetSearchResultsInput(BaseModel):
|
||||
"""获取搜索结果工具的输入参数模型"""
|
||||
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
|
||||
|
||||
explanation: str = Field(
|
||||
...,
|
||||
description="Clear explanation of why this tool is being used in the current context",
|
||||
)
|
||||
site: Optional[List[str]] = Field(None, description="Site name filters")
|
||||
season: Optional[List[str]] = Field(None, description="Season or episode filters")
|
||||
free_state: Optional[List[str]] = Field(None, description="Promotion state filters")
|
||||
video_code: Optional[List[str]] = Field(None, description="Video codec filters")
|
||||
edition: Optional[List[str]] = Field(None, description="Edition filters")
|
||||
resolution: Optional[List[str]] = Field(None, description="Resolution filters")
|
||||
release_group: Optional[List[str]] = Field(None, description="Release group filters")
|
||||
title_pattern: Optional[str] = Field(None, description="Regular expression pattern to filter torrent titles (e.g., '4K|2160p|UHD', '1080p.*BluRay')")
|
||||
show_filter_options: Optional[bool] = Field(False, description="Whether to return only optional filter options for re-checking available conditions")
|
||||
release_group: Optional[List[str]] = Field(
|
||||
None, description="Release group filters"
|
||||
)
|
||||
title_pattern: Optional[str] = Field(
|
||||
None,
|
||||
description="Regular expression pattern to filter torrent titles (e.g., '4K|2160p|UHD', '1080p.*BluRay')",
|
||||
)
|
||||
show_filter_options: Optional[bool] = Field(
|
||||
False,
|
||||
description="Whether to return only optional filter options for re-checking available conditions",
|
||||
)
|
||||
page: Optional[int] = Field(
|
||||
1,
|
||||
description="Page number for pagination (default: 1, each page returns up to 50 results)",
|
||||
)
|
||||
|
||||
|
||||
class GetSearchResultsTool(MoviePilotTool):
|
||||
name: str = "get_search_results"
|
||||
description: str = "Get cached torrent search results from search_torrents with optional filters. Returns at most the first 50 matches."
|
||||
description: str = "Get cached torrent search results from search_torrents with optional filters. Supports pagination with up to 50 results per page."
|
||||
args_schema: Type[BaseModel] = GetSearchResultsInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在获取搜索结果"
|
||||
|
||||
async def run(self, site: Optional[List[str]] = None, season: Optional[List[str]] = None,
|
||||
free_state: Optional[List[str]] = None, video_code: Optional[List[str]] = None,
|
||||
edition: Optional[List[str]] = None, resolution: Optional[List[str]] = None,
|
||||
release_group: Optional[List[str]] = None, title_pattern: Optional[str] = None,
|
||||
show_filter_options: bool = False,
|
||||
**kwargs) -> str:
|
||||
async def run(
|
||||
self,
|
||||
site: Optional[List[str]] = None,
|
||||
season: Optional[List[str]] = None,
|
||||
free_state: Optional[List[str]] = None,
|
||||
video_code: Optional[List[str]] = None,
|
||||
edition: Optional[List[str]] = None,
|
||||
resolution: Optional[List[str]] = None,
|
||||
release_group: Optional[List[str]] = None,
|
||||
title_pattern: Optional[str] = None,
|
||||
show_filter_options: bool = False,
|
||||
page: Optional[int] = 1,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
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}")
|
||||
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}"
|
||||
)
|
||||
|
||||
try:
|
||||
items = await SearchChain().async_last_search_results() or []
|
||||
@@ -79,8 +106,10 @@ class GetSearchResultsTool(MoviePilotTool):
|
||||
)
|
||||
if regex_pattern:
|
||||
filtered_items = [
|
||||
item for item in filtered_items
|
||||
if item.torrent_info and item.torrent_info.title
|
||||
item
|
||||
for item in filtered_items
|
||||
if item.torrent_info
|
||||
and item.torrent_info.title
|
||||
and regex_pattern.search(item.torrent_info.title)
|
||||
]
|
||||
if not filtered_items:
|
||||
@@ -88,19 +117,37 @@ class GetSearchResultsTool(MoviePilotTool):
|
||||
|
||||
total_count = len(filtered_items)
|
||||
filtered_ids = {id(item) for item in filtered_items}
|
||||
matched_indices = [index for index, item in enumerate(items, start=1) if id(item) in filtered_ids]
|
||||
limited_items = filtered_items[:TORRENT_RESULT_LIMIT]
|
||||
limited_indices = matched_indices[:TORRENT_RESULT_LIMIT]
|
||||
matched_indices = [
|
||||
index
|
||||
for index, item in enumerate(items, start=1)
|
||||
if id(item) in filtered_ids
|
||||
]
|
||||
|
||||
# 分页
|
||||
page_size = TORRENT_RESULT_LIMIT
|
||||
start = (page - 1) * page_size
|
||||
end = start + page_size
|
||||
page_items = filtered_items[start:end]
|
||||
page_indices = matched_indices[start:end]
|
||||
|
||||
if not page_items:
|
||||
return f"第 {page} 页没有数据,共 {total_count} 条结果,共 {(total_count + page_size - 1) // page_size} 页。"
|
||||
|
||||
results = [
|
||||
simplify_search_result(item, index)
|
||||
for item, index in zip(limited_items, limited_indices)
|
||||
for item, index in zip(page_items, page_indices)
|
||||
]
|
||||
total_pages = (total_count + page_size - 1) // page_size
|
||||
payload = {
|
||||
"total_count": total_count,
|
||||
"page": page,
|
||||
"total_pages": total_pages,
|
||||
"results": results,
|
||||
}
|
||||
if total_count > TORRENT_RESULT_LIMIT:
|
||||
payload["message"] = f"搜索结果共找到 {total_count} 条,仅显示前 {TORRENT_RESULT_LIMIT} 条结果。"
|
||||
if page < total_pages:
|
||||
payload["message"] = (
|
||||
f"搜索结果共 {total_count} 条,当前第 {page}/{total_pages} 页,可使用 page={page + 1} 获取下一页。"
|
||||
)
|
||||
return json.dumps(payload, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
error_message = f"获取搜索结果失败: {str(e)}"
|
||||
|
||||
@@ -120,8 +120,8 @@ class ListDirectoryTool(MoviePilotTool):
|
||||
result_json = json.dumps(simplified_items, ensure_ascii=False, indent=2)
|
||||
|
||||
# 如果结果被裁剪,添加提示信息
|
||||
if total_count > 20:
|
||||
return f"注意:目录中共有 {total_count} 个项目,为节省上下文空间,仅显示前 20 个项目。\n\n{result_json}"
|
||||
if total_count > 100:
|
||||
return f"注意:目录中共有 {total_count} 个项目,为节省上下文空间,仅显示前 100 个项目。\n\n{result_json}"
|
||||
else:
|
||||
return result_json
|
||||
except Exception as e:
|
||||
|
||||
79
app/agent/tools/impl/list_slash_commands.py
Normal file
79
app/agent/tools/impl/list_slash_commands.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""查询所有可用斜杠命令工具(系统命令 + 插件命令)"""
|
||||
|
||||
import json
|
||||
from typing import Optional, Type
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.log import logger
|
||||
|
||||
|
||||
class ListSlashCommandsInput(BaseModel):
|
||||
"""查询所有可用斜杠命令工具的输入参数模型"""
|
||||
|
||||
explanation: str = Field(
|
||||
...,
|
||||
description="Clear explanation of why this tool is being used in the current context",
|
||||
)
|
||||
|
||||
|
||||
class ListSlashCommandsTool(MoviePilotTool):
|
||||
name: str = "list_slash_commands"
|
||||
description: str = (
|
||||
"List all available slash commands in the system, including system preset commands "
|
||||
"(e.g. /cookiecloud, /sites, /subscribes, /downloading, /transfer, /restart, etc.) "
|
||||
"and plugin-registered commands. "
|
||||
"Use this tool to discover what slash commands are available before executing them with run_slash_command. "
|
||||
"This is especially useful when the user describes an action in natural language and you need to "
|
||||
"find the matching command to fulfill their request."
|
||||
)
|
||||
args_schema: Type[BaseModel] = ListSlashCommandsInput
|
||||
require_admin: bool = True
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""生成友好的提示消息"""
|
||||
return "正在查询所有可用命令"
|
||||
|
||||
async def run(self, **kwargs) -> str:
|
||||
logger.info(f"执行工具: {self.name}")
|
||||
|
||||
try:
|
||||
from app.command import Command
|
||||
|
||||
command_obj = Command()
|
||||
all_commands = command_obj.get_commands()
|
||||
|
||||
if not all_commands:
|
||||
return "当前没有可用的命令"
|
||||
|
||||
commands_list = []
|
||||
for cmd, info in all_commands.items():
|
||||
cmd_info = {
|
||||
"command": cmd,
|
||||
"description": info.get("description", ""),
|
||||
}
|
||||
if info.get("category"):
|
||||
cmd_info["category"] = info["category"]
|
||||
# 标识命令类型
|
||||
if info.get("type") == "scheduler":
|
||||
cmd_info["type"] = "scheduler"
|
||||
elif info.get("pid"):
|
||||
cmd_info["type"] = "plugin"
|
||||
cmd_info["plugin_id"] = info["pid"]
|
||||
else:
|
||||
cmd_info["type"] = "system"
|
||||
commands_list.append(cmd_info)
|
||||
|
||||
result = {
|
||||
"total": len(commands_list),
|
||||
"commands": commands_list,
|
||||
}
|
||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"查询可用命令失败: {e}", exc_info=True)
|
||||
return json.dumps(
|
||||
{"success": False, "message": f"查询可用命令时发生错误: {str(e)}"},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
66
app/agent/tools/impl/query_custom_identifiers.py
Normal file
66
app/agent/tools/impl/query_custom_identifiers.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""查询自定义识别词工具"""
|
||||
|
||||
import json
|
||||
from typing import Optional, Type
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.log import logger
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
|
||||
class QueryCustomIdentifiersInput(BaseModel):
|
||||
"""查询自定义识别词工具的输入参数模型"""
|
||||
|
||||
explanation: str = Field(
|
||||
...,
|
||||
description="Clear explanation of why this tool is being used in the current context",
|
||||
)
|
||||
|
||||
|
||||
class QueryCustomIdentifiersTool(MoviePilotTool):
|
||||
name: str = "query_custom_identifiers"
|
||||
description: str = (
|
||||
"Query all currently configured custom identifiers (自定义识别词). "
|
||||
"Returns the list of identifier rules used for preprocessing torrent/file names before media recognition. "
|
||||
"Use this tool to check existing rules before adding new ones to avoid duplicates."
|
||||
)
|
||||
args_schema: Type[BaseModel] = QueryCustomIdentifiersInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""生成友好的提示消息"""
|
||||
return "正在查询自定义识别词"
|
||||
|
||||
async def run(self, **kwargs) -> str:
|
||||
logger.info(f"执行工具: {self.name}")
|
||||
try:
|
||||
system_config_oper = SystemConfigOper()
|
||||
identifiers = system_config_oper.get(SystemConfigKey.CustomIdentifiers)
|
||||
if identifiers:
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"count": len(identifiers),
|
||||
"identifiers": identifiers,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"count": 0,
|
||||
"identifiers": [],
|
||||
"message": "当前没有配置自定义识别词",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"查询自定义识别词失败: {e}")
|
||||
return json.dumps(
|
||||
{"success": False, "message": f"查询自定义识别词时发生错误: {str(e)}"},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
@@ -58,14 +58,7 @@ class QueryInstalledPluginsTool(MoviePilotTool):
|
||||
}
|
||||
)
|
||||
|
||||
total_count = len(plugins_list)
|
||||
result_json = json.dumps(plugins_list, ensure_ascii=False, indent=2)
|
||||
|
||||
if total_count > 50:
|
||||
limited_plugins = plugins_list[:50]
|
||||
limited_json = json.dumps(limited_plugins, ensure_ascii=False, indent=2)
|
||||
return f"注意:共找到 {total_count} 个已安装插件,为节省上下文空间,仅显示前 50 个。\n\n{limited_json}"
|
||||
|
||||
return result_json
|
||||
except Exception as e:
|
||||
logger.error(f"查询已安装插件失败: {e}", exc_info=True)
|
||||
|
||||
@@ -10,52 +10,70 @@ from app.chain.mediaserver import MediaServerChain
|
||||
from app.helper.service import ServiceConfigHelper
|
||||
from app.log import logger
|
||||
|
||||
PAGE_SIZE = 20
|
||||
|
||||
|
||||
class QueryLibraryLatestInput(BaseModel):
|
||||
"""查询媒体服务器最近入库影片工具的输入参数模型"""
|
||||
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
|
||||
server: Optional[str] = Field(None, description="Media server name (optional, if not specified queries all enabled media servers)")
|
||||
count: Optional[int] = Field(20, description="Number of items to return (default: 20)")
|
||||
|
||||
explanation: str = Field(
|
||||
...,
|
||||
description="Clear explanation of why this tool is being used in the current context",
|
||||
)
|
||||
server: Optional[str] = Field(
|
||||
None,
|
||||
description="Media server name (optional, if not specified queries all enabled media servers)",
|
||||
)
|
||||
page: Optional[int] = Field(
|
||||
1, description="Page number for pagination (default: 1, 20 items per page)"
|
||||
)
|
||||
|
||||
|
||||
class QueryLibraryLatestTool(MoviePilotTool):
|
||||
name: str = "query_library_latest"
|
||||
description: str = "Query the latest media items added to the media server (Plex, Emby, Jellyfin). Returns recently added movies and TV series with their titles, images, links, and other metadata."
|
||||
description: str = "Query the latest media items added to the media server (Plex, Emby, Jellyfin). Returns recently added movies and TV series with their titles, images, links, and other metadata. Supports pagination with 20 items per page."
|
||||
args_schema: Type[BaseModel] = QueryLibraryLatestInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""根据查询参数生成友好的提示消息"""
|
||||
server = kwargs.get("server")
|
||||
count = kwargs.get("count", 20)
|
||||
|
||||
page = kwargs.get("page", 1)
|
||||
|
||||
parts = ["正在查询媒体服务器最近入库影片"]
|
||||
|
||||
|
||||
if server:
|
||||
parts.append(f"服务器: {server}")
|
||||
else:
|
||||
parts.append("所有服务器")
|
||||
|
||||
parts.append(f"数量: {count}条")
|
||||
|
||||
|
||||
parts.append(f"第{page}页")
|
||||
|
||||
return " | ".join(parts)
|
||||
|
||||
async def run(self, server: Optional[str] = None, count: Optional[int] = 20, **kwargs) -> str:
|
||||
logger.info(f"执行工具: {self.name}, 参数: server={server}, count={count}")
|
||||
async def run(
|
||||
self, server: Optional[str] = None, page: Optional[int] = 1, **kwargs
|
||||
) -> str:
|
||||
page = max(1, page or 1)
|
||||
# 为了支持分页,需要获取足够多的数据再切片
|
||||
fetch_count = page * PAGE_SIZE
|
||||
logger.info(f"执行工具: {self.name}, 参数: server={server}, page={page}")
|
||||
try:
|
||||
media_chain = MediaServerChain()
|
||||
results = []
|
||||
|
||||
|
||||
# 如果没有指定服务器,获取所有启用的媒体服务器
|
||||
if not server:
|
||||
mediaservers = ServiceConfigHelper.get_mediaserver_configs()
|
||||
enabled_servers = [ms.name for ms in mediaservers if ms.enabled]
|
||||
|
||||
|
||||
if not enabled_servers:
|
||||
return "未找到启用的媒体服务器"
|
||||
|
||||
|
||||
# 遍历所有启用的服务器
|
||||
for server_name in enabled_servers:
|
||||
latest_items = media_chain.latest(server=server_name, count=count, username=self._username)
|
||||
latest_items = media_chain.latest(
|
||||
server=server_name, count=fetch_count, username=self._username
|
||||
)
|
||||
if latest_items:
|
||||
for item in latest_items:
|
||||
item_dict = item.model_dump(exclude_none=True)
|
||||
@@ -63,24 +81,37 @@ class QueryLibraryLatestTool(MoviePilotTool):
|
||||
results.append(item_dict)
|
||||
else:
|
||||
# 查询指定服务器
|
||||
latest_items = media_chain.latest(server=server, count=count, username=self._username)
|
||||
latest_items = media_chain.latest(
|
||||
server=server, count=fetch_count, username=self._username
|
||||
)
|
||||
if latest_items:
|
||||
for item in latest_items:
|
||||
item_dict = item.model_dump(exclude_none=True)
|
||||
item_dict["server"] = server
|
||||
results.append(item_dict)
|
||||
|
||||
|
||||
if not results:
|
||||
server_info = f"服务器 {server}" if server else "所有服务器"
|
||||
return f"未找到 {server_info} 的最近入库影片"
|
||||
|
||||
# 限制返回数量,避免结果过多
|
||||
if len(results) > count:
|
||||
results = results[:count]
|
||||
|
||||
return json.dumps(results, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
# 分页
|
||||
total_count = len(results)
|
||||
start = (page - 1) * PAGE_SIZE
|
||||
end = start + PAGE_SIZE
|
||||
page_results = results[start:end]
|
||||
|
||||
if not page_results:
|
||||
total_pages = (total_count + PAGE_SIZE - 1) // PAGE_SIZE
|
||||
return f"第 {page} 页没有数据,共 {total_count} 条结果,共 {total_pages} 页。"
|
||||
|
||||
total_pages = (total_count + PAGE_SIZE - 1) // PAGE_SIZE
|
||||
payload_msg = f"第 {page}/{total_pages} 页,当前页 {len(page_results)} 条结果,共 {total_count} 条。"
|
||||
if page < total_pages:
|
||||
payload_msg += f" 可使用 page={page + 1} 获取下一页。"
|
||||
|
||||
result_json = json.dumps(page_results, ensure_ascii=False, indent=2)
|
||||
return f"{payload_msg}\n\n{result_json}"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"查询媒体服务器最近入库影片失败: {e}", exc_info=True)
|
||||
return f"查询媒体服务器最近入库影片时发生错误: {str(e)}"
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ class QueryPluginCapabilitiesTool(MoviePilotTool):
|
||||
name: str = "query_plugin_capabilities"
|
||||
description: str = (
|
||||
"Query the capabilities of installed plugins, including supported commands and scheduled services. "
|
||||
"Commands are slash-commands (e.g. /xxx) that can be executed via the run_plugin_command tool. "
|
||||
"Commands are slash-commands (e.g. /xxx) that can be executed via the run_slash_command tool. "
|
||||
"Scheduled services are periodic tasks that can be triggered via the run_scheduler tool. "
|
||||
"Optionally specify a plugin_id to query a specific plugin, or omit to query all running plugins."
|
||||
)
|
||||
|
||||
@@ -11,36 +11,61 @@ from app.db.models.subscribehistory import SubscribeHistory
|
||||
from app.log import logger
|
||||
from app.schemas.types import media_type_to_agent
|
||||
|
||||
PAGE_SIZE = 20
|
||||
|
||||
|
||||
class QuerySubscribeHistoryInput(BaseModel):
|
||||
"""查询订阅历史工具的输入参数模型"""
|
||||
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
|
||||
media_type: Optional[str] = Field("all", description="Allowed values: movie, tv, all")
|
||||
name: Optional[str] = Field(None, description="Filter by media name (partial match, optional)")
|
||||
|
||||
explanation: str = Field(
|
||||
...,
|
||||
description="Clear explanation of why this tool is being used in the current context",
|
||||
)
|
||||
media_type: Optional[str] = Field(
|
||||
"all", description="Allowed values: movie, tv, all"
|
||||
)
|
||||
name: Optional[str] = Field(
|
||||
None, description="Filter by media name (partial match, optional)"
|
||||
)
|
||||
page: Optional[int] = Field(
|
||||
1,
|
||||
description="Page number for pagination (default: 1, 20 items per page). Ignored when name filter is provided.",
|
||||
)
|
||||
|
||||
|
||||
class QuerySubscribeHistoryTool(MoviePilotTool):
|
||||
name: str = "query_subscribe_history"
|
||||
description: str = "Query subscription history records. Shows completed subscriptions with their details including name, type, rating, completion date, and other subscription information. Supports filtering by media type and name. Returns up to 30 records."
|
||||
description: str = "Query subscription history records. Shows completed subscriptions with their details including name, type, rating, completion date, and other subscription information. Supports filtering by media type and name. Supports pagination with 20 records per page."
|
||||
args_schema: Type[BaseModel] = QuerySubscribeHistoryInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""根据查询参数生成友好的提示消息"""
|
||||
media_type = kwargs.get("media_type", "all")
|
||||
name = kwargs.get("name")
|
||||
|
||||
page = kwargs.get("page", 1)
|
||||
|
||||
parts = ["正在查询订阅历史"]
|
||||
|
||||
|
||||
if media_type != "all":
|
||||
parts.append(f"类型: {media_type}")
|
||||
if name:
|
||||
parts.append(f"名称: {name}")
|
||||
|
||||
return " | ".join(parts) if len(parts) > 1 else parts[0]
|
||||
else:
|
||||
parts.append(f"第{page}页")
|
||||
|
||||
async def run(self, media_type: Optional[str] = "all",
|
||||
name: Optional[str] = None, **kwargs) -> str:
|
||||
logger.info(f"执行工具: {self.name}, 参数: media_type={media_type}, name={name}")
|
||||
return " | ".join(parts)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
media_type: Optional[str] = "all",
|
||||
name: Optional[str] = None,
|
||||
page: Optional[int] = 1,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
page = max(1, page or 1)
|
||||
logger.info(
|
||||
f"执行工具: {self.name}, 参数: media_type={media_type}, name={name}, page={page}"
|
||||
)
|
||||
|
||||
try:
|
||||
if media_type not in ["all", "movie", "tv"]:
|
||||
@@ -48,69 +73,115 @@ class QuerySubscribeHistoryTool(MoviePilotTool):
|
||||
|
||||
# 获取数据库会话
|
||||
async with AsyncSessionFactory() as db:
|
||||
# 根据类型查询
|
||||
if media_type == "all":
|
||||
# 查询所有类型,需要分别查询电影和电视剧
|
||||
movie_history = await SubscribeHistory.async_list_by_type(db, mtype="movie", page=1, count=100)
|
||||
tv_history = await SubscribeHistory.async_list_by_type(db, mtype="tv", page=1, count=100)
|
||||
all_history = list(movie_history) + list(tv_history)
|
||||
# 按日期排序
|
||||
all_history.sort(key=lambda x: x.date or "", reverse=True)
|
||||
else:
|
||||
# 查询指定类型
|
||||
all_history = await SubscribeHistory.async_list_by_type(db, mtype=media_type, page=1, count=100)
|
||||
|
||||
# 按名称过滤
|
||||
filtered_history = []
|
||||
if name:
|
||||
# 有名称过滤时,获取足够多的记录在内存中过滤,不分页
|
||||
fetch_count = 500
|
||||
if media_type == "all":
|
||||
movie_history = await SubscribeHistory.async_list_by_type(
|
||||
db, mtype="movie", page=1, count=fetch_count
|
||||
)
|
||||
tv_history = await SubscribeHistory.async_list_by_type(
|
||||
db, mtype="tv", page=1, count=fetch_count
|
||||
)
|
||||
all_history = list(movie_history) + list(tv_history)
|
||||
all_history.sort(key=lambda x: x.date or "", reverse=True)
|
||||
else:
|
||||
all_history = list(
|
||||
await SubscribeHistory.async_list_by_type(
|
||||
db, mtype=media_type, page=1, count=fetch_count
|
||||
)
|
||||
)
|
||||
|
||||
# 按名称过滤
|
||||
name_lower = name.lower()
|
||||
for record in all_history:
|
||||
if record.name and name_lower in record.name.lower():
|
||||
filtered_history.append(record)
|
||||
filtered_history = [
|
||||
record
|
||||
for record in all_history
|
||||
if record.name and name_lower in record.name.lower()
|
||||
]
|
||||
|
||||
if not filtered_history:
|
||||
return "未找到相关订阅历史记录"
|
||||
|
||||
# 名称过滤时直接返回所有匹配结果,不分页
|
||||
simplified_records = self._simplify_records(filtered_history)
|
||||
result_json = json.dumps(
|
||||
simplified_records, ensure_ascii=False, indent=2
|
||||
)
|
||||
return result_json
|
||||
else:
|
||||
filtered_history = all_history
|
||||
|
||||
# 无名称过滤时,直接利用数据库分页
|
||||
if media_type == "all":
|
||||
movie_history = await SubscribeHistory.async_list_by_type(
|
||||
db, mtype="movie", page=1, count=page * PAGE_SIZE
|
||||
)
|
||||
tv_history = await SubscribeHistory.async_list_by_type(
|
||||
db, mtype="tv", page=1, count=page * PAGE_SIZE
|
||||
)
|
||||
all_history = list(movie_history) + list(tv_history)
|
||||
all_history.sort(key=lambda x: x.date or "", reverse=True)
|
||||
filtered_history = all_history
|
||||
else:
|
||||
filtered_history = list(
|
||||
await SubscribeHistory.async_list_by_type(
|
||||
db, mtype=media_type, page=1, count=page * PAGE_SIZE
|
||||
)
|
||||
)
|
||||
|
||||
if not filtered_history:
|
||||
return "未找到相关订阅历史记录"
|
||||
|
||||
# 限制最多30条
|
||||
|
||||
# 分页切片
|
||||
total_count = len(filtered_history)
|
||||
limited_history = filtered_history[:30]
|
||||
|
||||
# 转换为字典格式,只保留关键信息
|
||||
simplified_records = []
|
||||
for record in limited_history:
|
||||
simplified = {
|
||||
"id": record.id,
|
||||
"name": record.name,
|
||||
"year": record.year,
|
||||
"type": media_type_to_agent(record.type),
|
||||
"season": record.season,
|
||||
"tmdbid": record.tmdbid,
|
||||
"doubanid": record.doubanid,
|
||||
"bangumiid": record.bangumiid,
|
||||
"poster": record.poster,
|
||||
"vote": record.vote,
|
||||
"total_episode": record.total_episode,
|
||||
"date": record.date,
|
||||
"username": record.username
|
||||
}
|
||||
# 添加过滤规则信息(如果有)
|
||||
if record.filter:
|
||||
simplified["filter"] = record.filter
|
||||
if record.quality:
|
||||
simplified["quality"] = record.quality
|
||||
if record.resolution:
|
||||
simplified["resolution"] = record.resolution
|
||||
simplified_records.append(simplified)
|
||||
|
||||
result_json = json.dumps(simplified_records, ensure_ascii=False, indent=2)
|
||||
|
||||
# 如果结果被裁剪,添加提示信息
|
||||
if total_count > 30:
|
||||
return f"注意:查询结果共找到 {total_count} 条,为节省上下文空间,仅显示前 30 条结果。\n\n{result_json}"
|
||||
|
||||
return result_json
|
||||
start = (page - 1) * PAGE_SIZE
|
||||
end = start + PAGE_SIZE
|
||||
page_records = filtered_history[start:end]
|
||||
|
||||
if not page_records:
|
||||
return f"第 {page} 页没有数据。"
|
||||
|
||||
simplified_records = self._simplify_records(page_records)
|
||||
result_json = json.dumps(
|
||||
simplified_records, ensure_ascii=False, indent=2
|
||||
)
|
||||
|
||||
has_more = total_count > end
|
||||
payload_msg = f"第 {page} 页,当前页 {len(simplified_records)} 条结果。"
|
||||
if has_more:
|
||||
payload_msg += (
|
||||
f" 可能有更多数据,可使用 page={page + 1} 获取下一页。"
|
||||
)
|
||||
|
||||
return f"{payload_msg}\n\n{result_json}"
|
||||
except Exception as e:
|
||||
logger.error(f"查询订阅历史失败: {e}", exc_info=True)
|
||||
return f"查询订阅历史时发生错误: {str(e)}"
|
||||
|
||||
@staticmethod
|
||||
def _simplify_records(records) -> list:
|
||||
"""转换为字典格式,只保留关键信息"""
|
||||
simplified_records = []
|
||||
for record in records:
|
||||
simplified = {
|
||||
"id": record.id,
|
||||
"name": record.name,
|
||||
"year": record.year,
|
||||
"type": media_type_to_agent(record.type),
|
||||
"season": record.season,
|
||||
"tmdbid": record.tmdbid,
|
||||
"doubanid": record.doubanid,
|
||||
"bangumiid": record.bangumiid,
|
||||
"poster": record.poster,
|
||||
"vote": record.vote,
|
||||
"total_episode": record.total_episode,
|
||||
"date": record.date,
|
||||
"username": record.username,
|
||||
}
|
||||
if record.filter:
|
||||
simplified["filter"] = record.filter
|
||||
if record.quality:
|
||||
simplified["quality"] = record.quality
|
||||
if record.resolution:
|
||||
simplified["resolution"] = record.resolution
|
||||
simplified_records.append(simplified)
|
||||
return simplified_records
|
||||
|
||||
@@ -11,6 +11,8 @@ from app.log import logger
|
||||
from app.schemas.subscribe import Subscribe as SubscribeSchema
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
PAGE_SIZE = 100
|
||||
|
||||
QUERY_SUBSCRIBE_OUTPUT_FIELDS = [
|
||||
"id",
|
||||
"name",
|
||||
@@ -35,47 +37,76 @@ QUERY_SUBSCRIBE_OUTPUT_FIELDS = [
|
||||
"custom_words",
|
||||
"media_category",
|
||||
"filter_groups",
|
||||
"episode_group"
|
||||
"episode_group",
|
||||
]
|
||||
|
||||
|
||||
class QuerySubscribesInput(BaseModel):
|
||||
"""查询订阅工具的输入参数模型"""
|
||||
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
|
||||
status: Optional[str] = Field("all",
|
||||
description="Filter subscriptions by status: 'R' for enabled subscriptions, 'S' for paused ones, 'all' for all subscriptions")
|
||||
media_type: Optional[str] = Field("all",
|
||||
description="Allowed values: movie, tv, all")
|
||||
tmdb_id: Optional[int] = Field(None, description="Filter by TMDB ID to check if a specific media is already subscribed")
|
||||
douban_id: Optional[str] = Field(None, description="Filter by Douban ID to check if a specific media is already subscribed")
|
||||
|
||||
explanation: str = Field(
|
||||
...,
|
||||
description="Clear explanation of why this tool is being used in the current context",
|
||||
)
|
||||
status: Optional[str] = Field(
|
||||
"all",
|
||||
description="Filter subscriptions by status: 'R' for enabled subscriptions, 'S' for paused ones, 'all' for all subscriptions",
|
||||
)
|
||||
media_type: Optional[str] = Field(
|
||||
"all", description="Allowed values: movie, tv, all"
|
||||
)
|
||||
tmdb_id: Optional[int] = Field(
|
||||
None,
|
||||
description="Filter by TMDB ID to check if a specific media is already subscribed",
|
||||
)
|
||||
douban_id: Optional[str] = Field(
|
||||
None,
|
||||
description="Filter by Douban ID to check if a specific media is already subscribed",
|
||||
)
|
||||
page: Optional[int] = Field(
|
||||
1, description="Page number for pagination (default: 1, 100 items per page)"
|
||||
)
|
||||
|
||||
|
||||
class QuerySubscribesTool(MoviePilotTool):
|
||||
name: str = "query_subscribes"
|
||||
description: str = "Query subscription status and list user subscriptions. Returns full subscription parameters for each matched subscription."
|
||||
description: str = "Query subscription status and list user subscriptions. Returns full subscription parameters for each matched subscription. Supports pagination with 100 items per page."
|
||||
args_schema: Type[BaseModel] = QuerySubscribesInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""根据查询参数生成友好的提示消息"""
|
||||
status = kwargs.get("status", "all")
|
||||
media_type = kwargs.get("media_type", "all")
|
||||
|
||||
page = kwargs.get("page", 1)
|
||||
|
||||
parts = ["正在查询订阅"]
|
||||
|
||||
|
||||
# 根据状态过滤条件生成提示
|
||||
if status != "all":
|
||||
status_map = {"R": "已启用", "S": "已暂停"}
|
||||
parts.append(f"状态: {status_map.get(status, status)}")
|
||||
|
||||
|
||||
# 根据媒体类型过滤条件生成提示
|
||||
if media_type != "all":
|
||||
parts.append(f"类型: {media_type}")
|
||||
|
||||
return " | ".join(parts) if len(parts) > 1 else parts[0]
|
||||
|
||||
async def run(self, status: Optional[str] = "all", media_type: Optional[str] = "all",
|
||||
tmdb_id: Optional[int] = None, douban_id: Optional[str] = None, **kwargs) -> str:
|
||||
logger.info(f"执行工具: {self.name}, 参数: status={status}, media_type={media_type}, tmdb_id={tmdb_id}, douban_id={douban_id}")
|
||||
parts.append(f"第{page}页")
|
||||
|
||||
return " | ".join(parts)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
status: Optional[str] = "all",
|
||||
media_type: Optional[str] = "all",
|
||||
tmdb_id: Optional[int] = None,
|
||||
douban_id: Optional[str] = None,
|
||||
page: Optional[int] = 1,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
page = max(1, page or 1)
|
||||
logger.info(
|
||||
f"执行工具: {self.name}, 参数: status={status}, media_type={media_type}, tmdb_id={tmdb_id}, douban_id={douban_id}, page={page}"
|
||||
)
|
||||
try:
|
||||
if media_type != "all" and not MediaType.from_agent(media_type):
|
||||
return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv', 'all'"
|
||||
@@ -86,7 +117,10 @@ class QuerySubscribesTool(MoviePilotTool):
|
||||
for sub in subscribes:
|
||||
if status != "all" and sub.state != status:
|
||||
continue
|
||||
if media_type != "all" and sub.type != MediaType.from_agent(media_type).value:
|
||||
if (
|
||||
media_type != "all"
|
||||
and sub.type != MediaType.from_agent(media_type).value
|
||||
):
|
||||
continue
|
||||
if tmdb_id is not None and sub.tmdbid != tmdb_id:
|
||||
continue
|
||||
@@ -94,21 +128,30 @@ class QuerySubscribesTool(MoviePilotTool):
|
||||
continue
|
||||
filtered_subscribes.append(sub)
|
||||
if filtered_subscribes:
|
||||
# 限制最多50条结果
|
||||
total_count = len(filtered_subscribes)
|
||||
limited_subscribes = filtered_subscribes[:50]
|
||||
# 分页
|
||||
start = (page - 1) * PAGE_SIZE
|
||||
end = start + PAGE_SIZE
|
||||
page_subscribes = filtered_subscribes[start:end]
|
||||
|
||||
if not page_subscribes:
|
||||
total_pages = (total_count + PAGE_SIZE - 1) // PAGE_SIZE
|
||||
return f"第 {page} 页没有数据,共 {total_count} 条结果,共 {total_pages} 页。"
|
||||
|
||||
full_subscribes = [
|
||||
SubscribeSchema.model_validate(s, from_attributes=True).model_dump(
|
||||
include=set(QUERY_SUBSCRIBE_OUTPUT_FIELDS),
|
||||
exclude_none=True
|
||||
include=set(QUERY_SUBSCRIBE_OUTPUT_FIELDS), exclude_none=True
|
||||
)
|
||||
for s in limited_subscribes
|
||||
for s in page_subscribes
|
||||
]
|
||||
result_json = json.dumps(full_subscribes, ensure_ascii=False, indent=2)
|
||||
# 如果结果被裁剪,添加提示信息
|
||||
if total_count > 50:
|
||||
return f"注意:查询结果共找到 {total_count} 条,为节省上下文空间,仅显示前 50 条结果。\n\n{result_json}"
|
||||
return result_json
|
||||
|
||||
total_pages = (total_count + PAGE_SIZE - 1) // PAGE_SIZE
|
||||
payload_msg = f"第 {page}/{total_pages} 页,当前页 {len(page_subscribes)} 条结果,共 {total_count} 条。"
|
||||
if page < total_pages:
|
||||
payload_msg += f" 可使用 page={page + 1} 获取下一页。"
|
||||
|
||||
return f"{payload_msg}\n\n{result_json}"
|
||||
return "未找到相关订阅"
|
||||
except Exception as e:
|
||||
logger.error(f"查询订阅失败: {e}", exc_info=True)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""运行插件命令工具"""
|
||||
"""运行斜杠命令工具(系统命令 + 插件命令)"""
|
||||
|
||||
import json
|
||||
from typing import Optional, Type
|
||||
@@ -7,13 +7,12 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.core.event import eventmanager
|
||||
from app.core.plugin import PluginManager
|
||||
from app.log import logger
|
||||
from app.schemas.types import EventType, MessageChannel
|
||||
|
||||
|
||||
class RunPluginCommandInput(BaseModel):
|
||||
"""运行插件命令工具的输入参数模型"""
|
||||
class RunSlashCommandInput(BaseModel):
|
||||
"""运行斜杠命令工具的输入参数模型"""
|
||||
|
||||
explanation: str = Field(
|
||||
...,
|
||||
@@ -23,26 +22,30 @@ class RunPluginCommandInput(BaseModel):
|
||||
...,
|
||||
description="The slash command to execute, e.g. '/cookiecloud'. "
|
||||
"Must start with '/'. Can include arguments after the command, e.g. '/command arg1 arg2'. "
|
||||
"Use query_plugin_capabilities tool to discover available commands first.",
|
||||
"Use query_plugin_capabilities tool to discover available plugin commands, "
|
||||
"or list_slash_commands tool to discover all available commands (including system commands).",
|
||||
)
|
||||
|
||||
|
||||
class RunPluginCommandTool(MoviePilotTool):
|
||||
name: str = "run_plugin_command"
|
||||
class RunSlashCommandTool(MoviePilotTool):
|
||||
name: str = "run_slash_command"
|
||||
description: str = (
|
||||
"Execute a plugin command by sending a CommandExcute event. "
|
||||
"Plugin commands are slash-commands (starting with '/') registered by plugins. "
|
||||
"Use the query_plugin_capabilities tool first to discover available commands and their descriptions. "
|
||||
"Execute a slash command (system or plugin) by sending a CommandExcute event. "
|
||||
"This tool supports ALL registered slash commands, including: "
|
||||
"1) System preset commands (e.g. /cookiecloud, /sites, /subscribes, /downloading, /transfer, /restart, etc.) "
|
||||
"2) Plugin commands registered by installed plugins. "
|
||||
"Use the query_plugin_capabilities tool to discover plugin commands, "
|
||||
"or the list_slash_commands tool to discover all available commands. "
|
||||
"The command will be executed asynchronously. "
|
||||
"Note: This tool triggers the command execution but the actual processing happens in the background."
|
||||
)
|
||||
args_schema: Type[BaseModel] = RunPluginCommandInput
|
||||
args_schema: Type[BaseModel] = RunSlashCommandInput
|
||||
require_admin: bool = True
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""生成友好的提示消息"""
|
||||
command = kwargs.get("command", "")
|
||||
return f"正在执行插件命令: {command}"
|
||||
return f"正在执行命令: {command}"
|
||||
|
||||
async def run(self, command: str, **kwargs) -> str:
|
||||
logger.info(f"执行工具: {self.name}, 参数: command={command}")
|
||||
@@ -52,21 +55,19 @@ class RunPluginCommandTool(MoviePilotTool):
|
||||
if not command.startswith("/"):
|
||||
command = f"/{command}"
|
||||
|
||||
# 验证命令是否存在
|
||||
plugin_manager = PluginManager()
|
||||
registered_commands = plugin_manager.get_plugin_commands()
|
||||
# 从全局 Command 单例中验证命令是否存在(包含系统预设命令 + 插件命令 + 其他命令)
|
||||
from app.command import Command
|
||||
|
||||
cmd_name = command.split()[0]
|
||||
matched_command = None
|
||||
for cmd in registered_commands:
|
||||
if cmd.get("cmd") == cmd_name:
|
||||
matched_command = cmd
|
||||
break
|
||||
command_obj = Command()
|
||||
matched_command = command_obj.get(cmd_name)
|
||||
|
||||
if not matched_command:
|
||||
# 列出可用命令帮助用户
|
||||
# 列出所有可用命令帮助用户
|
||||
all_commands = command_obj.get_commands()
|
||||
available_cmds = [
|
||||
f"{cmd.get('cmd')} - {cmd.get('desc', '无描述')}"
|
||||
for cmd in registered_commands
|
||||
f"{cmd} - {info.get('description', '无描述')}"
|
||||
for cmd, info in all_commands.items()
|
||||
]
|
||||
result = {
|
||||
"success": False,
|
||||
@@ -99,14 +100,16 @@ class RunPluginCommandTool(MoviePilotTool):
|
||||
"success": True,
|
||||
"message": f"命令 {cmd_name} 已触发执行",
|
||||
"command": command,
|
||||
"command_desc": matched_command.get("desc", ""),
|
||||
"plugin_id": matched_command.get("pid", ""),
|
||||
"command_desc": matched_command.get("description", ""),
|
||||
}
|
||||
# 如果是插件命令,附加插件ID
|
||||
if matched_command.get("pid"):
|
||||
result["plugin_id"] = matched_command["pid"]
|
||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"执行插件命令失败: {e}", exc_info=True)
|
||||
logger.error(f"执行命令失败: {e}", exc_info=True)
|
||||
return json.dumps(
|
||||
{"success": False, "message": f"执行插件命令时发生错误: {str(e)}"},
|
||||
{"success": False, "message": f"执行命令时发生错误: {str(e)}"},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
@@ -96,8 +96,8 @@ class SearchMediaTool(MoviePilotTool):
|
||||
simplified_results.append(simplified)
|
||||
result_json = json.dumps(simplified_results, ensure_ascii=False, indent=2)
|
||||
# 如果结果被裁剪,添加提示信息
|
||||
if total_count > 30:
|
||||
return f"注意:搜索结果共找到 {total_count} 条,为节省上下文空间,仅显示前 30 条结果。\n\n{result_json}"
|
||||
if total_count > 100:
|
||||
return f"注意:搜索结果共找到 {total_count} 条,为节省上下文空间,仅显示前 100 条结果。\n\n{result_json}"
|
||||
return result_json
|
||||
else:
|
||||
return f"未找到符合条件的媒体资源: {title}"
|
||||
|
||||
@@ -72,8 +72,8 @@ class SearchPersonTool(MoviePilotTool):
|
||||
|
||||
result_json = json.dumps(simplified_results, ensure_ascii=False, indent=2)
|
||||
# 如果结果被裁剪,添加提示信息
|
||||
if total_count > 30:
|
||||
return f"注意:搜索结果共找到 {total_count} 条,为节省上下文空间,仅显示前 30 条结果。\n\n{result_json}"
|
||||
if total_count > 50:
|
||||
return f"注意:搜索结果共找到 {total_count} 条,为节省上下文空间,仅显示前 50 条结果。\n\n{result_json}"
|
||||
return result_json
|
||||
else:
|
||||
return f"未找到相关人物信息: {name}"
|
||||
|
||||
@@ -27,7 +27,7 @@ class SearchWebInput(BaseModel):
|
||||
..., description="The search query string to search for on the web"
|
||||
)
|
||||
max_results: Optional[int] = Field(
|
||||
5,
|
||||
20,
|
||||
description="Maximum number of search results to return (default: 5, max: 10)",
|
||||
)
|
||||
|
||||
@@ -40,10 +40,10 @@ class SearchWebTool(MoviePilotTool):
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""根据搜索参数生成友好的提示消息"""
|
||||
query = kwargs.get("query", "")
|
||||
max_results = kwargs.get("max_results", 5)
|
||||
max_results = kwargs.get("max_results", 20)
|
||||
return f"正在搜索网络内容: {query} (最多返回 {max_results} 条结果)"
|
||||
|
||||
async def run(self, query: str, max_results: Optional[int] = 5, **kwargs) -> str:
|
||||
async def run(self, query: str, max_results: Optional[int] = 20, **kwargs) -> str:
|
||||
"""
|
||||
执行网络搜索
|
||||
"""
|
||||
@@ -53,7 +53,7 @@ class SearchWebTool(MoviePilotTool):
|
||||
|
||||
try:
|
||||
# 限制最大结果数
|
||||
max_results = min(max(1, max_results or 5), 10)
|
||||
max_results = min(max(1, max_results or 20), 20)
|
||||
results = []
|
||||
|
||||
# 1. 优先使用 Exa (如果配置了 API Key)
|
||||
@@ -216,7 +216,7 @@ class SearchWebTool(MoviePilotTool):
|
||||
source = result.get("source", "Unknown")
|
||||
|
||||
# 裁剪摘要
|
||||
max_snippet_length = 500 # 增加到500字符,提供更多上下文
|
||||
max_snippet_length = 1000 # 增加到1000字符,提供更多上下文
|
||||
if len(snippet) > max_snippet_length:
|
||||
snippet = snippet[:max_snippet_length] + "..."
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@ class SendMessageInput(BaseModel):
|
||||
description="The message content to send to the user (should be clear and informative)",
|
||||
)
|
||||
message_type: Optional[str] = Field(
|
||||
"info",
|
||||
description="Type of message: 'info' for general information, 'success' for successful operations, 'warning' for warnings, 'error' for error messages",
|
||||
None,
|
||||
description="Title of the message, a short summary of the message content",
|
||||
)
|
||||
|
||||
|
||||
@@ -34,30 +34,23 @@ class SendMessageTool(MoviePilotTool):
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""根据消息参数生成友好的提示消息"""
|
||||
message = kwargs.get("message", "")
|
||||
message_type = kwargs.get("message_type", "info")
|
||||
|
||||
type_map = {
|
||||
"info": "信息",
|
||||
"success": "成功",
|
||||
"warning": "警告",
|
||||
"error": "错误",
|
||||
}
|
||||
type_desc = type_map.get(message_type, message_type)
|
||||
title = kwargs.get("message_type") or ""
|
||||
|
||||
# 截断过长的消息
|
||||
if len(message) > 50:
|
||||
message = message[:50] + "..."
|
||||
|
||||
return f"正在发送{type_desc}消息: {message}"
|
||||
if title:
|
||||
return f"正在发送消息: [{title}] {message}"
|
||||
return f"正在发送消息: {message}"
|
||||
|
||||
async def run(
|
||||
self, message: str, message_type: Optional[str] = None, **kwargs
|
||||
) -> str:
|
||||
logger.info(
|
||||
f"执行工具: {self.name}, 参数: message={message}, message_type={message_type}"
|
||||
)
|
||||
title = message_type or ""
|
||||
logger.info(f"执行工具: {self.name}, 参数: title={title}, message={message}")
|
||||
try:
|
||||
await self.send_tool_message(message, title=message_type)
|
||||
await self.send_tool_message(message, title=title)
|
||||
return "消息已发送"
|
||||
except Exception as e:
|
||||
logger.error(f"发送消息失败: {e}")
|
||||
|
||||
95
app/agent/tools/impl/update_custom_identifiers.py
Normal file
95
app/agent/tools/impl/update_custom_identifiers.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""更新自定义识别词工具"""
|
||||
|
||||
import json
|
||||
from typing import List, Optional, Type
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.log import logger
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
|
||||
class UpdateCustomIdentifiersInput(BaseModel):
|
||||
"""更新自定义识别词工具的输入参数模型"""
|
||||
|
||||
explanation: str = Field(
|
||||
...,
|
||||
description="Clear explanation of why this tool is being used in the current context",
|
||||
)
|
||||
identifiers: List[str] = Field(
|
||||
...,
|
||||
description=(
|
||||
"The complete list of custom identifier rules to save. "
|
||||
"This REPLACES the entire existing list. "
|
||||
"Always query existing identifiers first, merge new rules, then pass the full list."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class UpdateCustomIdentifiersTool(MoviePilotTool):
|
||||
name: str = "update_custom_identifiers"
|
||||
description: str = (
|
||||
"Update the full list of custom identifiers (自定义识别词) used for preprocessing torrent/file names. "
|
||||
"This tool REPLACES all existing identifier rules with the provided list. "
|
||||
"IMPORTANT: Always use 'query_custom_identifiers' first to get existing rules, "
|
||||
"then merge new rules into the list before calling this tool to avoid accidentally deleting existing rules. "
|
||||
"Supported rule formats (spaces around operators are required): "
|
||||
"1) Block word: just the word/regex to remove; "
|
||||
"2) Replacement: '被替换词 => 替换词'; "
|
||||
"3) Episode offset: '前定位词 <> 后定位词 >> EP±N'; "
|
||||
"4) Combined: '被替换词 => 替换词 && 前定位词 <> 后定位词 >> EP±N'; "
|
||||
"Lines starting with '#' are comments. "
|
||||
"The replacement target supports: {[tmdbid=xxx;type=movie/tv;s=xxx;e=xxx]} for direct TMDB ID matching."
|
||||
)
|
||||
args_schema: Type[BaseModel] = UpdateCustomIdentifiersInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""生成友好的提示消息"""
|
||||
identifiers = kwargs.get("identifiers", [])
|
||||
return f"正在更新自定义识别词(共 {len(identifiers)} 条规则)"
|
||||
|
||||
async def run(self, identifiers: List[str] = None, **kwargs) -> str:
|
||||
logger.info(
|
||||
f"执行工具: {self.name}, 规则数量: {len(identifiers) if identifiers else 0}"
|
||||
)
|
||||
try:
|
||||
if identifiers is None:
|
||||
return json.dumps(
|
||||
{"success": False, "message": "必须提供 identifiers 参数"},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
# 过滤空字符串
|
||||
identifiers = [i for i in identifiers if i is not None]
|
||||
|
||||
system_config_oper = SystemConfigOper()
|
||||
|
||||
# 保存
|
||||
value = identifiers if identifiers else None
|
||||
success = await system_config_oper.async_set(
|
||||
SystemConfigKey.CustomIdentifiers, value
|
||||
)
|
||||
if success:
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"自定义识别词已更新,共 {len(identifiers)} 条规则",
|
||||
"count": len(identifiers),
|
||||
"identifiers": identifiers,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
else:
|
||||
return json.dumps(
|
||||
{"success": False, "message": "保存自定义识别词失败"},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"更新自定义识别词失败: {e}")
|
||||
return json.dumps(
|
||||
{"success": False, "message": f"更新自定义识别词时发生错误: {str(e)}"},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
@@ -260,6 +260,14 @@ async def remotes(token: str) -> Any:
|
||||
return PluginManager().get_plugin_remotes()
|
||||
|
||||
|
||||
@router.get("/sidebar_nav", summary="获取插件侧栏导航项", response_model=List[schemas.PluginSidebarNavItem])
|
||||
def plugin_sidebar_nav(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
聚合已启用 Vue 插件声明的侧栏入口(get_sidebar_nav),供前端主界面侧栏展示。
|
||||
"""
|
||||
return PluginManager().get_plugin_sidebar_nav()
|
||||
|
||||
|
||||
@router.get("/form/{plugin_id}", summary="获取插件表单页面")
|
||||
def plugin_form(plugin_id: str,
|
||||
_: User = Depends(get_current_active_superuser)) -> dict:
|
||||
|
||||
@@ -23,8 +23,11 @@ from app.core.module import ModuleManager
|
||||
from app.core.security import verify_apitoken, verify_resource_token, verify_token
|
||||
from app.db.models import User
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.db.user_oper import get_current_active_superuser, get_current_active_superuser_async, \
|
||||
get_current_active_user_async
|
||||
from app.db.user_oper import (
|
||||
get_current_active_superuser,
|
||||
get_current_active_superuser_async,
|
||||
get_current_active_user_async,
|
||||
)
|
||||
from app.helper.llm import LLMHelper
|
||||
from app.helper.mediaserver import MediaServerHelper
|
||||
from app.helper.message import MessageHelper
|
||||
@@ -47,12 +50,13 @@ router = APIRouter()
|
||||
|
||||
|
||||
async def fetch_image(
|
||||
url: str,
|
||||
proxy: Optional[bool] = None,
|
||||
use_cache: bool = False,
|
||||
if_none_match: Optional[str] = None,
|
||||
cookies: Optional[str | dict] = None,
|
||||
allowed_domains: Optional[set[str]] = None) -> Optional[Response]:
|
||||
url: str,
|
||||
proxy: Optional[bool] = None,
|
||||
use_cache: bool = False,
|
||||
if_none_match: Optional[str] = None,
|
||||
cookies: Optional[str | dict] = None,
|
||||
allowed_domains: Optional[set[str]] = None,
|
||||
) -> Optional[Response]:
|
||||
"""
|
||||
处理图片缓存逻辑,支持HTTP缓存和磁盘缓存
|
||||
"""
|
||||
@@ -83,47 +87,57 @@ async def fetch_image(
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=UrlUtils.get_mime_type(url, "image/jpeg"),
|
||||
headers=headers
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/img/{proxy}", summary="图片代理")
|
||||
async def proxy_img(
|
||||
imgurl: str,
|
||||
proxy: bool = False,
|
||||
cache: bool = False,
|
||||
use_cookies: bool = False,
|
||||
if_none_match: Annotated[str | None, Header()] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_resource_token)
|
||||
imgurl: str,
|
||||
proxy: bool = False,
|
||||
cache: bool = False,
|
||||
use_cookies: bool = False,
|
||||
if_none_match: Annotated[str | None, Header()] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_resource_token),
|
||||
) -> Response:
|
||||
"""
|
||||
图片代理,可选是否使用代理服务器,支持 HTTP 缓存
|
||||
"""
|
||||
# 媒体服务器添加图片代理支持
|
||||
hosts = [config.config.get("host") for config in MediaServerHelper().get_configs().values() if
|
||||
config and config.config and config.config.get("host")]
|
||||
hosts = [
|
||||
config.config.get("host")
|
||||
for config in MediaServerHelper().get_configs().values()
|
||||
if config and config.config and config.config.get("host")
|
||||
]
|
||||
allowed_domains = set(settings.SECURITY_IMAGE_DOMAINS) | set(hosts)
|
||||
cookies = (
|
||||
MediaServerChain().get_image_cookies(server=None, image_url=imgurl)
|
||||
if use_cookies
|
||||
else None
|
||||
)
|
||||
return await fetch_image(url=imgurl, proxy=proxy, use_cache=cache, cookies=cookies,
|
||||
if_none_match=if_none_match, allowed_domains=allowed_domains)
|
||||
return await fetch_image(
|
||||
url=imgurl,
|
||||
proxy=proxy,
|
||||
use_cache=cache,
|
||||
cookies=cookies,
|
||||
if_none_match=if_none_match,
|
||||
allowed_domains=allowed_domains,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/cache/image", summary="图片缓存")
|
||||
async def cache_img(
|
||||
url: str,
|
||||
if_none_match: Annotated[str | None, Header()] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_resource_token)
|
||||
url: str,
|
||||
if_none_match: Annotated[str | None, Header()] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_resource_token),
|
||||
) -> Response:
|
||||
"""
|
||||
本地缓存图片文件,支持 HTTP 缓存,如果启用全局图片缓存,则使用磁盘缓存
|
||||
"""
|
||||
# 如果没有启用全局图片缓存,则不使用磁盘缓存
|
||||
return await fetch_image(url=url, use_cache=settings.GLOBAL_IMAGE_CACHE,
|
||||
if_none_match=if_none_match)
|
||||
return await fetch_image(
|
||||
url=url, use_cache=settings.GLOBAL_IMAGE_CACHE, if_none_match=if_none_match
|
||||
)
|
||||
|
||||
|
||||
@router.get("/global", summary="查询非敏感系统设置", response_model=schemas.Response)
|
||||
@@ -144,15 +158,18 @@ def get_global_setting(token: str):
|
||||
}
|
||||
)
|
||||
# 追加版本信息(用于版本检查)
|
||||
info.update({
|
||||
"FRONTEND_VERSION": SystemChain.get_frontend_version(),
|
||||
"BACKEND_VERSION": APP_VERSION
|
||||
})
|
||||
return schemas.Response(success=True,
|
||||
data=info)
|
||||
info.update(
|
||||
{
|
||||
"FRONTEND_VERSION": SystemChain.get_frontend_version(),
|
||||
"BACKEND_VERSION": APP_VERSION,
|
||||
}
|
||||
)
|
||||
return schemas.Response(success=True, data=info)
|
||||
|
||||
|
||||
@router.get("/global/user", summary="查询用户相关系统设置", response_model=schemas.Response)
|
||||
@router.get(
|
||||
"/global/user", summary="查询用户相关系统设置", response_model=schemas.Response
|
||||
)
|
||||
async def get_user_global_setting(_: User = Depends(get_current_active_user_async)):
|
||||
"""
|
||||
查询用户相关系统设置(登录后获取)
|
||||
@@ -164,7 +181,7 @@ 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"
|
||||
"PASSKEY_ALLOW_REGISTER_WITHOUT_OTP",
|
||||
}
|
||||
)
|
||||
# 智能助手总开关未开启,智能推荐状态强制返回False
|
||||
@@ -173,13 +190,14 @@ async def get_user_global_setting(_: User = Depends(get_current_active_user_asyn
|
||||
|
||||
# 追加用户唯一ID和订阅分享管理权限
|
||||
share_admin = SubscribeHelper().is_admin_user()
|
||||
info.update({
|
||||
"USER_UNIQUE_ID": SubscribeHelper().get_user_uuid(),
|
||||
"SUBSCRIBE_SHARE_MANAGE": share_admin,
|
||||
"WORKFLOW_SHARE_MANAGE": share_admin,
|
||||
})
|
||||
return schemas.Response(success=True,
|
||||
data=info)
|
||||
info.update(
|
||||
{
|
||||
"USER_UNIQUE_ID": SubscribeHelper().get_user_uuid(),
|
||||
"SUBSCRIBE_SHARE_MANAGE": share_admin,
|
||||
"WORKFLOW_SHARE_MANAGE": share_admin,
|
||||
}
|
||||
)
|
||||
return schemas.Response(success=True, data=info)
|
||||
|
||||
|
||||
@router.get("/env", summary="查询系统配置", response_model=schemas.Response)
|
||||
@@ -187,22 +205,22 @@ async def get_env_setting(_: User = Depends(get_current_active_user_async)):
|
||||
"""
|
||||
查询系统环境变量,包括当前版本号(仅管理员)
|
||||
"""
|
||||
info = settings.model_dump(
|
||||
exclude={"SECRET_KEY", "RESOURCE_SECRET_KEY"}
|
||||
info = settings.model_dump(exclude={"SECRET_KEY", "RESOURCE_SECRET_KEY"})
|
||||
info.update(
|
||||
{
|
||||
"VERSION": APP_VERSION,
|
||||
"AUTH_VERSION": SitesHelper().auth_version,
|
||||
"INDEXER_VERSION": SitesHelper().indexer_version,
|
||||
"FRONTEND_VERSION": SystemChain().get_frontend_version(),
|
||||
}
|
||||
)
|
||||
info.update({
|
||||
"VERSION": APP_VERSION,
|
||||
"AUTH_VERSION": SitesHelper().auth_version,
|
||||
"INDEXER_VERSION": SitesHelper().indexer_version,
|
||||
"FRONTEND_VERSION": SystemChain().get_frontend_version()
|
||||
})
|
||||
return schemas.Response(success=True,
|
||||
data=info)
|
||||
return schemas.Response(success=True, data=info)
|
||||
|
||||
|
||||
@router.post("/env", summary="更新系统配置", response_model=schemas.Response)
|
||||
async def set_env_setting(env: dict,
|
||||
_: User = Depends(get_current_active_superuser_async)):
|
||||
async def set_env_setting(
|
||||
env: dict, _: User = Depends(get_current_active_superuser_async)
|
||||
):
|
||||
"""
|
||||
更新系统环境变量(仅管理员)
|
||||
"""
|
||||
@@ -215,30 +233,31 @@ async def set_env_setting(env: dict,
|
||||
return schemas.Response(
|
||||
success=False,
|
||||
message=f"{', '.join([v[1] for v in failed_updates.values()])}",
|
||||
data={
|
||||
"success_updates": success_updates,
|
||||
"failed_updates": failed_updates
|
||||
}
|
||||
data={"success_updates": success_updates, "failed_updates": failed_updates},
|
||||
)
|
||||
|
||||
if success_updates:
|
||||
# 发送配置变更事件
|
||||
await eventmanager.async_send_event(etype=EventType.ConfigChanged, data=ConfigChangeEventData(
|
||||
key=success_updates.keys(),
|
||||
change_type="update"
|
||||
))
|
||||
await eventmanager.async_send_event(
|
||||
etype=EventType.ConfigChanged,
|
||||
data=ConfigChangeEventData(
|
||||
key=success_updates.keys(), change_type="update"
|
||||
),
|
||||
)
|
||||
|
||||
return schemas.Response(
|
||||
success=True,
|
||||
message="所有配置项更新成功",
|
||||
data={
|
||||
"success_updates": success_updates
|
||||
}
|
||||
data={"success_updates": success_updates},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/progress/{process_type}", summary="实时进度")
|
||||
async def get_progress(request: Request, process_type: str, _: schemas.TokenPayload = Depends(verify_resource_token)):
|
||||
async def get_progress(
|
||||
request: Request,
|
||||
process_type: str,
|
||||
_: schemas.TokenPayload = Depends(verify_resource_token),
|
||||
):
|
||||
"""
|
||||
实时获取处理进度,返回格式为SSE
|
||||
"""
|
||||
@@ -259,8 +278,7 @@ async def get_progress(request: Request, process_type: str, _: schemas.TokenPayl
|
||||
|
||||
|
||||
@router.get("/setting/{key}", summary="查询系统设置", response_model=schemas.Response)
|
||||
async def get_setting(key: str,
|
||||
_: User = Depends(get_current_active_user_async)):
|
||||
async def get_setting(key: str, _: User = Depends(get_current_active_user_async)):
|
||||
"""
|
||||
查询系统设置(仅管理员)
|
||||
"""
|
||||
@@ -268,16 +286,14 @@ async def get_setting(key: str,
|
||||
value = getattr(settings, key)
|
||||
else:
|
||||
value = SystemConfigOper().get(key)
|
||||
return schemas.Response(success=True, data={
|
||||
"value": value
|
||||
})
|
||||
return schemas.Response(success=True, data={"value": value})
|
||||
|
||||
|
||||
@router.post("/setting/{key}", summary="更新系统设置", response_model=schemas.Response)
|
||||
async def set_setting(
|
||||
key: str,
|
||||
value: Annotated[Union[list, dict, bool, int, str] | None, Body()] = None,
|
||||
_: User = Depends(get_current_active_superuser_async),
|
||||
key: str,
|
||||
value: Annotated[Union[list, dict, bool, int, str] | None, Body()] = None,
|
||||
_: User = Depends(get_current_active_superuser_async),
|
||||
):
|
||||
"""
|
||||
更新系统设置(仅管理员)
|
||||
@@ -286,11 +302,10 @@ async def set_setting(
|
||||
success, message = settings.update_setting(key=key, value=value)
|
||||
if success:
|
||||
# 发送配置变更事件
|
||||
await eventmanager.async_send_event(etype=EventType.ConfigChanged, data=ConfigChangeEventData(
|
||||
key=key,
|
||||
value=value,
|
||||
change_type="update"
|
||||
))
|
||||
await eventmanager.async_send_event(
|
||||
etype=EventType.ConfigChanged,
|
||||
data=ConfigChangeEventData(key=key, value=value, change_type="update"),
|
||||
)
|
||||
elif success is None:
|
||||
success = True
|
||||
return schemas.Response(success=success, message=message)
|
||||
@@ -301,31 +316,40 @@ async def set_setting(
|
||||
success = await SystemConfigOper().async_set(key, value)
|
||||
if success:
|
||||
# 发送配置变更事件
|
||||
await eventmanager.async_send_event(etype=EventType.ConfigChanged, data=ConfigChangeEventData(
|
||||
key=key,
|
||||
value=value,
|
||||
change_type="update"
|
||||
))
|
||||
await eventmanager.async_send_event(
|
||||
etype=EventType.ConfigChanged,
|
||||
data=ConfigChangeEventData(key=key, value=value, change_type="update"),
|
||||
)
|
||||
return schemas.Response(success=True)
|
||||
else:
|
||||
return schemas.Response(success=False, message=f"配置项 '{key}' 不存在")
|
||||
|
||||
|
||||
@router.get("/llm-models", summary="获取LLM模型列表", response_model=schemas.Response)
|
||||
async def get_llm_models(provider: str, api_key: str, base_url: Optional[str] = None, _: User = Depends(get_current_active_user_async)):
|
||||
async def get_llm_models(
|
||||
provider: str,
|
||||
api_key: str,
|
||||
base_url: Optional[str] = None,
|
||||
_: User = Depends(get_current_active_user_async),
|
||||
):
|
||||
"""
|
||||
获取LLM模型列表
|
||||
"""
|
||||
try:
|
||||
models = LLMHelper().get_models(provider, api_key, base_url)
|
||||
models = await asyncio.to_thread(
|
||||
LLMHelper().get_models, provider, api_key, base_url
|
||||
)
|
||||
return schemas.Response(success=True, data=models)
|
||||
except Exception as e:
|
||||
return schemas.Response(success=False, message=str(e))
|
||||
|
||||
|
||||
@router.get("/message", summary="实时消息")
|
||||
async def get_message(request: Request, role: Optional[str] = "system",
|
||||
_: schemas.TokenPayload = Depends(verify_resource_token)):
|
||||
async def get_message(
|
||||
request: Request,
|
||||
role: Optional[str] = "system",
|
||||
_: schemas.TokenPayload = Depends(verify_resource_token),
|
||||
):
|
||||
"""
|
||||
实时获取系统消息,返回格式为SSE
|
||||
"""
|
||||
@@ -346,8 +370,12 @@ async def get_message(request: Request, role: Optional[str] = "system",
|
||||
|
||||
|
||||
@router.get("/logging", summary="实时日志")
|
||||
async def get_logging(request: Request, length: Optional[int] = 50, logfile: Optional[str] = "moviepilot.log",
|
||||
_: schemas.TokenPayload = Depends(verify_resource_token)):
|
||||
async def get_logging(
|
||||
request: Request,
|
||||
length: Optional[int] = 50,
|
||||
logfile: Optional[str] = "moviepilot.log",
|
||||
_: schemas.TokenPayload = Depends(verify_resource_token),
|
||||
):
|
||||
"""
|
||||
实时获取系统日志
|
||||
length = -1 时, 返回text/plain
|
||||
@@ -356,7 +384,9 @@ async def get_logging(request: Request, length: Optional[int] = 50, logfile: Opt
|
||||
base_path = AsyncPath(settings.LOG_PATH)
|
||||
log_path = base_path / logfile
|
||||
|
||||
if not await SecurityUtils.async_is_safe_path(base_path=base_path, user_path=log_path, allowed_suffixes={".log"}):
|
||||
if not await SecurityUtils.async_is_safe_path(
|
||||
base_path=base_path, user_path=log_path, allowed_suffixes={".log"}
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
|
||||
if not await log_path.exists() or not await log_path.is_file():
|
||||
@@ -371,7 +401,9 @@ async def get_logging(request: Request, length: Optional[int] = 50, logfile: Opt
|
||||
file_size = file_stat.st_size
|
||||
|
||||
# 读取历史日志
|
||||
async with aiofiles.open(log_path, mode="r", encoding="utf-8", errors="ignore") as f:
|
||||
async with aiofiles.open(
|
||||
log_path, mode="r", encoding="utf-8", errors="ignore"
|
||||
) as f:
|
||||
# 优化大文件读取策略
|
||||
if file_size > 100 * 1024:
|
||||
# 只读取最后100KB的内容
|
||||
@@ -380,9 +412,9 @@ async def get_logging(request: Request, length: Optional[int] = 50, logfile: Opt
|
||||
await f.seek(position)
|
||||
content = await f.read()
|
||||
# 找到第一个完整的行
|
||||
first_newline = content.find('\n')
|
||||
first_newline = content.find("\n")
|
||||
if first_newline != -1:
|
||||
content = content[first_newline + 1:]
|
||||
content = content[first_newline + 1 :]
|
||||
else:
|
||||
# 小文件直接读取全部内容
|
||||
content = await f.read()
|
||||
@@ -390,7 +422,7 @@ async def get_logging(request: Request, length: Optional[int] = 50, logfile: Opt
|
||||
# 按行分割并添加到队列,只保留非空行
|
||||
lines = [line.strip() for line in content.splitlines() if line.strip()]
|
||||
# 只取最后N行
|
||||
for line in lines[-max(length, 50):]:
|
||||
for line in lines[-max(length, 50) :]:
|
||||
lines_queue.append(line)
|
||||
|
||||
# 输出历史日志
|
||||
@@ -398,7 +430,9 @@ async def get_logging(request: Request, length: Optional[int] = 50, logfile: Opt
|
||||
yield f"data: {line}\n\n"
|
||||
|
||||
# 实时监听新日志
|
||||
async with aiofiles.open(log_path, mode="r", encoding="utf-8", errors="ignore") as f:
|
||||
async with aiofiles.open(
|
||||
log_path, mode="r", encoding="utf-8", errors="ignore"
|
||||
) as f:
|
||||
# 移动文件指针到文件末尾,继续监听新增内容
|
||||
await f.seek(0, 2)
|
||||
# 记录初始文件大小
|
||||
@@ -435,7 +469,9 @@ async def get_logging(request: Request, length: Optional[int] = 50, logfile: Opt
|
||||
return Response(content="日志文件不存在!", media_type="text/plain")
|
||||
try:
|
||||
# 使用 aiofiles 异步读取文件
|
||||
async with aiofiles.open(log_path, mode="r", encoding="utf-8", errors="ignore") as file:
|
||||
async with aiofiles.open(
|
||||
log_path, mode="r", encoding="utf-8", errors="ignore"
|
||||
) as file:
|
||||
text = await file.read()
|
||||
# 倒序输出
|
||||
text = "\n".join(text.split("\n")[::-1])
|
||||
@@ -447,13 +483,16 @@ async def get_logging(request: Request, length: Optional[int] = 50, logfile: Opt
|
||||
return StreamingResponse(log_generator(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@router.get("/versions", summary="查询Github所有Release版本", response_model=schemas.Response)
|
||||
@router.get(
|
||||
"/versions", summary="查询Github所有Release版本", response_model=schemas.Response
|
||||
)
|
||||
async def latest_version(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
查询Github所有Release版本
|
||||
"""
|
||||
version_res = await AsyncRequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS).get_res(
|
||||
f"https://api.github.com/repos/jxxghp/MoviePilot/releases")
|
||||
version_res = await AsyncRequestUtils(
|
||||
proxies=settings.PROXY, headers=settings.GITHUB_HEADERS
|
||||
).get_res(f"https://api.github.com/repos/jxxghp/MoviePilot/releases")
|
||||
if version_res:
|
||||
ver_json = version_res.json()
|
||||
if ver_json:
|
||||
@@ -462,10 +501,12 @@ async def latest_version(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
|
||||
|
||||
@router.get("/ruletest", summary="过滤规则测试", response_model=schemas.Response)
|
||||
def ruletest(title: str,
|
||||
rulegroup_name: str,
|
||||
subtitle: Optional[str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)):
|
||||
def ruletest(
|
||||
title: str,
|
||||
rulegroup_name: str,
|
||||
subtitle: Optional[str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token),
|
||||
):
|
||||
"""
|
||||
过滤规则测试,规则类型 1-订阅,2-洗版,3-搜索
|
||||
"""
|
||||
@@ -476,7 +517,9 @@ def ruletest(title: str,
|
||||
# 查询规则组详情
|
||||
rulegroup = RuleHelper().get_rule_group(rulegroup_name)
|
||||
if not rulegroup:
|
||||
return schemas.Response(success=False, message=f"过滤规则组 {rulegroup_name} 不存在!")
|
||||
return schemas.Response(
|
||||
success=False, message=f"过滤规则组 {rulegroup_name} 不存在!"
|
||||
)
|
||||
|
||||
# 根据标题查询媒体信息
|
||||
media_info = SearchChain().recognize_media(MetaInfo(title=title, subtitle=subtitle))
|
||||
@@ -484,21 +527,22 @@ def ruletest(title: str,
|
||||
return schemas.Response(success=False, message="未识别到媒体信息!")
|
||||
|
||||
# 过滤
|
||||
result = SearchChain().filter_torrents(rule_groups=[rulegroup.name],
|
||||
torrent_list=[torrent], mediainfo=media_info)
|
||||
result = SearchChain().filter_torrents(
|
||||
rule_groups=[rulegroup.name], torrent_list=[torrent], mediainfo=media_info
|
||||
)
|
||||
if not result:
|
||||
return schemas.Response(success=False, message="不符合过滤规则!")
|
||||
return schemas.Response(success=True, data={
|
||||
"priority": 100 - result[0].pri_order + 1
|
||||
})
|
||||
return schemas.Response(
|
||||
success=True, data={"priority": 100 - result[0].pri_order + 1}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/nettest", summary="测试网络连通性")
|
||||
async def nettest(
|
||||
url: str,
|
||||
proxy: bool,
|
||||
include: Optional[str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token),
|
||||
url: str,
|
||||
proxy: bool,
|
||||
include: Optional[str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token),
|
||||
):
|
||||
"""
|
||||
测试网络连通性
|
||||
@@ -570,21 +614,26 @@ async def nettest(
|
||||
return schemas.Response(success=False, message=message, data={"time": time})
|
||||
|
||||
|
||||
@router.get("/modulelist", summary="查询已加载的模块ID列表", response_model=schemas.Response)
|
||||
@router.get(
|
||||
"/modulelist", summary="查询已加载的模块ID列表", response_model=schemas.Response
|
||||
)
|
||||
def modulelist(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
查询已加载的模块ID列表
|
||||
"""
|
||||
modules = [{
|
||||
"id": k,
|
||||
"name": v.get_name(),
|
||||
} for k, v in ModuleManager().get_modules().items()]
|
||||
return schemas.Response(success=True, data={
|
||||
"modules": modules
|
||||
})
|
||||
modules = [
|
||||
{
|
||||
"id": k,
|
||||
"name": v.get_name(),
|
||||
}
|
||||
for k, v in ModuleManager().get_modules().items()
|
||||
]
|
||||
return schemas.Response(success=True, data={"modules": modules})
|
||||
|
||||
|
||||
@router.get("/moduletest/{moduleid}", summary="模块可用性测试", response_model=schemas.Response)
|
||||
@router.get(
|
||||
"/moduletest/{moduleid}", summary="模块可用性测试", response_model=schemas.Response
|
||||
)
|
||||
def moduletest(moduleid: str, _: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
模块可用性测试接口
|
||||
@@ -608,8 +657,7 @@ def restart_system(_: User = Depends(get_current_active_superuser)):
|
||||
|
||||
|
||||
@router.get("/runscheduler", summary="运行服务", response_model=schemas.Response)
|
||||
def run_scheduler(jobid: str,
|
||||
_: User = Depends(get_current_active_superuser)):
|
||||
def run_scheduler(jobid: str, _: User = Depends(get_current_active_superuser)):
|
||||
"""
|
||||
执行命令(仅管理员)
|
||||
"""
|
||||
@@ -622,9 +670,10 @@ def run_scheduler(jobid: str,
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/runscheduler2", summary="运行服务(API_TOKEN)", response_model=schemas.Response)
|
||||
def run_scheduler2(jobid: str,
|
||||
_: Annotated[str, Depends(verify_apitoken)]):
|
||||
@router.get(
|
||||
"/runscheduler2", summary="运行服务(API_TOKEN)", response_model=schemas.Response
|
||||
)
|
||||
def run_scheduler2(jobid: str, _: Annotated[str, Depends(verify_apitoken)]):
|
||||
"""
|
||||
执行命令(API_TOKEN认证)
|
||||
"""
|
||||
|
||||
@@ -580,7 +580,7 @@ class MessageChain(ChainBase):
|
||||
total = len(cache_list)
|
||||
# 加一页
|
||||
cache_list = cache_list[
|
||||
(_current_page + 1) * self._page_size: (_current_page + 2)
|
||||
(_current_page + 1) * self._page_size : (_current_page + 2)
|
||||
* self._page_size
|
||||
]
|
||||
if not cache_list:
|
||||
@@ -1134,6 +1134,59 @@ class MessageChain(ChainBase):
|
||||
)
|
||||
)
|
||||
|
||||
def remote_stop_agent(
|
||||
self,
|
||||
channel: MessageChannel,
|
||||
userid: Union[str, int],
|
||||
source: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
应急停止当前正在执行的Agent推理(远程命令接口)。
|
||||
与 /clear_session 不同,此命令不会清除会话和记忆,
|
||||
停止后用户仍可继续对话。
|
||||
"""
|
||||
# 查找用户的会话ID(不弹出,保留会话)
|
||||
session_info = self._user_sessions.get(userid)
|
||||
if session_info:
|
||||
session_id, _ = session_info
|
||||
try:
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
agent_manager.stop_current_task(session_id=session_id),
|
||||
global_vars.loop,
|
||||
)
|
||||
stopped = future.result(timeout=10)
|
||||
except Exception as e:
|
||||
logger.warning(f"停止Agent推理失败: {e}")
|
||||
stopped = False
|
||||
|
||||
if stopped:
|
||||
self.post_message(
|
||||
Notification(
|
||||
channel=channel,
|
||||
source=source,
|
||||
title="智能体推理已应急停止,会话记忆已保留,您可以继续对话",
|
||||
userid=userid,
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.post_message(
|
||||
Notification(
|
||||
channel=channel,
|
||||
source=source,
|
||||
title="当前没有正在执行的智能体任务",
|
||||
userid=userid,
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.post_message(
|
||||
Notification(
|
||||
channel=channel,
|
||||
source=source,
|
||||
title="您当前没有活跃的智能体会话",
|
||||
userid=userid,
|
||||
)
|
||||
)
|
||||
|
||||
def _handle_ai_message(
|
||||
self,
|
||||
text: str,
|
||||
|
||||
@@ -593,11 +593,17 @@ class SubscribeChain(ChainBase):
|
||||
|
||||
# 洗版
|
||||
if subscribe.best_version:
|
||||
# 洗版时,非整季不要
|
||||
if torrent_mediainfo.type == MediaType.TV:
|
||||
if torrent_meta.episode_list:
|
||||
logger.info(f'{subscribe.name} 正在洗版,{torrent_info.title} 不是整季')
|
||||
continue
|
||||
# 洗版时,不符合订阅集数的不要
|
||||
if (
|
||||
torrent_mediainfo.type == MediaType.TV
|
||||
and not self._is_episode_range_covered(
|
||||
meta=torrent_meta, subscribe=subscribe
|
||||
)
|
||||
):
|
||||
logger.info(
|
||||
f"{subscribe.name} 正在洗版,{torrent_info.title} 不符合订阅集数范围"
|
||||
)
|
||||
continue
|
||||
# 洗版时,优先级小于等于已下载优先级的不要
|
||||
if subscribe.current_priority \
|
||||
and torrent_info.pri_order <= subscribe.current_priority:
|
||||
@@ -985,11 +991,18 @@ class SubscribeChain(ChainBase):
|
||||
)
|
||||
continue
|
||||
else:
|
||||
# 洗版时,非整季不要
|
||||
if meta.type == MediaType.TV:
|
||||
if torrent_meta.episode_list:
|
||||
logger.debug(f'{subscribe.name} 正在洗版,{torrent_info.title} 不是整季')
|
||||
continue
|
||||
# 洗版时,不符合订阅集数的不要
|
||||
if (
|
||||
meta.type == MediaType.TV
|
||||
and not self._is_episode_range_covered(
|
||||
meta=torrent_meta,
|
||||
subscribe=subscribe,
|
||||
)
|
||||
):
|
||||
logger.debug(
|
||||
f"{subscribe.name} 正在洗版,{torrent_info.title} 不符合订阅集数范围"
|
||||
)
|
||||
continue
|
||||
|
||||
# 匹配订阅附加参数
|
||||
if not torrenthelper.filter_torrent(torrent_info=torrent_info,
|
||||
@@ -1821,6 +1834,23 @@ class SubscribeChain(ChainBase):
|
||||
# 返回结果,表示媒体未完全下载或存在
|
||||
return False, no_exists
|
||||
|
||||
@staticmethod
|
||||
def _is_episode_range_covered(meta: MetaBase, subscribe: Subscribe) -> bool:
|
||||
"""
|
||||
判断种子是否包含指定订阅的剧集范围
|
||||
"""
|
||||
episodes = meta.episode_list
|
||||
if not episodes:
|
||||
# 没有剧集信息,表示该种子为合集
|
||||
return True
|
||||
|
||||
min_ep = min(episodes)
|
||||
max_ep = max(episodes)
|
||||
start_ep = subscribe.start_episode or 1
|
||||
end_ep = subscribe.total_episode
|
||||
|
||||
return min_ep <= start_ep and max_ep >= end_ep
|
||||
|
||||
@staticmethod
|
||||
def get_states_for_search(state: str) -> str:
|
||||
"""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
193
app/command.py
193
app/command.py
@@ -45,109 +45,115 @@ class Command(metaclass=Singleton):
|
||||
"id": "cookiecloud",
|
||||
"type": "scheduler",
|
||||
"description": "同步站点",
|
||||
"category": "站点"
|
||||
"category": "站点",
|
||||
},
|
||||
"/sites": {
|
||||
"func": SiteChain().remote_list,
|
||||
"description": "查询站点",
|
||||
"category": "站点",
|
||||
"data": {}
|
||||
"data": {},
|
||||
},
|
||||
"/site_cookie": {
|
||||
"func": SiteChain().remote_cookie,
|
||||
"description": "更新站点Cookie",
|
||||
"data": {}
|
||||
"data": {},
|
||||
},
|
||||
"/site_statistic": {
|
||||
"func": SiteChain().remote_refresh_userdatas,
|
||||
"description": "站点数据统计",
|
||||
"data": {}
|
||||
"data": {},
|
||||
},
|
||||
"/site_enable": {
|
||||
"func": SiteChain().remote_enable,
|
||||
"description": "启用站点",
|
||||
"data": {}
|
||||
"data": {},
|
||||
},
|
||||
"/site_disable": {
|
||||
"func": SiteChain().remote_disable,
|
||||
"description": "禁用站点",
|
||||
"data": {}
|
||||
"data": {},
|
||||
},
|
||||
"/mediaserver_sync": {
|
||||
"id": "mediaserver_sync",
|
||||
"type": "scheduler",
|
||||
"description": "同步媒体服务器",
|
||||
"category": "管理"
|
||||
"category": "管理",
|
||||
},
|
||||
"/subscribes": {
|
||||
"func": SubscribeChain().remote_list,
|
||||
"description": "查询订阅",
|
||||
"category": "订阅",
|
||||
"data": {}
|
||||
"data": {},
|
||||
},
|
||||
"/subscribe_refresh": {
|
||||
"id": "subscribe_refresh",
|
||||
"type": "scheduler",
|
||||
"description": "刷新订阅",
|
||||
"category": "订阅"
|
||||
"category": "订阅",
|
||||
},
|
||||
"/subscribe_search": {
|
||||
"id": "subscribe_search",
|
||||
"type": "scheduler",
|
||||
"description": "搜索订阅",
|
||||
"category": "订阅"
|
||||
"category": "订阅",
|
||||
},
|
||||
"/subscribe_delete": {
|
||||
"func": SubscribeChain().remote_delete,
|
||||
"description": "删除订阅",
|
||||
"data": {}
|
||||
"data": {},
|
||||
},
|
||||
"/subscribe_tmdb": {
|
||||
"id": "subscribe_tmdb",
|
||||
"type": "scheduler",
|
||||
"description": "订阅元数据更新"
|
||||
"description": "订阅元数据更新",
|
||||
},
|
||||
"/downloading": {
|
||||
"func": DownloadChain().remote_downloading,
|
||||
"description": "正在下载",
|
||||
"category": "管理",
|
||||
"data": {}
|
||||
"data": {},
|
||||
},
|
||||
"/transfer": {
|
||||
"id": "transfer",
|
||||
"type": "scheduler",
|
||||
"description": "下载文件整理",
|
||||
"category": "管理"
|
||||
"category": "管理",
|
||||
},
|
||||
"/redo": {
|
||||
"func": TransferChain().remote_transfer,
|
||||
"description": "手动整理",
|
||||
"data": {}
|
||||
"data": {},
|
||||
},
|
||||
"/clear_cache": {
|
||||
"func": SystemChain().remote_clear_cache,
|
||||
"description": "清理缓存",
|
||||
"category": "管理",
|
||||
"data": {}
|
||||
"data": {},
|
||||
},
|
||||
"/restart": {
|
||||
"func": SystemChain().restart,
|
||||
"description": "重启系统",
|
||||
"category": "管理",
|
||||
"data": {}
|
||||
"data": {},
|
||||
},
|
||||
"/version": {
|
||||
"func": SystemChain().version,
|
||||
"description": "当前版本",
|
||||
"category": "管理",
|
||||
"data": {}
|
||||
"data": {},
|
||||
},
|
||||
"/clear_session": {
|
||||
"func": MessageChain().remote_clear_session,
|
||||
"description": "清除会话",
|
||||
"category": "管理",
|
||||
"data": {}
|
||||
}
|
||||
"data": {},
|
||||
},
|
||||
"/stop_agent": {
|
||||
"func": MessageChain().remote_stop_agent,
|
||||
"description": "停止推理",
|
||||
"category": "管理",
|
||||
"data": {},
|
||||
},
|
||||
}
|
||||
# 插件命令集合
|
||||
self._plugin_commands = {}
|
||||
@@ -182,7 +188,7 @@ class Command(metaclass=Singleton):
|
||||
self._commands = {
|
||||
**self._preset_commands,
|
||||
**self._plugin_commands,
|
||||
**self._other_commands
|
||||
**self._other_commands,
|
||||
}
|
||||
|
||||
# 强制触发注册
|
||||
@@ -195,32 +201,50 @@ class Command(metaclass=Singleton):
|
||||
event_data: CommandRegisterEventData = event.event_data
|
||||
# 如果事件被取消,跳过命令注册
|
||||
if event_data.cancel:
|
||||
logger.debug(f"Command initialization canceled by event: {event_data.source}")
|
||||
logger.debug(
|
||||
f"Command initialization canceled by event: {event_data.source}"
|
||||
)
|
||||
return
|
||||
# 如果拦截源与插件标识一致时,这里认为需要强制触发注册
|
||||
if pid is not None and pid == event_data.source:
|
||||
force_register = True
|
||||
initial_commands = event_data.commands or {}
|
||||
logger.debug(f"Registering command count from event: {len(initial_commands)}")
|
||||
logger.debug(
|
||||
f"Registering command count from event: {len(initial_commands)}"
|
||||
)
|
||||
else:
|
||||
logger.debug(f"Registering initial command count: {len(initial_commands)}")
|
||||
logger.debug(
|
||||
f"Registering initial command count: {len(initial_commands)}"
|
||||
)
|
||||
|
||||
# initial_commands 必须是 self._commands 的子集
|
||||
filtered_initial_commands = DictUtils.filter_keys_to_subset(initial_commands, self._commands)
|
||||
filtered_initial_commands = DictUtils.filter_keys_to_subset(
|
||||
initial_commands, self._commands
|
||||
)
|
||||
# 如果 filtered_initial_commands 为空,则跳过注册
|
||||
if not filtered_initial_commands and not force_register:
|
||||
logger.debug("Filtered commands are empty, skipping registration.")
|
||||
return
|
||||
|
||||
# 对比调整后的命令与当前命令
|
||||
if filtered_initial_commands != self._registered_commands or force_register:
|
||||
logger.debug("Command set has changed or force registration is enabled.")
|
||||
if (
|
||||
filtered_initial_commands != self._registered_commands
|
||||
or force_register
|
||||
):
|
||||
logger.debug(
|
||||
"Command set has changed or force registration is enabled."
|
||||
)
|
||||
self._registered_commands = filtered_initial_commands
|
||||
CommandChain().register_commands(commands=filtered_initial_commands)
|
||||
else:
|
||||
logger.debug("Command set unchanged, skipping broadcast registration.")
|
||||
logger.debug(
|
||||
"Command set unchanged, skipping broadcast registration."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error occurred during command initialization in background: {e}", exc_info=True)
|
||||
logger.error(
|
||||
f"Error occurred during command initialization in background: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def __trigger_register_commands_event(self) -> tuple[Optional[Event], dict]:
|
||||
"""
|
||||
@@ -238,7 +262,7 @@ class Command(metaclass=Singleton):
|
||||
command_data = {
|
||||
"type": command_type,
|
||||
"description": command.get("description"),
|
||||
"category": command.get("category")
|
||||
"category": command.get("category"),
|
||||
}
|
||||
# 如果有 pid,则添加到命令数据中
|
||||
plugin_id = command.get("pid")
|
||||
@@ -253,7 +277,9 @@ class Command(metaclass=Singleton):
|
||||
add_commands(self._other_commands, "other")
|
||||
|
||||
# 触发事件允许可以拦截和调整命令
|
||||
event_data = CommandRegisterEventData(commands=commands, origin="CommandChain", service=None)
|
||||
event_data = CommandRegisterEventData(
|
||||
commands=commands, origin="CommandChain", service=None
|
||||
)
|
||||
event = eventmanager.send_event(ChainEventType.CommandRegister, event_data)
|
||||
return event, commands
|
||||
|
||||
@@ -274,13 +300,19 @@ class Command(metaclass=Singleton):
|
||||
"show": command.get("show", True),
|
||||
"data": {
|
||||
"etype": command.get("event"),
|
||||
"data": command.get("data")
|
||||
}
|
||||
"data": command.get("data"),
|
||||
},
|
||||
}
|
||||
return plugin_commands
|
||||
|
||||
def __run_command(self, command: Dict[str, any], data_str: Optional[str] = "",
|
||||
channel: MessageChannel = None, source: Optional[str] = None, userid: Union[str, int] = None):
|
||||
def __run_command(
|
||||
self,
|
||||
command: Dict[str, any],
|
||||
data_str: Optional[str] = "",
|
||||
channel: MessageChannel = None,
|
||||
source: Optional[str] = None,
|
||||
userid: Union[str, int] = None,
|
||||
):
|
||||
"""
|
||||
运行定时服务
|
||||
"""
|
||||
@@ -292,7 +324,7 @@ class Command(metaclass=Singleton):
|
||||
channel=channel,
|
||||
source=source,
|
||||
title=f"开始执行 {command.get('description')} ...",
|
||||
userid=userid
|
||||
userid=userid,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -305,33 +337,33 @@ class Command(metaclass=Singleton):
|
||||
channel=channel,
|
||||
source=source,
|
||||
title=f"{command.get('description')} 执行完成",
|
||||
userid=userid
|
||||
userid=userid,
|
||||
)
|
||||
)
|
||||
else:
|
||||
# 命令
|
||||
cmd_data = copy.deepcopy(command['data']) if command.get('data') else {}
|
||||
args_num = ObjectUtils.arguments(command['func'])
|
||||
cmd_data = copy.deepcopy(command["data"]) if command.get("data") else {}
|
||||
args_num = ObjectUtils.arguments(command["func"])
|
||||
if args_num > 0:
|
||||
if cmd_data:
|
||||
# 有内置参数直接使用内置参数
|
||||
data = cmd_data.get("data") or {}
|
||||
data['channel'] = channel
|
||||
data['source'] = source
|
||||
data['user'] = userid
|
||||
data["channel"] = channel
|
||||
data["source"] = source
|
||||
data["user"] = userid
|
||||
if data_str:
|
||||
data['arg_str'] = data_str
|
||||
cmd_data['data'] = data
|
||||
command['func'](**cmd_data)
|
||||
data["arg_str"] = data_str
|
||||
cmd_data["data"] = data
|
||||
command["func"](**cmd_data)
|
||||
elif args_num == 3:
|
||||
# 没有输入参数,只输入渠道来源、用户ID和消息来源
|
||||
command['func'](channel, userid, source)
|
||||
command["func"](channel, userid, source)
|
||||
elif args_num > 3:
|
||||
# 多个输入参数:用户输入、用户ID
|
||||
command['func'](data_str, channel, userid, source)
|
||||
command["func"](data_str, channel, userid, source)
|
||||
else:
|
||||
# 没有参数
|
||||
command['func']()
|
||||
command["func"]()
|
||||
|
||||
def get_commands(self):
|
||||
"""
|
||||
@@ -345,9 +377,15 @@ class Command(metaclass=Singleton):
|
||||
"""
|
||||
return self._commands.get(cmd, {})
|
||||
|
||||
def register(self, cmd: str, func: Any, data: Optional[dict] = None,
|
||||
desc: Optional[str] = None, category: Optional[str] = None,
|
||||
show: bool = True) -> None:
|
||||
def register(
|
||||
self,
|
||||
cmd: str,
|
||||
func: Any,
|
||||
data: Optional[dict] = None,
|
||||
desc: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
show: bool = True,
|
||||
) -> None:
|
||||
"""
|
||||
注册单个命令
|
||||
"""
|
||||
@@ -357,12 +395,17 @@ class Command(metaclass=Singleton):
|
||||
"description": desc,
|
||||
"category": category,
|
||||
"data": data or {},
|
||||
"show": show
|
||||
"show": show,
|
||||
}
|
||||
|
||||
def execute(self, cmd: str, data_str: Optional[str] = "",
|
||||
channel: MessageChannel = None, source: Optional[str] = None,
|
||||
userid: Union[str, int] = None) -> None:
|
||||
def execute(
|
||||
self,
|
||||
cmd: str,
|
||||
data_str: Optional[str] = "",
|
||||
channel: MessageChannel = None,
|
||||
source: Optional[str] = None,
|
||||
userid: Union[str, int] = None,
|
||||
) -> None:
|
||||
"""
|
||||
执行命令
|
||||
"""
|
||||
@@ -370,23 +413,32 @@ class Command(metaclass=Singleton):
|
||||
if command:
|
||||
try:
|
||||
if userid:
|
||||
logger.info(f"用户 {userid} 开始执行:{command.get('description')} ...")
|
||||
logger.info(
|
||||
f"用户 {userid} 开始执行:{command.get('description')} ..."
|
||||
)
|
||||
else:
|
||||
logger.info(f"开始执行:{command.get('description')} ...")
|
||||
|
||||
# 执行命令
|
||||
self.__run_command(command, data_str=data_str,
|
||||
channel=channel, source=source, userid=userid)
|
||||
self.__run_command(
|
||||
command,
|
||||
data_str=data_str,
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
)
|
||||
|
||||
if userid:
|
||||
logger.info(f"用户 {userid} {command.get('description')} 执行完成")
|
||||
else:
|
||||
logger.info(f"{command.get('description')} 执行完成")
|
||||
except Exception as err:
|
||||
logger.error(f"执行命令 {cmd} 出错:{str(err)} - {traceback.format_exc()}")
|
||||
self.messagehelper.put(title=f"执行命令 {cmd} 出错",
|
||||
message=str(err),
|
||||
role="system")
|
||||
logger.error(
|
||||
f"执行命令 {cmd} 出错:{str(err)} - {traceback.format_exc()}"
|
||||
)
|
||||
self.messagehelper.put(
|
||||
title=f"执行命令 {cmd} 出错", message=str(err), role="system"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def send_plugin_event(etype: EventType, data: dict) -> None:
|
||||
@@ -404,19 +456,24 @@ class Command(metaclass=Singleton):
|
||||
}
|
||||
"""
|
||||
# 命令参数
|
||||
event_str = event.event_data.get('cmd')
|
||||
event_str = event.event_data.get("cmd")
|
||||
# 消息渠道
|
||||
event_channel = event.event_data.get('channel')
|
||||
event_channel = event.event_data.get("channel")
|
||||
# 消息来源
|
||||
event_source = event.event_data.get('source')
|
||||
event_source = event.event_data.get("source")
|
||||
# 消息用户
|
||||
event_user = event.event_data.get('user')
|
||||
event_user = event.event_data.get("user")
|
||||
if event_str:
|
||||
cmd = event_str.split()[0]
|
||||
args = " ".join(event_str.split()[1:])
|
||||
if self.get(cmd):
|
||||
self.execute(cmd=cmd, data_str=args,
|
||||
channel=event_channel, source=event_source, userid=event_user)
|
||||
self.execute(
|
||||
cmd=cmd,
|
||||
data_str=args,
|
||||
channel=event_channel,
|
||||
source=event_source,
|
||||
userid=event_user,
|
||||
)
|
||||
|
||||
@eventmanager.register(EventType.ModuleReload)
|
||||
def module_reload_event(self, _: ManagerEvent) -> None:
|
||||
|
||||
@@ -211,7 +211,7 @@ class CacheBackend(ABC):
|
||||
"""
|
||||
获取缓存的区
|
||||
"""
|
||||
return f"region:{region}" if region else "region:default"
|
||||
return f"region:{region}" if region else "region:DEFAULT"
|
||||
|
||||
@staticmethod
|
||||
def is_redis() -> bool:
|
||||
|
||||
@@ -535,6 +535,8 @@ class ConfigModel(BaseModel):
|
||||
AI_AGENT_JOB_INTERVAL: int = 0
|
||||
# AI智能体啰嗦模式,开启后会回复工具调用过程
|
||||
AI_AGENT_VERBOSE: bool = False
|
||||
# AI智能体自动重试整理失败记录开关
|
||||
AI_AGENT_RETRY_TRANSFER: bool = False
|
||||
|
||||
|
||||
class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
|
||||
@@ -85,7 +85,16 @@ class MetaVideo(MetaBase):
|
||||
self.total_season = 1
|
||||
return
|
||||
# 去掉名称中第1个[]的内容
|
||||
title = re.sub(r'%s' % self._name_no_begin_re, "", title, count=1)
|
||||
_first_bracket = re.match(r'^[\[【](.+?)[\]】]', title)
|
||||
if _first_bracket:
|
||||
_bracket_content = _first_bracket.group(1)
|
||||
# 如果第一个括号内为点分隔的英文发布名格式(含年份+资源类型),保留内容去掉括号
|
||||
if re.search(r'[A-Za-z]+\..+(?:19|20)\d{2}', _bracket_content) \
|
||||
and re.search(r'(?:2160|1080|720|480)[PIpi]|4K|UHD|Blu[\-.]?ray|REMUX|WEB[\-.]?DL|HDTV',
|
||||
_bracket_content, re.IGNORECASE):
|
||||
title = _bracket_content + title[_first_bracket.end():]
|
||||
else:
|
||||
title = title[_first_bracket.end():]
|
||||
# 把xxxx-xxxx年份换成前一个年份,常出现在季集上
|
||||
title = re.sub(r'([\s.]+)(\d{4})-(\d{4})', r'\1\2', title)
|
||||
# 把大小去掉
|
||||
@@ -247,9 +256,9 @@ class MetaVideo(MetaBase):
|
||||
if not self.cn_name:
|
||||
self.cn_name = token
|
||||
elif not self._stop_cnname_flag:
|
||||
if re.search("%s" % self._name_movie_words, token, flags=re.IGNORECASE) \
|
||||
if re.search("|".join(self._name_movie_words), token, flags=re.IGNORECASE) \
|
||||
or (not re.search("%s" % self._name_no_chinese_re, token, flags=re.IGNORECASE)
|
||||
and not re.search("%s" % self._name_se_words, token, flags=re.IGNORECASE)):
|
||||
and not any(w in token for w in self._name_se_words)):
|
||||
self.cn_name = "%s %s" % (self.cn_name, token)
|
||||
self._stop_cnname_flag = True
|
||||
else:
|
||||
|
||||
@@ -809,6 +809,64 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
||||
})
|
||||
return remotes
|
||||
|
||||
def get_plugin_sidebar_nav(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
聚合所有已启用 Vue 插件的侧栏导航项(get_sidebar_nav)。
|
||||
"""
|
||||
valid_sections = {"start", "discovery", "subscribe", "organize", "system"}
|
||||
valid_permissions = {"subscribe", "discovery", "search", "manage", "admin"}
|
||||
items: List[Dict[str, Any]] = []
|
||||
running_plugins_snapshot = dict(self._running_plugins)
|
||||
for plugin_id, plugin in running_plugins_snapshot.items():
|
||||
if not plugin.get_state():
|
||||
continue
|
||||
if not hasattr(plugin, "get_sidebar_nav") or not ObjectUtils.check_method(plugin.get_sidebar_nav):
|
||||
continue
|
||||
if not hasattr(plugin, "get_render_mode"):
|
||||
continue
|
||||
render_mode, _ = plugin.get_render_mode()
|
||||
if render_mode != "vue":
|
||||
continue
|
||||
try:
|
||||
nav_list = plugin.get_sidebar_nav()
|
||||
if not nav_list:
|
||||
continue
|
||||
for raw in nav_list:
|
||||
if not raw or not isinstance(raw, dict):
|
||||
continue
|
||||
nav_key = str(raw.get("nav_key") or raw.get("key") or "main").strip()
|
||||
if not nav_key or any(c in nav_key for c in ["/", "?", "#", " "]):
|
||||
logger.warning(f"插件[{plugin_id}]侧栏项 nav_key 无效,已跳过: {nav_key!r}")
|
||||
continue
|
||||
title = raw.get("title") or plugin.plugin_name
|
||||
icon = raw.get("icon") or "mdi-puzzle"
|
||||
section = str(raw.get("section") or "system").lower()
|
||||
if section not in valid_sections:
|
||||
section = "system"
|
||||
perm = raw.get("permission")
|
||||
if perm is not None and str(perm) not in valid_permissions:
|
||||
perm = None
|
||||
else:
|
||||
perm = str(perm) if perm is not None else None
|
||||
order = raw.get("order", 0)
|
||||
try:
|
||||
order = int(order)
|
||||
except (TypeError, ValueError):
|
||||
order = 0
|
||||
items.append({
|
||||
"plugin_id": plugin_id,
|
||||
"nav_key": nav_key,
|
||||
"title": title,
|
||||
"icon": icon,
|
||||
"section": section,
|
||||
"permission": perm,
|
||||
"order": order,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"获取插件[{plugin_id}]侧栏导航出错:{str(e)}")
|
||||
items.sort(key=lambda x: (x["section"], x["order"], x["plugin_id"], x["nav_key"]))
|
||||
return items
|
||||
|
||||
def get_plugin_dashboard_meta(self) -> List[Dict[str, str]]:
|
||||
"""
|
||||
获取所有插件仪表盘元信息
|
||||
|
||||
@@ -1,11 +1,61 @@
|
||||
"""LLM模型相关辅助功能"""
|
||||
|
||||
import inspect
|
||||
from typing import List
|
||||
|
||||
from app.core.config import settings
|
||||
from app.log import logger
|
||||
|
||||
|
||||
def _patch_gemini_thought_signature():
|
||||
"""
|
||||
修复 langchain-google-genai 中 Gemini 2.5 思考模型的 thought_signature 兼容问题。
|
||||
langchain-google-genai 的 _is_gemini_3_or_later() 仅检查 "gemini-3",
|
||||
导致 Gemini 2.5 思考模型(如 gemini-2.5-flash、gemini-2.5-pro)在工具调用时
|
||||
缺少 thought_signature 而报错 400。
|
||||
此补丁将检查范围扩展到 Gemini 2.5 模型。
|
||||
"""
|
||||
try:
|
||||
import langchain_google_genai.chat_models as _cm
|
||||
|
||||
# 仅在未修补时执行
|
||||
if getattr(_cm, "_thought_signature_patched", False):
|
||||
return
|
||||
|
||||
def _patched_is_gemini_3_or_later(model_name: str) -> bool:
|
||||
if not model_name:
|
||||
return False
|
||||
name = model_name.lower().replace("models/", "")
|
||||
# Gemini 2.5 思考模型也需要 thought_signature 支持
|
||||
return "gemini-3" in name or "gemini-2.5" in name
|
||||
|
||||
_cm._is_gemini_3_or_later = _patched_is_gemini_3_or_later
|
||||
_cm._thought_signature_patched = True
|
||||
logger.debug(
|
||||
"已修补 langchain-google-genai thought_signature 兼容性(覆盖 Gemini 2.5 模型)"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"修补 langchain-google-genai thought_signature 失败: {e}")
|
||||
|
||||
|
||||
def _get_httpx_proxy_key() -> str:
|
||||
"""
|
||||
获取当前 httpx 版本支持的代理参数名。
|
||||
httpx < 0.28 使用 "proxies"(复数),>= 0.28 使用 "proxy"(单数)。
|
||||
google-genai SDK 会静默过滤掉不在 httpx.Client.__init__ 签名中的参数,
|
||||
因此必须使用与当前 httpx 版本匹配的参数名。
|
||||
"""
|
||||
try:
|
||||
import httpx
|
||||
|
||||
params = inspect.signature(httpx.Client.__init__).parameters
|
||||
if "proxy" in params:
|
||||
return "proxy"
|
||||
return "proxies"
|
||||
except Exception:
|
||||
return "proxies"
|
||||
|
||||
|
||||
class LLMHelper:
|
||||
"""LLM模型相关辅助功能"""
|
||||
|
||||
@@ -23,31 +73,27 @@ class LLMHelper:
|
||||
raise ValueError("未配置LLM API Key")
|
||||
|
||||
if provider == "google":
|
||||
# 修补 Gemini 2.5 思考模型的 thought_signature 兼容性
|
||||
_patch_gemini_thought_signature()
|
||||
|
||||
# 统一使用 langchain-google-genai 原生接口
|
||||
# 不使用 OpenAI 兼容端点,因其不支持 Gemini 思考模型的 thought_signature,
|
||||
# 会导致工具调用时报错 400
|
||||
from langchain_google_genai import ChatGoogleGenerativeAI
|
||||
|
||||
client_args = None
|
||||
if settings.PROXY_HOST:
|
||||
# 通过代理使用 Google 的 OpenAI 兼容接口
|
||||
from langchain_openai import ChatOpenAI
|
||||
proxy_key = _get_httpx_proxy_key()
|
||||
client_args = {proxy_key: settings.PROXY_HOST}
|
||||
|
||||
model = ChatOpenAI(
|
||||
model=settings.LLM_MODEL,
|
||||
api_key=api_key,
|
||||
max_retries=3,
|
||||
base_url="https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
temperature=settings.LLM_TEMPERATURE,
|
||||
streaming=streaming,
|
||||
stream_usage=True,
|
||||
openai_proxy=settings.PROXY_HOST,
|
||||
)
|
||||
else:
|
||||
# 使用 langchain-google-genai 原生接口(v4 API 变更:google_api_key → api_key,max_retries → retries)
|
||||
from langchain_google_genai import ChatGoogleGenerativeAI
|
||||
|
||||
model = ChatGoogleGenerativeAI(
|
||||
model=settings.LLM_MODEL,
|
||||
api_key=api_key,
|
||||
retries=3,
|
||||
temperature=settings.LLM_TEMPERATURE,
|
||||
streaming=streaming
|
||||
)
|
||||
model = ChatGoogleGenerativeAI(
|
||||
model=settings.LLM_MODEL,
|
||||
api_key=api_key,
|
||||
retries=3,
|
||||
temperature=settings.LLM_TEMPERATURE,
|
||||
streaming=streaming,
|
||||
client_args=client_args,
|
||||
)
|
||||
elif provider == "deepseek":
|
||||
from langchain_deepseek import ChatDeepSeek
|
||||
|
||||
@@ -78,13 +124,14 @@ class LLMHelper:
|
||||
logger.info(f"使用LLM模型: {model.model},Profile: {model.profile}")
|
||||
else:
|
||||
model.profile = {
|
||||
"max_input_tokens": settings.LLM_MAX_CONTEXT_TOKENS * 1000, # 转换为token单位
|
||||
"max_input_tokens": settings.LLM_MAX_CONTEXT_TOKENS
|
||||
* 1000, # 转换为token单位
|
||||
}
|
||||
|
||||
return model
|
||||
|
||||
def get_models(
|
||||
self, provider: str, api_key: str, base_url: str = None
|
||||
self, provider: str, api_key: str, base_url: str = None
|
||||
) -> List[str]:
|
||||
"""获取模型列表"""
|
||||
logger.info(f"获取 {provider} 模型列表...")
|
||||
@@ -98,8 +145,18 @@ class LLMHelper:
|
||||
"""获取Google模型列表(使用 google-genai SDK v1)"""
|
||||
try:
|
||||
from google import genai
|
||||
from google.genai.types import HttpOptions
|
||||
|
||||
client = genai.Client(api_key=api_key)
|
||||
http_options = None
|
||||
if settings.PROXY_HOST:
|
||||
proxy_key = _get_httpx_proxy_key()
|
||||
proxy_args = {proxy_key: settings.PROXY_HOST}
|
||||
http_options = HttpOptions(
|
||||
client_args=proxy_args,
|
||||
async_client_args=proxy_args,
|
||||
)
|
||||
|
||||
client = genai.Client(api_key=api_key, http_options=http_options)
|
||||
models = client.models.list()
|
||||
return [
|
||||
m.name
|
||||
@@ -112,7 +169,7 @@ class LLMHelper:
|
||||
|
||||
@staticmethod
|
||||
def _get_openai_compatible_models(
|
||||
provider: str, api_key: str, base_url: str = None
|
||||
provider: str, api_key: str, base_url: str = None
|
||||
) -> List[str]:
|
||||
"""获取OpenAI兼容模型列表"""
|
||||
try:
|
||||
|
||||
@@ -140,7 +140,7 @@ class RedisHelper(ConfigReloadMixin, metaclass=Singleton):
|
||||
"""
|
||||
获取缓存的区
|
||||
"""
|
||||
return f"region:{quote(region)}" if region else "region:DEFAULT"
|
||||
return f"region:{region}" if region else "region:DEFAULT"
|
||||
|
||||
def __make_redis_key(self, region: str, key: str) -> str:
|
||||
"""
|
||||
@@ -370,7 +370,7 @@ class AsyncRedisHelper(ConfigReloadMixin, metaclass=Singleton):
|
||||
"""
|
||||
获取缓存的区
|
||||
"""
|
||||
return f"region:{region}" if region else "region:default"
|
||||
return f"region:{region}" if region else "region:DEFAULT"
|
||||
|
||||
def __make_redis_key(self, region: str, key: str) -> str:
|
||||
"""
|
||||
|
||||
@@ -182,7 +182,7 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]):
|
||||
)
|
||||
|
||||
if not configs:
|
||||
logger.warning("[Discord] get_configs() 返回空,没有可用的 Discord 配置")
|
||||
logger.debug("[Discord] get_configs() 返回空,没有可用的 Discord 配置")
|
||||
return
|
||||
|
||||
for conf in configs.values():
|
||||
|
||||
@@ -489,7 +489,7 @@ class Discord:
|
||||
return spans
|
||||
|
||||
def _find_colon_index(s: str, m: re.Match) -> Optional[int]:
|
||||
segment = s[m.start() : m.end()]
|
||||
segment = s[m.start(): m.end()]
|
||||
for i, ch in enumerate(segment):
|
||||
if ch in (":", ":"):
|
||||
return m.start() + i
|
||||
@@ -546,7 +546,7 @@ class Discord:
|
||||
last_end = 0
|
||||
for m in matches:
|
||||
# 追加匹配前的非空文本到描述
|
||||
prefix = line[last_end : m.start()].strip(" ,,;;。、")
|
||||
prefix = line[last_end: m.start()].strip(" ,,;;。、")
|
||||
# 仅当前缀不全是分隔符/空白时才记录
|
||||
if prefix and prefix.strip(" ,,;;。、"):
|
||||
desc_lines.append(prefix)
|
||||
|
||||
@@ -13,8 +13,15 @@ from app.helper.directory import DirectoryHelper
|
||||
from app.helper.message import TemplateHelper
|
||||
from app.log import logger
|
||||
from app.modules.filemanager.storages import StorageBase
|
||||
from app.schemas import TransferInfo, TmdbEpisode, TransferDirectoryConf, FileItem, TransferInterceptEventData, \
|
||||
TransferRenameEventData
|
||||
from app.schemas import (
|
||||
TransferInfo,
|
||||
TmdbEpisode,
|
||||
TransferDirectoryConf,
|
||||
FileItem,
|
||||
TransferInterceptEventData,
|
||||
TransferOverwriteCheckEventData,
|
||||
TransferRenameEventData,
|
||||
)
|
||||
from app.schemas.types import MediaType, ChainEventType
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
@@ -51,26 +58,27 @@ class TransHandler:
|
||||
elif isinstance(current_value, bool):
|
||||
current_value = value
|
||||
elif isinstance(current_value, int):
|
||||
current_value += (value or 0)
|
||||
current_value += value or 0
|
||||
else:
|
||||
current_value = value
|
||||
setattr(result, key, current_value)
|
||||
|
||||
def transfer_media(self,
|
||||
fileitem: FileItem,
|
||||
in_meta: MetaBase,
|
||||
mediainfo: MediaInfo,
|
||||
target_storage: str,
|
||||
target_path: Path,
|
||||
transfer_type: str,
|
||||
source_oper: StorageBase,
|
||||
target_oper: StorageBase,
|
||||
need_scrape: Optional[bool] = False,
|
||||
need_rename: Optional[bool] = True,
|
||||
need_notify: Optional[bool] = True,
|
||||
overwrite_mode: Optional[str] = None,
|
||||
episodes_info: List[TmdbEpisode] = None
|
||||
) -> TransferInfo:
|
||||
def transfer_media(
|
||||
self,
|
||||
fileitem: FileItem,
|
||||
in_meta: MetaBase,
|
||||
mediainfo: MediaInfo,
|
||||
target_storage: str,
|
||||
target_path: Path,
|
||||
transfer_type: str,
|
||||
source_oper: StorageBase,
|
||||
target_oper: StorageBase,
|
||||
need_scrape: Optional[bool] = False,
|
||||
need_rename: Optional[bool] = True,
|
||||
need_notify: Optional[bool] = True,
|
||||
overwrite_mode: Optional[str] = None,
|
||||
episodes_info: List[TmdbEpisode] = None,
|
||||
) -> TransferInfo:
|
||||
"""
|
||||
识别并整理一个文件或者一个目录下的所有文件
|
||||
:param fileitem: 整理的文件对象,可能是一个文件也可以是一个目录
|
||||
@@ -109,7 +117,9 @@ class TransHandler:
|
||||
"""
|
||||
if not _fileitem.extension:
|
||||
return False
|
||||
if f".{_fileitem.extension.lower()}" in (settings.RMT_SUBEXT + settings.RMT_AUDIOEXT):
|
||||
if f".{_fileitem.extension.lower()}" in (
|
||||
settings.RMT_SUBEXT + settings.RMT_AUDIOEXT
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -117,7 +127,6 @@ class TransHandler:
|
||||
result = TransferInfo()
|
||||
|
||||
try:
|
||||
|
||||
# 重命名格式
|
||||
rename_format = settings.RENAME_FORMAT(mediainfo.type)
|
||||
|
||||
@@ -128,8 +137,11 @@ class TransHandler:
|
||||
new_path = self.get_rename_path(
|
||||
path=target_path,
|
||||
template_string=rename_format,
|
||||
rename_dict=self.get_naming_dict(meta=in_meta,
|
||||
mediainfo=mediainfo)
|
||||
rename_dict=self.get_naming_dict(
|
||||
meta=in_meta, mediainfo=mediainfo
|
||||
),
|
||||
source_path=fileitem.path,
|
||||
source_item=fileitem,
|
||||
)
|
||||
new_path = DirectoryHelper.get_media_root_path(
|
||||
rename_format, rename_path=new_path
|
||||
@@ -148,40 +160,46 @@ class TransHandler:
|
||||
new_path = target_path / fileitem.name
|
||||
# 原盘大小只计算STREAM目录内的文件大小
|
||||
if stream_fileitem := source_oper.get_item(
|
||||
Path(fileitem.path) / "BDMV" / "STREAM"
|
||||
Path(fileitem.path) / "BDMV" / "STREAM"
|
||||
):
|
||||
fileitem.size = sum(
|
||||
file.size for file in source_oper.list(stream_fileitem) or []
|
||||
)
|
||||
# 整理目录
|
||||
new_diritem, errmsg = self.__transfer_dir(fileitem=fileitem,
|
||||
mediainfo=mediainfo,
|
||||
source_oper=source_oper,
|
||||
target_oper=target_oper,
|
||||
target_storage=target_storage,
|
||||
target_path=new_path,
|
||||
transfer_type=transfer_type,
|
||||
result=result)
|
||||
new_diritem, errmsg = self.__transfer_dir(
|
||||
fileitem=fileitem,
|
||||
mediainfo=mediainfo,
|
||||
source_oper=source_oper,
|
||||
target_oper=target_oper,
|
||||
target_storage=target_storage,
|
||||
target_path=new_path,
|
||||
transfer_type=transfer_type,
|
||||
result=result,
|
||||
)
|
||||
if not new_diritem:
|
||||
logger.error(f"文件夹 {fileitem.path} 整理失败:{errmsg}")
|
||||
self.__update_result(result=result,
|
||||
success=False,
|
||||
message=errmsg,
|
||||
fileitem=fileitem,
|
||||
transfer_type=transfer_type,
|
||||
need_notify=need_notify)
|
||||
self.__update_result(
|
||||
result=result,
|
||||
success=False,
|
||||
message=errmsg,
|
||||
fileitem=fileitem,
|
||||
transfer_type=transfer_type,
|
||||
need_notify=need_notify,
|
||||
)
|
||||
return result
|
||||
|
||||
logger.info(f"文件夹 {fileitem.path} 整理成功")
|
||||
# 返回整理后的路径
|
||||
self.__update_result(result=result,
|
||||
success=True,
|
||||
fileitem=fileitem,
|
||||
target_item=new_diritem,
|
||||
target_diritem=new_diritem,
|
||||
need_scrape=need_scrape,
|
||||
need_notify=need_notify,
|
||||
transfer_type=transfer_type)
|
||||
self.__update_result(
|
||||
result=result,
|
||||
success=True,
|
||||
fileitem=fileitem,
|
||||
target_item=new_diritem,
|
||||
target_diritem=new_diritem,
|
||||
need_scrape=need_scrape,
|
||||
need_notify=need_notify,
|
||||
transfer_type=transfer_type,
|
||||
)
|
||||
return result
|
||||
else:
|
||||
# 整理单个文件
|
||||
@@ -189,13 +207,15 @@ class TransHandler:
|
||||
# 电视剧
|
||||
if in_meta.begin_episode is None:
|
||||
logger.warn(f"文件 {fileitem.path} 整理失败:未识别到文件集数")
|
||||
self.__update_result(result=result,
|
||||
success=False,
|
||||
message="未识别到文件集数",
|
||||
fileitem=fileitem,
|
||||
fail_list=[fileitem.path],
|
||||
transfer_type=transfer_type,
|
||||
need_notify=need_notify)
|
||||
self.__update_result(
|
||||
result=result,
|
||||
success=False,
|
||||
message="未识别到文件集数",
|
||||
fileitem=fileitem,
|
||||
fail_list=[fileitem.path],
|
||||
transfer_type=transfer_type,
|
||||
need_notify=need_notify,
|
||||
)
|
||||
return result
|
||||
|
||||
# 文件结束季为空
|
||||
@@ -217,8 +237,10 @@ class TransHandler:
|
||||
meta=in_meta,
|
||||
mediainfo=mediainfo,
|
||||
episodes_info=episodes_info,
|
||||
file_ext=f".{fileitem.extension}"
|
||||
)
|
||||
file_ext=f".{fileitem.extension}",
|
||||
),
|
||||
source_path=fileitem.path,
|
||||
source_item=fileitem,
|
||||
)
|
||||
|
||||
# 针对字幕文件,文件名中补充额外标识信息
|
||||
@@ -248,13 +270,15 @@ class TransHandler:
|
||||
target_diritem = target_oper.get_folder(folder_path)
|
||||
if not target_diritem:
|
||||
logger.error(f"目标目录 {folder_path} 获取失败")
|
||||
self.__update_result(result=result,
|
||||
success=False,
|
||||
message=f"目标目录 {folder_path} 获取失败",
|
||||
fileitem=fileitem,
|
||||
fail_list=[fileitem.path],
|
||||
transfer_type=transfer_type,
|
||||
need_notify=need_notify)
|
||||
self.__update_result(
|
||||
result=result,
|
||||
success=False,
|
||||
message=f"目标目录 {folder_path} 获取失败",
|
||||
fileitem=fileitem,
|
||||
fail_list=[fileitem.path],
|
||||
transfer_type=transfer_type,
|
||||
need_notify=need_notify,
|
||||
)
|
||||
return result
|
||||
|
||||
# 判断是否要覆盖,附加文件强制覆盖
|
||||
@@ -272,92 +296,172 @@ class TransHandler:
|
||||
if not overflag:
|
||||
# 目标文件已存在
|
||||
logger.info(
|
||||
f"目的文件系统中已经存在同名文件 {target_file},当前整理覆盖模式设置为 {overwrite_mode}")
|
||||
if overwrite_mode == 'always':
|
||||
f"目的文件系统中已经存在同名文件 {target_file},当前整理覆盖模式设置为 {overwrite_mode}"
|
||||
)
|
||||
# 触发覆盖检查事件,允许插件提供源/目标文件真实大小
|
||||
# 或直接给出覆盖决策(例如 .strm 文件指向网盘原始文件)
|
||||
overwrite_event_data = TransferOverwriteCheckEventData(
|
||||
fileitem=fileitem,
|
||||
target_item=target_item,
|
||||
target_storage=target_storage,
|
||||
target_path=new_file,
|
||||
overwrite_mode=overwrite_mode or "",
|
||||
transfer_type=transfer_type,
|
||||
)
|
||||
overwrite_event = eventmanager.send_event(
|
||||
ChainEventType.TransferOverwriteCheck,
|
||||
overwrite_event_data,
|
||||
)
|
||||
plugin_overwrite: Optional[bool] = None
|
||||
plugin_source_size: Optional[int] = None
|
||||
plugin_target_size: Optional[int] = None
|
||||
if overwrite_event and overwrite_event.event_data:
|
||||
overwrite_event_data = overwrite_event.event_data
|
||||
plugin_overwrite = overwrite_event_data.overwrite
|
||||
plugin_source_size = overwrite_event_data.source_size
|
||||
plugin_target_size = overwrite_event_data.target_size
|
||||
if (
|
||||
plugin_overwrite is not None
|
||||
or plugin_source_size is not None
|
||||
or plugin_target_size is not None
|
||||
):
|
||||
logger.info(
|
||||
f"覆盖检查事件由 {overwrite_event_data.source} 处理:"
|
||||
f"overwrite={plugin_overwrite}, "
|
||||
f"source_size={plugin_source_size}, "
|
||||
f"target_size={plugin_target_size}, "
|
||||
f"reason={overwrite_event_data.reason}"
|
||||
)
|
||||
if plugin_overwrite is True:
|
||||
overflag = True
|
||||
elif plugin_overwrite is False:
|
||||
self.__update_result(
|
||||
result=result,
|
||||
success=False,
|
||||
message=overwrite_event_data.reason
|
||||
or "插件决定不覆盖已有文件",
|
||||
fileitem=fileitem,
|
||||
target_item=target_item,
|
||||
target_diritem=target_diritem,
|
||||
fail_list=[fileitem.path],
|
||||
transfer_type=transfer_type,
|
||||
need_notify=need_notify,
|
||||
)
|
||||
return result
|
||||
elif overwrite_mode == "always":
|
||||
# 总是覆盖同名文件
|
||||
overflag = True
|
||||
elif overwrite_mode == 'size':
|
||||
elif overwrite_mode == "size":
|
||||
# 存在时大覆盖小
|
||||
if target_item.size < fileitem.size:
|
||||
logger.info(f"目标文件文件大小更小,将覆盖:{new_file}")
|
||||
source_size = (
|
||||
plugin_source_size
|
||||
if plugin_source_size is not None
|
||||
else fileitem.size
|
||||
)
|
||||
target_size = (
|
||||
plugin_target_size
|
||||
if plugin_target_size is not None
|
||||
else target_item.size
|
||||
)
|
||||
if target_size < source_size:
|
||||
logger.info(
|
||||
f"目标文件文件大小更小,将覆盖:{new_file}"
|
||||
)
|
||||
overflag = True
|
||||
else:
|
||||
self.__update_result(result=result,
|
||||
success=False,
|
||||
message=f"媒体库存在同名文件,且质量更好",
|
||||
fileitem=fileitem,
|
||||
target_item=target_item,
|
||||
target_diritem=target_diritem,
|
||||
fail_list=[fileitem.path],
|
||||
transfer_type=transfer_type,
|
||||
need_notify=need_notify)
|
||||
self.__update_result(
|
||||
result=result,
|
||||
success=False,
|
||||
message=f"媒体库存在同名文件,且质量更好",
|
||||
fileitem=fileitem,
|
||||
target_item=target_item,
|
||||
target_diritem=target_diritem,
|
||||
fail_list=[fileitem.path],
|
||||
transfer_type=transfer_type,
|
||||
need_notify=need_notify,
|
||||
)
|
||||
return result
|
||||
elif overwrite_mode == 'never':
|
||||
elif overwrite_mode == "never":
|
||||
# 存在不覆盖
|
||||
self.__update_result(result=result,
|
||||
success=False,
|
||||
message=f"媒体库存在同名文件,当前覆盖模式为不覆盖",
|
||||
fileitem=fileitem,
|
||||
target_item=target_item,
|
||||
target_diritem=target_diritem,
|
||||
fail_list=[fileitem.path],
|
||||
transfer_type=transfer_type,
|
||||
need_notify=need_notify)
|
||||
self.__update_result(
|
||||
result=result,
|
||||
success=False,
|
||||
message=f"媒体库存在同名文件,当前覆盖模式为不覆盖",
|
||||
fileitem=fileitem,
|
||||
target_item=target_item,
|
||||
target_diritem=target_diritem,
|
||||
fail_list=[fileitem.path],
|
||||
transfer_type=transfer_type,
|
||||
need_notify=need_notify,
|
||||
)
|
||||
return result
|
||||
elif overwrite_mode == 'latest':
|
||||
elif overwrite_mode == "latest":
|
||||
# 仅保留最新版本
|
||||
logger.info(f"当前整理覆盖模式设置为仅保留最新版本,将覆盖:{new_file}")
|
||||
logger.info(
|
||||
f"当前整理覆盖模式设置为仅保留最新版本,将覆盖:{new_file}"
|
||||
)
|
||||
overflag = True
|
||||
else:
|
||||
if overwrite_mode == 'latest':
|
||||
if overwrite_mode == "latest":
|
||||
# 文件不存在,但仅保留最新版本
|
||||
logger.info(
|
||||
f"当前整理覆盖模式设置为 {overwrite_mode},仅保留最新版本,正在删除已有版本文件 ...")
|
||||
f"当前整理覆盖模式设置为 {overwrite_mode},仅保留最新版本,正在删除已有版本文件 ..."
|
||||
)
|
||||
self.__delete_version_files(target_oper, new_file)
|
||||
else:
|
||||
# 附加文件 总是需要覆盖
|
||||
overflag = True
|
||||
|
||||
# 整理文件
|
||||
new_item, err_msg = self.__transfer_file(fileitem=fileitem,
|
||||
mediainfo=mediainfo,
|
||||
target_storage=target_storage,
|
||||
target_file=new_file,
|
||||
transfer_type=transfer_type,
|
||||
over_flag=overflag,
|
||||
source_oper=source_oper,
|
||||
target_oper=target_oper,
|
||||
result=result)
|
||||
new_item, err_msg = self.__transfer_file(
|
||||
fileitem=fileitem,
|
||||
mediainfo=mediainfo,
|
||||
target_storage=target_storage,
|
||||
target_file=new_file,
|
||||
transfer_type=transfer_type,
|
||||
over_flag=overflag,
|
||||
source_oper=source_oper,
|
||||
target_oper=target_oper,
|
||||
result=result,
|
||||
)
|
||||
if not new_item:
|
||||
logger.error(f"文件 {fileitem.path} 整理失败:{err_msg}")
|
||||
self.__update_result(result=result,
|
||||
success=False,
|
||||
message=err_msg,
|
||||
fileitem=fileitem,
|
||||
fail_list=[fileitem.path],
|
||||
transfer_type=transfer_type,
|
||||
need_notify=need_notify)
|
||||
self.__update_result(
|
||||
result=result,
|
||||
success=False,
|
||||
message=err_msg,
|
||||
fileitem=fileitem,
|
||||
fail_list=[fileitem.path],
|
||||
transfer_type=transfer_type,
|
||||
need_notify=need_notify,
|
||||
)
|
||||
return result
|
||||
|
||||
logger.info(f"文件 {fileitem.path} 整理成功")
|
||||
self.__update_result(result=result,
|
||||
success=True,
|
||||
fileitem=fileitem,
|
||||
target_item=new_item,
|
||||
target_diritem=target_diritem,
|
||||
need_scrape=need_scrape,
|
||||
transfer_type=transfer_type,
|
||||
need_notify=need_notify)
|
||||
self.__update_result(
|
||||
result=result,
|
||||
success=True,
|
||||
fileitem=fileitem,
|
||||
target_item=new_item,
|
||||
target_diritem=target_diritem,
|
||||
need_scrape=need_scrape,
|
||||
transfer_type=transfer_type,
|
||||
need_notify=need_notify,
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"媒体整理出错:{e}")
|
||||
return TransferInfo(success=False, message=str(e))
|
||||
|
||||
@staticmethod
|
||||
def __transfer_command(fileitem: FileItem, target_storage: str,
|
||||
source_oper: StorageBase, target_oper: StorageBase,
|
||||
target_file: Path, transfer_type: str,
|
||||
) -> Tuple[Optional[FileItem], str]:
|
||||
def __transfer_command(
|
||||
fileitem: FileItem,
|
||||
target_storage: str,
|
||||
source_oper: StorageBase,
|
||||
target_oper: StorageBase,
|
||||
target_file: Path,
|
||||
transfer_type: str,
|
||||
) -> Tuple[Optional[FileItem], str]:
|
||||
"""
|
||||
处理单个文件
|
||||
:param fileitem: 源文件
|
||||
@@ -379,12 +483,15 @@ class TransHandler:
|
||||
basename=_path.stem,
|
||||
type="file",
|
||||
size=_path.stat().st_size,
|
||||
extension=_path.suffix.lstrip('.'),
|
||||
modify_time=_path.stat().st_mtime
|
||||
extension=_path.suffix.lstrip("."),
|
||||
modify_time=_path.stat().st_mtime,
|
||||
)
|
||||
|
||||
if (fileitem.storage != target_storage
|
||||
and fileitem.storage != "local" and target_storage != "local"):
|
||||
if (
|
||||
fileitem.storage != target_storage
|
||||
and fileitem.storage != "local"
|
||||
and target_storage != "local"
|
||||
):
|
||||
return None, f"不支持 {fileitem.storage} 到 {target_storage} 的文件整理"
|
||||
|
||||
if fileitem.storage == "local" and target_storage == "local":
|
||||
@@ -417,20 +524,27 @@ class TransHandler:
|
||||
target_fileitem = target_oper.get_folder(target_file.parent)
|
||||
if target_fileitem:
|
||||
# 上传文件
|
||||
new_item = target_oper.upload(target_fileitem, filepath, target_file.name)
|
||||
new_item = target_oper.upload(
|
||||
target_fileitem, filepath, target_file.name
|
||||
)
|
||||
if new_item:
|
||||
return new_item, ""
|
||||
else:
|
||||
return None, f"{fileitem.path} 上传 {target_storage} 失败"
|
||||
else:
|
||||
return None, f"【{target_storage}】{target_file.parent} 目录获取失败"
|
||||
return (
|
||||
None,
|
||||
f"【{target_storage}】{target_file.parent} 目录获取失败",
|
||||
)
|
||||
elif transfer_type == "move":
|
||||
# 移动
|
||||
# 根据目的路径获取文件夹
|
||||
target_fileitem = target_oper.get_folder(target_file.parent)
|
||||
if target_fileitem:
|
||||
# 上传文件
|
||||
new_item = target_oper.upload(target_fileitem, filepath, target_file.name)
|
||||
new_item = target_oper.upload(
|
||||
target_fileitem, filepath, target_file.name
|
||||
)
|
||||
if new_item:
|
||||
# 删除源文件
|
||||
source_oper.delete(fileitem)
|
||||
@@ -438,7 +552,10 @@ class TransHandler:
|
||||
else:
|
||||
return None, f"{fileitem.path} 上传 {target_storage} 失败"
|
||||
else:
|
||||
return None, f"【{target_storage}】{target_file.parent} 目录获取失败"
|
||||
return (
|
||||
None,
|
||||
f"【{target_storage}】{target_file.parent} 目录获取失败",
|
||||
)
|
||||
elif fileitem.storage != "local" and target_storage == "local":
|
||||
# 网盘到本地
|
||||
if target_file.exists():
|
||||
@@ -447,7 +564,9 @@ class TransHandler:
|
||||
# 网盘到本地
|
||||
if transfer_type in ["copy", "move"]:
|
||||
# 下载
|
||||
tmp_file = source_oper.download(fileitem=fileitem, path=target_file.parent)
|
||||
tmp_file = source_oper.download(
|
||||
fileitem=fileitem, path=target_file.parent
|
||||
)
|
||||
if tmp_file:
|
||||
# 创建目录
|
||||
if not target_file.parent.exists():
|
||||
@@ -469,22 +588,32 @@ class TransHandler:
|
||||
# 复制文件到新目录
|
||||
target_fileitem = target_oper.get_folder(target_file.parent)
|
||||
if target_fileitem:
|
||||
if source_oper.copy(fileitem, Path(target_fileitem.path), target_file.name):
|
||||
if source_oper.copy(
|
||||
fileitem, Path(target_fileitem.path), target_file.name
|
||||
):
|
||||
return target_oper.get_item(target_file), ""
|
||||
else:
|
||||
return None, f"【{target_storage}】{fileitem.path} 复制文件失败"
|
||||
else:
|
||||
return None, f"【{target_storage}】{target_file.parent} 目录获取失败"
|
||||
return (
|
||||
None,
|
||||
f"【{target_storage}】{target_file.parent} 目录获取失败",
|
||||
)
|
||||
elif transfer_type == "move":
|
||||
# 移动文件到新目录
|
||||
target_fileitem = target_oper.get_folder(target_file.parent)
|
||||
if target_fileitem:
|
||||
if source_oper.move(fileitem, Path(target_fileitem.path), target_file.name):
|
||||
if source_oper.move(
|
||||
fileitem, Path(target_fileitem.path), target_file.name
|
||||
):
|
||||
return target_oper.get_item(target_file), ""
|
||||
else:
|
||||
return None, f"【{target_storage}】{fileitem.path} 移动文件失败"
|
||||
else:
|
||||
return None, f"【{target_storage}】{target_file.parent} 目录获取失败"
|
||||
return (
|
||||
None,
|
||||
f"【{target_storage}】{target_file.parent} 目录获取失败",
|
||||
)
|
||||
elif transfer_type == "link":
|
||||
if source_oper.link(fileitem, target_file):
|
||||
return target_oper.get_item(target_file), ""
|
||||
@@ -501,22 +630,28 @@ class TransHandler:
|
||||
重命名字幕文件,补充附加信息
|
||||
"""
|
||||
# 字幕正则式
|
||||
_zhcn_sub_re = r"([.\[(\s](((zh[-_])?(cn|ch[si]|sg|sc))|zho?" \
|
||||
r"|chinese|(cn|ch[si]|sg|zho?)[-_&]?(cn|ch[si]|sg|zho?|eng|jap|ja|jpn)" \
|
||||
r"|eng[-_&]?(cn|ch[si]|sg|zho?)|(jap|ja|jpn)[-_&]?(cn|ch[si]|sg|zho?)" \
|
||||
r"|简[体中]?)[.\])\s])" \
|
||||
r"|([\u4e00-\u9fa5]{0,3}[中双][\u4e00-\u9fa5]{0,2}[字文语][\u4e00-\u9fa5]{0,3})" \
|
||||
r"|简体|简中|JPSC|sc_jp" \
|
||||
r"|(?<![a-z0-9])gb(?![a-z0-9])"
|
||||
_zhtw_sub_re = r"([.\[(\s](((zh[-_])?(hk|tw|cht|tc))" \
|
||||
r"|cht[-_&]?(cht|eng|jap|ja|jpn)" \
|
||||
r"|eng[-_&]?cht|(jap|ja|jpn)[-_&]?cht" \
|
||||
r"|繁[体中]?)[.\])\s])" \
|
||||
r"|繁体中[文字]|中[文字]繁体|繁体|JPTC|tc_jp" \
|
||||
r"|(?<![a-z0-9])big5(?![a-z0-9])"
|
||||
_ja_sub_re = r"([.\[(\s](ja-jp|jap|ja|jpn" \
|
||||
r"|(jap|ja|jpn)[-_&]?eng|eng[-_&]?(jap|ja|jpn))[.\])\s])" \
|
||||
r"|日本語|日語"
|
||||
_zhcn_sub_re = (
|
||||
r"([.\[(\s](((zh[-_])?(cn|ch[si]|sg|sc))|zho?"
|
||||
r"|chinese|(cn|ch[si]|sg|zho?)[-_&]?(cn|ch[si]|sg|zho?|eng|jap|ja|jpn)"
|
||||
r"|eng[-_&]?(cn|ch[si]|sg|zho?)|(jap|ja|jpn)[-_&]?(cn|ch[si]|sg|zho?)"
|
||||
r"|简[体中]?)[.\])\s])"
|
||||
r"|([\u4e00-\u9fa5]{0,3}[中双][\u4e00-\u9fa5]{0,2}[字文语][\u4e00-\u9fa5]{0,3})"
|
||||
r"|简体|简中|JPSC|sc_jp"
|
||||
r"|(?<![a-z0-9])gb(?![a-z0-9])"
|
||||
)
|
||||
_zhtw_sub_re = (
|
||||
r"([.\[(\s](((zh[-_])?(hk|tw|cht|tc))"
|
||||
r"|cht[-_&]?(cht|eng|jap|ja|jpn)"
|
||||
r"|eng[-_&]?cht|(jap|ja|jpn)[-_&]?cht"
|
||||
r"|繁[体中]?)[.\])\s])"
|
||||
r"|繁体中[文字]|中[文字]繁体|繁体|JPTC|tc_jp"
|
||||
r"|(?<![a-z0-9])big5(?![a-z0-9])"
|
||||
)
|
||||
_ja_sub_re = (
|
||||
r"([.\[(\s](ja-jp|jap|ja|jpn"
|
||||
r"|(jap|ja|jpn)[-_&]?eng|eng[-_&]?(jap|ja|jpn))[.\])\s])"
|
||||
r"|日本語|日語"
|
||||
)
|
||||
_eng_sub_re = r"[.\[(\s]eng[.\])\s]"
|
||||
|
||||
# 原文件后缀
|
||||
@@ -535,20 +670,29 @@ class TransHandler:
|
||||
new_file_type = ".eng"
|
||||
|
||||
# 添加默认字幕标识
|
||||
if ((settings.DEFAULT_SUB == "zh-cn" and new_file_type == ".chi.zh-cn")
|
||||
or (settings.DEFAULT_SUB == "zh-tw" and new_file_type == ".zh-tw")
|
||||
or (settings.DEFAULT_SUB == "ja" and new_file_type == ".ja")
|
||||
or (settings.DEFAULT_SUB == "eng" and new_file_type == ".eng")):
|
||||
if (
|
||||
(settings.DEFAULT_SUB == "zh-cn" and new_file_type == ".chi.zh-cn")
|
||||
or (settings.DEFAULT_SUB == "zh-tw" and new_file_type == ".zh-tw")
|
||||
or (settings.DEFAULT_SUB == "ja" and new_file_type == ".ja")
|
||||
or (settings.DEFAULT_SUB == "eng" and new_file_type == ".eng")
|
||||
):
|
||||
new_sub_tag = ".default" + new_file_type
|
||||
else:
|
||||
new_sub_tag = new_file_type
|
||||
|
||||
return new_file.with_name(new_file.stem + new_sub_tag + file_ext)
|
||||
|
||||
def __transfer_dir(self, fileitem: FileItem, mediainfo: MediaInfo,
|
||||
source_oper: StorageBase, target_oper: StorageBase,
|
||||
transfer_type: str, target_storage: str, target_path: Path,
|
||||
result: TransferInfo) -> Tuple[Optional[FileItem], str]:
|
||||
def __transfer_dir(
|
||||
self,
|
||||
fileitem: FileItem,
|
||||
mediainfo: MediaInfo,
|
||||
source_oper: StorageBase,
|
||||
target_oper: StorageBase,
|
||||
transfer_type: str,
|
||||
target_storage: str,
|
||||
target_path: Path,
|
||||
result: TransferInfo,
|
||||
) -> Tuple[Optional[FileItem], str]:
|
||||
"""
|
||||
整理整个文件夹
|
||||
:param fileitem: 源文件
|
||||
@@ -568,7 +712,7 @@ class TransHandler:
|
||||
mediainfo=mediainfo,
|
||||
target_storage=target_storage,
|
||||
target_path=target_path,
|
||||
transfer_type=transfer_type
|
||||
transfer_type=transfer_type,
|
||||
)
|
||||
event = eventmanager.send_event(ChainEventType.TransferIntercept, event_data)
|
||||
if event and event.event_data:
|
||||
@@ -577,25 +721,34 @@ class TransHandler:
|
||||
if event_data.cancel:
|
||||
logger.debug(
|
||||
f"Transfer dir canceled by event: {event_data.source},"
|
||||
f"Reason: {event_data.reason}")
|
||||
f"Reason: {event_data.reason}"
|
||||
)
|
||||
return None, event_data.reason
|
||||
# 处理所有文件
|
||||
state, errmsg = self.__transfer_dir_files(fileitem=fileitem,
|
||||
target_storage=target_storage,
|
||||
source_oper=source_oper,
|
||||
target_oper=target_oper,
|
||||
target_path=target_path,
|
||||
transfer_type=transfer_type,
|
||||
result=result)
|
||||
state, errmsg = self.__transfer_dir_files(
|
||||
fileitem=fileitem,
|
||||
target_storage=target_storage,
|
||||
source_oper=source_oper,
|
||||
target_oper=target_oper,
|
||||
target_path=target_path,
|
||||
transfer_type=transfer_type,
|
||||
result=result,
|
||||
)
|
||||
if state:
|
||||
return target_item, errmsg
|
||||
else:
|
||||
return None, errmsg
|
||||
|
||||
def __transfer_dir_files(self, fileitem: FileItem, target_storage: str,
|
||||
source_oper: StorageBase, target_oper: StorageBase,
|
||||
transfer_type: str, target_path: Path,
|
||||
result: TransferInfo) -> Tuple[bool, str]:
|
||||
def __transfer_dir_files(
|
||||
self,
|
||||
fileitem: FileItem,
|
||||
target_storage: str,
|
||||
source_oper: StorageBase,
|
||||
target_oper: StorageBase,
|
||||
transfer_type: str,
|
||||
target_path: Path,
|
||||
result: TransferInfo,
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
按目录结构整理目录下所有文件
|
||||
:param fileitem: 源文件
|
||||
@@ -611,24 +764,28 @@ class TransHandler:
|
||||
if item.type == "dir":
|
||||
# 递归整理目录
|
||||
new_path = target_path / item.name
|
||||
state, errmsg = self.__transfer_dir_files(fileitem=item,
|
||||
target_storage=target_storage,
|
||||
source_oper=source_oper,
|
||||
target_oper=target_oper,
|
||||
transfer_type=transfer_type,
|
||||
target_path=new_path,
|
||||
result=result)
|
||||
state, errmsg = self.__transfer_dir_files(
|
||||
fileitem=item,
|
||||
target_storage=target_storage,
|
||||
source_oper=source_oper,
|
||||
target_oper=target_oper,
|
||||
transfer_type=transfer_type,
|
||||
target_path=new_path,
|
||||
result=result,
|
||||
)
|
||||
if not state:
|
||||
return False, errmsg
|
||||
else:
|
||||
# 整理文件
|
||||
new_file = target_path / item.name
|
||||
new_item, errmsg = self.__transfer_command(fileitem=item,
|
||||
target_storage=target_storage,
|
||||
source_oper=source_oper,
|
||||
target_oper=target_oper,
|
||||
target_file=new_file,
|
||||
transfer_type=transfer_type)
|
||||
new_item, errmsg = self.__transfer_command(
|
||||
fileitem=item,
|
||||
target_storage=target_storage,
|
||||
source_oper=source_oper,
|
||||
target_oper=target_oper,
|
||||
target_file=new_file,
|
||||
transfer_type=transfer_type,
|
||||
)
|
||||
if not new_item:
|
||||
return False, errmsg
|
||||
self.__update_result(
|
||||
@@ -639,11 +796,18 @@ class TransHandler:
|
||||
# 返回成功
|
||||
return True, ""
|
||||
|
||||
def __transfer_file(self, fileitem: FileItem, mediainfo: MediaInfo,
|
||||
source_oper: StorageBase, target_oper: StorageBase,
|
||||
target_storage: str, target_file: Path,
|
||||
transfer_type: str, result: TransferInfo,
|
||||
over_flag: Optional[bool] = False) -> Tuple[Optional[FileItem], str]:
|
||||
def __transfer_file(
|
||||
self,
|
||||
fileitem: FileItem,
|
||||
mediainfo: MediaInfo,
|
||||
source_oper: StorageBase,
|
||||
target_oper: StorageBase,
|
||||
target_storage: str,
|
||||
target_file: Path,
|
||||
transfer_type: str,
|
||||
result: TransferInfo,
|
||||
over_flag: Optional[bool] = False,
|
||||
) -> Tuple[Optional[FileItem], str]:
|
||||
"""
|
||||
整理一个文件,同时处理其他相关文件
|
||||
:param fileitem: 原文件
|
||||
@@ -657,17 +821,17 @@ class TransHandler:
|
||||
:param source_oper: 源存储操作对象
|
||||
:param target_oper: 目标存储操作对象
|
||||
"""
|
||||
logger.info(f"正在整理文件:【{fileitem.storage}】{fileitem.path} 到 【{target_storage}】{target_file},"
|
||||
f"操作类型:{transfer_type}")
|
||||
logger.info(
|
||||
f"正在整理文件:【{fileitem.storage}】{fileitem.path} 到 【{target_storage}】{target_file},"
|
||||
f"操作类型:{transfer_type}"
|
||||
)
|
||||
event_data = TransferInterceptEventData(
|
||||
fileitem=fileitem,
|
||||
mediainfo=mediainfo,
|
||||
target_storage=target_storage,
|
||||
target_path=target_file,
|
||||
transfer_type=transfer_type,
|
||||
options={
|
||||
"over_flag": over_flag
|
||||
}
|
||||
options={"over_flag": over_flag},
|
||||
)
|
||||
event = eventmanager.send_event(ChainEventType.TransferIntercept, event_data)
|
||||
if event and event.event_data:
|
||||
@@ -676,9 +840,12 @@ class TransHandler:
|
||||
if event_data.cancel:
|
||||
logger.debug(
|
||||
f"Transfer file canceled by event: {event_data.source},"
|
||||
f"Reason: {event_data.reason}")
|
||||
f"Reason: {event_data.reason}"
|
||||
)
|
||||
return None, event_data.reason
|
||||
if target_storage == "local" and (target_file.exists() or target_file.is_symlink()):
|
||||
if target_storage == "local" and (
|
||||
target_file.exists() or target_file.is_symlink()
|
||||
):
|
||||
if not over_flag:
|
||||
logger.warn(f"文件已存在:{target_file}")
|
||||
return None, f"{target_file} 已存在"
|
||||
@@ -692,15 +859,19 @@ class TransHandler:
|
||||
logger.warn(f"文件已存在:【{target_storage}】{target_file}")
|
||||
return None, f"【{target_storage}】{target_file} 已存在"
|
||||
else:
|
||||
logger.info(f"正在删除已存在的文件:【{target_storage}】{target_file}")
|
||||
logger.info(
|
||||
f"正在删除已存在的文件:【{target_storage}】{target_file}"
|
||||
)
|
||||
target_oper.delete(exists_item)
|
||||
# 执行文件整理命令
|
||||
new_item, errmsg = self.__transfer_command(fileitem=fileitem,
|
||||
target_storage=target_storage,
|
||||
source_oper=source_oper,
|
||||
target_oper=target_oper,
|
||||
target_file=target_file,
|
||||
transfer_type=transfer_type)
|
||||
new_item, errmsg = self.__transfer_command(
|
||||
fileitem=fileitem,
|
||||
target_storage=target_storage,
|
||||
source_oper=source_oper,
|
||||
target_oper=target_oper,
|
||||
target_file=target_file,
|
||||
transfer_type=transfer_type,
|
||||
)
|
||||
if new_item:
|
||||
self.__update_result(
|
||||
result=result,
|
||||
@@ -714,8 +885,12 @@ class TransHandler:
|
||||
return None, errmsg
|
||||
|
||||
@staticmethod
|
||||
def get_dest_path(mediainfo: MediaInfo, target_path: Path,
|
||||
need_type_folder: Optional[bool] = False, need_category_folder: Optional[bool] = False):
|
||||
def get_dest_path(
|
||||
mediainfo: MediaInfo,
|
||||
target_path: Path,
|
||||
need_type_folder: Optional[bool] = False,
|
||||
need_category_folder: Optional[bool] = False,
|
||||
):
|
||||
"""
|
||||
获取目标路径
|
||||
"""
|
||||
@@ -726,8 +901,12 @@ class TransHandler:
|
||||
return target_path
|
||||
|
||||
@staticmethod
|
||||
def get_dest_dir(mediainfo: MediaInfo, target_dir: TransferDirectoryConf,
|
||||
need_type_folder: Optional[bool] = None, need_category_folder: Optional[bool] = None) -> Path:
|
||||
def get_dest_dir(
|
||||
mediainfo: MediaInfo,
|
||||
target_dir: TransferDirectoryConf,
|
||||
need_type_folder: Optional[bool] = None,
|
||||
need_category_folder: Optional[bool] = None,
|
||||
) -> Path:
|
||||
"""
|
||||
根据设置并装媒体库目录
|
||||
:param mediainfo: 媒体信息
|
||||
@@ -747,7 +926,11 @@ class TransHandler:
|
||||
library_dir = Path(target_dir.library_path) / target_dir.media_type
|
||||
else:
|
||||
library_dir = Path(target_dir.library_path)
|
||||
if not target_dir.media_category and need_category_folder and mediainfo.category:
|
||||
if (
|
||||
not target_dir.media_category
|
||||
and need_category_folder
|
||||
and mediainfo.category
|
||||
):
|
||||
# 二级自动分类
|
||||
library_dir = library_dir / mediainfo.category
|
||||
elif target_dir.media_category and need_category_folder:
|
||||
@@ -757,8 +940,12 @@ class TransHandler:
|
||||
return library_dir
|
||||
|
||||
@staticmethod
|
||||
def get_naming_dict(meta: MetaBase, mediainfo: MediaInfo, file_ext: Optional[str] = None,
|
||||
episodes_info: List[TmdbEpisode] = None) -> dict:
|
||||
def get_naming_dict(
|
||||
meta: MetaBase,
|
||||
mediainfo: MediaInfo,
|
||||
file_ext: Optional[str] = None,
|
||||
episodes_info: List[TmdbEpisode] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
根据媒体信息,返回Format字典
|
||||
:param meta: 文件元数据
|
||||
@@ -766,8 +953,12 @@ class TransHandler:
|
||||
:param file_ext: 文件扩展名
|
||||
:param episodes_info: 当前季的全部集信息
|
||||
"""
|
||||
return TemplateHelper().builder.build(meta=meta, mediainfo=mediainfo,
|
||||
file_extension=file_ext, episodes_info=episodes_info)
|
||||
return TemplateHelper().builder.build(
|
||||
meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
file_extension=file_ext,
|
||||
episodes_info=episodes_info,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def __delete_version_files(storage_oper: StorageBase, path: Path) -> bool:
|
||||
@@ -814,12 +1005,20 @@ class TransHandler:
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def get_rename_path(template_string: str, rename_dict: dict, path: Path = None) -> Path:
|
||||
def get_rename_path(
|
||||
template_string: str,
|
||||
rename_dict: dict,
|
||||
path: Optional[Path] = None,
|
||||
source_path: Optional[str] = None,
|
||||
source_item: Optional[FileItem] = None,
|
||||
) -> Path:
|
||||
"""
|
||||
生成重命名后的完整路径,支持智能重命名事件
|
||||
:param template_string: Jinja2 模板字符串
|
||||
:param rename_dict: 渲染上下文,用于替换模板中的变量
|
||||
:param path: 可选的基础路径,如果提供,将在其基础上拼接生成的路径
|
||||
:param source_path: 源文件路径,即待整理的文件路径
|
||||
:param source_item: 源文件信息,即待整理的文件信息
|
||||
:return: 生成的完整路径
|
||||
"""
|
||||
# 创建jinja2模板对象
|
||||
@@ -833,15 +1032,19 @@ class TransHandler:
|
||||
template_string=template_string,
|
||||
rename_dict=rename_dict,
|
||||
render_str=render_str,
|
||||
path=path
|
||||
path=path,
|
||||
source_path=source_path,
|
||||
source_item=source_item,
|
||||
)
|
||||
event = eventmanager.send_event(ChainEventType.TransferRename, event_data)
|
||||
# 检查事件返回的结果
|
||||
if event and event.event_data:
|
||||
event_data: TransferRenameEventData = event.event_data
|
||||
if event_data.updated and event_data.updated_str:
|
||||
logger.debug(f"Render string updated by event: "
|
||||
f"{render_str} -> {event_data.updated_str} (source: {event_data.source})")
|
||||
logger.debug(
|
||||
f"Render string updated by event: "
|
||||
f"{render_str} -> {event_data.updated_str} (source: {event_data.source})"
|
||||
)
|
||||
render_str = event_data.updated_str
|
||||
|
||||
# 目的路径
|
||||
|
||||
@@ -19,6 +19,7 @@ class QQBotModule(_ModuleBase, _MessageBase[QQBot]):
|
||||
"""QQ Bot 通知模块"""
|
||||
|
||||
def init_module(self) -> None:
|
||||
self.stop()
|
||||
super().init_service(service_name=QQBot.__name__.lower(), service_type=QQBot)
|
||||
self._channel = MessageChannel.QQ
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ QQ Bot Gateway WebSocket 客户端
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable, Optional
|
||||
from typing import Callable, List, Optional
|
||||
|
||||
import websocket
|
||||
|
||||
@@ -24,6 +24,7 @@ def run_gateway(
|
||||
get_gateway_url_fn: Callable[[str], str],
|
||||
on_message_fn: Callable[[dict], None],
|
||||
stop_event: threading.Event,
|
||||
ws_holder: List,
|
||||
) -> None:
|
||||
"""
|
||||
在后台线程中运行 Gateway WebSocket 连接
|
||||
@@ -34,20 +35,20 @@ def run_gateway(
|
||||
:param get_gateway_url_fn: 获取 gateway URL 的函数 (token) -> url
|
||||
:param on_message_fn: 收到消息时的回调 (payload_dict) -> None
|
||||
:param stop_event: 停止事件,set 时退出循环
|
||||
:param ws_holder: 调用方持有的单元素列表,存放当前 WebSocketApp,供 stop() 时 close 以打断 run_forever
|
||||
"""
|
||||
last_seq: Optional[int] = None
|
||||
heartbeat_interval_ms: Optional[int] = None
|
||||
heartbeat_timer: Optional[threading.Timer] = None
|
||||
ws_ref: list = [] # 用于在闭包中保持 ws 引用
|
||||
|
||||
def send_heartbeat():
|
||||
nonlocal heartbeat_timer
|
||||
if stop_event.is_set():
|
||||
return
|
||||
try:
|
||||
if ws_ref and ws_ref[0]:
|
||||
if ws_holder and ws_holder[0]:
|
||||
payload = {"op": 1, "d": last_seq}
|
||||
ws_ref[0].send(json.dumps(payload))
|
||||
ws_holder[0].send(json.dumps(payload))
|
||||
logger.debug(f"[QQ Gateway:{config_name}] Heartbeat sent, seq={last_seq}")
|
||||
except Exception as err:
|
||||
logger.debug(f"[QQ Gateway:{config_name}] Heartbeat error: {err}")
|
||||
@@ -87,7 +88,7 @@ def run_gateway(
|
||||
"shard": [0, 1],
|
||||
},
|
||||
}
|
||||
ws_ref[0].send(json.dumps(identify))
|
||||
ws_holder[0].send(json.dumps(identify))
|
||||
logger.info(f"[QQ Gateway:{config_name}] Identify sent")
|
||||
|
||||
# 启动心跳
|
||||
@@ -139,8 +140,8 @@ def run_gateway(
|
||||
|
||||
elif op == 9: # Invalid Session
|
||||
logger.warning(f"[QQ Gateway:{config_name}] Invalid session")
|
||||
if ws_ref and ws_ref[0]:
|
||||
ws_ref[0].close()
|
||||
if ws_holder and ws_holder[0]:
|
||||
ws_holder[0].close()
|
||||
|
||||
def on_ws_error(_, error):
|
||||
logger.error(f"[QQ Gateway:{config_name}] WebSocket error: {error}")
|
||||
@@ -149,6 +150,7 @@ def run_gateway(
|
||||
logger.info(f"[QQ Gateway:{config_name}] WebSocket closed: {close_status_code} {close_msg}")
|
||||
if heartbeat_timer:
|
||||
heartbeat_timer.cancel()
|
||||
ws_holder.clear()
|
||||
|
||||
reconnect_delays = [1, 2, 5, 10, 30, 60]
|
||||
attempt = 0
|
||||
@@ -165,8 +167,8 @@ def run_gateway(
|
||||
on_error=on_ws_error,
|
||||
on_close=on_ws_close,
|
||||
)
|
||||
ws_ref.clear()
|
||||
ws_ref.append(ws)
|
||||
ws_holder.clear()
|
||||
ws_holder.append(ws)
|
||||
|
||||
# run_forever 会阻塞,需要传入 stop_event 的检查
|
||||
# websocket-client 的 run_forever 支持 ping_interval, ping_timeout
|
||||
|
||||
@@ -50,6 +50,9 @@ class QQBot:
|
||||
:param QQ_GROUP_OPENID: 默认群组 openid(群聊,与 QQ_OPENID 二选一)
|
||||
:param name: 配置名称,用于消息来源标识和 Gateway 接收
|
||||
"""
|
||||
self._gateway_stop = None
|
||||
self._gateway_thread = None
|
||||
self._gateway_ws_holder: list = []
|
||||
if not QQ_APP_ID or not QQ_APP_SECRET:
|
||||
logger.error("QQ Bot 配置不完整:缺少 AppID 或 AppSecret")
|
||||
self._ready = False
|
||||
@@ -151,6 +154,7 @@ class QQBot:
|
||||
"get_gateway_url_fn": get_gateway_url,
|
||||
"on_message_fn": self._on_gateway_message,
|
||||
"stop_event": self._gateway_stop,
|
||||
"ws_holder": self._gateway_ws_holder,
|
||||
},
|
||||
daemon=True,
|
||||
)
|
||||
@@ -161,10 +165,19 @@ class QQBot:
|
||||
|
||||
def stop(self) -> None:
|
||||
"""停止 Gateway 连接"""
|
||||
if self._gateway_stop:
|
||||
if self._gateway_stop is not None:
|
||||
self._gateway_stop.set()
|
||||
if self._gateway_thread and self._gateway_thread.is_alive():
|
||||
self._gateway_thread.join(timeout=5)
|
||||
try:
|
||||
if self._gateway_ws_holder:
|
||||
self._gateway_ws_holder[0].close()
|
||||
except Exception as e:
|
||||
logger.debug(f"QQ Bot Gateway WebSocket close: {e}")
|
||||
if self._gateway_thread is not None and self._gateway_thread.is_alive():
|
||||
self._gateway_thread.join(timeout=20)
|
||||
if self._gateway_thread.is_alive():
|
||||
logger.warning(
|
||||
"QQ Bot Gateway 线程在 stop 后仍未退出,可能存在重复收消息,请重启进程"
|
||||
)
|
||||
|
||||
def get_state(self) -> bool:
|
||||
"""获取就绪状态"""
|
||||
|
||||
@@ -11,6 +11,7 @@ class Event(BaseModel):
|
||||
"""
|
||||
事件模型
|
||||
"""
|
||||
|
||||
event_type: str = Field(..., description="事件类型")
|
||||
event_data: Optional[dict] = Field(default={}, description="事件数据")
|
||||
priority: Optional[int] = Field(0, description="事件优先级")
|
||||
@@ -20,6 +21,7 @@ class BaseEventData(BaseModel):
|
||||
"""
|
||||
事件数据的基类,所有具体事件数据类应继承自此类
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@@ -27,11 +29,14 @@ class ConfigChangeEventData(BaseEventData):
|
||||
"""
|
||||
ConfigChange 事件的数据模型
|
||||
"""
|
||||
|
||||
key: set[str] = Field(..., description="配置项的键(集合类型)")
|
||||
value: Optional[Any] = Field(default=None, description="配置项的新值")
|
||||
change_type: str = Field(default="update", description="配置项的变更类型,如 'add', 'update', 'delete'")
|
||||
change_type: str = Field(
|
||||
default="update", description="配置项的变更类型,如 'add', 'update', 'delete'"
|
||||
)
|
||||
|
||||
@field_validator('key', mode='before')
|
||||
@field_validator("key", mode="before")
|
||||
@classmethod
|
||||
def convert_to_set(cls, v):
|
||||
"""将输入的 str、list、dict.keys() 等转为 set"""
|
||||
@@ -55,6 +60,7 @@ class ChainEventData(BaseEventData):
|
||||
"""
|
||||
链式事件数据的基类,所有具体事件数据类应继承自此类
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@@ -73,12 +79,24 @@ class AuthCredentials(ChainEventData):
|
||||
channel (Optional[str]): 认证渠道
|
||||
service (Optional[str]): 服务名称
|
||||
"""
|
||||
|
||||
# 输入参数
|
||||
username: Optional[str] = Field(None, description="用户名,适用于 'password' 认证类型")
|
||||
password: Optional[str] = Field(None, description="用户密码,适用于 'password' 认证类型")
|
||||
mfa_code: Optional[str] = Field(None, description="一次性密码,目前仅适用于 'password' 认证类型")
|
||||
code: Optional[str] = Field(None, description="授权码,适用于 'authorization_code' 认证类型")
|
||||
grant_type: str = Field(..., description="认证类型,如 'password', 'authorization_code', 'client_credentials'")
|
||||
username: Optional[str] = Field(
|
||||
None, description="用户名,适用于 'password' 认证类型"
|
||||
)
|
||||
password: Optional[str] = Field(
|
||||
None, description="用户密码,适用于 'password' 认证类型"
|
||||
)
|
||||
mfa_code: Optional[str] = Field(
|
||||
None, description="一次性密码,目前仅适用于 'password' 认证类型"
|
||||
)
|
||||
code: Optional[str] = Field(
|
||||
None, description="授权码,适用于 'authorization_code' 认证类型"
|
||||
)
|
||||
grant_type: str = Field(
|
||||
...,
|
||||
description="认证类型,如 'password', 'authorization_code', 'client_credentials'",
|
||||
)
|
||||
# scope: List[str] = Field(default_factory=list, description="权限范围,如 ['read', 'write']")
|
||||
|
||||
# 输出参数
|
||||
@@ -87,7 +105,7 @@ class AuthCredentials(ChainEventData):
|
||||
channel: Optional[str] = Field(default=None, description="认证渠道")
|
||||
service: Optional[str] = Field(default=None, description="服务名称")
|
||||
|
||||
@model_validator(mode='before')
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def check_fields_based_on_grant_type(cls, values): # noqa
|
||||
grant_type = values.get("grant_type")
|
||||
@@ -97,7 +115,9 @@ class AuthCredentials(ChainEventData):
|
||||
|
||||
if grant_type == "password":
|
||||
if not values.get("username") or not values.get("password"):
|
||||
raise ValueError("username and password are required for grant_type 'password'")
|
||||
raise ValueError(
|
||||
"username and password are required for grant_type 'password'"
|
||||
)
|
||||
|
||||
elif grant_type == "authorization_code":
|
||||
if not values.get("code"):
|
||||
@@ -122,11 +142,15 @@ class AuthInterceptCredentials(ChainEventData):
|
||||
source (str): 拦截源,默认值为 "未知拦截源"
|
||||
cancel (bool): 是否取消认证,默认值为 False
|
||||
"""
|
||||
|
||||
# 输入参数
|
||||
username: Optional[str] = Field(..., description="用户名")
|
||||
channel: str = Field(..., description="认证渠道")
|
||||
service: str = Field(..., description="服务名称")
|
||||
status: str = Field(..., description="认证状态, 包含 'triggered' 表示认证触发,'completed' 表示认证成功")
|
||||
status: str = Field(
|
||||
...,
|
||||
description="认证状态, 包含 'triggered' 表示认证触发,'completed' 表示认证成功",
|
||||
)
|
||||
token: Optional[str] = Field(default=None, description="认证令牌")
|
||||
|
||||
# 输出参数
|
||||
@@ -148,6 +172,7 @@ class CommandRegisterEventData(ChainEventData):
|
||||
source (str): 拦截源,默认值为 "未知拦截源"
|
||||
cancel (bool): 是否取消认证,默认值为 False
|
||||
"""
|
||||
|
||||
# 输入参数
|
||||
commands: Dict[str, dict] = Field(..., description="菜单命令")
|
||||
origin: str = Field(..., description="事件源")
|
||||
@@ -168,17 +193,26 @@ class TransferRenameEventData(ChainEventData):
|
||||
rename_dict (dict): 渲染上下文
|
||||
render_str (str): 渲染生成的字符串
|
||||
path (Optional[Path]): 当前文件的目标路径
|
||||
source_path (Optional[str]): 源文件路径,即待整理的文件路径
|
||||
source_item (Optional[FileItem]): 源文件信息,即待整理的文件信息
|
||||
|
||||
# 输出参数
|
||||
updated (bool): 是否已更新,默认值为 False
|
||||
updated_str (str): 更新后的字符串
|
||||
source (str): 拦截源,默认值为 "未知拦截源"
|
||||
"""
|
||||
|
||||
# 输入参数
|
||||
template_string: str = Field(..., description="模板字符串")
|
||||
rename_dict: Dict[str, Any] = Field(..., description="渲染上下文")
|
||||
path: Optional[Path] = Field(None, description="文件的目标路径")
|
||||
render_str: str = Field(..., description="渲染生成的字符串")
|
||||
source_path: Optional[str] = Field(
|
||||
None, description="源文件路径,即待整理的文件路径"
|
||||
)
|
||||
source_item: Optional[FileItem] = Field(
|
||||
None, description="源文件信息,即待整理的文件信息"
|
||||
)
|
||||
|
||||
# 输出参数
|
||||
updated: bool = Field(default=False, description="是否已更新")
|
||||
@@ -200,6 +234,7 @@ class ResourceSelectionEventData(BaseModel):
|
||||
updated_contexts (Optional[List[Context]]): 已更新的资源上下文列表,默认值为 None
|
||||
source (str): 更新源,默认值为 "未知更新源"
|
||||
"""
|
||||
|
||||
# 输入参数
|
||||
contexts: Any = Field(None, description="待选择的资源上下文列表")
|
||||
downloader: Optional[str] = Field(None, description="下载器")
|
||||
@@ -207,7 +242,9 @@ class ResourceSelectionEventData(BaseModel):
|
||||
|
||||
# 输出参数
|
||||
updated: bool = Field(default=False, description="是否已更新")
|
||||
updated_contexts: Optional[List[Any]] = Field(default=None, description="已更新的资源上下文列表")
|
||||
updated_contexts: Optional[List[Any]] = Field(
|
||||
default=None, description="已更新的资源上下文列表"
|
||||
)
|
||||
source: Optional[str] = Field(default="未知拦截源", description="拦截源")
|
||||
|
||||
|
||||
@@ -229,6 +266,7 @@ class ResourceDownloadEventData(ChainEventData):
|
||||
source (str): 拦截源,默认值为 "未知拦截源"
|
||||
reason (str): 拦截原因,描述拦截的具体原因
|
||||
"""
|
||||
|
||||
# 输入参数
|
||||
context: Any = Field(None, description="当前资源上下文")
|
||||
episodes: Optional[Set[int]] = Field(None, description="需要下载的集数")
|
||||
@@ -260,6 +298,7 @@ class TransferInterceptEventData(ChainEventData):
|
||||
source (str): 拦截源,默认值为 "未知拦截源"
|
||||
reason (str): 拦截原因,描述拦截的具体原因
|
||||
"""
|
||||
|
||||
# 输入参数
|
||||
fileitem: FileItem = Field(..., description="源文件")
|
||||
mediainfo: Any = Field(..., description="媒体信息")
|
||||
@@ -274,16 +313,73 @@ class TransferInterceptEventData(ChainEventData):
|
||||
reason: str = Field(default="", description="拦截原因")
|
||||
|
||||
|
||||
class TransferOverwriteCheckEventData(ChainEventData):
|
||||
"""
|
||||
TransferOverwriteCheck 事件的数据模型
|
||||
|
||||
在覆盖模式判断(如按文件大小覆盖)执行之前触发,允许插件提供源文件与
|
||||
目标文件的真实大小(例如本地 .strm 文件指向的网盘原始文件大小),或者
|
||||
直接给出覆盖决策。
|
||||
|
||||
Attributes:
|
||||
# 输入参数
|
||||
fileitem (FileItem): 源文件
|
||||
target_item (FileItem): 目标文件(已存在)
|
||||
target_storage (str): 目标存储
|
||||
target_path (Path): 目标文件路径
|
||||
overwrite_mode (str): 覆盖模式(always、size、never、latest)
|
||||
transfer_type (str): 整理方式
|
||||
options (dict): 其他参数
|
||||
|
||||
# 输出参数
|
||||
source_size (Optional[int]): 由插件提供的源文件真实大小,覆盖
|
||||
fileitem.size 用于 size 模式比较;为 None 时表示不修改
|
||||
target_size (Optional[int]): 由插件提供的目标文件真实大小,覆盖
|
||||
target_item.size 用于 size 模式比较;为 None 时表示不修改
|
||||
overwrite (Optional[bool]): 由插件直接给出的覆盖决策,非 None 时
|
||||
将完全跳过 MoviePilot 内置的 size/never/latest 等比较逻辑
|
||||
source (str): 处理来源
|
||||
reason (str): 处理原因,描述插件做出决策或修改的原因
|
||||
"""
|
||||
|
||||
# 输入参数
|
||||
fileitem: FileItem = Field(..., description="源文件")
|
||||
target_item: FileItem = Field(..., description="目标已存在文件")
|
||||
target_storage: str = Field(..., description="目标存储")
|
||||
target_path: Path = Field(..., description="目标文件路径")
|
||||
overwrite_mode: str = Field(..., description="覆盖模式")
|
||||
transfer_type: str = Field(..., description="整理方式")
|
||||
options: Optional[dict] = Field(default=None, description="其他参数")
|
||||
|
||||
# 输出参数
|
||||
source_size: Optional[int] = Field(
|
||||
default=None, description="插件提供的源文件真实大小"
|
||||
)
|
||||
target_size: Optional[int] = Field(
|
||||
default=None, description="插件提供的目标文件真实大小"
|
||||
)
|
||||
overwrite: Optional[bool] = Field(
|
||||
default=None, description="插件直接给出的覆盖决策"
|
||||
)
|
||||
source: str = Field(default="未知处理源", description="处理来源")
|
||||
reason: str = Field(default="", description="处理原因")
|
||||
|
||||
|
||||
class DiscoverMediaSource(BaseModel):
|
||||
"""
|
||||
探索媒体数据源的基类
|
||||
"""
|
||||
|
||||
name: str = Field(..., description="数据源名称")
|
||||
mediaid_prefix: str = Field(..., description="媒体ID的前缀,不含:")
|
||||
api_path: str = Field(..., description="媒体数据源API地址")
|
||||
filter_params: Optional[Dict[str, Any]] = Field(default=None, description="过滤参数")
|
||||
filter_params: Optional[Dict[str, Any]] = Field(
|
||||
default=None, description="过滤参数"
|
||||
)
|
||||
filter_ui: Optional[List[dict]] = Field(default=[], description="过滤参数UI配置")
|
||||
depends: Optional[Dict[str, list]] = Field(default=None, description="UI依赖关系字典")
|
||||
depends: Optional[Dict[str, list]] = Field(
|
||||
default=None, description="UI依赖关系字典"
|
||||
)
|
||||
|
||||
|
||||
class DiscoverSourceEventData(ChainEventData):
|
||||
@@ -294,14 +390,18 @@ class DiscoverSourceEventData(ChainEventData):
|
||||
# 输出参数
|
||||
extra_sources (List[DiscoverMediaSource]): 额外媒体数据源
|
||||
"""
|
||||
|
||||
# 输出参数
|
||||
extra_sources: List[DiscoverMediaSource] = Field(default_factory=list, description="额外媒体数据源")
|
||||
extra_sources: List[DiscoverMediaSource] = Field(
|
||||
default_factory=list, description="额外媒体数据源"
|
||||
)
|
||||
|
||||
|
||||
class RecommendMediaSource(BaseModel):
|
||||
"""
|
||||
推荐媒体数据源的基类
|
||||
"""
|
||||
|
||||
name: str = Field(..., description="数据源名称")
|
||||
api_path: str = Field(..., description="媒体数据源API地址")
|
||||
type: str = Field(..., description="类型")
|
||||
@@ -315,8 +415,11 @@ class RecommendSourceEventData(ChainEventData):
|
||||
# 输出参数
|
||||
extra_sources (List[RecommendMediaSource]): 额外媒体数据源
|
||||
"""
|
||||
|
||||
# 输出参数
|
||||
extra_sources: List[RecommendMediaSource] = Field(default_factory=list, description="额外媒体数据源")
|
||||
extra_sources: List[RecommendMediaSource] = Field(
|
||||
default_factory=list, description="额外媒体数据源"
|
||||
)
|
||||
|
||||
|
||||
class MediaRecognizeConvertEventData(ChainEventData):
|
||||
@@ -331,12 +434,15 @@ class MediaRecognizeConvertEventData(ChainEventData):
|
||||
# 输出参数
|
||||
media_dict (dict): TheMovieDb/豆瓣的媒体数据
|
||||
"""
|
||||
|
||||
# 输入参数
|
||||
mediaid: str = Field(..., description="媒体ID")
|
||||
convert_type: str = Field(..., description="转换类型(themoviedb/douban)")
|
||||
|
||||
# 输出参数
|
||||
media_dict: dict = Field(default_factory=dict, description="转换后的媒体信息(TheMovieDb/豆瓣)")
|
||||
media_dict: dict = Field(
|
||||
default_factory=dict, description="转换后的媒体信息(TheMovieDb/豆瓣)"
|
||||
)
|
||||
|
||||
|
||||
class StorageOperSelectionEventData(ChainEventData):
|
||||
@@ -350,6 +456,7 @@ class StorageOperSelectionEventData(ChainEventData):
|
||||
# 输出参数
|
||||
storage_oper (Callable): 存储操作对象
|
||||
"""
|
||||
|
||||
# 输入参数
|
||||
storage: Optional[str] = Field(default=None, description="存储类型")
|
||||
|
||||
|
||||
@@ -69,6 +69,24 @@ class PluginDashboard(Plugin):
|
||||
elements: Optional[List[dict]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PluginSidebarNavItem(BaseModel):
|
||||
"""
|
||||
插件侧栏导航项(前端全页路由)
|
||||
"""
|
||||
plugin_id: str = Field(description="插件 ID")
|
||||
nav_key: str = Field(description="导航键,对应 URL 段")
|
||||
title: str = Field(description="侧栏标题")
|
||||
icon: str = Field(default="mdi-puzzle", description="MDI 图标名")
|
||||
section: str = Field(
|
||||
description="分组:start / discovery / subscribe / organize / system",
|
||||
)
|
||||
permission: Optional[str] = Field(
|
||||
default=None,
|
||||
description="权限:subscribe / discovery / search / manage / admin",
|
||||
)
|
||||
order: int = Field(default=0, description="同组内排序,越小越靠前")
|
||||
|
||||
|
||||
class PluginMemoryInfo(BaseModel):
|
||||
"""插件内存信息"""
|
||||
plugin_id: str = Field(description="插件ID")
|
||||
|
||||
@@ -156,6 +156,8 @@ class ChainEventType(Enum):
|
||||
TransferRename = "transfer.rename"
|
||||
# 整理拦截
|
||||
TransferIntercept = "transfer.intercept"
|
||||
# 整理覆盖检查
|
||||
TransferOverwriteCheck = "transfer.overwrite.check"
|
||||
# 资源选择
|
||||
ResourceSelection = "resource.selection"
|
||||
# 资源下载
|
||||
|
||||
74
skills/command-dispatch/SKILL.md
Normal file
74
skills/command-dispatch/SKILL.md
Normal file
@@ -0,0 +1,74 @@
|
||||
---
|
||||
name: command-dispatch
|
||||
version: 1
|
||||
description: >-
|
||||
Use this skill when the user's intent is to execute a system or plugin function. Applicable scenarios include:
|
||||
1) The user sends a slash command starting with / (e.g. /cookiecloud, /sites, /subscribes, etc.);
|
||||
2) The user describes an action in natural language that can be fulfilled by a system or plugin command
|
||||
(e.g. "sync sites", "show subscriptions", "refresh subscriptions", "check downloads", etc.).
|
||||
This skill helps you identify the user's intent, find the matching command, extract necessary parameters,
|
||||
and execute the corresponding command.
|
||||
allowed-tools: list_slash_commands query_plugin_capabilities run_slash_command
|
||||
---
|
||||
|
||||
# Command Dispatch
|
||||
|
||||
Use this skill to identify user intent and dispatch the corresponding system or plugin command.
|
||||
|
||||
## When to Use
|
||||
|
||||
- The user sends a `/xxx` slash command (execute directly)
|
||||
- The user describes an action in natural language, for example:
|
||||
- "Sync sites" → `/cookiecloud`
|
||||
- "Show my subscriptions" → `/subscribes`
|
||||
- "Refresh subscriptions" → `/subscribe_refresh`
|
||||
- "What's downloading?" → `/downloading`
|
||||
- "Organize downloaded files" → `/transfer`
|
||||
- "Clear cache" → `/clear_cache`
|
||||
- "Restart the system" → `/restart`
|
||||
- "Pause all QB tasks" → `/pause_torrents` (plugin command)
|
||||
|
||||
## Tools
|
||||
|
||||
- `list_slash_commands` — List all available slash commands (system + plugin), returns command name, description, and category
|
||||
- `query_plugin_capabilities` — Query detailed plugin capabilities (commands, actions, scheduled services)
|
||||
- `run_slash_command` — Execute a specified command (works for both system and plugin commands)
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Identify User Intent
|
||||
|
||||
Determine whether the user's message is requesting the execution of a command:
|
||||
|
||||
- **Direct command**: Message starts with `/`, e.g. `/sites`, `/subscribes` → skip to Step 3
|
||||
- **Natural language**: The user describes an actionable request → continue to Step 2
|
||||
|
||||
### Step 2: Find Matching Command
|
||||
|
||||
Use `list_slash_commands` to retrieve all available commands. Match the user's described intent against the `description` and `category` fields of each command.
|
||||
|
||||
If the user's description involves a specific plugin's functionality, additionally use `query_plugin_capabilities` to query that plugin's detailed capabilities.
|
||||
|
||||
**Matching strategy**:
|
||||
- Prefer exact matches on command description
|
||||
- Then narrow down by category and match
|
||||
- If no matching command is found, inform the user that no corresponding function is available
|
||||
|
||||
### Step 3: Extract Parameters and Execute
|
||||
|
||||
Some commands support additional arguments (space-separated after the command), for example:
|
||||
- `/redo <history_id>` — Manually re-organize a specific record
|
||||
- `/subscribe_delete <name>` — Delete a specific subscription
|
||||
|
||||
Use `run_slash_command` to execute the command in the format `/command_name arg1 arg2`.
|
||||
|
||||
### Step 4: Report Result
|
||||
|
||||
Command execution is asynchronous. After triggering, inform the user that the command has started. If the command does not exist, list available commands for reference.
|
||||
|
||||
## Important Notes
|
||||
|
||||
- Command execution requires admin privileges; the tool will automatically check permissions
|
||||
- Both system and plugin commands are executed via the `run_slash_command` tool — no need to distinguish between them
|
||||
- If you are unsure which command matches the user's intent, use `list_slash_commands` first to look up before deciding
|
||||
- Never guess non-existent commands; always select from the available command list
|
||||
232
skills/database-operation/SKILL.md
Normal file
232
skills/database-operation/SKILL.md
Normal file
@@ -0,0 +1,232 @@
|
||||
---
|
||||
name: database-operation
|
||||
version: 1
|
||||
description: >-
|
||||
Use this skill when you need to execute SQL against the MoviePilot database.
|
||||
This skill guides you through connecting to the database and executing SQL statements.
|
||||
The database type (SQLite or PostgreSQL) and connection details are provided in the system prompt <system_info>.
|
||||
Applicable scenarios include:
|
||||
1) The user asks about data statistics, counts, or aggregations that existing tools don't cover;
|
||||
2) The user wants to inspect, modify, or fix raw database records;
|
||||
3) The user asks to clean up data, update records, or perform database maintenance;
|
||||
4) The user asks questions like "how many downloads", "show me site stats", "delete old records", etc.
|
||||
allowed-tools: execute_command read_file
|
||||
---
|
||||
|
||||
# Database Query (数据库查询)
|
||||
|
||||
This skill guides you through executing SQL against the MoviePilot database. Both read and write operations are supported.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
You need the following tools:
|
||||
- `execute_command` - Execute shell commands to run database queries
|
||||
|
||||
## Getting Database Connection Info
|
||||
|
||||
The system prompt `<system_info>` section already contains all the database connection details you need:
|
||||
- **数据库类型** — `sqlite` or `postgresql`
|
||||
- **数据库** — Full connection info:
|
||||
- For SQLite: the database file path, e.g. `SQLite (/config/db/moviepilot.db)`
|
||||
- For PostgreSQL: the connection string, e.g. `PostgreSQL (user:password@host:port/database)`
|
||||
|
||||
**Do NOT run any detection commands.** Extract the database type and connection details directly from `<system_info>`.
|
||||
|
||||
## Executing Queries
|
||||
|
||||
### SQLite Mode
|
||||
|
||||
Extract the database file path from `<system_info>` (the path inside the parentheses after `SQLite`).
|
||||
|
||||
Use `execute_command` to run queries:
|
||||
|
||||
```bash
|
||||
sqlite3 -header -column <DB_PATH> "YOUR SQL QUERY HERE;"
|
||||
```
|
||||
|
||||
For JSON-formatted output (easier to parse):
|
||||
|
||||
```bash
|
||||
sqlite3 -json <DB_PATH> "YOUR SQL QUERY HERE;"
|
||||
```
|
||||
|
||||
**List all tables:**
|
||||
|
||||
```bash
|
||||
sqlite3 -header -column <DB_PATH> "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name;"
|
||||
```
|
||||
|
||||
**View table schema:**
|
||||
|
||||
```bash
|
||||
sqlite3 <DB_PATH> ".schema tablename"
|
||||
```
|
||||
|
||||
### PostgreSQL Mode
|
||||
|
||||
Extract the connection parameters from `<system_info>` (parse `user:password@host:port/database` from the parentheses after `PostgreSQL`).
|
||||
|
||||
Use `execute_command` to run queries via `psql`:
|
||||
|
||||
```bash
|
||||
PGPASSWORD=<password> psql -h <host> -p <port> -U <user> -d <database> -c "YOUR SQL QUERY HERE;"
|
||||
```
|
||||
|
||||
**List all tables:**
|
||||
|
||||
```bash
|
||||
PGPASSWORD=<password> psql -h <host> -p <port> -U <user> -d <database> -c "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename;"
|
||||
```
|
||||
|
||||
**View table schema:**
|
||||
|
||||
```bash
|
||||
PGPASSWORD=<password> psql -h <host> -p <port> -U <user> -d <database> -c "\d tablename"
|
||||
```
|
||||
|
||||
## Interpret Results
|
||||
|
||||
After executing the query, analyze the results and present them in a clear, user-friendly format. Use aggregation, sorting, and filtering as needed.
|
||||
|
||||
## Database Schema Reference
|
||||
|
||||
MoviePilot uses the following core tables:
|
||||
|
||||
### downloadhistory (下载历史)
|
||||
Key columns: `id`, `path`, `type`, `title`, `year`, `tmdbid`, `imdbid`, `doubanid`, `seasons`, `episodes`, `downloader`, `download_hash`, `torrent_name`, `torrent_site`, `userid`, `username`, `date`, `media_category`
|
||||
|
||||
### downloadfiles (下载文件)
|
||||
Key columns: `id`, `downloader`, `download_hash`, `fullpath`, `savepath`, `filepath`, `torrentname`, `state`
|
||||
|
||||
### transferhistory (整理历史)
|
||||
Key columns: `id`, `src`, `dest`, `mode`, `type`, `category`, `title`, `year`, `tmdbid`, `seasons`, `episodes`, `download_hash`, `status` (boolean: true=success, false=failed), `errmsg`, `date`
|
||||
|
||||
### subscribe (订阅)
|
||||
Key columns: `id`, `name`, `year`, `type`, `tmdbid`, `doubanid`, `season`, `total_episode`, `start_episode`, `lack_episode`, `state` ('N'=new, 'R'=running, 'S'=paused), `filter`, `include`, `exclude`, `quality`, `resolution`, `sites`, `best_version`, `date`, `username`
|
||||
|
||||
### subscribehistory (订阅历史)
|
||||
Key columns: `id`, `name`, `year`, `type`, `tmdbid`, `doubanid`, `season`, `total_episode`, `start_episode`, `date`, `username`
|
||||
|
||||
### user (用户)
|
||||
Key columns: `id`, `name`, `email`, `is_active`, `is_superuser`, `permissions`, `settings`
|
||||
|
||||
### site (站点)
|
||||
Key columns: `id`, `name`, `domain`, `url`, `pri` (priority), `cookie`, `proxy`, `is_active`, `downloader`, `limit_interval`, `limit_count`
|
||||
|
||||
### siteuserdata (站点用户数据)
|
||||
Key columns: `id`, `domain`, `name`, `username`, `user_level`, `bonus`, `upload`, `download`, `ratio`, `seeding`, `leeching`, `seeding_size`, `updated_day`
|
||||
|
||||
### sitestatistic (站点统计)
|
||||
Key columns: `id`, `domain`, `success`, `fail`, `seconds`, `lst_state`, `lst_mod_date`
|
||||
|
||||
### mediaserveritem (媒体库条目)
|
||||
Key columns: `id`, `server`, `library`, `item_id`, `item_type`, `title`, `original_title`, `year`, `tmdbid`, `imdbid`, `tvdbid`, `path`
|
||||
|
||||
### systemconfig (系统配置)
|
||||
Key columns: `id`, `key`, `value` (JSON)
|
||||
|
||||
### userconfig (用户配置)
|
||||
Key columns: `id`, `username`, `key`, `value` (JSON)
|
||||
|
||||
### plugindata (插件数据)
|
||||
Key columns: `id`, `plugin_id`, `key`, `value` (JSON)
|
||||
|
||||
### message (消息)
|
||||
Key columns: `id`, `channel`, `source`, `mtype`, `title`, `text`, `image`, `link`, `userid`, `reg_time`
|
||||
|
||||
### workflow (工作流)
|
||||
Key columns: `id`, `name`, `description`, `timer`, `trigger_type`, `event_type`, `state` ('W'=waiting, 'R'=running), `run_count`, `actions`, `flows`, `last_time`
|
||||
|
||||
### passkey (通行密钥)
|
||||
Key columns: `id`, `user_id`, `credential_id`, `public_key`, `name`, `created_at`, `last_used_at`, `is_active`
|
||||
|
||||
### siteicon (站点图标)
|
||||
Key columns: `id`, `name`, `domain`, `url`, `base64`
|
||||
|
||||
## Common Query Examples
|
||||
|
||||
### Count total downloads
|
||||
```sql
|
||||
SELECT COUNT(*) AS total FROM downloadhistory;
|
||||
```
|
||||
|
||||
### Recent download history
|
||||
```sql
|
||||
SELECT title, year, type, torrent_site, date FROM downloadhistory ORDER BY id DESC LIMIT 10;
|
||||
```
|
||||
|
||||
### Failed transfers
|
||||
```sql
|
||||
SELECT id, title, src, errmsg, date FROM transferhistory WHERE status = 0 ORDER BY id DESC LIMIT 10;
|
||||
```
|
||||
|
||||
### Active subscriptions
|
||||
```sql
|
||||
SELECT name, year, type, season, state, lack_episode FROM subscribe WHERE state = 'R';
|
||||
```
|
||||
|
||||
### Site upload/download statistics
|
||||
```sql
|
||||
SELECT name, domain, upload, download, ratio, bonus, seeding, user_level FROM siteuserdata ORDER BY upload DESC;
|
||||
```
|
||||
|
||||
### Media library statistics
|
||||
```sql
|
||||
SELECT server, library, COUNT(*) AS count FROM mediaserveritem GROUP BY server, library;
|
||||
```
|
||||
|
||||
### Site access success rate
|
||||
```sql
|
||||
SELECT domain, success, fail, ROUND(success * 100.0 / (success + fail), 1) AS success_rate FROM sitestatistic WHERE success + fail > 0 ORDER BY success_rate DESC;
|
||||
```
|
||||
|
||||
### Plugin data inspection
|
||||
```sql
|
||||
SELECT plugin_id, key FROM plugindata ORDER BY plugin_id, key;
|
||||
```
|
||||
|
||||
### Delete old download history (write operation)
|
||||
```sql
|
||||
DELETE FROM downloadhistory WHERE date < '2024-01-01';
|
||||
```
|
||||
|
||||
### Update subscription state (write operation)
|
||||
```sql
|
||||
UPDATE subscribe SET state = 'S' WHERE id = 123;
|
||||
```
|
||||
|
||||
### Clean up failed transfer records (write operation)
|
||||
```sql
|
||||
DELETE FROM transferhistory WHERE status = 0 AND date < '2024-06-01';
|
||||
```
|
||||
|
||||
## Safety Rules
|
||||
|
||||
1. **Confirm before writing** — For any `INSERT`, `UPDATE`, `DELETE`, `DROP`, `ALTER`, or `TRUNCATE` operation, always describe what the statement will do and ask the user to confirm before executing. For `SELECT` queries, execute directly without confirmation
|
||||
2. **Back up before destructive operations** — Before executing `DELETE`, `DROP`, or `TRUNCATE` on important tables, suggest the user back up the data first (e.g., export with `.dump` for SQLite or `pg_dump` for PostgreSQL)
|
||||
3. **Use WHERE clauses** — Never run `UPDATE` or `DELETE` without a `WHERE` clause unless the user explicitly intends to affect all rows
|
||||
4. **Use LIMIT for queries** — When querying large tables with `SELECT`, add `LIMIT` to prevent excessive output
|
||||
5. **Sensitive data** — The `site` table contains `cookie`, `apikey`, and `token` fields. NEVER display these values to the user. Exclude them from SELECT or replace with `'***'`
|
||||
6. **Password data** — The `user` table contains `hashed_password` and `otp_secret` fields. NEVER display these values
|
||||
7. **Output limits** — If the query results are very long, summarize or truncate them
|
||||
|
||||
## SQL Dialect Differences
|
||||
|
||||
When writing queries, be aware of differences between SQLite and PostgreSQL:
|
||||
|
||||
| Feature | SQLite | PostgreSQL |
|
||||
|---------|--------|------------|
|
||||
| Boolean values | `0` / `1` | `false` / `true` |
|
||||
| String concat | `\|\|` | `\|\|` or `CONCAT()` |
|
||||
| Current time | `datetime('now')` | `NOW()` |
|
||||
| LIMIT syntax | `LIMIT n` | `LIMIT n` |
|
||||
| JSON access | `json_extract(col, '$.key')` | `col->>'key'` |
|
||||
| Case sensitivity | Case-insensitive by default | Case-sensitive |
|
||||
| LIKE | Case-insensitive | Use `ILIKE` for case-insensitive |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **sqlite3 not found**: The `sqlite3` CLI should be pre-installed in the MoviePilot Docker container. If missing, you can try using Python: `python3 -c "import sqlite3; ..."`
|
||||
- **psql not found**: For PostgreSQL, if `psql` is not available, use Python: `python3 -c "import psycopg2; ..."`
|
||||
- **Permission denied**: Database queries require admin privileges
|
||||
- **Table not found**: Use the "list all tables" query first to verify table names
|
||||
227
skills/generate-identifiers/SKILL.md
Normal file
227
skills/generate-identifiers/SKILL.md
Normal file
@@ -0,0 +1,227 @@
|
||||
---
|
||||
name: generate-identifiers
|
||||
version: 1
|
||||
description: >-
|
||||
Use this skill when a user provides a torrent name or file name and wants to fix recognition issues,
|
||||
or asks to add/manage custom identifiers (自定义识别词).
|
||||
This skill generates identifier rules based on the WordsMatcher preprocessing logic,
|
||||
checks for duplicates against existing rules, and saves them via MCP tools.
|
||||
Applicable scenarios include:
|
||||
1) A torrent or file name is incorrectly recognized (wrong title, season, episode, etc.);
|
||||
2) The user wants to block unwanted keywords from torrent names;
|
||||
3) The user needs episode offset rules for series with non-standard numbering;
|
||||
4) The user wants to force recognition of a specific media by TMDB/Douban ID.
|
||||
allowed-tools: query_custom_identifiers update_custom_identifiers recognize_media
|
||||
---
|
||||
|
||||
# Generate Custom Identifiers (生成自定义识别词)
|
||||
|
||||
This skill helps generate custom identifier rules for MoviePilot's media recognition system. Custom identifiers preprocess torrent/file names before the recognition engine runs, correcting naming issues that cause misidentification.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
You need the following tools:
|
||||
- `query_custom_identifiers` - Query all existing custom identifier rules
|
||||
- `update_custom_identifiers` - Save the updated identifier list (replaces the full list)
|
||||
- `recognize_media` - Test recognition of a torrent title or file path (optional, for verification)
|
||||
|
||||
## Supported Rule Formats
|
||||
|
||||
There are **four formats**. Operators must have spaces on both sides.
|
||||
|
||||
### 1. Block Word (屏蔽词)
|
||||
|
||||
Removes matched text from the title. Supports regex.
|
||||
|
||||
```
|
||||
REPACK
|
||||
```
|
||||
|
||||
### 2. Replacement (被替换词 => 替换词)
|
||||
|
||||
Regex substitution. The left side is a regex pattern, the right side is the replacement (supports backreferences).
|
||||
|
||||
```
|
||||
被替换词 => 替换词
|
||||
```
|
||||
|
||||
**Special replacement for direct ID specification:**
|
||||
```
|
||||
被替换词 => {[tmdbid=xxx;type=movie/tv;s=xxx;e=xxx]}
|
||||
被替换词 => {[doubanid=xxx;type=movie/tv;s=xxx;e=xxx]}
|
||||
```
|
||||
Where `s` (season) and `e` (episode) are optional.
|
||||
|
||||
### 3. Episode Offset (集偏移)
|
||||
|
||||
Shifts episode numbers found between the front and back delimiter words. `EP` is the placeholder for the original episode number.
|
||||
|
||||
```
|
||||
前定位词 <> 后定位词 >> EP-12
|
||||
```
|
||||
|
||||
### 4. Combined Replacement + Episode Offset
|
||||
|
||||
First performs replacement; episode offset only runs if replacement succeeded.
|
||||
|
||||
```
|
||||
被替换词 => 替换词 && 前定位词 <> 后定位词 >> EP-12
|
||||
```
|
||||
|
||||
### Comments
|
||||
|
||||
Lines starting with `#` are comments and will be skipped during processing.
|
||||
|
||||
## Important Rules for Writing Identifiers
|
||||
|
||||
1. **Regex support**: All patterns support regular expressions. Special characters (`. * + ? ^ $ { } [ ] ( ) | \`) must be escaped with `\` when matching literally.
|
||||
2. **Spaces matter**: The operators ` => `, ` <> `, ` >> `, ` && ` must have spaces on both sides.
|
||||
3. **One rule per string**: Each element in the identifiers list is one rule.
|
||||
4. **EP placeholder**: In episode offset expressions, `EP` represents the original episode number. Common patterns:
|
||||
- `EP-12` means subtract 12
|
||||
- `EP+5` means add 5
|
||||
- `EP*2` means multiply by 2
|
||||
5. **Chinese number support**: Episode offset handles Chinese numbers (一二三四五六七八九十).
|
||||
6. **Empty replacement**: Using nothing after `=>` is equivalent to a block word.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Analyze the Problem
|
||||
|
||||
Parse the torrent/file name provided by the user. Identify:
|
||||
- What is being incorrectly recognized (title, season, episode, year, quality, etc.)
|
||||
- What the correct recognition result should be
|
||||
- Which identifier format(s) will solve the problem
|
||||
|
||||
### Step 2: Generate the Identifier Rule(s)
|
||||
|
||||
Write the rule using the appropriate format. Ensure:
|
||||
- Regex special characters are properly escaped
|
||||
- Add a comment line (starting with `#`) above the rule to describe what it does
|
||||
- Test the regex mentally against the provided name to verify correctness
|
||||
|
||||
### Step 3: Query Existing Identifiers
|
||||
|
||||
Use the `query_custom_identifiers` tool to get all current rules:
|
||||
|
||||
```
|
||||
query_custom_identifiers(explanation="Checking existing identifiers before adding new rules to avoid duplicates")
|
||||
```
|
||||
|
||||
### Step 4: Check for Duplicates
|
||||
|
||||
Compare each new rule against the existing identifiers:
|
||||
- **Exact duplicate**: The rule string is identical to an existing rule — skip it
|
||||
- **Functional duplicate**: A different rule that produces the same effect on the same input (e.g., same regex pattern with trivial whitespace differences) — warn the user
|
||||
- **Conflict**: An existing rule modifies the same text in a different way — warn the user and ask which to keep
|
||||
|
||||
### Step 5: Save the Updated Identifiers
|
||||
|
||||
Merge new non-duplicate rules into the existing list, then use `update_custom_identifiers` to save the **complete** list:
|
||||
|
||||
```
|
||||
update_custom_identifiers(
|
||||
explanation="Adding new identifier rules for [description]",
|
||||
identifiers=["existing rule 1", "existing rule 2", "# new comment", "new rule"]
|
||||
)
|
||||
```
|
||||
|
||||
**CRITICAL**: Always include ALL existing rules in the list. This tool replaces the entire list.
|
||||
|
||||
### Step 6: Verify (Optional)
|
||||
|
||||
If the user wants to verify the rule works, use `recognize_media` to test:
|
||||
|
||||
```
|
||||
recognize_media(explanation="Testing recognition after adding identifier", title="the torrent title to test")
|
||||
```
|
||||
|
||||
### Step 7: Report
|
||||
|
||||
Tell the user:
|
||||
- What rule(s) were added
|
||||
- What effect they will have on the title
|
||||
- Whether any duplicates or conflicts were found
|
||||
|
||||
## Common Scenarios and Examples
|
||||
|
||||
### Wrong Season/Episode Parsing
|
||||
|
||||
**User**: "种子名 `[SubGroup] My Show - 13 [1080P]`,这是第二季第1集,但被识别成第13集"
|
||||
|
||||
**Solution**: Episode offset to subtract 12:
|
||||
```
|
||||
# My Show 第二季集数偏移(13->1)
|
||||
\[SubGroup\] <> \[1080P\] >> EP-12
|
||||
```
|
||||
|
||||
### Unwanted Text Causing Wrong Identification
|
||||
|
||||
**User**: "种子名 `My.Show.2024.REPACK.1080p.mkv`,REPACK导致识别异常"
|
||||
|
||||
**Solution**: Block word:
|
||||
```
|
||||
# 屏蔽REPACK标记
|
||||
REPACK
|
||||
```
|
||||
|
||||
### Non-Standard Naming
|
||||
|
||||
**User**: "文件名 `[OldName] EP01.mkv`,应该识别为 NewName"
|
||||
|
||||
**Solution**: Replacement:
|
||||
```
|
||||
# OldName替换为NewName
|
||||
OldName => NewName
|
||||
```
|
||||
|
||||
### Force TMDB ID Recognition
|
||||
|
||||
**User**: "种子名 `Some.Weird.Name.S01E01.1080p.mkv`,识别不到,TMDB ID是12345,是电视剧"
|
||||
|
||||
**Solution**: Direct ID specification:
|
||||
```
|
||||
# 强制识别Some.Weird.Name为TMDB ID 12345
|
||||
Some\.Weird\.Name => {[tmdbid=12345;type=tv;s=1]}
|
||||
```
|
||||
|
||||
### Combined Fix
|
||||
|
||||
**User**: "种子名 `[Baha][OldTitle][13][1080P]`,标题应该是NewTitle,而且13应该是第二季第1集"
|
||||
|
||||
**Solution**: Combined replacement + episode offset:
|
||||
```
|
||||
# OldTitle替换为NewTitle并偏移集数
|
||||
OldTitle => NewTitle && \[Baha\] <> \[1080P\] >> EP-12
|
||||
```
|
||||
|
||||
### Multiple Episode Numbers in One Title
|
||||
|
||||
**User**: "种子名 `[Group] Title - 13-14 [1080P]`,应该是第1-2集"
|
||||
|
||||
**Solution**: Episode offset (handles multiple numbers between delimiters):
|
||||
```
|
||||
# Title 集数偏移
|
||||
\[Group\] <> \[1080P\] >> EP-12
|
||||
```
|
||||
|
||||
## WordsMatcher Processing Logic Reference
|
||||
|
||||
The `WordsMatcher.prepare()` method (in `app/core/meta/words.py`) processes each rule in order:
|
||||
|
||||
1. Skip empty lines and lines starting with `#`
|
||||
2. Detect format by checking operator presence:
|
||||
- Contains ` => ` AND ` && ` AND ` >> ` AND ` <> ` → Combined format (4)
|
||||
- Contains ` => ` → Replacement format (2)
|
||||
- Contains ` >> ` AND ` <> ` → Episode offset format (3)
|
||||
- Otherwise → Block word format (1)
|
||||
3. For combined format, replacement runs first; episode offset only runs if replacement succeeded
|
||||
4. Returns the modified title and a list of rules that were actually applied
|
||||
5. Priority: per-subscribe `custom_words` parameter takes precedence over global `CustomIdentifiers`
|
||||
|
||||
## Safety Notes
|
||||
|
||||
- Always query existing rules first before updating
|
||||
- Never remove existing rules unless the user explicitly asks
|
||||
- Add comment lines before new rules for maintainability
|
||||
- When uncertain about the correct approach, present multiple options and let the user choose
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
name: moviepilot-api
|
||||
version: 1
|
||||
description: Use this skill when you need to call MoviePilot REST API endpoints directly. Covers all 237 API endpoints across 27 categories including media search, downloads, subscriptions, library management, site management, system administration, plugins, workflows, and more. Use this skill whenever the user asks to interact with MoviePilot via its HTTP API, or when the moviepilot-cli skill cannot cover a specific operation.
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
name: moviepilot-cli
|
||||
version: 1
|
||||
description: Use this skill for any request involving movies, TV shows, or anime, including searching, downloads, subscriptions, library management. Also use this skill whenever the user explicitly mentions MoviePilot.
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
name: moviepilot-update
|
||||
version: 1
|
||||
description: Use this skill when you need to restart or upgrade MoviePilot. This skill covers system restart, version check, and manual upgrade procedures.
|
||||
---
|
||||
|
||||
|
||||
185
skills/transfer-failed-retry/SKILL.md
Normal file
185
skills/transfer-failed-retry/SKILL.md
Normal file
@@ -0,0 +1,185 @@
|
||||
---
|
||||
name: transfer-failed-retry
|
||||
version: 1
|
||||
description: Use this skill when you need to retry failed file transfers/organizations. Given one or more failed transfer history record IDs, this skill guides you through querying the failure details, deleting the old records, and re-identifying and re-organizing the files. Supports batch processing of multiple files from the same media (e.g., multiple episodes of a TV show). This skill is automatically triggered when the system detects transfer failures and the AI agent retry feature is enabled.
|
||||
allowed-tools: query_transfer_history delete_transfer_history recognize_media transfer_file search_media
|
||||
---
|
||||
|
||||
# Transfer Failed Retry (整理失败重试)
|
||||
|
||||
This skill handles retrying failed file transfers/organizations. When file transfers fail, you can use this skill to analyze the failures, remove stale history records, and attempt to re-identify and re-organize the files. It supports both single-file and batch retry scenarios.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
You need the following tools:
|
||||
- `query_transfer_history` - Query transfer history records
|
||||
- `delete_transfer_history` - Delete a transfer history record
|
||||
- `recognize_media` - Recognize media info from file path or title
|
||||
- `transfer_file` - Transfer/organize files to the media library
|
||||
- `search_media` - Search TMDB for media information
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Query the Failed Transfer History
|
||||
|
||||
Use `query_transfer_history` to get details about the failed record(s). Filter by status `failed` to find the specific records.
|
||||
|
||||
If you are given a specific history record ID (or multiple IDs), query with those IDs to understand the failure context:
|
||||
|
||||
```
|
||||
query_transfer_history(status="failed")
|
||||
```
|
||||
|
||||
From each record, extract the following key information:
|
||||
- **id**: The history record ID
|
||||
- **src**: Source file path
|
||||
- **title**: The recognized title (may be incorrect)
|
||||
- **errmsg**: The error message explaining why the transfer failed
|
||||
- **type**: Media type (movie/tv)
|
||||
- **tmdbid**: TMDB ID (if available)
|
||||
- **seasons/episodes**: Season/episode info (if TV show)
|
||||
- **downloader**: Which downloader was used
|
||||
- **download_hash**: The torrent hash
|
||||
|
||||
### Step 2: Analyze the Failure Reason
|
||||
|
||||
Common failure reasons and how to handle them:
|
||||
|
||||
| Error Message | Cause | Solution |
|
||||
|---------------|-------|----------|
|
||||
| 未识别到媒体信息 | File name couldn't be matched to any media | Use `search_media` to find the correct TMDB ID, then use `transfer_file` with explicit `tmdbid` |
|
||||
| 源目录不存在 | Source file was moved or deleted | Cannot retry - skip this record |
|
||||
| 目标路径不存在 | Target directory issue | Retry transfer - the directory config may have been fixed |
|
||||
| 文件已存在 | Target file already exists | May need to use `force` mode or skip |
|
||||
| 未找到有效的集数信息 | Episode number not recognized | Use `recognize_media` with the file path to get better metadata, or specify season/episode in `transfer_file` |
|
||||
| 未获取到转移目录设置 | No transfer directory configured for this media type | Cannot auto-fix - notify user about directory configuration |
|
||||
|
||||
### Step 3: Delete the Failed History Record(s)
|
||||
|
||||
Before retrying, you **must** delete the old failed history record(s). The system skips files that already have a transfer history entry (even failed ones).
|
||||
|
||||
```
|
||||
delete_transfer_history(history_id=<record_id>)
|
||||
```
|
||||
|
||||
### Step 4: Re-identify and Re-organize
|
||||
|
||||
Based on the failure analysis in Step 2:
|
||||
|
||||
#### Case A: Unrecognized Media (未识别到媒体信息)
|
||||
|
||||
1. Try recognizing the media from file path:
|
||||
```
|
||||
recognize_media(path="<source_file_path>")
|
||||
```
|
||||
|
||||
2. If recognition fails, try searching TMDB with keywords extracted from the filename:
|
||||
```
|
||||
search_media(title="<extracted_title>", media_type="movie" or "tv")
|
||||
```
|
||||
|
||||
3. Once you have the correct TMDB ID, re-transfer with explicit identification:
|
||||
```
|
||||
transfer_file(file_path="<source_path>", tmdbid=<tmdb_id>, media_type="movie" or "tv")
|
||||
```
|
||||
|
||||
#### Case B: Transfer Error (file operation failed)
|
||||
|
||||
Simply retry the transfer:
|
||||
```
|
||||
transfer_file(file_path="<source_path>")
|
||||
```
|
||||
|
||||
#### Case C: Episode Recognition Issue
|
||||
|
||||
For TV shows where episode info couldn't be determined:
|
||||
1. Use `recognize_media` to get better metadata
|
||||
2. Re-transfer with explicit season info:
|
||||
```
|
||||
transfer_file(file_path="<source_path>", tmdbid=<tmdb_id>, media_type="tv", season=<season_number>)
|
||||
```
|
||||
|
||||
### Step 5: Report Result
|
||||
|
||||
After the retry attempt, report the result:
|
||||
- If successful: Confirm the file(s) have been organized correctly
|
||||
- If failed again: Report the new error and suggest manual intervention
|
||||
- For batch operations: Report a summary (e.g., "成功 8/10,失败 2/10")
|
||||
|
||||
## Batch Processing (批量处理)
|
||||
|
||||
When multiple files from the same source fail simultaneously (e.g., 10 episodes of the same TV show all fail with the same error), the system groups them and triggers a single batch retry.
|
||||
|
||||
### Key Optimization Rules for Batch Processing:
|
||||
|
||||
1. **Identify media ONCE, apply to ALL files**: Since batch files typically belong to the same media, perform media recognition (`recognize_media`) or search (`search_media`) only ONCE using the first file, then reuse the result (tmdbid, media_type) for all subsequent files.
|
||||
|
||||
2. **Process each file individually for delete + transfer**: Even though the media identity is shared, you must still:
|
||||
- Delete each failed history record individually
|
||||
- Transfer each file individually (they have different source paths)
|
||||
|
||||
3. **Stop early if root cause is unfixable**: If the first file fails due to an unfixable issue (e.g., missing directory configuration), skip all remaining files with the same error rather than retrying each one.
|
||||
|
||||
4. **Process in order**: Handle files sequentially to avoid race conditions.
|
||||
|
||||
### Batch Example Flow:
|
||||
|
||||
```
|
||||
# Given failed records: IDs = [42, 43, 44, 45] (4 episodes of the same show)
|
||||
# All have errmsg="未识别到媒体信息"
|
||||
|
||||
# 1. Query all failed records
|
||||
query_transfer_history(status="failed")
|
||||
|
||||
# 2. Identify media ONCE using the first file
|
||||
recognize_media(path="/downloads/Show.Name.S01E01.1080p.mkv")
|
||||
# Found: tmdb_id=789, media_type="tv"
|
||||
|
||||
# 3. For each record: delete history, then re-transfer
|
||||
delete_transfer_history(history_id=42)
|
||||
transfer_file(file_path="/downloads/Show.Name.S01E01.1080p.mkv", tmdbid=789, media_type="tv")
|
||||
|
||||
delete_transfer_history(history_id=43)
|
||||
transfer_file(file_path="/downloads/Show.Name.S01E02.1080p.mkv", tmdbid=789, media_type="tv")
|
||||
|
||||
delete_transfer_history(history_id=44)
|
||||
transfer_file(file_path="/downloads/Show.Name.S01E03.1080p.mkv", tmdbid=789, media_type="tv")
|
||||
|
||||
delete_transfer_history(history_id=45)
|
||||
transfer_file(file_path="/downloads/Show.Name.S01E04.1080p.mkv", tmdbid=789, media_type="tv")
|
||||
|
||||
# 4. Report summary: "重试完成:4/4 成功"
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **Always delete the old history record first** before retrying. The system will skip files with existing history.
|
||||
- **Do not retry** if the source file no longer exists (源目录不存在).
|
||||
- **Do not retry** if the error is about missing directory configuration - this requires user intervention.
|
||||
- **For unrecognized media**, always try `recognize_media` with the file path first before falling back to `search_media`.
|
||||
- **Be cautious with TV shows** - ensure the correct season and episode information is used.
|
||||
- **For batch processing**, always reuse media identification results across all files to save time and resources.
|
||||
- When this skill is triggered automatically by the system, it provides the `history_id`(s) directly. Start from Step 1 with those specific IDs.
|
||||
|
||||
## Example: Single File Retry Flow
|
||||
|
||||
```
|
||||
# 1. Query the failed record
|
||||
query_transfer_history(status="failed", page=1)
|
||||
# Found: id=42, src="/downloads/Movie.Name.2024.1080p.mkv", errmsg="未识别到媒体信息"
|
||||
|
||||
# 2. Try to recognize the media from path
|
||||
recognize_media(path="/downloads/Movie.Name.2024.1080p.mkv")
|
||||
# Recognition failed
|
||||
|
||||
# 3. Search TMDB
|
||||
search_media(title="Movie Name", year="2024", media_type="movie")
|
||||
# Found: tmdb_id=123456
|
||||
|
||||
# 4. Delete old history record
|
||||
delete_transfer_history(history_id=42)
|
||||
|
||||
# 5. Re-transfer with correct identification
|
||||
transfer_file(file_path="/downloads/Movie.Name.2024.1080p.mkv", tmdbid=123456, media_type="movie")
|
||||
# Success!
|
||||
```
|
||||
@@ -1234,4 +1234,55 @@ meta_cases = [{
|
||||
"video_codec": "x265 10bit",
|
||||
"audio_codec": "2Audio"
|
||||
}
|
||||
}, {
|
||||
# 第一个括号包含完整发布名称(含年份+分辨率),应提取标题而非丢弃
|
||||
"title": "[Caligula.The.Ultimate.Cut.2023.2160p.UHD.Blu-ray.HEVC.DTS-HD.MA.5.1-BHYS@OurBits][DIY中字原盘] [罗马帝国艳情史:最终剪辑版][澳大利亚版UHD原盘 DIY 简体简英字幕][91.86GB].iso",
|
||||
"subtitle": "",
|
||||
"target": {
|
||||
"type": "未知",
|
||||
"cn_name": "",
|
||||
"en_name": "Caligula The Ultimate Cut",
|
||||
"year": "2023",
|
||||
"part": "",
|
||||
"season": "",
|
||||
"episode": "",
|
||||
"restype": "UHD",
|
||||
"pix": "2160p",
|
||||
"video_codec": "HEVC",
|
||||
"audio_codec": "DTS-HD MA 5.1"
|
||||
}
|
||||
}, {
|
||||
# 第一个括号包含完整发布名称(含年份+BluRay),应提取标题
|
||||
"title": "[The.Shawshank.Redemption.1994.1080p.BluRay.x264-GROUP][中文字幕]",
|
||||
"subtitle": "",
|
||||
"target": {
|
||||
"type": "未知",
|
||||
"cn_name": "",
|
||||
"en_name": "The Shawshank Redemption",
|
||||
"year": "1994",
|
||||
"part": "",
|
||||
"season": "",
|
||||
"episode": "",
|
||||
"restype": "BluRay",
|
||||
"pix": "1080p",
|
||||
"video_codec": "x264",
|
||||
"audio_codec": ""
|
||||
}
|
||||
}, {
|
||||
# 第一个括号为短标签(无年份无分辨率),应正常移除
|
||||
"title": "[YTS.MX] The Shawshank Redemption 1994 1080p BluRay x264",
|
||||
"subtitle": "",
|
||||
"target": {
|
||||
"type": "未知",
|
||||
"cn_name": "",
|
||||
"en_name": "The Shawshank Redemption",
|
||||
"year": "1994",
|
||||
"part": "",
|
||||
"season": "",
|
||||
"episode": "",
|
||||
"restype": "BluRay",
|
||||
"pix": "1080p",
|
||||
"video_codec": "x264",
|
||||
"audio_codec": ""
|
||||
}
|
||||
}]
|
||||
|
||||
@@ -10,6 +10,7 @@ from tests.test_mediascrape import (
|
||||
)
|
||||
from tests.test_metainfo import MetaInfoTest
|
||||
from tests.test_object import ObjectUtilsTest
|
||||
from tests.test_subscribe_chain import SubscribeChainTest
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
@@ -36,6 +37,9 @@ if __name__ == '__main__':
|
||||
suite.addTest(unittest.TestLoader().loadTestsFromTestCase(TestMediaScrapingTVDirectory))
|
||||
suite.addTest(unittest.TestLoader().loadTestsFromTestCase(TestMediaScrapeEvents))
|
||||
|
||||
# 测试订阅洗版匹配
|
||||
suite.addTest(SubscribeChainTest('test_is_episode_range_covered'))
|
||||
|
||||
# 运行测试
|
||||
runner = unittest.TextTestRunner()
|
||||
runner.run(suite)
|
||||
|
||||
@@ -2,7 +2,7 @@ import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
# ruff: noqa: E402
|
||||
sys.modules['app.helper.sites'] = MagicMock()
|
||||
sys.modules['app.db.systemconfig_oper'] = MagicMock()
|
||||
sys.modules['app.db.systemconfig_oper'].SystemConfigOper.return_value.get.return_value = None
|
||||
@@ -172,6 +172,62 @@ class TestMediaScrapingImages(unittest.TestCase):
|
||||
self.assertEqual(len(calls), 1)
|
||||
self.assertEqual(calls[0].kwargs["url"], "http://season01")
|
||||
|
||||
def test_scrape_episode_thumb_image_path(self):
|
||||
fileitem = schemas.FileItem(path="/tv/Show/Season 1/S01E01.mp4", name="S01E01.mp4", type="file", storage="local")
|
||||
parent_item = schemas.FileItem(path="/tv/Show/Season 1", name="Season 1", type="dir", storage="local")
|
||||
mediainfo = MediaInfo()
|
||||
self.media_chain.metadata_img.return_value = {
|
||||
"thumb.jpg": "http://episode-thumb"
|
||||
}
|
||||
self.media_chain.scraping_policies.option.return_value = ScrapingOption("episode", "thumb", ScrapingPolicy.OVERWRITE)
|
||||
self.media_chain.storagechain.get_file_item.return_value = None
|
||||
|
||||
self.media_chain._scrape_images_generic(
|
||||
fileitem,
|
||||
mediainfo,
|
||||
ScrapingTarget.EPISODE,
|
||||
parent_fileitem=parent_item,
|
||||
season_number=1,
|
||||
episode_number=1
|
||||
)
|
||||
|
||||
self.media_chain.metadata_img.assert_called_once_with(
|
||||
mediainfo=mediainfo,
|
||||
season=1,
|
||||
episode=1
|
||||
)
|
||||
self.media_chain._download_and_save_image.assert_called_once_with(
|
||||
fileitem=parent_item,
|
||||
path=Path("/tv/Show/Season 1/S01E01.jpg"),
|
||||
url="http://episode-thumb"
|
||||
)
|
||||
|
||||
def test_scrape_episode_thumb_image_path_via_parent_lookup(self):
|
||||
fileitem = schemas.FileItem(path="/tv/Show/Season 1/S01E01.mp4", name="S01E01.mp4", type="file", storage="local")
|
||||
parent_item = schemas.FileItem(path="/tv/Show/Season 1", name="Season 1", type="dir", storage="local")
|
||||
mediainfo = MediaInfo()
|
||||
self.media_chain.metadata_img.return_value = {
|
||||
"thumb.jpg": "http://episode-thumb"
|
||||
}
|
||||
self.media_chain.scraping_policies.option.return_value = ScrapingOption("episode", "thumb", ScrapingPolicy.OVERWRITE)
|
||||
self.media_chain.storagechain.get_parent_item.return_value = parent_item
|
||||
self.media_chain.storagechain.get_file_item.return_value = None
|
||||
|
||||
self.media_chain._scrape_images_generic(
|
||||
fileitem,
|
||||
mediainfo,
|
||||
ScrapingTarget.EPISODE,
|
||||
season_number=1,
|
||||
episode_number=1
|
||||
)
|
||||
|
||||
self.media_chain.storagechain.get_parent_item.assert_called_once_with(fileitem)
|
||||
self.media_chain._download_and_save_image.assert_called_once_with(
|
||||
fileitem=parent_item,
|
||||
path=Path("/tv/Show/Season 1/S01E01.jpg"),
|
||||
url="http://episode-thumb"
|
||||
)
|
||||
|
||||
@patch("app.chain.media.RequestUtils")
|
||||
@patch("app.chain.media.NamedTemporaryFile")
|
||||
@patch("app.chain.media.Path.chmod")
|
||||
@@ -225,16 +281,22 @@ class TestMediaScrapingTVDirectory(unittest.TestCase):
|
||||
def test_initialize_tv_directory_specials(self, mock_settings):
|
||||
# mock specials directory recognition
|
||||
mock_settings.RENAME_FORMAT_S0_NAMES = ["Specials", "SPs"]
|
||||
mock_settings.RMT_MEDIAEXT = [".mp4", ".mkv"]
|
||||
|
||||
fileitem = schemas.FileItem(path="/tv/Show/Specials", name="Specials", type="dir", storage="local")
|
||||
meta = MetaInfo("Show")
|
||||
mediainfo = MediaInfo(type=MediaType.TV)
|
||||
self.media_chain.storagechain.list_files.return_value = []
|
||||
filepath = Path(fileitem.path)
|
||||
|
||||
self.media_chain._handle_tv_scraping(fileitem, meta, mediainfo, init_folder=True, parent=None, overwrite=False, recursive=True)
|
||||
self.media_chain._initialize_tv_directory_metadata(
|
||||
fileitem=fileitem,
|
||||
filepath=filepath,
|
||||
meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
parent=None,
|
||||
overwrite=False,
|
||||
)
|
||||
|
||||
self.media_chain._scrape_nfo_generic.assert_called_with(
|
||||
self.media_chain._scrape_nfo_generic.assert_called_once_with(
|
||||
current_fileitem=fileitem,
|
||||
meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
@@ -242,7 +304,7 @@ class TestMediaScrapingTVDirectory(unittest.TestCase):
|
||||
overwrite=False,
|
||||
season_number=0
|
||||
)
|
||||
self.media_chain._scrape_images_generic.assert_called_with(
|
||||
self.media_chain._scrape_images_generic.assert_called_once_with(
|
||||
current_fileitem=fileitem,
|
||||
mediainfo=mediainfo,
|
||||
item_type=ScrapingTarget.SEASON,
|
||||
@@ -251,15 +313,25 @@ class TestMediaScrapingTVDirectory(unittest.TestCase):
|
||||
season_number=0
|
||||
)
|
||||
|
||||
def test_initialize_tv_directory_season(self):
|
||||
@patch("app.chain.media.settings")
|
||||
def test_initialize_tv_directory_season(self, mock_settings):
|
||||
mock_settings.RENAME_FORMAT_S0_NAMES = ["Specials", "SPs"]
|
||||
|
||||
fileitem = schemas.FileItem(path="/tv/Show/Season 1", name="Season 1", type="dir", storage="local")
|
||||
meta = MetaInfo("Show")
|
||||
mediainfo = MediaInfo(type=MediaType.TV)
|
||||
self.media_chain.storagechain.list_files.return_value = []
|
||||
filepath = Path(fileitem.path)
|
||||
|
||||
self.media_chain._handle_tv_scraping(fileitem, meta, mediainfo, init_folder=True, parent=None, overwrite=False, recursive=True)
|
||||
self.media_chain._initialize_tv_directory_metadata(
|
||||
fileitem=fileitem,
|
||||
filepath=filepath,
|
||||
meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
parent=None,
|
||||
overwrite=False,
|
||||
)
|
||||
|
||||
self.media_chain._scrape_nfo_generic.assert_called_with(
|
||||
self.media_chain._scrape_nfo_generic.assert_called_once_with(
|
||||
current_fileitem=fileitem,
|
||||
meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
@@ -272,18 +344,17 @@ class TestMediaScrapingTVDirectory(unittest.TestCase):
|
||||
class TestMediaScrapeEvents(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.media_chain = MediaChain()
|
||||
self.media_chain.storagechain = MagicMock()
|
||||
|
||||
@patch("app.chain.media.MediaChain.scrape_metadata")
|
||||
@patch("app.chain.media.StorageChain.get_item")
|
||||
@patch("app.chain.media.StorageChain.get_parent_item")
|
||||
def test_scrape_metadata_event_file(
|
||||
self, mock_get_parent, mock_get_item, mock_scrape_metadata
|
||||
self, mock_scrape_metadata
|
||||
):
|
||||
fileitem = schemas.FileItem(path="/movies/movie.mkv", name="movie.mkv", type="file", storage="local")
|
||||
parent_item = schemas.FileItem(path="/movies", name="movies", type="dir", storage="local")
|
||||
|
||||
mock_get_item.return_value = fileitem
|
||||
mock_get_parent.return_value = parent_item
|
||||
self.media_chain.storagechain.get_item.return_value = fileitem
|
||||
self.media_chain.storagechain.get_parent_item.return_value = parent_item
|
||||
|
||||
mediainfo = MediaInfo()
|
||||
event = Event(
|
||||
@@ -306,15 +377,13 @@ class TestMediaScrapeEvents(unittest.TestCase):
|
||||
)
|
||||
|
||||
@patch("app.chain.media.MediaChain.scrape_metadata")
|
||||
@patch("app.chain.media.StorageChain.get_item")
|
||||
@patch("app.chain.media.StorageChain.is_bluray_folder")
|
||||
def test_scrape_metadata_event_dir_bluray(
|
||||
self, mock_is_bluray, mock_get_item, mock_scrape_metadata
|
||||
self, mock_scrape_metadata
|
||||
):
|
||||
fileitem = schemas.FileItem(path="/movies/bluray_movie", name="bluray_movie", type="dir", storage="local")
|
||||
|
||||
mock_get_item.return_value = fileitem
|
||||
mock_is_bluray.return_value = True
|
||||
self.media_chain.storagechain.get_item.return_value = fileitem
|
||||
self.media_chain.storagechain.is_bluray_folder.return_value = True
|
||||
|
||||
mediainfo = MediaInfo()
|
||||
event = Event(
|
||||
@@ -338,22 +407,19 @@ class TestMediaScrapeEvents(unittest.TestCase):
|
||||
)
|
||||
|
||||
@patch("app.chain.media.MediaChain.scrape_metadata")
|
||||
@patch("app.chain.media.StorageChain.get_item")
|
||||
@patch("app.chain.media.StorageChain.is_bluray_folder")
|
||||
@patch("app.chain.media.StorageChain.get_file_item")
|
||||
def test_scrape_metadata_event_dir_with_filelist(
|
||||
self, mock_get_file_item, mock_is_bluray, mock_get_item, mock_scrape_metadata
|
||||
self, mock_scrape_metadata
|
||||
):
|
||||
fileitem = schemas.FileItem(path="/tv/show", name="show", type="dir", storage="local")
|
||||
|
||||
mock_get_item.return_value = fileitem
|
||||
mock_is_bluray.return_value = False
|
||||
self.media_chain.storagechain.get_item.return_value = fileitem
|
||||
self.media_chain.storagechain.is_bluray_folder.return_value = False
|
||||
|
||||
def side_effect_get_file_item(storage, path):
|
||||
path_str = str(path)
|
||||
return schemas.FileItem(path=path_str, name=Path(path_str).name, type="dir" if "." not in path_str else "file", storage="local")
|
||||
|
||||
mock_get_file_item.side_effect = side_effect_get_file_item
|
||||
self.media_chain.storagechain.get_file_item.side_effect = side_effect_get_file_item
|
||||
|
||||
mediainfo = MediaInfo()
|
||||
event = Event(
|
||||
@@ -377,13 +443,12 @@ class TestMediaScrapeEvents(unittest.TestCase):
|
||||
self.assertIn("/tv/show/Season 1/S01E01.mp4", paths)
|
||||
|
||||
@patch("app.chain.media.MediaChain.scrape_metadata")
|
||||
@patch("app.chain.media.StorageChain.get_item")
|
||||
def test_scrape_metadata_event_dir_full(
|
||||
self, mock_get_item, mock_scrape_metadata
|
||||
self, mock_scrape_metadata
|
||||
):
|
||||
fileitem = schemas.FileItem(path="/movies/movie", name="movie", type="dir", storage="local")
|
||||
|
||||
mock_get_item.return_value = fileitem
|
||||
self.media_chain.storagechain.get_item.return_value = fileitem
|
||||
|
||||
mediainfo = MediaInfo()
|
||||
meta = MetaInfo("movie")
|
||||
@@ -501,22 +566,19 @@ class TestMediaScrapeEvents(unittest.TestCase):
|
||||
mock_handle_tv.assert_not_called()
|
||||
|
||||
@patch("app.chain.media.MediaChain.scrape_metadata")
|
||||
@patch("app.chain.media.StorageChain.get_item")
|
||||
@patch("app.chain.media.StorageChain.is_bluray_folder")
|
||||
@patch("app.chain.media.StorageChain.get_file_item")
|
||||
def test_scrape_metadata_event_dir_with_multiple_files(
|
||||
self, mock_get_file_item, mock_is_bluray, mock_get_item, mock_scrape_metadata
|
||||
self, mock_scrape_metadata
|
||||
):
|
||||
fileitem = schemas.FileItem(path="/movies/collection", name="collection", type="dir", storage="local")
|
||||
|
||||
mock_get_item.return_value = fileitem
|
||||
mock_is_bluray.return_value = False
|
||||
self.media_chain.storagechain.get_item.return_value = fileitem
|
||||
self.media_chain.storagechain.is_bluray_folder.return_value = False
|
||||
|
||||
def side_effect_get_file_item(storage, path):
|
||||
path_str = str(path)
|
||||
return schemas.FileItem(path=path_str, name=Path(path_str).name, type="dir" if "." not in path_str else "file", storage="local")
|
||||
|
||||
mock_get_file_item.side_effect = side_effect_get_file_item
|
||||
self.media_chain.storagechain.get_file_item.side_effect = side_effect_get_file_item
|
||||
|
||||
mediainfo = MediaInfo()
|
||||
event = Event(
|
||||
@@ -546,22 +608,19 @@ class TestMediaScrapeEvents(unittest.TestCase):
|
||||
self.assertIn("/movies/collection/movie3.avi", paths)
|
||||
|
||||
@patch("app.chain.media.MediaChain.scrape_metadata")
|
||||
@patch("app.chain.media.StorageChain.get_item")
|
||||
@patch("app.chain.media.StorageChain.is_bluray_folder")
|
||||
@patch("app.chain.media.StorageChain.get_file_item")
|
||||
def test_scrape_metadata_event_dir_with_tv_multi_seasons_episodes(
|
||||
self, mock_get_file_item, mock_is_bluray, mock_get_item, mock_scrape_metadata
|
||||
self, mock_scrape_metadata
|
||||
):
|
||||
fileitem = schemas.FileItem(path="/tv/MultiSeasonShow", name="MultiSeasonShow", type="dir", storage="local")
|
||||
|
||||
mock_get_item.return_value = fileitem
|
||||
mock_is_bluray.return_value = False
|
||||
self.media_chain.storagechain.get_item.return_value = fileitem
|
||||
self.media_chain.storagechain.is_bluray_folder.return_value = False
|
||||
|
||||
def side_effect_get_file_item(storage, path):
|
||||
path_str = str(path)
|
||||
return schemas.FileItem(path=path_str, name=Path(path_str).name, type="dir" if "." not in path_str else "file", storage="local")
|
||||
|
||||
mock_get_file_item.side_effect = side_effect_get_file_item
|
||||
self.media_chain.storagechain.get_file_item.side_effect = side_effect_get_file_item
|
||||
|
||||
mediainfo = MediaInfo()
|
||||
event = Event(
|
||||
|
||||
175
tests/test_subscribe_chain.py
Normal file
175
tests/test_subscribe_chain.py
Normal file
@@ -0,0 +1,175 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest import TestCase
|
||||
|
||||
from app.chain.subscribe import SubscribeChain
|
||||
from app.core.metainfo import MetaInfo
|
||||
|
||||
|
||||
class SubscribeChainTest(TestCase):
|
||||
def test_is_episode_range_covered(self):
|
||||
cases = [
|
||||
{
|
||||
"title": "Cherry Season S01 2014 2160p 60fps WEB-DL H265 AAC-XXX",
|
||||
"subtitle": "",
|
||||
"subscribe": {"start_episode": None, "total_episode": 51},
|
||||
"expected": True,
|
||||
},
|
||||
{
|
||||
"title": "【爪爪字幕组】★7月新番[欢迎来到实力至上主义的教室 第二季/Youkoso Jitsuryoku Shijou Shugi no Kyoushitsu e S2][11][1080p][HEVC][GB][MP4][招募翻译校对]",
|
||||
"subtitle": "",
|
||||
"subscribe": {"start_episode": None, "total_episode": 13},
|
||||
"expected": False,
|
||||
},
|
||||
{
|
||||
"title": "[秋叶原冥途战争][Akiba Maid Sensou][2022][WEB-DL][1080][TV Series][第01话][LeagueWEB]",
|
||||
"subtitle": "",
|
||||
"subscribe": {"start_episode": None, "total_episode": 12},
|
||||
"expected": False,
|
||||
},
|
||||
{
|
||||
"title": "Qi Refining for 3000 Years S01E06 2022 1080p B-Blobal WEB-DL X264 AAC-AnimeS@AdWeb",
|
||||
"subtitle": "",
|
||||
"subscribe": {"start_episode": None, "total_episode": 16},
|
||||
"expected": False,
|
||||
},
|
||||
{
|
||||
"title": "The Heart of Genius S01 13-14 2022 1080p WEB-DL H264 AAC",
|
||||
"subtitle": "",
|
||||
"subscribe": {"start_episode": None, "total_episode": 34},
|
||||
"expected": False,
|
||||
},
|
||||
{
|
||||
"title": "[xyx98]传颂之物/Utawarerumono/うたわれるもの[BDrip][1920x1080][TV 01-26 Fin][hevc-yuv420p10 flac_ac3][ENG PGS]",
|
||||
"subtitle": "",
|
||||
"subscribe": {"start_episode": None, "total_episode": 26},
|
||||
"expected": True,
|
||||
},
|
||||
{
|
||||
"title": "I Woke Up a Vampire S02 2023 2160p NF WEB-DL DDP5.1 Atmos H 265-HHWEB",
|
||||
"subtitle": "醒来变成吸血鬼 第二季 | 全8集 | 4K | 类型: 喜剧/家庭/奇幻 | 导演: TommyLynch | 主演: NikoCeci/ZebastinBorjeau/安娜·阿劳约/KaileenAngelicChang/KrisSiddiqi",
|
||||
"subscribe": {"start_episode": None, "total_episode": 8},
|
||||
"expected": True,
|
||||
},
|
||||
{
|
||||
"title": "Shadows of the Void S01 2024 1080p WEB-DL H264 AAC-HHWEB",
|
||||
"subtitle": "虚无边境 | 第01-02集 | 1080p | 类型: 动画 | 导演: 巴西 | 主演: 山新/周一菡/皇贞季/Kenz/李佳怡 [内嵌中字]",
|
||||
"subscribe": {"start_episode": None, "total_episode": 13},
|
||||
"expected": False,
|
||||
},
|
||||
{
|
||||
"title": "Mai Xiang S01 2019 2160p WEB-DL H.265 DDP2.0-HHWEB",
|
||||
"subtitle": "麦香 | 全36集 | 4K | 类型:剧情/爱情/家庭 | 主演:傅晶/章呈赫/王伟/沙景昌/何音",
|
||||
"subscribe": {"start_episode": None, "total_episode": 36},
|
||||
"expected": True,
|
||||
},
|
||||
{
|
||||
"title": "Jigokuraku S01E14-E25 2023 1080p CR WEB-DL x264 AAC-Nest@ADWeb",
|
||||
"subtitle": "地狱乐 / 地獄楽 / Hell’s Paradise [14-25Fin] [中日双语字幕]",
|
||||
"subscribe": {"start_episode": 14, "total_episode": 25},
|
||||
"expected": True,
|
||||
},
|
||||
{
|
||||
"title": "Jigokuraku S01 2023 1080p BluRay Remux AVC FLAC 2.0-AnimeF@ADE",
|
||||
"subtitle": "地狱乐/Hell's Paradise: Jigokuraku [01-13Fin] [中日双语字幕]",
|
||||
"subscribe": {"start_episode": None, "total_episode": 13},
|
||||
"expected": True,
|
||||
},
|
||||
{
|
||||
"title": "Jigokuraku S02E12 2026 1080p NF WEB-DL x264 AAC-ADWeb",
|
||||
"subtitle": "地狱乐 第二季 地獄楽 第二期 第12集 | 类型: 动画",
|
||||
"subscribe": {"start_episode": None, "total_episode": 12},
|
||||
"expected": False,
|
||||
},
|
||||
{
|
||||
"title": "Jigokuraku S02E05-E07 2026 1080p NF WEB-DL x264 AAC-ADWeb",
|
||||
"subtitle": "地狱乐 第二季 地獄楽 第二期 第05-07集 | 类型: 动画",
|
||||
"subscribe": {"start_episode": None, "total_episode": 12},
|
||||
"expected": False,
|
||||
},
|
||||
{
|
||||
"title": "Bungo Stray Dogs S01 2016 1080p KKTV WEB-DL x264 AAC-ADWeb",
|
||||
"subtitle": "文豪野犬 文豪ストレイドッグス 又名: 文豪Stray Dogs 第一季 全12集 | 类型: 剧情 / 动作 / 动画 主演: 上村祐翔 / 宫野真守 / 细谷佳正 *内嵌繁体字幕*",
|
||||
"subscribe": {"start_episode": None, "total_episode": 12},
|
||||
"expected": True,
|
||||
},
|
||||
{
|
||||
"title": "Bungou Stray Dogs S1+S2+S3+OAD 1080p BDRip HEVC FLAC-Snow-Raws",
|
||||
"subtitle": "文豪野犬 第1-3季",
|
||||
"subscribe": {"start_episode": None, "total_episode": 36},
|
||||
"expected": True,
|
||||
},
|
||||
{
|
||||
"title": "Bungou Stray Dogs S1+S2+S3+OAD 1080p BDRip HEVC FLAC-Snow-Raws",
|
||||
"subtitle": "文豪野犬 第1-3季",
|
||||
"subscribe": {"start_episode": None, "total_episode": 60},
|
||||
"expected": True, # 识别不到集数全匹配
|
||||
},
|
||||
{
|
||||
"title": "Fu Gui S01 2005 2160p WEB-DL H265 AAC-HHWEB",
|
||||
"subtitle": "福贵 | 全33集 | 4K | 类型: 剧情/家庭 | 导演: 朱正/袁进 | 主演: 陈创/刘敏涛/李丁/张鹰/温玉娟",
|
||||
"subscribe": {"start_episode": None, "total_episode": 33},
|
||||
"expected": True,
|
||||
},
|
||||
{
|
||||
"title": "The Story of Ming Lan S01 2018 2160p WEB-DL CHDWEB",
|
||||
"subtitle": "知否知否应是绿肥红瘦 全78集 | 2160p | 国语/中字 | 60帧高码TV版 | 类型:剧情/爱情/古装 | 主演:赵丽颖/冯绍峰/朱一龙/施诗/张佳宁",
|
||||
"subscribe": {"start_episode": None, "total_episode": 78},
|
||||
"expected": True,
|
||||
},
|
||||
{
|
||||
"title": "Love Beyond the Grave S01 2026 2160p WEB-DL H265 AAC-HHWEB",
|
||||
"subtitle": "白日提灯 / 慕胥辞 | 第18集 | 4K | 类型: 剧情 | 导演: 秦榛 | 主演: 迪丽热巴/陈飞宇/魏哲鸣/张俪/高鹤元",
|
||||
"subscribe": {"start_episode": None, "total_episode": 40},
|
||||
"expected": False,
|
||||
},
|
||||
{
|
||||
"title": "The Long Ballad S01 2021 2160p WEB-DL H265 AAC-HHWEB",
|
||||
"subtitle": "长歌行 | 全49集 | 4K | 类型: 剧情/爱情/古装 | 主演: 迪丽热巴/吴磊/刘宇宁/赵露思/方逸伦",
|
||||
"subscribe": {"start_episode": None, "total_episode": 49},
|
||||
"expected": True,
|
||||
},
|
||||
{
|
||||
"title": "The Long Ballad S01E01-E04 2021 2160p WEB-DL H265 AAC-HHWEB",
|
||||
"subtitle": "长歌行 | 第01-04集 | 4K | 类型: 剧情/爱情/古装 | 主演: 迪丽热巴/吴磊/刘宇宁/赵露思/方逸伦",
|
||||
"subscribe": {"start_episode": None, "total_episode": 49},
|
||||
"expected": False,
|
||||
},
|
||||
{
|
||||
"title": "Spy x Family S02 2023 1080p Baha WEB-DL x264 AAC-ADWeb",
|
||||
"subtitle": "间谍过家家 第二季 / SPY×FAMILY Season 2 [01-12Fin] [简繁内封字幕]",
|
||||
"subscribe": {"start_episode": None, "total_episode": 12},
|
||||
"expected": True,
|
||||
},
|
||||
{
|
||||
"title": "Spy x Family S02E03-E07 2023 1080p Baha WEB-DL x264 AAC-ADWeb",
|
||||
"subtitle": "间谍过家家 第二季 / SPY×FAMILY Season 2 第03-07集 [简繁内封字幕]",
|
||||
"subscribe": {"start_episode": None, "total_episode": 12},
|
||||
"expected": False,
|
||||
},
|
||||
{
|
||||
"title": "Naruto Shippuden S01-S21 Complete 1080p BluRay x264 AAC-ADWeb",
|
||||
"subtitle": "火影忍者 疾风传 全500集 [1080p][简中字幕]",
|
||||
"subscribe": {"start_episode": None, "total_episode": 500},
|
||||
"expected": True,
|
||||
},
|
||||
{
|
||||
"title": "Naruto Shippuden S01-S21 Complete 1080p BluRay x264 AAC-ADWeb",
|
||||
"subtitle": "火影忍者 疾风传 第01-500集 [1080p][简中字幕]",
|
||||
"subscribe": {"start_episode": 201, "total_episode": 500},
|
||||
"expected": True,
|
||||
},
|
||||
]
|
||||
|
||||
for case in cases:
|
||||
meta = MetaInfo(
|
||||
title=case["title"], subtitle=case["subtitle"], custom_words=["#"]
|
||||
)
|
||||
subscribe = SimpleNamespace(**case["subscribe"])
|
||||
|
||||
self.assertEqual(
|
||||
SubscribeChain._is_episode_range_covered(
|
||||
meta=meta,
|
||||
subscribe=subscribe,
|
||||
),
|
||||
case["expected"],
|
||||
)
|
||||
@@ -1,2 +1,2 @@
|
||||
APP_VERSION = 'v2.9.24'
|
||||
FRONTEND_VERSION = 'v2.9.24'
|
||||
APP_VERSION = 'v2.9.27'
|
||||
FRONTEND_VERSION = 'v2.9.27'
|
||||
|
||||
Reference in New Issue
Block a user