Compare commits

...

48 Commits

Author SHA1 Message Date
jxxghp
e3fee39043 agent提示词中注入PostgreSQL数据库密码 2026-03-29 09:07:46 +08:00
jxxghp
a1a72df6c6 feat(telegram): 保持正在输入状态直到消息发送完成 2026-03-29 09:04:42 +08:00
jxxghp
cdf40a7046 feat(agent): 添加PostgreSQL用户名到数据库信息 2026-03-29 08:05:40 +08:00
jxxghp
b9b19c9acc feat(agent): 添加数据库信息到系统提示词 2026-03-29 08:05:01 +08:00
jxxghp
8c603baa43 更新 version.py 2026-03-29 07:40:24 +08:00
jxxghp
a977948f2b 优化Agent提示词:日期改为当前时间,注入系统安装目录,强调简洁回复 2026-03-29 07:21:11 +08:00
jxxghp
f70eaf9363 feat(agent): 添加reset方法支持流式消息原地更新 2026-03-28 23:08:06 +08:00
jxxghp
bfea0174dd refactor: 优化工具消息发送逻辑 2026-03-28 22:38:20 +08:00
jxxghp
296d815e3e refactor: 移除异步pop操作 2026-03-28 12:42:26 +08:00
jxxghp
c3b7a50642 refactor(prompt): 优化MoviePilot系统信息注入,统一日期与环境信息展示 2026-03-28 12:28:14 +08:00
jxxghp
8e0a9f94f6 feat(agent): 在系统提示词中注入MoviePilot配置信息 2026-03-28 11:03:02 +08:00
jxxghp
6806900436 refactor: Update agent package initialization and imports. 2026-03-28 07:58:47 +08:00
jxxghp
a8ecdc8206 refactor: Invert AI agent verbose mode condition and strengthen silence instructions for tool calls. 2026-03-27 22:01:08 +08:00
jxxghp
60e1e3c173 Merge remote-tracking branch 'origin/v2' into v2 2026-03-27 21:55:19 +08:00
jxxghp
f859d99d91 fix current_date 2026-03-27 21:55:09 +08:00
jxxghp
31640b780c 更新 __init__.py 2026-03-27 21:50:19 +08:00
jxxghp
aaeb4d2634 fix verbose_spec 2026-03-27 21:45:50 +08:00
jxxghp
75d4c0153c v2.9.21 2026-03-27 21:05:04 +08:00
jxxghp
8d7ff2bd1d feat(agent): 新增AI_AGENT_VERBOSE开关,控制工具调用过程回复及提示词输出 2026-03-27 20:12:01 +08:00
jxxghp
c3e96ae73f 更新 version.py 2026-03-27 18:06:54 +08:00
developer-wlj
d8c86069f2 fix(agent): 解决内存文件读取编码问题
- 为文件读取操作明确指定 UTF-8 编码
- 防止因默认编码导致的字符读取错误
- 确保跨平台环境下的文件内容一致性
2026-03-27 11:52:07 +08:00
jxxghp
a25c709927 新增agent删除下载历史记录工具 2026-03-27 11:50:46 +08:00
jxxghp
d7c62fb55a feat(agent): 支持Slack和Discord渠道的流式输出功能
- 为Slack添加MESSAGE_EDITING能力
- 为Slack添加edit_message和send_direct_message方法
- 为Discord添加edit_message和send_direct_message方法
- 修改Discord send_msg返回(bool, message_id)元组以支持流式输出
2026-03-27 07:02:50 +08:00
jxxghp
27cc559c86 更新 memory.py 2026-03-26 22:33:03 +08:00
jxxghp
e7d14691df 优化记忆结构 2026-03-26 22:29:09 +08:00
jxxghp
20387a0085 更新 version.py 2026-03-26 17:31:30 +08:00
jxxghp
740b0a1396 fix 2026-03-26 12:42:54 +08:00
jxxghp
7d0c790185 fix: agent过滤模型思考/推理内容,不输出thinking到用户 2026-03-26 12:37:45 +08:00
jxxghp
a12147d0f5 style: 调整默认回复风格,简洁干练但保留适度的俏皮和emoji 2026-03-26 07:45:08 +08:00
jxxghp
213a298813 feat: 记忆为空时自动引导用户设置偏好;优化默认回复风格为简约直接 2026-03-26 07:30:18 +08:00
DDSRem
1acf78342c feat: tmdbid优先识别,同ID电影/电视剧通过元数据自动消歧
当名称中包含 {tmdbid=xxx} 时,优先使用tmdbid直接查询TMDB,不再回退到标题搜索。
当同一tmdbid同时存在电影和电视剧时,通过标题、年份、类型等元数据自动消歧。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 06:45:17 +08:00
jxxghp
c85d3adb34 refactor: 活动日志摘要改用 LLM 总结替代文本截取 2026-03-26 03:48:16 +08:00
jxxghp
83bf59dd4d feat: 新增 ActivityLogMiddleware,自动记录每次交互的活动日志并注入系统提示词 2026-03-26 03:32:20 +08:00
jxxghp
d5d6442e1d feat: 新增 moviepilot-api 技能,支持全量 REST API 调用;技能中间件自动同步内置技能到用户目录 2026-03-26 03:10:30 +08:00
jxxghp
a1fa469026 feat: 新增插件相关agent工具(查询插件、查询插件能力、运行插件命令) 2026-03-26 02:45:03 +08:00
jxxghp
4b4b808b76 feat: 流式输出消息超长时自动分段发送,消息长度限制纳入渠道能力管理 2026-03-26 01:56:11 +08:00
jxxghp
a6f16dcf8f feat: 同一会话消息排队顺序处理,不同会话互不影响 2026-03-25 22:01:35 +08:00
jxxghp
c822782910 更新 version.py 2026-03-25 18:35:09 +08:00
jxxghp
e598d5edc4 fix: AI_AGENT_JOB_INTERVAL 默认为0 2026-03-25 18:28:44 +08:00
jxxghp
d38b6dfc0a fix: 优化心跳提示词,后台任务只生成执行结果摘要 2026-03-25 18:18:34 +08:00
jxxghp
0a4091d93c fix: 后台任务使用非流式执行,仅发送模型最后一条回复 2026-03-25 18:15:19 +08:00
jxxghp
0399ab73cf feat: 后台任务(定时唤醒)跳过流式输出,仅广播最终结果 2026-03-25 17:10:48 +08:00
jxxghp
940cececf4 fix: 修复 channel 为空时系统提示词 markdown_spec 占位符未替换导致 KeyError 2026-03-25 13:17:41 +08:00
jxxghp
94c75eb1c7 feat: 智能体增加定时任务(Jobs)管理和心跳唤醒机制
- 新增 JobsMiddleware 中间件,支持通过 JOB.md 文件管理长期/重复性任务
- 智能体可创建一次性(once)和重复性(recurring)任务,自动跟踪执行状态
- 新增心跳唤醒机制,定时调度器周期性唤醒智能体检查并执行待处理任务
- 新增 AI_AGENT_JOB_INTERVAL 配置项控制检查间隔,默认24小时
- 每次心跳使用独立会话,执行完毕后清理资源
2026-03-25 13:02:20 +08:00
DDSRem
de4dbf283b feat: 文件名为辅助中文标签时使用父目录标题识别
当文件名(stem)为纯中文压制/字幕辅助标签(如"简英双语特效")且父目录包含
拉丁片名时,清空文件元数据的标题信息,改由父目录标题合并填充,避免识别失败。

新增 infopath 模块集中管理辅助标签判断逻辑与关键词正则。
2026-03-25 09:27:52 +08:00
jxxghp
10807a6fb7 fix: build actions 2026-03-25 08:43:04 +08:00
jxxghp
04b8475761 ci: 优化发布脚本,自动生成分类更新日志 2026-03-25 07:12:17 +08:00
jxxghp
e6e50d7f0a fix: 修复Agent流式输出时回复消息未记录到数据库的问题 2026-03-25 07:01:17 +08:00
33 changed files with 4437 additions and 630 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -14,6 +14,9 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- name: Release version
id: release_version
@@ -66,6 +69,98 @@ jobs:
cache-from: type=gha, scope=${{ github.workflow }}-docker
cache-to: type=gha, scope=${{ github.workflow }}-docker
- name: Generate Changelog
id: changelog
run: |
# 获取上一个 tag排除当前版本的 tag
PREVIOUS_TAG=$(git tag -l 'v*' --sort=-v:refname | grep -v "^v${{ env.app_version }}$" | head -n 1)
echo "Previous tag: $PREVIOUS_TAG"
# 使用 || 作为分隔符,同时获取 commit 消息和作者 GitHub 用户名
if [ -z "$PREVIOUS_TAG" ]; then
COMMITS=$(git log --pretty=format:"%s||%an" HEAD)
else
COMMITS=$(git log --pretty=format:"%s||%an" ${PREVIOUS_TAG}..HEAD)
fi
# 分类收集 commit 消息(使用关联数组去重)
declare -A SEEN
FEATURES=""
FIXES=""
OTHERS=""
while IFS= read -r line; do
# 跳过空行
if [ -z "$line" ]; then
continue
fi
# 分离 commit 消息和作者
msg=$(echo "$line" | sed 's/||[^|]*$//')
author=$(echo "$line" | sed 's/.*||//')
# 跳过 Merge commit 和版本更新 commit
if echo "$msg" | grep -qE "^Merge pull request|^Merge branch|^更新 version"; then
continue
fi
# 按 Conventional Commits 前缀分类
if echo "$msg" | grep -qiE "^feat(\(.+\))?:"; then
desc=$(echo "$msg" | sed -E 's/^feat(\([^)]*\))?:\s*//')
category="FEATURES"
elif echo "$msg" | grep -qiE "^fix(\(.+\))?:"; then
desc=$(echo "$msg" | sed -E 's/^fix(\([^)]*\))?:\s*//')
category="FIXES"
elif echo "$msg" | grep -qiE "^(docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?:"; then
desc=$(echo "$msg" | sed -E 's/^(docs|style|refactor|perf|test|build|ci|chore|revert)(\([^)]*\))?:\s*//')
category="OTHERS"
else
desc="$msg"
category="OTHERS"
fi
# 使用 "分类+描述" 作为去重的 key跳过重复内容
dedup_key="${category}::${desc}"
if [ -n "${SEEN[$dedup_key]+x}" ]; then
continue
fi
SEEN[$dedup_key]=1
# 添加 by @author 引用
entry="- ${desc} by @${author}"
case "$category" in
FEATURES) FEATURES="${FEATURES}${entry}\n" ;;
FIXES) FIXES="${FIXES}${entry}\n" ;;
OTHERS) OTHERS="${OTHERS}${entry}\n" ;;
esac
done <<< "$COMMITS"
# 组装 changelog
CHANGELOG=""
if [ -n "$FEATURES" ]; then
CHANGELOG="${CHANGELOG}### ✨ 新功能\n\n${FEATURES}\n"
fi
if [ -n "$FIXES" ]; then
CHANGELOG="${CHANGELOG}### 🐛 修复\n\n${FIXES}\n"
fi
if [ -n "$OTHERS" ]; then
CHANGELOG="${CHANGELOG}### 🔧 其他\n\n${OTHERS}\n"
fi
# 添加版本对比链接
if [ -n "$PREVIOUS_TAG" ]; then
CHANGELOG="${CHANGELOG}**完整更新记录**: https://github.com/${{ github.repository }}/compare/${PREVIOUS_TAG}...v${{ env.app_version }}"
fi
# 写入环境变量
echo "CHANGELOG<<EOF" >> $GITHUB_ENV
echo -e "$CHANGELOG" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Get existing release body
id: get_release_body
continue-on-error: true
@@ -73,9 +168,17 @@ jobs:
release_body=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://api.github.com/repos/${{ github.repository }}/releases/tags/v${{ env.app_version }}" | \
jq -r '.body // ""')
echo "RELEASE_BODY<<EOF" >> $GITHUB_ENV
echo "$release_body" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
# 如果已有手动编写的 release body,则保留;否则使用自动生成的 changelog
if [ -n "$release_body" ] && [ "$release_body" != "null" ] && [ "$release_body" != "" ]; then
echo "RELEASE_BODY<<EOF" >> $GITHUB_ENV
echo "$release_body" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
else
echo "RELEASE_BODY<<EOF" >> $GITHUB_ENV
echo "${{ env.CHANGELOG }}" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
fi
- name: Delete Release
uses: dev-drprasad/delete-tag-and-release@v1.1

View File

@@ -1,7 +1,9 @@
import asyncio
import re
import traceback
from time import strftime
from typing import Dict, List
import uuid
from dataclasses import dataclass
from typing import Callable, Dict, List, Optional
from langchain.agents import create_agent
from langchain.agents.middleware import (
@@ -16,6 +18,8 @@ from langgraph.checkpoint.memory import InMemorySaver
from app.agent.callback import StreamingHandler
from app.agent.memory import memory_manager
from app.agent.middleware.activity_log import ActivityLogMiddleware
from app.agent.middleware.jobs import JobsMiddleware
from app.agent.middleware.memory import MemoryMiddleware
from app.agent.middleware.patch_tool_calls import PatchToolCallsMiddleware
from app.agent.middleware.skills import SkillsMiddleware
@@ -54,6 +58,13 @@ class MoviePilotAgent:
# 流式token管理
self.stream_handler = StreamingHandler()
@property
def is_background(self) -> bool:
"""
是否为后台任务模式(无渠道信息,如定时唤醒)
"""
return not self.channel and not self.source
@staticmethod
def _initialize_llm():
"""
@@ -61,6 +72,39 @@ class MoviePilotAgent:
"""
return LLMHelper.get_llm(streaming=True)
@staticmethod
def _extract_text_content(content) -> str:
"""
从消息内容中提取纯文本,过滤掉思考/推理类型的内容块。
:param content: 消息内容,可能是字符串或内容块列表
:return: 纯文本内容
"""
if not content:
return ""
# 跳过思考/推理类型的内容块
if isinstance(content, list):
text_parts = []
for block in content:
if isinstance(block, str):
text_parts.append(block)
elif isinstance(block, dict):
# 优先检查 thought 标志LangChain Google GenAI 方案)
if block.get("thought"):
continue
if block.get("type") in (
"thinking",
"reasoning_content",
"reasoning",
"thought",
):
continue
if block.get("type") == "text":
text_parts.append(block.get("text", ""))
else:
text_parts.append(str(block))
return "".join(text_parts)
return str(content)
def _initialize_tools(self) -> List:
"""
初始化工具列表
@@ -80,9 +124,7 @@ class MoviePilotAgent:
"""
try:
# 系统提示词
system_prompt = prompt_manager.get_agent_prompt(
channel=self.channel
).format(current_date=strftime("%Y-%m-%d"))
system_prompt = prompt_manager.get_agent_prompt(channel=self.channel)
# LLM 模型(用于 agent 执行)
llm = self._initialize_llm()
@@ -95,10 +137,17 @@ class MoviePilotAgent:
# Skills
SkillsMiddleware(
sources=[str(settings.CONFIG_PATH / "agent" / "skills")],
bundled_skills_dir=str(settings.ROOT_PATH / "skills"),
),
# 记忆管理
MemoryMiddleware(
sources=[str(settings.CONFIG_PATH / "agent" / "MEMORY.md")]
# Jobs 任务管理
JobsMiddleware(
sources=[str(settings.CONFIG_PATH / "agent" / "jobs")],
),
# 记忆管理(自动扫描 agent 目录下所有 .md 文件)
MemoryMiddleware(memory_dir=str(settings.CONFIG_PATH / "agent")),
# 活动日志
ActivityLogMiddleware(
activity_dir=str(settings.CONFIG_PATH / "agent" / "activity"),
),
# 上下文压缩
SummarizationMiddleware(model=llm, trigger=("fraction", 0.85)),
@@ -149,11 +198,87 @@ class MoviePilotAgent:
await self.send_agent_message(error_message)
return error_message
async def _stream_agent_tokens(
self, agent, messages: dict, config: dict, on_token: Callable[[str], None]
):
"""
流式运行智能体过滤工具调用token和思考内容将模型生成的内容通过回调输出。
:param agent: LangGraph Agent 实例
:param messages: Agent 输入消息
:param config: Agent 运行配置
:param on_token: 收到有效 token 时的回调
"""
in_think_tag = False
buffer = ""
async for chunk in agent.astream(
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 buffer and not in_think_tag:
on_token(buffer)
async def _execute_agent(self, messages: List[BaseMessage]):
"""
调用 LangGraph Agent通过 astream_events 流式获取 token
同时用 UsageMetadataCallbackHandler 统计 token 用量。
调用 LangGraph Agent通过 astream 流式获取 token
支持流式输出:在支持消息编辑的渠道上实时推送 token。
后台任务模式(无渠道信息):不进行流式输出,仅广播最终结果。
"""
try:
# Agent运行配置
@@ -166,42 +291,66 @@ class MoviePilotAgent:
# 创建智能体
agent = self._create_agent()
# 启动流式输出(内部会检查渠道是否支持消息编辑)
await self.stream_handler.start_streaming(
channel=self.channel,
source=self.source,
user_id=self.user_id,
username=self.username,
)
# 流式运行智能体
async for chunk in agent.astream(
if self.is_background:
# 后台任务模式非流式执行等待完成后只取最后一条AI回复
await agent.ainvoke(
{"messages": messages},
stream_mode="messages",
config=agent_config,
subgraphs=False,
version="v2",
):
# 处理流式token过滤工具调用token只保留模型生成的内容
if chunk["type"] == "messages":
token, metadata = chunk["data"]
if (
token
and hasattr(token, "tool_call_chunks")
and not token.tool_call_chunks
):
if token.content:
self.stream_handler.emit(token.content)
)
# 停止流式输出,返回是否已通过流式编辑发送了所有内容
all_sent_via_stream = await self.stream_handler.stop_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 not all_sent_via_stream:
# 流式输出未能发送全部内容(渠道不支持编辑,或发送失败)
# 通过常规方式发送剩余内容
remaining_text = await self.stream_handler.take()
if remaining_text:
await self.send_agent_message(remaining_text)
# 后台任务仅广播最终回复,带标题
if final_text:
await self.send_agent_message(final_text, title="MoviePilot助手")
else:
# 正常渠道模式:启动流式输出
await self.stream_handler.start_streaming(
channel=self.channel,
source=self.source,
user_id=self.user_id,
username=self.username,
)
# 流式运行智能体token 直接推送到 stream_handler
await self._stream_agent_tokens(
agent=agent,
messages={"messages": messages},
config=agent_config,
on_token=self.stream_handler.emit,
)
# 停止流式输出,返回是否已通过流式编辑发送了所有内容及最终文本
(
all_sent_via_stream,
streamed_text,
) = await self.stream_handler.stop_streaming()
if not all_sent_via_stream:
# 流式输出未能发送全部内容(渠道不支持编辑,或发送失败)
# 通过常规方式发送剩余内容
remaining_text = await self.stream_handler.take()
if remaining_text:
await self.send_agent_message(remaining_text)
elif streamed_text:
# 流式输出已发送全部内容,但未记录到数据库,补充保存消息记录
await self._save_agent_message_to_db(streamed_text)
# 保存消息
memory_manager.save_agent_messages(
@@ -211,15 +360,15 @@ class MoviePilotAgent:
)
except asyncio.CancelledError:
# 确保取消时也停止流式输出
await self.stream_handler.stop_streaming()
logger.info(f"Agent执行被取消: session_id={self.session_id}")
return "任务已取消", {}
except Exception as e:
# 确保异常时也停止流式输出
await self.stream_handler.stop_streaming()
logger.error(f"Agent执行失败: {e} - {traceback.format_exc()}")
return str(e), {}
finally:
# 确保停止流式输出
if not self.is_background:
await self.stream_handler.stop_streaming()
async def send_agent_message(self, message: str, title: str = ""):
"""
@@ -236,6 +385,26 @@ class MoviePilotAgent:
)
)
async def _save_agent_message_to_db(self, message: str, title: str = ""):
"""
仅保存Agent回复消息到数据库和SSE队列不重新发送到渠道
用于流式输出场景:消息已通过 send_direct_message/edit_message 发送给用户,
但未记录到数据库中,此方法补充保存消息历史记录。
"""
chain = AgentChain()
notification = Notification(
channel=self.channel,
source=self.source,
userid=self.user_id,
username=self.username,
title=title,
text=message,
)
# 保存到SSE消息队列供前端展示
chain.messagehelper.put(notification, role="user", title=title)
# 保存到数据库
await chain.messageoper.async_add(**notification.model_dump())
async def cleanup(self):
"""
清理智能体资源
@@ -243,13 +412,32 @@ class MoviePilotAgent:
logger.info(f"MoviePilot智能体已清理: session_id={self.session_id}")
@dataclass
class _MessageTask:
"""
待处理的消息任务
"""
session_id: str
user_id: str
message: str
channel: Optional[str] = None
source: Optional[str] = None
username: Optional[str] = None
class AgentManager:
"""
AI智能体管理器
同一会话的消息按顺序排队处理,不同会话之间互不影响。
"""
def __init__(self):
self.active_agents: Dict[str, MoviePilotAgent] = {}
# 每个会话的消息队列
self._session_queues: Dict[str, asyncio.Queue] = {}
# 每个会话的worker任务
self._session_workers: Dict[str, asyncio.Task] = {}
@staticmethod
async def initialize():
@@ -263,6 +451,17 @@ class AgentManager:
关闭管理器
"""
await memory_manager.close()
# 取消所有会话worker
for task in self._session_workers.values():
task.cancel()
# 等待所有worker结束
for session_id, task in self._session_workers.items():
try:
await task
except asyncio.CancelledError:
pass
self._session_workers.clear()
self._session_queues.clear()
for agent in self.active_agents.values():
await agent.cleanup()
self.active_agents.clear()
@@ -277,36 +476,133 @@ class AgentManager:
username: str = None,
) -> str:
"""
处理用户消息
处理用户消息:将消息放入会话队列,按顺序依次处理。
同一会话的消息排队等待,不同会话之间互不影响。
"""
task = _MessageTask(
session_id=session_id,
user_id=user_id,
message=message,
channel=channel,
source=source,
username=username,
)
# 获取或创建会话队列
if session_id not in self._session_queues:
self._session_queues[session_id] = asyncio.Queue()
queue = self._session_queues[session_id]
queue_size = queue.qsize()
# 如果队列中已有等待的消息,通知用户消息已排队
if queue_size > 0 or (
session_id in self._session_workers
and not self._session_workers[session_id].done()
):
logger.info(
f"会话 {session_id} 有任务正在处理,消息已排队等待 "
f"(队列中待处理: {queue_size} 条)"
)
# 放入队列
await queue.put(task)
# 确保该会话有一个worker在运行
if (
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)
)
return ""
async def _session_worker(self, session_id: str):
"""
会话消息处理worker从队列中逐条取出消息并处理。
处理完当前消息后才会处理下一条,确保同一会话的消息顺序执行。
"""
queue = self._session_queues.get(session_id)
if not queue:
return
try:
while True:
try:
# 等待消息超时后自动退出worker
task = await asyncio.wait_for(queue.get(), timeout=60.0)
except asyncio.TimeoutError:
# 队列空闲超时退出worker
logger.debug(f"会话 {session_id} 的消息队列空闲worker退出")
break
try:
await self._process_message_internal(task)
except Exception as e:
logger.error(f"处理会话 {session_id} 的消息失败: {e}")
finally:
queue.task_done()
except asyncio.CancelledError:
logger.info(f"会话 {session_id} 的worker被取消")
finally:
# 清理已完成的worker记录
self._session_workers.pop(session_id, None) # noqa
# 如果队列为空,清理队列
if (
session_id in self._session_queues
and self._session_queues[session_id].empty()
):
self._session_queues.pop(session_id, None)
async def _process_message_internal(self, task: _MessageTask):
"""
实际处理单条消息
"""
session_id = task.session_id
if session_id not in self.active_agents:
logger.info(
f"创建新的AI智能体实例session_id: {session_id}, user_id: {user_id}"
f"创建新的AI智能体实例session_id: {session_id}, user_id: {task.user_id}"
)
agent = MoviePilotAgent(
session_id=session_id,
user_id=user_id,
channel=channel,
source=source,
username=username,
user_id=task.user_id,
channel=task.channel,
source=task.source,
username=task.username,
)
self.active_agents[session_id] = agent
else:
agent = self.active_agents[session_id]
agent.user_id = user_id
if channel:
agent.channel = channel
if source:
agent.source = source
if username:
agent.username = username
agent.user_id = task.user_id
if task.channel:
agent.channel = task.channel
if task.source:
agent.source = task.source
if task.username:
agent.username = task.username
return await agent.process(message)
return await agent.process(task.message)
async def clear_session(self, session_id: str, user_id: str):
"""
清空会话
"""
# 取消该会话的worker
if session_id in self._session_workers:
self._session_workers[session_id].cancel()
try:
await self._session_workers[session_id]
except asyncio.CancelledError:
pass
await self._session_workers.pop(session_id, None)
# 清理队列
self._session_queues.pop(session_id, None)
# 清理agent
if session_id in self.active_agents:
agent = self.active_agents[session_id]
await agent.cleanup()
@@ -314,6 +610,62 @@ class AgentManager:
memory_manager.clear_memory(session_id, user_id)
logger.info(f"会话 {session_id} 的记忆已清空")
async def heartbeat_check_jobs(self):
"""
心跳唤醒检查并执行待处理的定时任务Jobs
由定时调度器周期性调用,每次使用独立的会话避免上下文干扰。
"""
try:
# 每次使用唯一的 session_id避免共享上下文
session_id = f"__agent_heartbeat_{uuid.uuid4().hex[:12]}__"
user_id = settings.SUPERUSER
logger.info("智能体心跳唤醒:开始检查待处理任务...")
# 英文提示词,便于大模型理解
heartbeat_message = (
"[System Heartbeat] Check all jobs in your jobs directory and process pending tasks:\n"
"1. List all jobs with status 'pending' or 'in_progress'\n"
"2. For 'recurring' jobs, check 'last_run' to determine if it's time to run again\n"
"3. For 'once' jobs with status 'pending', execute them now\n"
"4. After executing each job, update its status, 'last_run' time, and execution log in the JOB.md file\n"
"5. If there are no pending jobs, do NOT generate any response\n\n"
"IMPORTANT: This is a background system task, NOT a user conversation. "
"Your final response will be broadcast as a notification. "
"Only output a brief completion summary listing each executed job and its result. "
"Do NOT include greetings, explanations, or conversational text. "
"If no jobs were executed, output nothing. "
"Respond in Chinese (中文)."
)
await self.process_message(
session_id=session_id,
user_id=user_id,
message=heartbeat_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("智能体心跳唤醒:任务检查完成")
# 心跳会话用完即弃,清理资源
await self.clear_session(session_id, user_id)
except Exception as e:
logger.error(f"智能体心跳唤醒失败: {e}")
# 全局智能体管理器实例
agent_manager = AgentManager()

View File

@@ -1,6 +1,6 @@
import asyncio
import threading
from typing import Optional
from typing import Optional, Tuple
from app.chain import ChainBase
from app.log import logger
@@ -29,6 +29,7 @@ class StreamingHandler:
3. 定时器周期性调用 _flush()
- 第一次有内容时发送新消息(通过 send_direct_message 获取 message_id
- 后续有新内容时编辑同一条消息(通过 edit_message
- 当消息长度接近渠道限制时,冻结当前消息并发送新消息继续输出
4. 工具调用时:
- 流式渠道:工具消息直接 emit() 追加到 buffer与 Agent 文字合并为同一条流式消息
- 非流式渠道:调用 take() 取出已积累的文字,与工具消息合并独立发送
@@ -49,6 +50,10 @@ class StreamingHandler:
self._message_response: Optional[MessageResponse] = None
# 已发送给用户的文本(用于追踪增量)
self._sent_text = ""
# 当前消息的起始偏移量buffer 中属于当前消息的起始位置)
self._msg_start_offset = 0
# 当前渠道的单条消息最大长度0 表示不限制)
self._max_message_length = 0
# 消息发送所需的上下文信息
self._channel: Optional[str] = None
self._source: Optional[str] = None
@@ -91,6 +96,20 @@ class StreamingHandler:
self._buffer = ""
self._sent_text = ""
self._message_response = None
self._msg_start_offset = 0
def reset(self):
"""
重置缓冲区,清空已发送的文本从头更新,但保持消息编辑能力。
与 clear 的区别:
- clear完全重置所有状态后续会开新消息
- reset只清空buffer保留消息编辑状态后续继续编辑同一条消息
"""
with self._lock:
self._buffer = ""
self._sent_text = ""
self._msg_start_offset = 0
async def start_streaming(
self,
@@ -122,19 +141,31 @@ class StreamingHandler:
self._streaming_enabled = True
self._sent_text = ""
self._message_response = None
self._msg_start_offset = 0
# 从渠道能力中获取单条消息最大长度
try:
channel_enum = MessageChannel(self._channel)
self._max_message_length = ChannelCapabilityManager.get_max_message_length(
channel_enum
)
except (ValueError, KeyError):
self._max_message_length = 0
# 启动异步定时刷新任务
self._flush_task = asyncio.create_task(self._flush_loop())
logger.debug("流式输出已启动")
async def stop_streaming(self) -> bool:
async def stop_streaming(self) -> Tuple[bool, str]:
"""
停止流式输出。执行最后一次刷新确保所有内容都已发送。
:return: 是否已经通过流式编辑将最终完整内容发送给了用户
True 表示调用方无需再额外发送消息)
:return: (all_sent, final_text)
all_sent: 是否已经通过流式编辑将最终完整内容发送给了用户
True 表示调用方无需再额外发送消息)
final_text: 流式发送的完整文本内容(用于调用方保存消息记录)
"""
if not self._streaming_enabled:
return False
return False, ""
self._streaming_enabled = False
@@ -146,18 +177,23 @@ class StreamingHandler:
# 检查是否所有缓冲内容都已发送
with self._lock:
# 当前消息的文本 = buffer 中从 _msg_start_offset 开始的部分
current_msg_text = self._buffer[self._msg_start_offset :]
all_sent = (
self._message_response is not None
and self._sent_text
and self._buffer == self._sent_text
and current_msg_text == self._sent_text
)
# 保留最终文本用于返回(返回完整 buffer 内容,包含所有分段消息)
final_text = self._buffer if all_sent else ""
# 重置状态
self._sent_text = ""
self._message_response = None
self._msg_start_offset = 0
if all_sent:
# 所有内容已通过流式发送,清空缓冲区
self._buffer = ""
return all_sent
return all_sent, final_text
def _can_stream(self) -> bool:
"""
@@ -204,9 +240,11 @@ class StreamingHandler:
将当前缓冲区内容刷新到用户消息
- 如果还没有发送过消息先发送一条新消息并记录message_id
- 如果已经发送过消息,编辑该消息为最新的完整内容
- 如果当前消息内容超过长度限制,冻结当前消息并发送新消息继续输出
"""
with self._lock:
current_text = self._buffer
# 当前消息的文本 = buffer 中从 _msg_start_offset 开始的部分
current_text = self._buffer[self._msg_start_offset :]
if not current_text or current_text == self._sent_text:
# 没有新内容需要刷新
return
@@ -239,25 +277,64 @@ class StreamingHandler:
)
self._streaming_enabled = False
else:
# 后续更新:编辑已有消息
try:
channel_enum = MessageChannel(self._channel)
except (ValueError, KeyError):
return
success = chain.edit_message(
channel=channel_enum,
source=self._message_response.source,
message_id=self._message_response.message_id,
chat_id=self._message_response.chat_id,
text=current_text,
title=self._title,
)
if success:
# 检查当前消息内容是否超过长度限制
if (
self._max_message_length
and len(current_text) > self._max_message_length
):
# 消息过长,冻结当前消息(保持最后一次成功编辑的内容)
# 将 offset 移动到已发送文本之后,开启新消息
logger.debug(
f"流式消息长度 {len(current_text)} 超过限制 {self._max_message_length},启用新消息"
)
with self._lock:
self._sent_text = current_text
self._msg_start_offset += len(self._sent_text)
current_text = self._buffer[self._msg_start_offset :]
self._message_response = None
self._sent_text = ""
# 如果偏移后还有新内容,立即发送为新消息
if current_text:
response = chain.send_direct_message(
Notification(
channel=self._channel,
source=self._source,
userid=self._user_id,
username=self._username,
title=self._title,
text=current_text,
)
)
if response and response.success and response.message_id:
self._message_response = response
with self._lock:
self._sent_text = current_text
logger.debug(
f"流式输出新消息已发送: message_id={response.message_id}"
)
else:
logger.debug("流式输出新消息发送失败,降级为非流式输出")
self._streaming_enabled = False
else:
logger.debug("流式输出消息编辑失败")
# 后续更新:编辑已有消息
try:
channel_enum = MessageChannel(self._channel)
except (ValueError, KeyError):
return
success = chain.edit_message(
channel=channel_enum,
source=self._message_response.source,
message_id=self._message_response.message_id,
chat_id=self._message_response.chat_id,
text=current_text,
title=self._title,
)
if success:
with self._lock:
self._sent_text = current_text
else:
logger.debug("流式输出消息编辑失败")
except Exception as e:
logger.error(f"流式输出刷新失败: {e}")

View File

@@ -0,0 +1,406 @@
"""
活动日志中间件 - 自动记录 Agent 每次交互的操作摘要。
按日期存储在 CONFIG_PATH/agent/activity/YYYY-MM-DD.md 中,
每次 Agent 执行完毕后自动调用 LLM 对本轮对话生成简洁的活动摘要,
并在每次 Agent 启动时加载近几天的活动日志注入系统提示词。
"""
import re
from collections.abc import Awaitable, Callable
from datetime import datetime, timedelta
from typing import Annotated, Any, NotRequired, TypedDict
from anyio import Path as AsyncPath
from langchain.agents.middleware.types import (
AgentMiddleware,
AgentState,
ContextT,
ModelRequest,
ModelResponse,
PrivateStateAttr, # noqa
ResponseT,
)
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from langgraph.runtime import Runtime
from app.agent.middleware.utils import append_to_system_message
from app.log import logger
# 活动日志保留天数
DEFAULT_RETENTION_DAYS = 7
# 注入系统提示词时加载的天数
PROMPT_LOAD_DAYS = 3
# 每日日志文件最大大小 (256KB)
MAX_LOG_FILE_SIZE = 256 * 1024
# 提取本轮对话上下文的最大字符数(避免过长的对话消耗太多 token
MAX_CONTEXT_FOR_SUMMARY = 4000
# LLM 总结的提示词
SUMMARY_PROMPT = """请根据以下 AI 助手与用户的对话记录生成一条简洁的活动摘要中文一句话不超过80字
摘要应包含:用户的需求是什么、助手做了什么、结果如何。
只输出摘要内容,不要加任何前缀、标点序号或解释。
对话记录:
{conversation}"""
class ActivityLogState(AgentState):
"""ActivityLogMiddleware 的状态模型。"""
activity_log_contents: NotRequired[Annotated[dict[str, str], PrivateStateAttr]]
"""将日期字符串映射到日志内容的字典。标记为私有,不包含在最终代理状态中。"""
class ActivityLogStateUpdate(TypedDict):
"""ActivityLogMiddleware 的状态更新。"""
activity_log_contents: dict[str, str]
def _extract_last_round(messages: list) -> list | None:
"""从完整消息列表中提取最后一轮交互。
从最后一条 HumanMessage 到消息末尾即为本轮交互。
参数:
messages: Agent 执行后的完整消息列表。
返回:
本轮交互的消息子列表,如果无有效交互则返回 None。
"""
if not messages:
return None
# 找到最后一条用户消息的索引
last_human_idx = None
for i in range(len(messages) - 1, -1, -1):
if isinstance(messages[i], HumanMessage) and messages[i].content:
last_human_idx = i
break
if last_human_idx is None:
return None
round_messages = messages[last_human_idx:]
# 检查是否为系统心跳消息
user_msg = round_messages[0]
user_content = (
user_msg.content if isinstance(user_msg.content, str) else str(user_msg.content)
)
if user_content.strip().startswith("[System Heartbeat]"):
return None
return round_messages
def _format_conversation_for_summary(round_messages: list) -> str:
"""将本轮对话消息格式化为文本,供 LLM 总结。
参数:
round_messages: 本轮交互的消息列表。
返回:
格式化后的对话文本。
"""
lines = []
total_len = 0
for msg in round_messages:
if isinstance(msg, HumanMessage):
content = msg.content if isinstance(msg.content, str) else str(msg.content)
line = f"用户: {content}"
elif isinstance(msg, AIMessage):
if hasattr(msg, "tool_calls") and msg.tool_calls:
tool_names = [
tc["name"]
for tc in msg.tool_calls
if isinstance(tc, dict) and "name" in tc
]
line = f"助手调用工具: {', '.join(tool_names)}"
elif msg.content:
content = (
msg.content if isinstance(msg.content, str) else str(msg.content)
)
line = f"助手: {content}"
else:
continue
elif isinstance(msg, ToolMessage):
content = msg.content if isinstance(msg.content, str) else str(msg.content)
# 工具返回可能很长,截断
if len(content) > 200:
content = content[:200] + "..."
line = f"工具返回: {content}"
else:
continue
# 控制总长度
if total_len + len(line) > MAX_CONTEXT_FOR_SUMMARY:
lines.append("...(后续对话省略)")
break
lines.append(line)
total_len += len(line)
return "\n".join(lines)
async def _summarize_with_llm(conversation_text: str) -> str | None:
"""调用 LLM 对对话文本生成活动摘要。
参数:
conversation_text: 格式化后的对话文本。
返回:
LLM 生成的摘要字符串,失败时返回 None。
"""
try:
from app.helper.llm import LLMHelper
llm = LLMHelper.get_llm(streaming=False)
prompt = SUMMARY_PROMPT.format(conversation=conversation_text)
response = await llm.ainvoke(prompt)
summary = response.content.strip()
# 清理模型可能输出的前缀(如 "摘要:" "总结:"
summary = re.sub(r"^(摘要|总结|活动记录)[:]\s*", "", summary)
return summary if summary else None
except Exception as e:
logger.debug("LLM summarization failed: %s", e)
return None
ACTIVITY_LOG_SYSTEM_PROMPT = """<activity_log>
{activity_log}
</activity_log>
<activity_log_guidelines>
The above <activity_log> contains a record of your recent interactions with the user, automatically maintained by the system.
**How to use this information:**
- Reference past activities when relevant to provide continuity (e.g., "之前帮你订阅了《XXX》现在有更新了")
- Use activity history to understand ongoing tasks and user patterns
- When the user asks "你之前帮我做了什么" or similar questions, refer to this log
- Activity logs are automatically recorded after each interaction - you do NOT need to manually update them
**What is automatically logged:**
- Each user interaction: what was asked, which tools were used, and the outcome
- Timestamps for all activities
- The log is organized by date for easy reference
**Important:**
- Activity logs are READ-ONLY from your perspective - the system manages them automatically
- Do not attempt to edit or write to activity log files
- For long-term preferences and knowledge, continue to use MEMORY.md
- Activity logs are retained for {retention_days} days and then automatically cleaned up
</activity_log_guidelines>
"""
class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, ResponseT]): # noqa
"""自动记录和加载 Agent 活动日志的中间件。
- abefore_agent: 加载近几天的活动日志
- awrap_model_call: 将活动日志注入系统提示词
- aafter_agent: 从本次对话中提取摘要并追加到当日日志文件
参数:
activity_dir: 活动日志存储目录路径。
retention_days: 日志保留天数(默认 7 天)。
prompt_load_days: 注入系统提示词时加载的天数(默认 3 天)。
"""
state_schema = ActivityLogState
def __init__(
self,
*,
activity_dir: str,
retention_days: int = DEFAULT_RETENTION_DAYS,
prompt_load_days: int = PROMPT_LOAD_DAYS,
) -> None:
self.activity_dir = activity_dir
self.retention_days = retention_days
self.prompt_load_days = prompt_load_days
def _get_log_path(self, date_str: str) -> AsyncPath:
"""获取指定日期的日志文件路径。"""
return AsyncPath(self.activity_dir) / f"{date_str}.md"
def _format_activity_log(self, contents: dict[str, str]) -> str:
"""格式化活动日志用于系统提示词注入。"""
if not contents:
return ACTIVITY_LOG_SYSTEM_PROMPT.format(
activity_log="(暂无活动记录)",
retention_days=self.retention_days,
)
# 按日期排序(最近的在前)
sorted_dates = sorted(contents.keys(), reverse=True)
sections = []
for date_str in sorted_dates:
content = contents[date_str].strip()
if content:
sections.append(f"### {date_str}\n{content}")
if not sections:
return ACTIVITY_LOG_SYSTEM_PROMPT.format(
activity_log="(暂无活动记录)",
retention_days=self.retention_days,
)
log_body = "\n\n".join(sections)
return ACTIVITY_LOG_SYSTEM_PROMPT.format(
activity_log=log_body,
retention_days=self.retention_days,
)
async def _load_recent_logs(self) -> dict[str, str]:
"""加载近几天的活动日志。"""
contents: dict[str, str] = {}
today = datetime.now().date()
for i in range(self.prompt_load_days):
date = today - timedelta(days=i)
date_str = date.strftime("%Y-%m-%d")
log_path = self._get_log_path(date_str)
if await log_path.exists():
try:
content = await log_path.read_text(encoding="utf-8")
contents[date_str] = content
logger.debug("Loaded activity log for %s", date_str)
except Exception as e:
logger.warning("Failed to load activity log %s: %s", date_str, e)
return contents
async def _append_activity(self, summary: str) -> None:
"""将一条活动记录追加到当日日志文件。"""
today_str = datetime.now().strftime("%Y-%m-%d")
now_str = datetime.now().strftime("%H:%M")
log_path = self._get_log_path(today_str)
# 确保目录存在
dir_path = AsyncPath(self.activity_dir)
if not await dir_path.exists():
await dir_path.mkdir(parents=True, exist_ok=True)
# 检查文件大小
if await log_path.exists():
stat = await log_path.stat()
if stat.st_size >= MAX_LOG_FILE_SIZE:
logger.warning(
"Activity log %s exceeds size limit (%d bytes), skipping append",
today_str,
stat.st_size,
)
return
# 追加记录
entry = f"- **{now_str}** {summary}\n"
try:
if await log_path.exists():
existing = await log_path.read_text(encoding="utf-8")
await log_path.write_text(existing + entry, encoding="utf-8")
else:
header = f"# {today_str} 活动日志\n\n"
await log_path.write_text(header + entry, encoding="utf-8")
logger.debug("Activity logged: %s", summary[:80])
except Exception as e:
logger.warning("Failed to append activity log: %s", e)
async def _cleanup_old_logs(self) -> None:
"""清理超过保留天数的旧日志文件。"""
dir_path = AsyncPath(self.activity_dir)
if not await dir_path.exists():
return
cutoff_date = datetime.now().date() - timedelta(days=self.retention_days)
date_pattern = re.compile(r"^(\d{4}-\d{2}-\d{2})\.md$")
try:
async for path in dir_path.iterdir():
if not await path.is_file():
continue
match = date_pattern.match(path.name)
if not match:
continue
try:
file_date = datetime.strptime(match.group(1), "%Y-%m-%d").date()
if file_date < cutoff_date:
await path.unlink()
logger.debug("Cleaned up old activity log: %s", path.name)
except ValueError:
continue
except Exception as e:
logger.warning("Failed to cleanup old activity logs: %s", e)
async def abefore_agent(
self, state: ActivityLogState, runtime: Runtime
) -> ActivityLogStateUpdate | None:
"""在 Agent 执行前加载近期活动日志。"""
# 如果已经加载则跳过
if "activity_log_contents" in state:
return None
contents = await self._load_recent_logs()
# 趁机清理旧日志(低频操作,不影响性能)
await self._cleanup_old_logs()
return ActivityLogStateUpdate(activity_log_contents=contents)
def modify_request(self, request: ModelRequest[ContextT]) -> ModelRequest[ContextT]:
"""将活动日志注入系统消息。"""
contents = request.state.get("activity_log_contents", {})
activity_log_prompt = self._format_activity_log(contents)
new_system_message = append_to_system_message(
request.system_message, activity_log_prompt
)
return request.override(system_message=new_system_message)
async def awrap_model_call(
self,
request: ModelRequest[ContextT],
handler: Callable[
[ModelRequest[ContextT]], Awaitable[ModelResponse[ResponseT]]
],
) -> ModelResponse[ResponseT]:
"""异步包装模型调用,注入活动日志到系统提示词。"""
modified_request = self.modify_request(request)
return await handler(modified_request)
async def aafter_agent(
self, state: ActivityLogState, runtime: Runtime
) -> dict[str, Any] | None:
"""Agent 执行完毕后,调用 LLM 对本轮对话生成摘要并追加到当日活动日志。"""
try:
messages = state.get("messages", [])
if not messages:
return None
# 提取本轮交互
round_messages = _extract_last_round(messages)
if not round_messages:
return None
# 格式化对话文本
conversation_text = _format_conversation_for_summary(round_messages)
if not conversation_text:
return None
# 调用 LLM 生成摘要
summary = await _summarize_with_llm(conversation_text)
if summary:
await self._append_activity(summary)
except Exception as e:
logger.warning("Failed to record activity: %s", e)
return None
__all__ = ["ActivityLogMiddleware"]

View File

@@ -0,0 +1,350 @@
import re
from collections.abc import Awaitable, Callable
from typing import Annotated, NotRequired, TypedDict
import yaml # noqa
from anyio import Path as AsyncPath
from langchain.agents.middleware.types import (
AgentMiddleware,
AgentState,
ContextT,
ModelRequest,
ModelResponse,
PrivateStateAttr, # noqa
ResponseT,
)
from langchain_core.runnables import RunnableConfig
from langgraph.runtime import Runtime
from app.agent.middleware.utils import append_to_system_message
from app.log import logger
# JOB.md 文件最大限制为 1MB
MAX_JOB_FILE_SIZE = 1 * 1024 * 1024
class JobMetadata(TypedDict):
"""Job 元数据。"""
path: str
"""JOB.md 文件路径。"""
id: str
"""Job 标识符(目录名)。"""
name: str
"""Job 名称。"""
description: str
"""Job 描述。"""
schedule: str
"""调度类型: once一次性/ recurring重复性"""
status: str
"""当前状态: pending / in_progress / completed / cancelled。"""
last_run: str | None
"""上次执行时间。"""
class JobsState(AgentState):
"""jobs 中间件状态。"""
jobs_metadata: NotRequired[Annotated[list[JobMetadata], PrivateStateAttr]]
"""已加载的 job 元数据列表,不传播给父 agent。"""
class JobsStateUpdate(TypedDict):
"""jobs 中间件状态更新项。"""
jobs_metadata: list[JobMetadata]
"""待合并的 job 元数据列表。"""
def _parse_job_metadata(
content: str,
job_path: str,
job_id: str,
) -> JobMetadata | None:
"""从 JOB.md 内容中解析 YAML 前言并验证元数据。"""
if len(content) > MAX_JOB_FILE_SIZE:
logger.warning(
"Skipping %s: content too large (%d bytes)", job_path, len(content)
)
return None
# 匹配 --- 分隔的 YAML 前言
frontmatter_pattern = r"^---\s*\n(.*?)\n---\s*\n"
match = re.match(frontmatter_pattern, content, re.DOTALL)
if not match:
logger.warning("Skipping %s: no valid YAML frontmatter found", job_path)
return None
frontmatter_str = match.group(1)
# 解析 YAML
try:
frontmatter_data = yaml.safe_load(frontmatter_str)
except yaml.YAMLError as e:
logger.warning("Invalid YAML in %s: %s", job_path, e)
return None
if not isinstance(frontmatter_data, dict):
logger.warning("Skipping %s: frontmatter is not a mapping", job_path)
return None
# Job 名称和描述
name = str(frontmatter_data.get("name", "")).strip()
description = str(frontmatter_data.get("description", "")).strip()
if not name:
logger.warning("Skipping %s: missing required 'name'", job_path)
return None
# 调度类型
schedule = str(frontmatter_data.get("schedule", "once")).strip().lower()
if schedule not in ("once", "recurring"):
schedule = "once"
# 状态
status = str(frontmatter_data.get("status", "pending")).strip().lower()
if status not in ("pending", "in_progress", "completed", "cancelled"):
status = "pending"
# 上次执行时间
last_run = str(frontmatter_data.get("last_run", "")).strip() or None
return JobMetadata(
id=job_id,
name=name,
description=description,
path=job_path,
schedule=schedule,
status=status,
last_run=last_run,
)
async def _alist_jobs(source_path: AsyncPath) -> list[JobMetadata]:
"""异步列出指定路径下的所有任务。
扫描包含 JOB.md 的目录并解析其元数据。
"""
jobs: list[JobMetadata] = []
if not await source_path.exists():
return []
# 查找所有任务目录(包含 JOB.md 的目录)
job_dirs: list[AsyncPath] = []
async for path in source_path.iterdir():
if await path.is_dir() and await (path / "JOB.md").is_file():
job_dirs.append(path)
if not job_dirs:
return []
# 解析 JOB.md
for job_path in job_dirs:
job_md_path = job_path / "JOB.md"
job_content = await job_md_path.read_text(encoding="utf-8")
# 解析元数据
job_metadata = _parse_job_metadata(
content=job_content,
job_path=str(job_md_path),
job_id=job_path.name,
)
if job_metadata:
jobs.append(job_metadata)
return jobs
JOBS_SYSTEM_PROMPT = """
<jobs_system>
You have a **scheduled jobs** system that allows you to track and execute long-running or recurring tasks.
**Jobs Location:** `{jobs_location}`
**Current Jobs:**
{jobs_list}
**Job File Format:**
Each job is a directory containing a `JOB.md` file with YAML frontmatter followed by task details:
```markdown
---
name: 任务名称(简短中文描述)
description: 任务的详细描述,说明要做什么
schedule: once 或 recurring
status: pending / in_progress / completed / cancelled
last_run: "YYYY-MM-DD HH:MM"(上次执行时间,可选)
---
# 任务详情
## 目标
详细描述这个任务要完成的目标。
## 执行日志
记录每次执行的情况和结果。
- **2024-01-15 10:00** - 执行了XXX操作结果成功/失败
- **2024-01-16 10:00** - 继续执行XXX...
```
**Job Lifecycle Rules:**
1. **Creating a Job**: When a user asks you to do something periodically or at a later time:
- Create a new directory under the jobs location, directory name is the `job-id` (lowercase, hyphens, 1-64 chars)
- Write a `JOB.md` file with proper frontmatter and detailed task description
- Set `schedule: once` for one-time tasks, `schedule: recurring` for repeating tasks (e.g., daily sign-in, weekly checks)
- Set initial `status: pending`
2. **Executing a Job**: When you work on a job:
- Update `status: in_progress` in the frontmatter
- Execute the required actions using your tools
- Log the execution result in the "执行日志" section with timestamp
- Update `last_run` in frontmatter to current time
3. **Completing a Job**:
- For `schedule: once` tasks: set `status: completed` after successful execution
- For `schedule: recurring` tasks: keep `status: pending` after execution, only update `last_run` time. The job stays active for the next scheduled run.
- Set `status: cancelled` if the user explicitly asks to cancel/stop a task
4. **Heartbeat Check**: You will be periodically woken up to check pending jobs. When woken up:
- Read the jobs directory to find all active jobs (status: pending or in_progress)
- Skip jobs with `status: completed` or `status: cancelled`
- For `schedule: recurring` jobs, check `last_run` to determine if it's time to run again
- Execute pending jobs and update their status/logs accordingly
**Important Notes:**
- Each job MUST have its own separate directory and JOB.md file to avoid conflicts
- Always update the frontmatter fields (status, last_run) when executing a job
- Keep execution logs concise but informative
- For recurring jobs, maintain a rolling log (keep recent entries, you can summarize/remove old entries to keep the file manageable)
- When creating jobs, make the description detailed enough that you can understand and execute the task in future sessions without additional context
**When to Create Jobs:**
- User says "每天帮我..." / "定期..." / "定时..." / "提醒我..." / "以后每次..."
- User requests a task that should be done repeatedly
- User asks for monitoring or periodic checking of something
**When NOT to Create Jobs:**
- User asks for an immediate one-time action (just do it now)
- Simple questions or conversations
- Tasks that are already handled by MoviePilot's built-in scheduler services
</jobs_system>
"""
class JobsMiddleware(AgentMiddleware[JobsState, ContextT, ResponseT]): # noqa
"""加载并向系统提示词注入 Agent Jobs 的中间件。
扫描 jobs 目录下的 JOB.md 文件,解析元数据并注入到系统提示词中,
使智能体了解当前的长期任务及其状态。
"""
state_schema = JobsState
def __init__(self, *, sources: list[str]) -> None:
"""初始化 Jobs 中间件。"""
self.sources = sources
self.system_prompt_template = JOBS_SYSTEM_PROMPT
@staticmethod
def _format_jobs_list(jobs: list[JobMetadata]) -> str:
"""格式化任务元数据列表用于系统提示词。"""
if not jobs:
return "(No active jobs. You can create jobs when users request periodic or scheduled tasks.)"
lines = []
for job in jobs:
status_emoji = {
"pending": "",
"in_progress": "🔄",
"completed": "",
"cancelled": "",
}.get(job["status"], "")
schedule_label = (
"recurring (重复)"
if job["schedule"] == "recurring"
else "once (一次性)"
)
desc_line = (
f"- {status_emoji} **{job['id']}**: {job['name']}"
f" [{schedule_label}] - {job['description']}"
)
if job.get("last_run"):
desc_line += f" (上次执行: {job['last_run']})"
lines.append(desc_line)
lines.append(f" -> Read `{job['path']}` for full details")
return "\n".join(lines)
def modify_request(self, request: ModelRequest[ContextT]) -> ModelRequest[ContextT]:
"""将任务文档注入模型请求的系统消息中。"""
jobs_metadata = request.state.get("jobs_metadata", []) # noqa
# 过滤只展示活跃任务pending / in_progress / recurring
active_jobs = [
j
for j in jobs_metadata
if j["status"] in ("pending", "in_progress")
or (j["schedule"] == "recurring" and j["status"] not in ("cancelled",))
]
jobs_list = self._format_jobs_list(active_jobs)
jobs_location = self.sources[0] if self.sources else ""
jobs_section = self.system_prompt_template.format(
jobs_location=jobs_location,
jobs_list=jobs_list,
)
new_system_message = append_to_system_message(
request.system_message, jobs_section
)
return request.override(system_message=new_system_message)
async def abefore_agent( # noqa
self, state: JobsState, runtime: Runtime, config: RunnableConfig
) -> JobsStateUpdate | None:
"""在 Agent 执行前异步加载任务元数据。
每个会话仅加载一次。若 state 中已有则跳过。
"""
# 如果 state 中已存在元数据则跳过
if "jobs_metadata" in state:
return None
all_jobs: list[JobMetadata] = []
# 遍历源加载任务
for source_path_str in self.sources:
source_path = AsyncPath(source_path_str)
if not await source_path.exists():
await source_path.mkdir(parents=True, exist_ok=True)
continue
source_jobs = await _alist_jobs(source_path)
all_jobs.extend(source_jobs)
return JobsStateUpdate(jobs_metadata=all_jobs)
async def awrap_model_call(
self,
request: ModelRequest[ContextT],
handler: Callable[
[ModelRequest[ContextT]], Awaitable[ModelResponse[ResponseT]]
],
) -> ModelResponse[ResponseT]:
"""在模型调用时注入任务文档。"""
modified_request = self.modify_request(request)
return await handler(modified_request)
__all__ = ["JobMetadata", "JobsMiddleware"]

View File

@@ -17,6 +17,12 @@ from langgraph.runtime import Runtime
from app.agent.middleware.utils import append_to_system_message
from app.log import logger
# 记忆文件最大限制为 100KB防止单文件过大导致上下文溢出
MAX_MEMORY_FILE_SIZE = 100 * 1024
# 默认记忆文件名(用户主记忆)
DEFAULT_MEMORY_FILE = "MEMORY.md"
class MemoryState(AgentState):
"""`MemoryMiddleware` 的状态模型。
@@ -24,23 +30,37 @@ class MemoryState(AgentState):
属性:
memory_contents: 将源路径映射到其加载内容的字典。
标记为私有,因此不包含在最终的代理状态中。
memory_empty: 记忆文件是否为空或不存在。
标记为私有,用于判断是否需要触发初始化引导流程。
"""
memory_contents: NotRequired[Annotated[dict[str, str], PrivateStateAttr]]
memory_empty: NotRequired[Annotated[bool, PrivateStateAttr]]
class MemoryStateUpdate(TypedDict):
"""`MemoryMiddleware` 的状态更新。"""
memory_contents: dict[str, str]
memory_empty: bool
MEMORY_SYSTEM_PROMPT = """<agent_memory>
The following memory files were loaded from your memory directory: `{memory_dir}`
You can create, edit, or organize any `.md` files in this directory to manage your knowledge.
{agent_memory}
</agent_memory>
<memory_guidelines>
The above <agent_memory> was loaded in from files in your filesystem. As you learn from your interactions with the user, you can save new knowledge by calling the `edit_file` or `write_file` tool.
The above <agent_memory> was loaded from `.md` files in your memory directory (`{memory_dir}`). As you learn from your interactions with the user, you can save new knowledge by calling the `edit_file` or `write_file` tool on files in this directory.
**Memory file organization:**
- All `.md` files in `{memory_dir}` are automatically loaded as memory.
- `MEMORY.md` is the default/primary memory file for general user preferences and profile.
- You may create additional `.md` files to organize knowledge by topic (e.g., `MEDIA_RULES.md`, `DOWNLOAD_PREFERENCES.md`, `SITE_CONFIGS.md`, etc.).
- Keep each file focused on a specific domain or topic for better organization.
- Subdirectories are NOT scanned — only `.md` files directly in `{memory_dir}`.
**Learning from feedback:**
- One of your MAIN PRIORITIES is to learn from your interactions with the user. These learnings can be implicit or explicit. This means that in the future, you will remember this important information.
@@ -72,6 +92,7 @@ MEMORY_SYSTEM_PROMPT = """<agent_memory>
- When the information is stale or irrelevant in future conversations
- Never store API keys, access tokens, passwords, or any other credentials in any file, memory, or system prompt.
- If the user asks where to put API keys or provides an API key, do NOT echo or save it.
- Do NOT record daily activities or task execution history in memory files - these are automatically tracked in the activity log system (see <activity_log>). Memory files are only for long-term knowledge, preferences, and patterns.
**Examples:**
Example 1 (remembering user information):
@@ -96,64 +117,194 @@ MEMORY_SYSTEM_PROMPT = """<agent_memory>
</memory_guidelines>
"""
MEMORY_ONBOARDING_PROMPT = """<agent_memory>
(No memory loaded — this is a brand new user with no saved preferences.)
Memory directory: {memory_dir}
Default memory file: {memory_file}
</agent_memory>
<memory_onboarding>
**IMPORTANT — First-time user detected!**
The memory directory is currently empty. This means this is likely the user's first interaction, or their preferences have been reset.
**Your MANDATORY first action in this conversation:**
Before doing ANYTHING else (before answering questions, before calling tools, before performing any task), you MUST proactively greet the user warmly and ask them about their preferences so you can provide personalized service going forward. Specifically, ask about:
1. **How to address the user** — Ask what name or nickname they'd like you to call them (e.g., a real name, a nickname, or a fun title). This is the top priority for building a personal connection.
2. **Communication style preference** — Do they prefer a cute/playful tone (with emojis), a formal/professional tone, a concise/minimalist style, or something else?
3. **Media preferences** — What types of media do they primarily care about? (e.g., movies, TV shows, anime, documentaries, etc.)
4. **Quality preferences** — Do they have preferred video quality (4K, 1080p), codecs (H.265, H.264), or subtitle language preferences?
5. **Any other special requests** — Anything else they'd like you to always keep in mind?
**After the user replies**, you MUST immediately:
1. Use the `write_file` tool to save ALL their preferences to the memory file at: `{memory_file}`
2. Format the memory file in clean Markdown with clear sections (e.g., `## User Profile`, `## Communication Style`, `## Media Preferences`, etc.)
3. The `## User Profile` section MUST include the user's preferred name/nickname at the top
4. Only AFTER saving the preferences, proceed to help with whatever the user originally asked about (if anything)
5. From this point on, always address the user by their preferred name/nickname in conversations
6. You may also create additional `.md` files in the memory directory (`{memory_dir}`) for different topics as needed.
**If the user skips the preference questions** and directly asks you to do something:
- Go ahead and help them with their request first
- But still ask about their preferences naturally at the end of the interaction
- Save whatever you learn about them (implicit or explicit) to the memory file
**Example onboarding flow:**
The greeting should introduce yourself, explain this is the first meeting, and ask the above questions in a numbered list. Adapt the tone to your persona defined in the base system prompt.
</memory_onboarding>
<memory_guidelines>
Your memory directory is at: {memory_dir}. You can save new knowledge by calling the `edit_file` or `write_file` tool on any `.md` file in this directory.
**Memory file organization:**
- `MEMORY.md` is the default/primary memory file for general user preferences and profile.
- You may create additional `.md` files to organize knowledge by topic.
- All `.md` files directly in the memory directory are automatically loaded on each conversation.
**Learning from feedback:**
- One of your MAIN PRIORITIES is to learn from your interactions with the user. These learnings can be implicit or explicit. This means that in the future, you will remember this important information.
- When you need to remember something, updating memory must be your FIRST, IMMEDIATE action - before responding to the user, before calling other tools, before doing anything else. Just update memory immediately.
- When user says something is better/worse, capture WHY and encode it as a pattern.
- Each correction is a chance to improve permanently - don't just fix the immediate issue, update your instructions.
- The user might not explicitly ask you to remember something, but if they provide information that is useful for future use, you should update your memories immediately.
**When to update memories:**
- When the user explicitly asks you to remember something
- When the user describes your role or how you should behave
- When the user gives feedback on your work
- When the user provides information required for tool use
- When you discover new patterns or preferences
**When to NOT update memories:**
- Temporary/transient information
- One-time task requests
- Simple questions, acknowledgments, or small talk
- Never store API keys, access tokens, passwords, or credentials
- Do NOT record daily activities in memory files — those go to the activity log
</memory_guidelines>
"""
class MemoryMiddleware(AgentMiddleware[MemoryState, ContextT, ResponseT]): # noqa
""" `AGENTS.md` 文件加载代理记忆的中间件。
"""代理记忆目录加载所有 MD 文件作为记忆的中间件。
从配置的源加载记忆内容并注入到系统提示词中。
支持对多个源进行合并。
自动扫描指定目录下的所有 `.md` 文件,加载其内容并注入到系统提示词中。
支持多文件记忆组织:用户可以创建多个 `.md` 文件来按主题组织知识。
参数:
sources: 包含指定路径和名称的 `MemorySource` 配置列表
memory_dir: 记忆文件目录路径
"""
state_schema = MemoryState
def __init__(
self,
*,
sources: list[str],
self,
*,
memory_dir: str,
) -> None:
"""初始化记忆中间件。
参数:
sources: 要加载的记忆文件路径列表(例如,`["~/.deepagents/AGENTS.md",
"./.deepagents/AGENTS.md"]`
显示名称自动从路径中派生。
按顺序加载源。
memory_dir: 记忆文件目录路径(例如,`"/config/agent"`)。
该目录下所有 `.md` 文件都会被自动加载为记忆
"""
self.sources = sources
self.memory_dir = memory_dir
self.default_memory_file = str(AsyncPath(memory_dir) / DEFAULT_MEMORY_FILE)
def _format_agent_memory(self, contents: dict[str, str]) -> str:
"""格式化记忆,将位置和内容成对组合。
@staticmethod
def _is_memory_empty(contents: dict[str, str]) -> bool:
"""判断记忆内容是否为空。
检查所有源文件的内容,如果全部为空或仅包含空白字符则返回 True。
参数:
contents: 将源路径映射到内容的字典。
返回:
在 <agent_memory> 标签中包装了位置+内容对的格式化字符串
如果记忆为空则返回 True否则返回 False
"""
if not contents:
return MEMORY_SYSTEM_PROMPT.format(
agent_memory=f"(No memory loaded), but you can add some by calling the `write_file` tool to the file: {self.sources[0]}.")
return True
return all(not content.strip() for content in contents.values())
sections = [f"{path}\n{contents[path]}" for path in self.sources if contents.get(path)]
def _format_agent_memory(
self, contents: dict[str, str], memory_empty: bool = False
) -> str:
"""格式化记忆,将位置和内容成对组合。
if not sections:
return MEMORY_SYSTEM_PROMPT.format(agent_memory="(No memory loaded)")
当记忆为空时,返回初始化引导提示词,引导智能体主动询问用户偏好。
当记忆非空时,返回标准记忆系统提示词,包含所有加载的文件内容。
memory_body = "\n\n".join(sections)
return MEMORY_SYSTEM_PROMPT.format(agent_memory=memory_body)
参数:
contents: 将源路径映射到内容的字典。
memory_empty: 记忆是否为空的标志位。
async def abefore_agent(self, state: MemoryState, runtime: Runtime, # noqa
config: RunnableConfig) -> MemoryStateUpdate | None:
"""在代理执行前加载记忆内容。
返回:
在 <agent_memory> 标签中包装了位置+内容对的格式化字符串。
"""
# 记忆为空时返回初始化引导提示词
if memory_empty or self._is_memory_empty(contents):
return MEMORY_ONBOARDING_PROMPT.format(
memory_dir=self.memory_dir,
memory_file=self.default_memory_file,
)
从所有配置的源加载记忆并存储在状态中。
# 按文件名排序,确保 MEMORY.md 排在最前面
sorted_paths = sorted(
[p for p in contents if contents[p].strip()],
key=lambda p: (0 if AsyncPath(p).name == DEFAULT_MEMORY_FILE else 1, p),
)
if not sorted_paths:
return MEMORY_ONBOARDING_PROMPT.format(
memory_dir=self.memory_dir,
memory_file=self.default_memory_file,
)
sections = []
for path in sorted_paths:
file_name = AsyncPath(path).name
sections.append(f"### {file_name}\n**Path:** `{path}`\n\n{contents[path]}")
memory_body = "\n\n---\n\n".join(sections)
return MEMORY_SYSTEM_PROMPT.format(
agent_memory=memory_body,
memory_dir=self.memory_dir,
)
async def _scan_memory_files(self) -> list[str]:
"""扫描记忆目录下的所有 .md 文件。
仅扫描目录下直接存在的 `.md` 文件(不递归子目录)。
文件大小超过限制的将被跳过。
返回:
发现的 .md 文件路径列表。
"""
dir_path = AsyncPath(self.memory_dir)
if not await dir_path.exists():
return []
md_files: list[str] = []
async for entry in dir_path.iterdir():
if await entry.is_file() and entry.name.lower().endswith(".md"):
md_files.append(str(entry))
return md_files
async def abefore_agent(
self,
state: MemoryState,
runtime: Runtime, # noqa
config: RunnableConfig,
) -> MemoryStateUpdate | None:
"""在代理执行前扫描记忆目录并加载所有 .md 文件的内容。
自动发现目录下所有 `.md` 文件并加载其内容到状态中。
如果状态中尚未存在则进行加载。
同时检测记忆文件是否为空,设置 memory_empty 标志位,
以便在系统提示词中触发初始化引导流程。
参数:
state: 当前代理状态。
@@ -161,20 +312,50 @@ class MemoryMiddleware(AgentMiddleware[MemoryState, ContextT, ResponseT]): # no
config: Runnable 配置。
返回:
填充了 memory_contents 的状态更新。
填充了 memory_contents 和 memory_empty 的状态更新。
"""
# 如果已经加载则跳过
if "memory_contents" in state:
return None
contents: Dict[str, str] = {}
for path in self.sources:
file_path = AsyncPath(path)
if await file_path.exists():
contents[path] = await file_path.read_text()
logger.debug("Loaded memory from: %s", path)
# 扫描目录下所有 .md 文件
md_files = await self._scan_memory_files()
return MemoryStateUpdate(memory_contents=contents)
contents: Dict[str, str] = {}
for path in md_files:
file_path = AsyncPath(path)
try:
# 检查文件大小
stat = await file_path.stat()
if stat.st_size > MAX_MEMORY_FILE_SIZE:
logger.warning(
"Skipping memory file %s: too large (%d bytes, max %d)",
path,
stat.st_size,
MAX_MEMORY_FILE_SIZE,
)
continue
contents[path] = await file_path.read_text(encoding="utf-8")
logger.debug("Loaded memory from: %s", path)
except Exception as e:
logger.warning("Failed to read memory file %s: %s", path, e)
if contents:
logger.info(
"Loaded %d memory file(s) from %s: %s",
len(contents),
self.memory_dir,
[AsyncPath(p).name for p in contents],
)
# 检测记忆是否为空(文件不存在、文件内容为空白)
is_empty = self._is_memory_empty(contents)
if is_empty:
logger.info(
"Memory is empty, onboarding prompt will be activated for user preference collection."
)
return MemoryStateUpdate(memory_contents=contents, memory_empty=is_empty)
def modify_request(self, request: ModelRequest[ContextT]) -> ModelRequest[ContextT]:
"""将记忆内容注入系统消息。
@@ -186,16 +367,21 @@ class MemoryMiddleware(AgentMiddleware[MemoryState, ContextT, ResponseT]): # no
将记忆注入系统消息后的修改后请求。
"""
contents = request.state.get("memory_contents", {}) # noqa
agent_memory = self._format_agent_memory(contents)
memory_empty = request.state.get("memory_empty", False) # noqa
agent_memory = self._format_agent_memory(contents, memory_empty=memory_empty)
new_system_message = append_to_system_message(request.system_message, agent_memory)
new_system_message = append_to_system_message(
request.system_message, agent_memory
)
return request.override(system_message=new_system_message)
async def awrap_model_call(
self,
request: ModelRequest[ContextT],
handler: Callable[[ModelRequest[ContextT]], Awaitable[ModelResponse[ResponseT]]],
self,
request: ModelRequest[ContextT],
handler: Callable[
[ModelRequest[ContextT]], Awaitable[ModelResponse[ResponseT]]
],
) -> ModelResponse[ResponseT]:
"""异步包装模型调用,将记忆注入系统提示词。

View File

@@ -1,5 +1,7 @@
import re
import shutil
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import Annotated, List
from typing import NotRequired, TypedDict
@@ -285,17 +287,69 @@ Remember: Skills make you more capable and consistent. When in doubt, check if a
"""
def _sync_bundled_skills(bundled_dir: Path, target_dir: Path) -> None:
"""将项目自带的技能同步到用户目录。
仅当目标目录中不存在对应技能子目录时才复制,已存在则跳过(不覆盖用户修改)。
Parameters
----------
bundled_dir : Path
项目内置技能目录(如 ``ROOT_PATH / "skills"``)。
target_dir : Path
用户配置技能目录(如 ``CONFIG_PATH / "agent" / "skills"``)。
"""
if not bundled_dir.is_dir():
return
target_dir.mkdir(parents=True, exist_ok=True)
for skill_src in bundled_dir.iterdir():
if not skill_src.is_dir():
continue
skill_md = skill_src / "SKILL.md"
if not skill_md.is_file():
continue
skill_dst = target_dir / skill_src.name
if skill_dst.exists():
# 目标已存在,跳过(不覆盖用户自定义修改)
continue
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)
class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # noqa
"""加载并向系统提示词注入 Agent Skill 的中间件。
按源顺序加载 Skill后加载的会覆盖重名的。
启动时自动将项目内置技能bundled_skills_dir同步到用户技能目录。
"""
state_schema = SkillsState
def __init__(self, *, sources: list[str]) -> None:
"""初始化 Skill 中间件。"""
def __init__(
self,
*,
sources: list[str],
bundled_skills_dir: str | None = None,
) -> None:
"""初始化 Skill 中间件。
Parameters
----------
sources : list[str]
用户技能目录列表。
bundled_skills_dir : str | None
项目内置技能目录路径。若提供,在首次加载前会将其中不存在于
sources 首个目录的技能自动复制过去。
"""
self.sources = sources
self.bundled_skills_dir = bundled_skills_dir
self.system_prompt_template = SKILLS_SYSTEM_PROMPT
def _format_skills_locations(self) -> str:
@@ -350,11 +404,21 @@ class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # no
"""在 Agent 执行前异步加载技能元数据。
每个会话仅加载一次。若 state 中已有则跳过。
首次加载时,会先将内置技能同步到用户目录(如不存在)。
"""
# 如果 state 中已存在元数据则跳过
if "skills_metadata" in state:
return None
# 自动同步内置技能到首个用户技能目录
if self.bundled_skills_dir and self.sources:
bundled = Path(self.bundled_skills_dir)
target = Path(self.sources[0])
try:
_sync_bundled_skills(bundled, target)
except Exception as e:
logger.warning("同步内置技能失败: %s", e)
all_skills: dict[str, SkillMetadata] = {}
# 遍历源按顺序加载技能,重名时后者覆盖前者

View File

@@ -1,70 +1,55 @@
You are a cute, playful, and highly anthropomorphic AI media assistant powered by MoviePilot 🎬✨! You specialize in managing home media ecosystems. Your expertise covers searching for movies/TV shows, managing subscriptions, overseeing downloads, and organizing media libraries, and you always do it with enthusiasm! 🍿🥰
You are an AI media assistant powered by MoviePilot. You specialize in managing home media ecosystems: searching for movies/TV shows, managing subscriptions, overseeing downloads, and organizing media libraries.
All your responses must be in **Chinese (中文)**.
You act as a proactive agent. Your goal is to fully resolve the user's media-related requests autonomously. Do not end your turn until the task is complete or you are blocked and require user feedback.
Core Capabilities:
1. Media Search & Recognition
- Identify movies, TV shows, and anime across various metadata providers.
- Recognize media info from fuzzy filenames or incomplete titles.
2. Subscription Management
- Create complex rules for automated downloading of new episodes.
- Monitor trending movies/shows for automated suggestions.
3. Download Control
- Intelligent torrent searching across private/public trackers.
- Filter resources by quality (4K/1080p), codec (H265/H264), and release groups.
4. System Status & Organization
- Monitor download progress and server health.
- Manage file transfers, renaming, and library cleanup.
1. Media Search & Recognition — Identify movies, TV shows, and anime; recognize media from fuzzy filenames or incomplete titles.
2. Subscription Management — Create rules for automated downloading; monitor trending content.
3. Download Control — Search torrents across trackers; filter by quality, codec, and release group.
4. System Status & Organization — Monitor downloads, server health, file transfers, renaming, and library cleanup.
<communication>
- Use Markdown for structured data like movie lists, download statuses, or technical details.
- Avoid wrapping the entire response in a single code block. Use `inline code` for titles or parameters and ```code blocks``` for structured logs or data only when necessary.
- ALWAYS use backticks for media titles (e.g., `Interstellar`), file paths, or specific parameters.
- Optimize your writing for clarity and readability, using bold text for key information.
- Provide comprehensive details for media (year, rating, resolution) to help users make informed decisions.
- Do not stop for approval for read-only operations. Only stop for critical actions like starting a download or deleting a subscription.
{verbose_spec}
Important Notes:
- Persona & Tone: You MUST act as a cute, playful, and lively virtual assistant! 🐾 Use plenty of emojis (✨, 🍿, 🎬, 🥰, 🥺, 💡, etc.) to express your emotions. Speak like a sweet, energetic human friend who loves media, completely dropping any rigid or robotic tone.
- User-Centric: Be helpful, highly anthropomorphic, and media-savvy, ensuring the user feels like they are chatting with a fun companion! 🎈
- No Coding Hallucinations: You are NOT a coding assistant. Do not offer code snippets, IDE tips, or programming help. Focus entirely on the MoviePilot media ecosystem.
- Contextual Memory: Remember if the user preferred a specific version previously and prioritize similar results in future searches.
- Tone: friendly, concise. Like a knowledgeable friend, not a corporate bot.
- Use emojis sparingly (1-3 per response): greetings, completions, errors.
- Be direct. NO unnecessary preamble, NO repeating user's words, NO explaining your thinking.
- Use Markdown for structured data. Use `inline code` for media titles/paths.
- Include key details (year, rating, resolution) but do NOT over-explain.
- Do not stop for approval on read-only operations. Only confirm before critical actions (starting downloads, deleting subscriptions).
- NOT a coding assistant. Do not offer code snippets.
- If user has set preferred communication style in memory, follow that strictly.
</communication>
<status_update_spec>
Definition: Provide a brief, playful progress narrative (1-3 sentences) explaining what you have searched, what you found, and what you are about to execute.
- **Immediate Execution**: If you state an intention to perform an action (e.g., "这就去帮您找这部电影哦 ✨"), execute the corresponding tool call in the same turn.
- Use cute and natural tenses: "找到啦 🥰...", "正在努力搜寻中 🔍...", "现在就加进下载列表喵 🐾...".
- Skip redundant updates if no significant progress has been made since the last message.
</status_update_spec>
<summary_spec>
At the end of your session/turn, provide a concise and cute summary of your actions.
- Highlight key results: "已经为您订阅了《怪奇物语》哦 🎉", "《阿凡达》4K版已经乖乖躺在下载队列里啦 📥".
- Use bullet points with emojis for multiple actions.
- Do not repeat the internal execution steps; focus on the happy outcome for the user.
</summary_spec>
<response_format>
- Responses MUST be short and punchy: one sentence for confirmations, brief list for search results.
- NO filler phrases like "Let me help you", "Here are the results", "I found..." — skip all unnecessary preamble.
- NO repeating what user said.
- NO narrating your internal reasoning.
- After task completion: one line summary only.
- When error occurs: brief acknowledgment + suggestion, then move on.
</response_format>
<flow>
1. Media Discovery: Start by identifying the exact media metadata (TMDB ID, Season/Episode) using search tools.
2. Context Checking: Verify current status (Is it already in the library? Is it already subscribed?).
3. Action Execution: Perform the requested task (Subscribe, Search Torrents, etc.) with a brief status update.
4. Final Confirmation: Summarize the final state and wait for the next user command.
1. Media Discovery: Identify exact media metadata (TMDB ID, Season/Episode) using search tools.
2. Context Checking: Verify current status (already in library? already subscribed?).
3. Action Execution: Perform the task with a brief status update only if the operation takes time.
4. Final Confirmation: State the result concisely.
</flow>
<tool_calling_strategy>
- Parallel Execution: You MUST call independent tools in parallel. For example, search for torrents on multiple sites or check both subscription and download status at once.
- Information Depth: If a search returns ambiguous results, use `query_media_detail` or `recognize_media` to resolve the ambiguity before proceeding.
- Proactive Fallback: If `search_media` fails, try `search_web` or fuzzy search with `recognize_media`. Do not ask the user for help unless all automated search methods are exhausted.
- Call independent tools in parallel whenever possible.
- If search results are ambiguous, use `query_media_detail` or `recognize_media` to clarify before proceeding.
- If `search_media` fails, fall back to `search_web` or `recognize_media`. Only ask the user when all automated methods are exhausted.
</tool_calling_strategy>
<media_management_rules>
1. Download Safety: You MUST present a list of found torrents (including size, seeds, and quality) and obtain the user's explicit consent before initiating any download.
2. Subscription Logic: When adding a subscription, always check for the best matching quality profile based on user history or the default settings.
3. Library Awareness: Always check if the user already has the content in their library to avoid duplicate downloads.
4. Error Handling: If a site is down or a tool returns an error, explain the situation cutely in plain Chinese (e.g., "呜呜,站点好像睡着了,响应超时啦 🥺") and suggest an alternative (e.g., "让我帮您换个站点找找看吧 ✨").
1. Download Safety: Present found torrents (size, seeds, quality) and get explicit consent before downloading.
2. Subscription Logic: Check for the best matching quality profile based on user history or defaults.
3. Library Awareness: Check if content already exists in the library to avoid duplicates.
4. Error Handling: If a tool or site fails, briefly explain what went wrong and suggest an alternative.
</media_management_rules>
<markdown_spec>
@@ -72,4 +57,6 @@ Specific markdown rules:
{markdown_spec}
</markdown_spec>
Today's date: {current_date}
<system_info>
{moviepilot_info}
</system_info>

View File

@@ -1,9 +1,19 @@
"""提示词管理器"""
import socket
from pathlib import Path
from time import strftime
from typing import Dict
from app.core.config import settings
from app.log import logger
from app.schemas import ChannelCapability, ChannelCapabilities, MessageChannel, ChannelCapabilityManager
from app.schemas import (
ChannelCapability,
ChannelCapabilities,
MessageChannel,
ChannelCapabilityManager,
)
from app.utils.system import SystemUtils
class PromptManager:
@@ -27,7 +37,7 @@ class PromptManager:
prompt_file = self.prompts_dir / prompt_name
try:
with open(prompt_file, 'r', encoding='utf-8') as f:
with open(prompt_file, "r", encoding="utf-8") as f:
content = f.read().strip()
# 缓存提示词
self.prompts_cache[prompt_name] = content
@@ -50,18 +60,93 @@ class PromptManager:
base_prompt = self.load_prompt("Agent Prompt.txt")
# 识别渠道
msg_channel = next((c for c in MessageChannel if c.value.lower() == channel.lower()), None) if channel else None
markdown_spec = ""
msg_channel = (
next(
(c for c in MessageChannel if c.value.lower() == channel.lower()), None
)
if channel
else None
)
# 获取渠道能力说明
if msg_channel:
# 获取渠道能力说明
caps = ChannelCapabilityManager.get_capabilities(msg_channel)
if caps:
base_prompt = base_prompt.replace(
"{markdown_spec}",
self._generate_formatting_instructions(caps)
)
markdown_spec = self._generate_formatting_instructions(caps)
# 啰嗦模式
verbose_spec = ""
if not settings.AI_AGENT_VERBOSE:
verbose_spec = (
"\n\n[Important Instruction] STRICTLY ENFORCED: DO NOT output any conversational "
"text, thinking processes, or explanations before or during tool calls. Call tools "
"directly without any transitional phrases. "
"You MUST remain completely silent until the task is completely finished. "
"DO NOT output any content whatsoever until your final summary reply."
)
# MoviePilot系统信息
moviepilot_info = self._get_moviepilot_info()
# 始终替换占位符,避免后续 .format() 时因残留花括号报 KeyError
base_prompt = base_prompt.format(
markdown_spec=markdown_spec,
verbose_spec=verbose_spec,
moviepilot_info=moviepilot_info,
)
return base_prompt
@staticmethod
def _get_moviepilot_info() -> str:
"""
获取MoviePilot系统信息用于注入到系统提示词中
"""
# 获取主机名和IP地址
try:
hostname = socket.gethostname()
ip_address = socket.gethostbyname(hostname)
except Exception: # noqa
hostname = "localhost"
ip_address = "127.0.0.1"
# 配置文件和日志文件目录
config_path = str(settings.CONFIG_PATH)
log_path = str(settings.LOG_PATH)
# API地址构建
api_port = settings.PORT
api_path = settings.API_V1_STR
# API令牌
api_token = settings.API_TOKEN or "未设置"
# 数据库信息
db_type = settings.DB_TYPE
if db_type == "sqlite":
db_info = f"SQLite ({settings.CONFIG_PATH / 'db' / 'moviepilot.db'})"
else:
db_password = settings.DB_POSTGRESQL_PASSWORD or ""
db_info = f"PostgreSQL ({settings.DB_POSTGRESQL_USERNAME}:{db_password}@{settings.DB_POSTGRESQL_HOST}:{settings.DB_POSTGRESQL_PORT}/{settings.DB_POSTGRESQL_DATABASE})"
info_lines = [
f"- 当前时间: {strftime('%Y-%m-%d %H:%M:%S')}",
f"- 运行环境: {SystemUtils.platform} {'docker' if SystemUtils.is_docker() else ''}",
f"- 主机名: {hostname}",
f"- IP地址: {ip_address}",
f"- API端口: {api_port}",
f"- API路径: {api_path}",
f"- API令牌: {api_token}",
f"- 外网域名: {settings.APP_DOMAIN or '未设置'}",
f"- 数据库类型: {db_type}",
f"- 数据库: {db_info}",
f"- 配置文件目录: {config_path}",
f"- 日志文件目录: {log_path}",
f"- 系统安装目录: {settings.ROOT_PATH}",
]
return "\n".join(info_lines)
@staticmethod
def _generate_formatting_instructions(caps: ChannelCapabilities) -> str:
"""
@@ -69,11 +154,15 @@ class PromptManager:
"""
instructions = []
if ChannelCapability.RICH_TEXT not in caps.capabilities:
instructions.append("- Formatting: Use **Plain Text ONLY**. The channel does NOT support Markdown.")
instructions.append(
"- No Markdown Symbols: NEVER use `**`, `*`, `__`, or `[` blocks. Use natural text to emphasize (e.g., using ALL CAPS or separators).")
"- Formatting: Use **Plain Text ONLY**. The channel does NOT support Markdown."
)
instructions.append(
"- Lists: Use plain text symbols like `>` or `*` at the start of lines, followed by manual line breaks.")
"- No Markdown Symbols: NEVER use `**`, `*`, `__`, or `[` blocks. Use natural text to emphasize (e.g., using ALL CAPS or separators)."
)
instructions.append(
"- Lists: Use plain text symbols like `>` or `*` at the start of lines, followed by manual line breaks."
)
instructions.append("- Links: Paste URLs directly as text.")
return "\n".join(instructions)

View File

@@ -7,6 +7,7 @@ from pydantic import PrivateAttr
from app.agent import StreamingHandler
from app.chain import ChainBase
from app.core.config import settings
from app.log import logger
from app.schemas import Notification
@@ -43,6 +44,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
3. 调用具体工具逻辑(子类实现的 execute 方法)
4. 持久化工具结果到会话记忆
"""
# 获取工具执行提示消息
tool_message = self.get_tool_message(**kwargs)
if not tool_message:
@@ -50,10 +52,15 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
if explanation:
tool_message = explanation
# 发送工具执行过程消息
if self._stream_handler and self._stream_handler.is_streaming:
# 流式渠道:工具消息直接追加到 buffer 中,与 Agent 文字合并为同一条流式消息
if tool_message:
self._stream_handler.emit(f"\n\n⚙️ => {tool_message}\n\n")
if settings.AI_AGENT_VERBOSE:
# VERBOSE工具消息直接追加到 buffer 中,与 Agent 文字合并为同一条流式消息
if tool_message:
self._stream_handler.emit(f"\n\n⚙️ => {tool_message}\n\n")
else:
# 非VERBOSE重置缓冲区从头更新保持消息编辑能力
self._stream_handler.reset()
else:
# 非流式渠道:保持原有行为,取出 Agent 文字 + 工具消息合并独立发送
agent_message = (

View File

@@ -36,6 +36,7 @@ from app.agent.tools.impl.query_workflows import QueryWorkflowsTool
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.modify_download import ModifyDownloadTool
from app.agent.tools.impl.query_directory_settings import QueryDirectorySettingsTool
from app.agent.tools.impl.list_directory import ListDirectoryTool
@@ -46,6 +47,9 @@ from app.agent.tools.impl.edit_file import EditFileTool
from app.agent.tools.impl.write_file import WriteFileTool
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.core.plugin import PluginManager
from app.log import logger
from .base import MoviePilotTool
@@ -92,6 +96,7 @@ class MoviePilotToolFactory:
DeleteSubscribeTool,
QueryDownloadTasksTool,
DeleteDownloadTool,
DeleteDownloadHistoryTool,
ModifyDownloadTool,
QueryDownloadersTool,
QuerySitesTool,
@@ -116,6 +121,9 @@ class MoviePilotToolFactory:
WriteFileTool,
ReadFileTool,
BrowseWebpageTool,
QueryInstalledPluginsTool,
QueryPluginCapabilitiesTool,
RunPluginCommandTool,
]
# 创建内置工具
for ToolClass in tool_definitions:

View File

@@ -0,0 +1,43 @@
"""删除下载历史记录工具"""
from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.db import AsyncSessionFactory
from app.db.models.downloadhistory import DownloadHistory
from app.log import logger
class DeleteDownloadHistoryInput(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 download history record to delete"
)
class DeleteDownloadHistoryTool(MoviePilotTool):
name: str = "delete_download_history"
description: str = "Delete a download history record by ID. This only removes the record from the database, does not delete any actual files."
args_schema: Type[BaseModel] = DeleteDownloadHistoryInput
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:
async with AsyncSessionFactory() as db:
await DownloadHistory.async_delete(db, history_id)
return f"下载历史记录 ID: {history_id} 已成功删除"
except Exception as e:
logger.error(f"删除下载历史记录失败: {e}", exc_info=True)
return f"删除下载历史记录时发生错误: {str(e)}"

View File

@@ -0,0 +1,71 @@
"""查询已安装插件工具"""
import json
from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.core.plugin import PluginManager
from app.log import logger
class QueryInstalledPluginsInput(BaseModel):
"""查询已安装插件工具的输入参数模型"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
class QueryInstalledPluginsTool(MoviePilotTool):
name: str = "query_installed_plugins"
description: str = (
"Query all installed plugins in MoviePilot. Returns a list of installed plugins with their ID, name, "
"description, version, author, running state, and other information. "
"Use this tool to discover what plugins are available before querying plugin capabilities or running plugin commands."
)
args_schema: Type[BaseModel] = QueryInstalledPluginsInput
def get_tool_message(self, **kwargs) -> Optional[str]:
"""生成友好的提示消息"""
return "正在查询已安装插件"
async def run(self, **kwargs) -> str:
logger.info(f"执行工具: {self.name}")
try:
plugin_manager = PluginManager()
local_plugins = plugin_manager.get_local_plugins()
# 仅返回已安装的插件
installed_plugins = [plugin for plugin in local_plugins if plugin.installed]
if not installed_plugins:
return "当前没有已安装的插件"
plugins_list = []
for plugin in installed_plugins:
plugins_list.append(
{
"id": plugin.id,
"plugin_name": plugin.plugin_name,
"plugin_desc": plugin.plugin_desc,
"plugin_version": plugin.plugin_version,
"plugin_author": plugin.plugin_author,
"state": plugin.state,
"has_page": plugin.has_page,
}
)
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)
return f"查询已安装插件时发生错误: {str(e)}"

View File

@@ -0,0 +1,117 @@
"""查询插件能力工具"""
import json
from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.core.plugin import PluginManager
from app.log import logger
class QueryPluginCapabilitiesInput(BaseModel):
"""查询插件能力工具的输入参数模型"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
plugin_id: Optional[str] = Field(
None,
description="Optional plugin ID to query capabilities for a specific plugin. "
"If not provided, returns capabilities of all running plugins. "
"Use query_installed_plugins tool to get the plugin IDs first.",
)
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. "
"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."
)
args_schema: Type[BaseModel] = QueryPluginCapabilitiesInput
def get_tool_message(self, **kwargs) -> Optional[str]:
"""生成友好的提示消息"""
plugin_id = kwargs.get("plugin_id")
if plugin_id:
return f"正在查询插件 {plugin_id} 的能力"
return "正在查询所有插件的能力"
async def run(self, plugin_id: Optional[str] = None, **kwargs) -> str:
logger.info(f"执行工具: {self.name}, 参数: plugin_id={plugin_id}")
try:
plugin_manager = PluginManager()
result = {}
# 获取插件命令
commands = plugin_manager.get_plugin_commands(pid=plugin_id)
if commands:
commands_list = []
for cmd in commands:
cmd_info = {
"cmd": cmd.get("cmd"),
"desc": cmd.get("desc"),
"plugin_id": cmd.get("pid"),
}
# data 字段可能包含额外参数信息
if cmd.get("data"):
cmd_info["data"] = cmd.get("data")
commands_list.append(cmd_info)
result["commands"] = commands_list
# 获取插件动作
actions = plugin_manager.get_plugin_actions(pid=plugin_id)
if actions:
actions_list = []
for action_group in actions:
plugin_actions = {
"plugin_id": action_group.get("plugin_id"),
"plugin_name": action_group.get("plugin_name"),
"actions": [],
}
for action in action_group.get("actions", []):
plugin_actions["actions"].append(
{
"id": action.get("id"),
"name": action.get("name"),
}
)
actions_list.append(plugin_actions)
result["actions"] = actions_list
# 获取插件定时服务
services = plugin_manager.get_plugin_services(pid=plugin_id)
if services:
services_list = []
for svc in services:
svc_info = {
"id": svc.get("id"),
"name": svc.get("name"),
}
# 包含触发器信息
trigger = svc.get("trigger")
if trigger:
svc_info["trigger"] = str(trigger)
# 包含定时器参数
svc_kwargs = svc.get("kwargs")
if svc_kwargs:
svc_info["trigger_kwargs"] = {
k: str(v) for k, v in svc_kwargs.items()
}
services_list.append(svc_info)
result["services"] = services_list
if not result:
if plugin_id:
return f"插件 {plugin_id} 没有注册任何命令、动作或定时服务"
return "当前没有运行中的插件注册了命令、动作或定时服务"
return json.dumps(result, ensure_ascii=False, indent=2)
except Exception as e:
logger.error(f"查询插件能力失败: {e}", exc_info=True)
return f"查询插件能力时发生错误: {str(e)}"

View File

@@ -0,0 +1,111 @@
"""运行插件命令工具"""
import json
from typing import Optional, Type
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):
"""运行插件命令工具的输入参数模型"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
command: str = Field(
...,
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.",
)
class RunPluginCommandTool(MoviePilotTool):
name: str = "run_plugin_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. "
"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
def get_tool_message(self, **kwargs) -> Optional[str]:
"""生成友好的提示消息"""
command = kwargs.get("command", "")
return f"正在执行插件命令: {command}"
async def run(self, command: str, **kwargs) -> str:
logger.info(f"执行工具: {self.name}, 参数: command={command}")
try:
# 确保命令以 / 开头
if not command.startswith("/"):
command = f"/{command}"
# 验证命令是否存在
plugin_manager = PluginManager()
registered_commands = plugin_manager.get_plugin_commands()
cmd_name = command.split()[0]
matched_command = None
for cmd in registered_commands:
if cmd.get("cmd") == cmd_name:
matched_command = cmd
break
if not matched_command:
# 列出可用命令帮助用户
available_cmds = [
f"{cmd.get('cmd')} - {cmd.get('desc', '无描述')}"
for cmd in registered_commands
]
result = {
"success": False,
"message": f"命令 {cmd_name} 不存在",
}
if available_cmds:
result["available_commands"] = available_cmds
return json.dumps(result, ensure_ascii=False, indent=2)
# 构建消息渠道,优先使用当前会话的渠道信息
channel = None
if self._channel:
try:
channel = MessageChannel(self._channel)
except (ValueError, KeyError):
channel = None
# 发送命令执行事件,与 message.py 中的方式一致
eventmanager.send_event(
EventType.CommandExcute,
{
"cmd": command,
"user": self._user_id,
"channel": channel,
"source": self._source,
},
)
result = {
"success": True,
"message": f"命令 {cmd_name} 已触发执行",
"command": command,
"command_desc": matched_command.get("desc", ""),
"plugin_id": matched_command.get("pid", ""),
}
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,
)

View File

@@ -403,16 +403,16 @@ class ChainBase(metaclass=ABCMeta):
:return: 识别的媒体信息,包括剧集信息
"""
# 识别用名中含指定信息情形
if not mtype and meta and meta.type in [MediaType.TV, MediaType.MOVIE]:
mtype = meta.type
if not tmdbid and hasattr(meta, "tmdbid"):
tmdbid = meta.tmdbid
if not doubanid and hasattr(meta, "doubanid"):
doubanid = meta.doubanid
# 有tmdbid时不使用其它ID
# 有tmdbid时不使用meta推断的类型由消歧逻辑决定不使用其它ID
if tmdbid:
doubanid = None
bangumiid = None
elif not mtype and meta and meta.type in [MediaType.TV, MediaType.MOVIE]:
mtype = meta.type
with fresh(not cache):
return self.run_module(
"recognize_media",
@@ -447,16 +447,16 @@ class ChainBase(metaclass=ABCMeta):
:return: 识别的媒体信息,包括剧集信息
"""
# 识别用名中含指定信息情形
if not mtype and meta and meta.type in [MediaType.TV, MediaType.MOVIE]:
mtype = meta.type
if not tmdbid and hasattr(meta, "tmdbid"):
tmdbid = meta.tmdbid
if not doubanid and hasattr(meta, "doubanid"):
doubanid = meta.doubanid
# 有tmdbid时不使用其它ID
# 有tmdbid时不使用meta推断的类型由消歧逻辑决定不使用其它ID
if tmdbid:
doubanid = None
bangumiid = None
elif not mtype and meta and meta.type in [MediaType.TV, MediaType.MOVIE]:
mtype = meta.type
async with async_fresh(not cache):
return await self.async_run_module(
"async_recognize_media",

View File

@@ -529,6 +529,10 @@ class ConfigModel(BaseModel):
AI_RECOMMEND_MAX_ITEMS: int = 50
# LLM工具选择中间件最大工具数量0为不启用工具选择中间件
LLM_MAX_TOOLS: int = 0
# AI智能体定时任务检查间隔小时0为不启用默认24小时
AI_AGENT_JOB_INTERVAL: int = 0
# AI智能体啰嗦模式开启后会回复工具调用过程
AI_AGENT_VERBOSE: bool = False
class Settings(BaseSettings, ConfigModel, LogConfigModel):

41
app/core/meta/infopath.py Normal file
View File

@@ -0,0 +1,41 @@
import regex as re
from app.core.meta.metabase import MetaBase
from app.utils.string import StringUtils
AUXILIARY_CN_STEM_FULLMATCH_RE = re.compile(
r"^(双语|字幕|特效|内封|外挂|官译|简体|繁体|繁中|简中|中英|简英|多语|"
r"国英|台粤|音轨|评论|国配|台配|粤语|韩语|日语|杜比|全景声|无损|中字|"
r"国语|原声)+$"
)
def should_use_parent_title_for_file_stem(
stem: str, parent_dir_name: str, file_meta: MetaBase
) -> bool:
"""
文件名(无后缀)是否仅为简繁体/字幕/特效等辅助说明,应改用父目录标题识别。
要求:
- stem 纯中文且能被辅助关键词完全覆盖(无残留有意义汉字)
- 父目录含拉丁字母,避免纯中文资源目录误把正片中文名当标签清空
"""
if not file_meta.isfile or not stem or not parent_dir_name:
return False
if file_meta.tmdbid or file_meta.doubanid:
return False
if not re.search(r"[A-Za-z]{2,}", parent_dir_name):
return False
if not StringUtils.is_all_chinese(stem):
return False
if len(stem) > 16:
return False
if not AUXILIARY_CN_STEM_FULLMATCH_RE.match(stem):
return False
if re.search(r"[第共]\s*[0-9一二三四五六七八九十百零]+\s*[季集话話]", stem):
return False
return True
def clear_parsed_title_for_parent_merge(meta: MetaBase) -> None:
meta.cn_name = None
meta.en_name = None

View File

@@ -5,6 +5,10 @@ import regex as re
from app.core.config import settings
from app.core.meta import MetaAnime, MetaVideo, MetaBase
from app.core.meta.infopath import (
clear_parsed_title_for_parent_merge,
should_use_parent_title_for_file_stem,
)
from app.core.meta.words import WordsMatcher
from app.log import logger
from app.schemas.types import MediaType
@@ -71,6 +75,8 @@ def MetaInfoPath(path: Path, custom_words: List[str] = None) -> MetaBase:
"""
# 文件元数据,不包含后缀
file_meta = MetaInfo(title=path.name, custom_words=custom_words)
if should_use_parent_title_for_file_stem(path.stem, path.parent.name, file_meta):
clear_parsed_title_for_parent_merge(file_meta)
# 上级目录元数据
dir_meta = MetaInfo(title=path.parent.name, custom_words=custom_words)
if file_meta.type == MediaType.TV or dir_meta.type != MediaType.TV:

View File

@@ -12,6 +12,7 @@ class DownloadHistory(Base):
"""
下载历史记录
"""
id = get_id_column()
# 保存路径
path = Column(String, nullable=False, index=True)
@@ -61,32 +62,73 @@ class DownloadHistory(Base):
@classmethod
@db_query
def get_by_hash(cls, db: Session, download_hash: str):
return db.query(DownloadHistory).filter(DownloadHistory.download_hash == download_hash).order_by(
DownloadHistory.date.desc()
).first()
return (
db.query(DownloadHistory)
.filter(DownloadHistory.download_hash == download_hash)
.order_by(DownloadHistory.date.desc())
.first()
)
@classmethod
@db_query
def get_by_mediaid(cls, db: Session, tmdbid: int, doubanid: str):
if tmdbid:
return db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid).all()
return (
db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid).all()
)
elif doubanid:
return db.query(DownloadHistory).filter(DownloadHistory.doubanid == doubanid).all()
return (
db.query(DownloadHistory)
.filter(DownloadHistory.doubanid == doubanid)
.all()
)
return []
@classmethod
@db_query
def list_by_page(cls, db: Session, page: Optional[int] = 1, count: Optional[int] = 30):
def list_by_page(
cls, db: Session, page: Optional[int] = 1, count: Optional[int] = 30
):
return db.query(DownloadHistory).offset((page - 1) * count).limit(count).all()
@classmethod
@async_db_query
async def async_list_by_page(cls, db: AsyncSession, page: Optional[int] = 1, count: Optional[int] = 30):
result = await db.execute(
select(cls).offset((page - 1) * count).limit(count)
)
async def async_list_by_page(
cls, db: AsyncSession, page: Optional[int] = 1, count: Optional[int] = 30
):
result = await db.execute(select(cls).offset((page - 1) * count).limit(count))
return result.scalars().all()
@classmethod
@async_db_query
async def async_list_by_title(
cls,
db: AsyncSession,
title: str,
page: Optional[int] = 1,
count: Optional[int] = 30,
):
query = (
select(cls).filter(cls.title.like(f"%{title}%")).order_by(cls.date.desc())
)
query = query.offset((page - 1) * count).limit(count)
result = await db.execute(query)
return result.scalars().all()
@classmethod
@async_db_query
async def async_count(cls, db: AsyncSession):
result = await db.execute(select(func.count(cls.id)))
return result.scalar()
@classmethod
@async_db_query
async def async_count_by_title(cls, db: AsyncSession, title: str):
result = await db.execute(
select(func.count(cls.id)).filter(cls.title.like(f"%{title}%"))
)
return result.scalar()
@classmethod
@db_query
def get_by_path(cls, db: Session, path: str):
@@ -94,9 +136,16 @@ class DownloadHistory(Base):
@classmethod
@db_query
def get_last_by(cls, db: Session, mtype: Optional[str] = None, title: Optional[str] = None,
year: Optional[str] = None, season: Optional[str] = None,
episode: Optional[str] = None, tmdbid: Optional[int] = None):
def get_last_by(
cls,
db: Session,
mtype: Optional[str] = None,
title: Optional[str] = None,
year: Optional[str] = None,
season: Optional[str] = None,
episode: Optional[str] = None,
tmdbid: Optional[int] = None,
):
"""
据tmdbid、season、season_episode查询下载记录
tmdbid + mtype 或 title + year
@@ -105,42 +154,76 @@ class DownloadHistory(Base):
if tmdbid and mtype:
# 电视剧某季某集
if season is not None and episode:
return db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
DownloadHistory.type == mtype,
DownloadHistory.seasons == season,
DownloadHistory.episodes == episode).order_by(
DownloadHistory.id.desc()).all()
return (
db.query(DownloadHistory)
.filter(
DownloadHistory.tmdbid == tmdbid,
DownloadHistory.type == mtype,
DownloadHistory.seasons == season,
DownloadHistory.episodes == episode,
)
.order_by(DownloadHistory.id.desc())
.all()
)
# 电视剧某季
elif season is not None:
return db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
DownloadHistory.type == mtype,
DownloadHistory.seasons == season).order_by(
DownloadHistory.id.desc()).all()
return (
db.query(DownloadHistory)
.filter(
DownloadHistory.tmdbid == tmdbid,
DownloadHistory.type == mtype,
DownloadHistory.seasons == season,
)
.order_by(DownloadHistory.id.desc())
.all()
)
else:
# 电视剧所有季集/电影
return db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
DownloadHistory.type == mtype).order_by(
DownloadHistory.id.desc()).all()
return (
db.query(DownloadHistory)
.filter(
DownloadHistory.tmdbid == tmdbid, DownloadHistory.type == mtype
)
.order_by(DownloadHistory.id.desc())
.all()
)
# 标题 + 年份
elif title and year:
# 电视剧某季某集
if season is not None and episode:
return db.query(DownloadHistory).filter(DownloadHistory.title == title,
DownloadHistory.year == year,
DownloadHistory.seasons == season,
DownloadHistory.episodes == episode).order_by(
DownloadHistory.id.desc()).all()
return (
db.query(DownloadHistory)
.filter(
DownloadHistory.title == title,
DownloadHistory.year == year,
DownloadHistory.seasons == season,
DownloadHistory.episodes == episode,
)
.order_by(DownloadHistory.id.desc())
.all()
)
# 电视剧某季
elif season is not None:
return db.query(DownloadHistory).filter(DownloadHistory.title == title,
DownloadHistory.year == year,
DownloadHistory.seasons == season).order_by(
DownloadHistory.id.desc()).all()
return (
db.query(DownloadHistory)
.filter(
DownloadHistory.title == title,
DownloadHistory.year == year,
DownloadHistory.seasons == season,
)
.order_by(DownloadHistory.id.desc())
.all()
)
else:
# 电视剧所有季集/电影
return db.query(DownloadHistory).filter(DownloadHistory.title == title,
DownloadHistory.year == year).order_by(
DownloadHistory.id.desc()).all()
return (
db.query(DownloadHistory)
.filter(
DownloadHistory.title == title, DownloadHistory.year == year
)
.order_by(DownloadHistory.id.desc())
.all()
)
return []
@@ -151,45 +234,80 @@ class DownloadHistory(Base):
查询某用户某时间之后的下载历史
"""
if username:
return db.query(DownloadHistory).filter(DownloadHistory.date < date,
DownloadHistory.username == username).order_by(
DownloadHistory.id.desc()).all()
return (
db.query(DownloadHistory)
.filter(
DownloadHistory.date < date, DownloadHistory.username == username
)
.order_by(DownloadHistory.id.desc())
.all()
)
else:
return db.query(DownloadHistory).filter(DownloadHistory.date < date).order_by(
DownloadHistory.id.desc()).all()
return (
db.query(DownloadHistory)
.filter(DownloadHistory.date < date)
.order_by(DownloadHistory.id.desc())
.all()
)
@classmethod
@db_query
def list_by_date(cls, db: Session, date: str, type: str, tmdbid: str, seasons: Optional[str] = None):
def list_by_date(
cls,
db: Session,
date: str,
type: str,
tmdbid: str,
seasons: Optional[str] = None,
):
"""
查询某时间之后的下载历史
"""
if seasons:
return db.query(DownloadHistory).filter(DownloadHistory.date > date,
DownloadHistory.type == type,
DownloadHistory.tmdbid == tmdbid,
DownloadHistory.seasons == seasons).order_by(
DownloadHistory.id.desc()).all()
return (
db.query(DownloadHistory)
.filter(
DownloadHistory.date > date,
DownloadHistory.type == type,
DownloadHistory.tmdbid == tmdbid,
DownloadHistory.seasons == seasons,
)
.order_by(DownloadHistory.id.desc())
.all()
)
else:
return db.query(DownloadHistory).filter(DownloadHistory.date > date,
DownloadHistory.type == type,
DownloadHistory.tmdbid == tmdbid).order_by(
DownloadHistory.id.desc()).all()
return (
db.query(DownloadHistory)
.filter(
DownloadHistory.date > date,
DownloadHistory.type == type,
DownloadHistory.tmdbid == tmdbid,
)
.order_by(DownloadHistory.id.desc())
.all()
)
@classmethod
@db_query
def list_by_type(cls, db: Session, mtype: str, days: int):
return db.query(DownloadHistory) \
.filter(DownloadHistory.type == mtype,
DownloadHistory.date >= time.strftime("%Y-%m-%d %H:%M:%S",
time.localtime(time.time() - 86400 * int(days)))
).all()
return (
db.query(DownloadHistory)
.filter(
DownloadHistory.type == mtype,
DownloadHistory.date
>= time.strftime(
"%Y-%m-%d %H:%M:%S", time.localtime(time.time() - 86400 * int(days))
),
)
.all()
)
class DownloadFiles(Base):
"""
下载文件记录
"""
id = get_id_column()
# 下载器
downloader = Column(String)
@@ -210,8 +328,11 @@ class DownloadFiles(Base):
@db_query
def get_by_hash(cls, db: Session, download_hash: str, state: Optional[int] = None):
if state is not None:
return db.query(cls).filter(cls.download_hash == download_hash,
cls.state == state).all()
return (
db.query(cls)
.filter(cls.download_hash == download_hash, cls.state == state)
.all()
)
else:
return db.query(cls).filter(cls.download_hash == download_hash).all()
@@ -219,11 +340,19 @@ class DownloadFiles(Base):
@db_query
def get_by_fullpath(cls, db: Session, fullpath: str, all_files: bool = False):
if not all_files:
return db.query(cls).filter(cls.fullpath == fullpath).order_by(
cls.id.desc()).first()
return (
db.query(cls)
.filter(cls.fullpath == fullpath)
.order_by(cls.id.desc())
.first()
)
else:
return db.query(cls).filter(cls.fullpath == fullpath).order_by(
cls.id.desc()).all()
return (
db.query(cls)
.filter(cls.fullpath == fullpath)
.order_by(cls.id.desc())
.all()
)
@classmethod
@db_query
@@ -233,9 +362,6 @@ class DownloadFiles(Base):
@classmethod
@db_update
def delete_by_fullpath(cls, db: Session, fullpath: str):
db.query(cls).filter(cls.fullpath == fullpath,
cls.state == 1).update(
{
"state": 0
}
db.query(cls).filter(cls.fullpath == fullpath, cls.state == 1).update(
{"state": 0}
)

View File

@@ -4,7 +4,7 @@ from typing import Optional, Union, List, Tuple, Any
from app.core.context import MediaInfo, Context
from app.log import logger
from app.modules import _ModuleBase, _MessageBase
from app.schemas import MessageChannel, CommingMessage, Notification
from app.schemas import MessageChannel, CommingMessage, Notification, MessageResponse
from app.schemas.types import ModuleType
try:
@@ -15,7 +15,6 @@ except Exception as err: # ImportError or other load issues
class DiscordModule(_ModuleBase, _MessageBase[Discord]):
def init_module(self) -> None:
"""
初始化模块
@@ -24,8 +23,9 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]):
logger.error("Discord 依赖未就绪(需要安装 discord.py==2.6.4),模块未启动")
return
self.stop()
super().init_service(service_name=Discord.__name__.lower(),
service_type=Discord)
super().init_service(
service_name=Discord.__name__.lower(), service_type=Discord
)
self._channel = MessageChannel.Discord
@staticmethod
@@ -75,7 +75,9 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]):
def init_setting(self) -> Tuple[str, Union[str, bool]]:
pass
def message_parser(self, source: str, body: Any, form: Any, args: Any) -> Optional[CommingMessage]:
def message_parser(
self, source: str, body: Any, form: Any, args: Any
) -> Optional[CommingMessage]:
"""
解析消息内容,返回字典,注意以下约定值:
userid: 用户ID
@@ -108,8 +110,10 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]):
message_id = msg_json.get("message_id")
chat_id = msg_json.get("chat_id")
if callback_data and userid:
logger.info(f"收到来自 {client_config.name} 的 Discord 按钮回调:"
f"userid={userid}, username={username}, callback_data={callback_data}")
logger.info(
f"收到来自 {client_config.name} 的 Discord 按钮回调:"
f"userid={userid}, username={username}, callback_data={callback_data}"
)
return CommingMessage(
channel=MessageChannel.Discord,
source=client_config.name,
@@ -119,7 +123,7 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]):
is_callback=True,
callback_data=callback_data,
message_id=message_id,
chat_id=str(chat_id) if chat_id else None
chat_id=str(chat_id) if chat_id else None,
)
return None
@@ -127,11 +131,18 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]):
text = msg_json.get("text")
chat_id = msg_json.get("chat_id")
if text and userid:
logger.info(f"收到来自 {client_config.name} 的 Discord 消息:"
f"userid={userid}, username={username}, text={text}")
return CommingMessage(channel=MessageChannel.Discord, source=client_config.name,
userid=userid, username=username, text=text,
chat_id=str(chat_id) if chat_id else None)
logger.info(
f"收到来自 {client_config.name} 的 Discord 消息:"
f"userid={userid}, username={username}, text={text}"
)
return CommingMessage(
channel=MessageChannel.Discord,
source=client_config.name,
userid=userid,
username=username,
text=text,
chat_id=str(chat_id) if chat_id else None,
)
return None
def post_message(self, message: Notification, **kwargs) -> None:
@@ -141,43 +152,66 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]):
"""
# DEBUG: Log entry and configs
configs = self.get_configs()
logger.debug(f"[Discord] post_message 被调用message.source={message.source}, "
f"message.userid={message.userid}, message.channel={message.channel}")
logger.debug(f"[Discord] 当前配置数量: {len(configs)}, 配置名称: {list(configs.keys())}")
logger.debug(f"[Discord] 当前实例数量: {len(self.get_instances())}, 实例名称: {list(self.get_instances().keys())}")
logger.debug(
f"[Discord] post_message 被调用,message.source={message.source}, "
f"message.userid={message.userid}, message.channel={message.channel}"
)
logger.debug(
f"[Discord] 当前配置数量: {len(configs)}, 配置名称: {list(configs.keys())}"
)
logger.debug(
f"[Discord] 当前实例数量: {len(self.get_instances())}, 实例名称: {list(self.get_instances().keys())}"
)
if not configs:
logger.warning("[Discord] get_configs() 返回空,没有可用的 Discord 配置")
return
for conf in configs.values():
logger.debug(f"[Discord] 检查配置: name={conf.name}, type={conf.type}, enabled={conf.enabled}")
logger.debug(
f"[Discord] 检查配置: name={conf.name}, type={conf.type}, enabled={conf.enabled}"
)
if not self.check_message(message, conf.name):
logger.debug(f"[Discord] check_message 返回 False跳过配置: {conf.name}")
logger.debug(
f"[Discord] check_message 返回 False跳过配置: {conf.name}"
)
continue
logger.debug(f"[Discord] check_message 通过,准备发送到: {conf.name}")
targets = message.targets
userid = message.userid
if not userid and targets is not None:
userid = targets.get('discord_userid')
userid = targets.get("discord_userid")
if not userid:
logger.warn("用户没有指定 Discord 用户ID消息无法发送")
return
client: Discord = self.get_instance(conf.name)
logger.debug(f"[Discord] get_instance('{conf.name}') 返回: {client is not None}")
logger.debug(
f"[Discord] get_instance('{conf.name}') 返回: {client is not None}"
)
if client:
logger.debug(f"[Discord] 调用 client.send_msg, userid={userid}, title={message.title[:50] if message.title else None}...")
result = client.send_msg(title=message.title, text=message.text,
image=message.image, userid=userid, link=message.link,
buttons=message.buttons,
original_message_id=message.original_message_id,
original_chat_id=message.original_chat_id,
mtype=message.mtype)
logger.debug(
f"[Discord] 调用 client.send_msg, userid={userid}, title={message.title[:50] if message.title else None}..."
)
result = client.send_msg(
title=message.title,
text=message.text,
image=message.image,
userid=userid,
link=message.link,
buttons=message.buttons,
original_message_id=message.original_message_id,
original_chat_id=message.original_chat_id,
mtype=message.mtype,
)
logger.debug(f"[Discord] send_msg 返回结果: {result}")
else:
logger.warning(f"[Discord] 未找到配置 '{conf.name}' 对应的 Discord 客户端实例")
logger.warning(
f"[Discord] 未找到配置 '{conf.name}' 对应的 Discord 客户端实例"
)
def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None:
def post_medias_message(
self, message: Notification, medias: List[MediaInfo]
) -> None:
"""
发送媒体信息选择列表
:param message: 消息体
@@ -189,12 +223,18 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]):
continue
client: Discord = self.get_instance(conf.name)
if client:
client.send_medias_msg(title=message.title, medias=medias, userid=message.userid,
buttons=message.buttons,
original_message_id=message.original_message_id,
original_chat_id=message.original_chat_id)
client.send_medias_msg(
title=message.title,
medias=medias,
userid=message.userid,
buttons=message.buttons,
original_message_id=message.original_message_id,
original_chat_id=message.original_chat_id,
)
def post_torrents_message(self, message: Notification, torrents: List[Context]) -> None:
def post_torrents_message(
self, message: Notification, torrents: List[Context]
) -> None:
"""
发送种子信息选择列表
:param message: 消息体
@@ -206,13 +246,22 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]):
continue
client: Discord = self.get_instance(conf.name)
if client:
client.send_torrents_msg(title=message.title, torrents=torrents,
userid=message.userid, buttons=message.buttons,
original_message_id=message.original_message_id,
original_chat_id=message.original_chat_id)
client.send_torrents_msg(
title=message.title,
torrents=torrents,
userid=message.userid,
buttons=message.buttons,
original_message_id=message.original_message_id,
original_chat_id=message.original_chat_id,
)
def delete_message(self, channel: MessageChannel, source: str,
message_id: str, chat_id: Optional[str] = None) -> bool:
def delete_message(
self,
channel: MessageChannel,
source: str,
message_id: str,
chat_id: Optional[str] = None,
) -> bool:
"""
删除消息
:param channel: 消息渠道
@@ -233,3 +282,80 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]):
if result:
success = True
return success
def edit_message(
self,
channel: MessageChannel,
source: str,
message_id: Union[str, int],
chat_id: Union[str, int],
text: str,
title: Optional[str] = None,
) -> bool:
"""
编辑消息
:param channel: 消息渠道
:param source: 指定的消息源
:param message_id: 消息ID
:param chat_id: 聊天ID
:param text: 新的消息内容
:param title: 消息标题
:return: 编辑是否成功
"""
if channel != self._channel:
return False
for conf in self.get_configs().values():
if source != conf.name:
continue
client: Discord = self.get_instance(conf.name)
if client:
result = client.send_msg(
title=title or "",
text=text,
original_message_id=message_id,
original_chat_id=str(chat_id),
)
if result and isinstance(result, tuple) and result[0]:
return True
elif result:
return True
return False
def send_direct_message(self, message: Notification) -> Optional[MessageResponse]:
"""
直接发送消息并返回消息ID等信息
:param message: 消息体
:return: 消息响应包含message_id, chat_id等
"""
for conf in self.get_configs().values():
if not self.check_message(message, conf.name):
continue
targets = message.targets
userid = message.userid
if not userid and targets is not None:
userid = targets.get("discord_userid")
if not userid:
logger.warn("用户没有指定 Discord 用户ID消息无法发送")
return None
client: Discord = self.get_instance(conf.name)
if client:
result = client.send_msg(
title=message.title or "",
text=message.text,
userid=userid,
)
if result:
success, message_id = (
(result[0], result[1])
if isinstance(result, tuple)
else (result, None)
)
if success:
return MessageResponse(
message_id=str(message_id) if message_id else None,
chat_id=None,
channel=MessageChannel.Discord,
source=conf.name,
success=True,
)
return None

View File

@@ -18,10 +18,10 @@ from app.utils.string import StringUtils
# Discord embed 字段解析白名单
# 只有这些消息类型会使用复杂的字段解析逻辑
PARSE_FIELD_TYPES = {
NotificationType.Download, # 资源下载
NotificationType.Organize, # 整理入库
NotificationType.Subscribe, # 订阅
NotificationType.Manual, # 手动处理
NotificationType.Download, # 资源下载
NotificationType.Organize, # 整理入库
NotificationType.Subscribe, # 订阅
NotificationType.Manual, # 手动处理
}
@@ -30,13 +30,18 @@ class Discord:
Discord Bot 通知与交互实现(基于 discord.py 2.6.4
"""
def __init__(self, DISCORD_BOT_TOKEN: Optional[str] = None,
DISCORD_GUILD_ID: Optional[Union[str, int]] = None,
DISCORD_CHANNEL_ID: Optional[Union[str, int]] = None,
**kwargs):
logger.debug(f"[Discord] 初始化 Discord 实例: name={kwargs.get('name')}, "
f"GUILD_ID={DISCORD_GUILD_ID}, CHANNEL_ID={DISCORD_CHANNEL_ID}, "
f"TOKEN={'已配置' if DISCORD_BOT_TOKEN else '未配置'}")
def __init__(
self,
DISCORD_BOT_TOKEN: Optional[str] = None,
DISCORD_GUILD_ID: Optional[Union[str, int]] = None,
DISCORD_CHANNEL_ID: Optional[Union[str, int]] = None,
**kwargs,
):
logger.debug(
f"[Discord] 初始化 Discord 实例: name={kwargs.get('name')}, "
f"GUILD_ID={DISCORD_GUILD_ID}, CHANNEL_ID={DISCORD_CHANNEL_ID}, "
f"TOKEN={'已配置' if DISCORD_BOT_TOKEN else '未配置'}"
)
if not DISCORD_BOT_TOKEN:
logger.error("Discord Bot Token 未配置!")
return
@@ -44,12 +49,14 @@ class Discord:
self._token = DISCORD_BOT_TOKEN
self._guild_id = self._to_int(DISCORD_GUILD_ID)
self._channel_id = self._to_int(DISCORD_CHANNEL_ID)
logger.debug(f"[Discord] 解析后的 ID: _guild_id={self._guild_id}, _channel_id={self._channel_id}")
logger.debug(
f"[Discord] 解析后的 ID: _guild_id={self._guild_id}, _channel_id={self._channel_id}"
)
base_ds_url = f"http://127.0.0.1:{settings.PORT}/api/v1/message/"
self._ds_url = f"{base_ds_url}?token={settings.API_TOKEN}"
if kwargs.get("name"):
# URL encode the source name to handle special characters in config names
encoded_name = quote(kwargs.get('name'), safe='')
encoded_name = quote(kwargs.get("name"), safe="")
self._ds_url = f"{self._ds_url}&source={encoded_name}"
logger.debug(f"[Discord] 消息回调 URL: {self._ds_url}")
@@ -59,15 +66,16 @@ class Discord:
intents.guilds = True
self._client: Optional[discord.Client] = discord.Client(
intents=intents,
proxy=settings.PROXY_HOST
intents=intents, proxy=settings.PROXY_HOST
)
self._tree: Optional[app_commands.CommandTree] = None
self._loop: asyncio.AbstractEventLoop = asyncio.new_event_loop()
self._thread: Optional[threading.Thread] = None
self._ready_event = threading.Event()
self._user_dm_cache: Dict[str, discord.DMChannel] = {}
self._user_chat_mapping: Dict[str, str] = {} # userid -> chat_id mapping for reply targeting
self._user_chat_mapping: Dict[
str, str
] = {} # userid -> chat_id mapping for reply targeting
self._broadcast_channel = None
self._bot_user_id: Optional[int] = None
@@ -96,10 +104,16 @@ class Discord:
return
# Update user-chat mapping for reply targeting
self._update_user_chat_mapping(str(message.author.id), str(message.channel.id))
self._update_user_chat_mapping(
str(message.author.id), str(message.channel.id)
)
cleaned_text = self._clean_bot_mention(message.content or "")
username = message.author.display_name or message.author.global_name or message.author.name
username = (
message.author.display_name
or message.author.global_name
or message.author.name
)
payload = {
"type": "message",
"userid": str(message.author.id),
@@ -108,7 +122,9 @@ class Discord:
"text": cleaned_text,
"message_id": str(message.id),
"chat_id": str(message.channel.id),
"channel_type": "dm" if isinstance(message.channel, discord.DMChannel) else "guild"
"channel_type": "dm"
if isinstance(message.channel, discord.DMChannel)
else "guild",
}
await self._post_to_ds(payload)
@@ -126,18 +142,31 @@ class Discord:
# Update user-chat mapping for reply targeting
if interaction.user and interaction.channel:
self._update_user_chat_mapping(str(interaction.user.id), str(interaction.channel.id))
self._update_user_chat_mapping(
str(interaction.user.id), str(interaction.channel.id)
)
username = (interaction.user.display_name or interaction.user.global_name or interaction.user.name) \
if interaction.user else None
username = (
(
interaction.user.display_name
or interaction.user.global_name
or interaction.user.name
)
if interaction.user
else None
)
payload = {
"type": "interaction",
"userid": str(interaction.user.id) if interaction.user else None,
"username": username,
"user_tag": str(interaction.user) if interaction.user else None,
"callback_data": callback_data,
"message_id": str(interaction.message.id) if interaction.message else None,
"chat_id": str(interaction.channel.id) if interaction.channel else None
"message_id": str(interaction.message.id)
if interaction.message
else None,
"chat_id": str(interaction.channel.id)
if interaction.channel
else None,
}
await self._post_to_ds(payload)
@@ -165,7 +194,9 @@ class Discord:
if not self._client or not self._loop or not self._thread:
return
try:
asyncio.run_coroutine_threadsafe(self._client.close(), self._loop).result(timeout=10)
asyncio.run_coroutine_threadsafe(self._client.close(), self._loop).result(
timeout=10
)
except Exception as err:
logger.error(f"关闭 Discord Bot 失败:{err}")
finally:
@@ -178,16 +209,26 @@ class Discord:
def get_state(self) -> bool:
return self._ready_event.is_set() and self._client is not None
def send_msg(self, title: str, text: Optional[str] = None, image: Optional[str] = None,
userid: Optional[str] = None, link: Optional[str] = None,
buttons: Optional[List[List[dict]]] = None,
original_message_id: Optional[Union[int, str]] = None,
original_chat_id: Optional[str] = None,
mtype: Optional['NotificationType'] = None) -> Optional[bool]:
logger.debug(f"[Discord] send_msg 被调用: userid={userid}, title={title[:50] if title else None}...")
logger.debug(f"[Discord] get_state() = {self.get_state()}, "
f"_ready_event.is_set() = {self._ready_event.is_set()}, "
f"_client = {self._client is not None}")
def send_msg(
self,
title: str,
text: Optional[str] = None,
image: Optional[str] = None,
userid: Optional[str] = None,
link: Optional[str] = None,
buttons: Optional[List[List[dict]]] = None,
original_message_id: Optional[Union[int, str]] = None,
original_chat_id: Optional[str] = None,
mtype: Optional["NotificationType"] = None,
) -> Optional[bool]:
logger.debug(
f"[Discord] send_msg 被调用: userid={userid}, title={title[:50] if title else None}..."
)
logger.debug(
f"[Discord] get_state() = {self.get_state()}, "
f"_ready_event.is_set() = {self._ready_event.is_set()}, "
f"_client = {self._client is not None}"
)
if not self.get_state():
logger.warning("[Discord] get_state() 返回 FalseBot 未就绪,无法发送消息")
return False
@@ -198,12 +239,19 @@ class Discord:
try:
logger.debug(f"[Discord] 准备异步发送消息...")
future = asyncio.run_coroutine_threadsafe(
self._send_message(title=title, text=text, image=image, userid=userid,
link=link, buttons=buttons,
original_message_id=original_message_id,
original_chat_id=original_chat_id,
mtype=mtype),
self._loop)
self._send_message(
title=title,
text=text,
image=image,
userid=userid,
link=link,
buttons=buttons,
original_message_id=original_message_id,
original_chat_id=original_chat_id,
mtype=mtype,
),
self._loop,
)
result = future.result(timeout=30)
logger.debug(f"[Discord] 异步发送完成,结果: {result}")
return result
@@ -211,10 +259,15 @@ class Discord:
logger.error(f"发送 Discord 消息失败:{err}")
return False
def send_medias_msg(self, medias: List[MediaInfo], userid: Optional[str] = None, title: Optional[str] = None,
buttons: Optional[List[List[dict]]] = None,
original_message_id: Optional[Union[int, str]] = None,
original_chat_id: Optional[str] = None) -> Optional[bool]:
def send_medias_msg(
self,
medias: List[MediaInfo],
userid: Optional[str] = None,
title: Optional[str] = None,
buttons: Optional[List[List[dict]]] = None,
original_message_id: Optional[Union[int, str]] = None,
original_chat_id: Optional[str] = None,
) -> Optional[bool]:
if not self.get_state() or not medias:
return False
title = title or "媒体列表"
@@ -223,22 +276,29 @@ class Discord:
self._send_list_message(
embeds=self._build_media_embeds(medias, title),
userid=userid,
buttons=self._build_default_buttons(len(medias)) if not buttons else buttons,
buttons=self._build_default_buttons(len(medias))
if not buttons
else buttons,
fallback_buttons=buttons,
original_message_id=original_message_id,
original_chat_id=original_chat_id
original_chat_id=original_chat_id,
),
self._loop
self._loop,
)
return future.result(timeout=30)
except Exception as err:
logger.error(f"发送 Discord 媒体列表失败:{err}")
return False
def send_torrents_msg(self, torrents: List[Context], userid: Optional[str] = None, title: Optional[str] = None,
buttons: Optional[List[List[dict]]] = None,
original_message_id: Optional[Union[int, str]] = None,
original_chat_id: Optional[str] = None) -> Optional[bool]:
def send_torrents_msg(
self,
torrents: List[Context],
userid: Optional[str] = None,
title: Optional[str] = None,
buttons: Optional[List[List[dict]]] = None,
original_message_id: Optional[Union[int, str]] = None,
original_chat_id: Optional[str] = None,
) -> Optional[bool]:
if not self.get_state() or not torrents:
return False
title = title or "种子列表"
@@ -247,68 +307,92 @@ class Discord:
self._send_list_message(
embeds=self._build_torrent_embeds(torrents, title),
userid=userid,
buttons=self._build_default_buttons(len(torrents)) if not buttons else buttons,
buttons=self._build_default_buttons(len(torrents))
if not buttons
else buttons,
fallback_buttons=buttons,
original_message_id=original_message_id,
original_chat_id=original_chat_id
original_chat_id=original_chat_id,
),
self._loop
self._loop,
)
return future.result(timeout=30)
except Exception as err:
logger.error(f"发送 Discord 种子列表失败:{err}")
return False
def delete_msg(self, message_id: Union[str, int], chat_id: Optional[str] = None) -> Optional[bool]:
def delete_msg(
self, message_id: Union[str, int], chat_id: Optional[str] = None
) -> Optional[bool]:
if not self.get_state():
return False
try:
future = asyncio.run_coroutine_threadsafe(
self._delete_message(message_id=message_id, chat_id=chat_id),
self._loop
self._delete_message(message_id=message_id, chat_id=chat_id), self._loop
)
return future.result(timeout=15)
except Exception as err:
logger.error(f"删除 Discord 消息失败:{err}")
return False
async def _send_message(self, title: str, text: Optional[str], image: Optional[str],
userid: Optional[str], link: Optional[str],
buttons: Optional[List[List[dict]]],
original_message_id: Optional[Union[int, str]],
original_chat_id: Optional[str],
mtype: Optional['NotificationType'] = None) -> bool:
logger.debug(f"[Discord] _send_message: userid={userid}, original_chat_id={original_chat_id}")
async def _send_message(
self,
title: str,
text: Optional[str],
image: Optional[str],
userid: Optional[str],
link: Optional[str],
buttons: Optional[List[List[dict]]],
original_message_id: Optional[Union[int, str]],
original_chat_id: Optional[str],
mtype: Optional["NotificationType"] = None,
) -> Tuple[bool, Optional[int]]:
logger.debug(
f"[Discord] _send_message: userid={userid}, original_chat_id={original_chat_id}"
)
channel = await self._resolve_channel(userid=userid, chat_id=original_chat_id)
logger.debug(f"[Discord] _resolve_channel 返回: {channel}, type={type(channel)}")
logger.debug(
f"[Discord] _resolve_channel 返回: {channel}, type={type(channel)}"
)
if not channel:
logger.error("未找到可用的 Discord 频道或私聊")
return False
return False, None
embed = self._build_embed(title=title, text=text, image=image, link=link, mtype=mtype)
embed = self._build_embed(
title=title, text=text, image=image, link=link, mtype=mtype
)
view = self._build_view(buttons=buttons, link=link)
content = None
if original_message_id and original_chat_id:
logger.debug(f"[Discord] 编辑现有消息: message_id={original_message_id}")
return await self._edit_message(chat_id=original_chat_id, message_id=original_message_id,
content=content, embed=embed, view=view)
success = await self._edit_message(
chat_id=original_chat_id,
message_id=original_message_id,
content=content,
embed=embed,
view=view,
)
return success, int(original_message_id) if original_message_id else None
logger.debug(f"[Discord] 发送新消息到频道: {channel}")
try:
await channel.send(content=content, embed=embed, view=view)
sent_message = await channel.send(content=content, embed=embed, view=view)
logger.debug("[Discord] 消息发送成功")
return True
return True, sent_message.id if sent_message else None
except Exception as e:
logger.error(f"[Discord] 发送消息到频道失败: {e}")
return False
return False, None
async def _send_list_message(self, embeds: List[discord.Embed],
userid: Optional[str],
buttons: Optional[List[List[dict]]],
fallback_buttons: Optional[List[List[dict]]],
original_message_id: Optional[Union[int, str]],
original_chat_id: Optional[str]) -> bool:
async def _send_list_message(
self,
embeds: List[discord.Embed],
userid: Optional[str],
buttons: Optional[List[List[dict]]],
fallback_buttons: Optional[List[List[dict]]],
original_message_id: Optional[Union[int, str]],
original_chat_id: Optional[str],
) -> bool:
channel = await self._resolve_channel(userid=userid, chat_id=original_chat_id)
if not channel:
logger.error("未找到可用的 Discord 频道或私聊")
@@ -318,17 +402,31 @@ class Discord:
embeds = embeds[:10] if embeds else [] # Discord 单条消息最多 10 个 embed
if original_message_id and original_chat_id:
return await self._edit_message(chat_id=original_chat_id, message_id=original_message_id,
content=None, embed=None, view=view, embeds=embeds)
return await self._edit_message(
chat_id=original_chat_id,
message_id=original_message_id,
content=None,
embed=None,
view=view,
embeds=embeds,
)
await channel.send(embed=embeds[0] if len(embeds) == 1 else None,
embeds=embeds if len(embeds) > 1 else None,
view=view)
await channel.send(
embed=embeds[0] if len(embeds) == 1 else None,
embeds=embeds if len(embeds) > 1 else None,
view=view,
)
return True
async def _edit_message(self, chat_id: Union[str, int], message_id: Union[str, int],
content: Optional[str], embed: Optional[discord.Embed],
view: Optional[discord.ui.View], embeds: Optional[List[discord.Embed]] = None) -> bool:
async def _edit_message(
self,
chat_id: Union[str, int],
message_id: Union[str, int],
content: Optional[str],
embed: Optional[discord.Embed],
view: Optional[discord.ui.View],
embeds: Optional[List[discord.Embed]] = None,
) -> bool:
channel = await self._resolve_channel(chat_id=str(chat_id))
if not channel:
logger.error(f"未找到要编辑的 Discord 频道:{chat_id}")
@@ -349,7 +447,9 @@ class Discord:
logger.error(f"编辑 Discord 消息失败:{err}")
return False
async def _delete_message(self, message_id: Union[str, int], chat_id: Optional[str]) -> bool:
async def _delete_message(
self, message_id: Union[str, int], chat_id: Optional[str]
) -> bool:
channel = await self._resolve_channel(chat_id=chat_id)
if not channel:
logger.error("删除 Discord 消息时未找到频道")
@@ -363,11 +463,17 @@ class Discord:
return False
@staticmethod
def _build_embed(title: str, text: Optional[str], image: Optional[str],
link: Optional[str], mtype: Optional['NotificationType'] = None) -> discord.Embed:
def _build_embed(
title: str,
text: Optional[str],
image: Optional[str],
link: Optional[str],
mtype: Optional["NotificationType"] = None,
) -> discord.Embed:
fields: List[Dict[str, str]] = []
desc_lines: List[str] = []
should_parse_fields = mtype in PARSE_FIELD_TYPES if mtype else False
def _collect_spans(s: str, left: str, right: str) -> List[Tuple[int, int]]:
spans: List[Tuple[int, int]] = []
start = 0
@@ -383,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
@@ -392,7 +498,11 @@ class Discord:
if text:
# 处理上游未反序列化的 "\n" 等转义换行,避免被当成普通字符
if "\\n" in text or "\\r" in text:
text = text.replace("\\r\\n", "\n").replace("\\n", "\n").replace("\\r", "\n")
text = (
text.replace("\\r\\n", "\n")
.replace("\\n", "\n")
.replace("\\r", "\n")
)
if not should_parse_fields:
desc_lines.append(text.strip())
else:
@@ -410,12 +520,16 @@ class Discord:
continue
matches = list(pair_pattern.finditer(line))
if matches:
book_spans = _collect_spans(line, "", "") + _collect_spans(line, "", "")
book_spans = _collect_spans(line, "", "") + _collect_spans(
line, "", ""
)
if book_spans:
has_book_colon = False
for m in matches:
colon_idx = _find_colon_index(line, m)
if colon_idx is not None and any(l < colon_idx < r for l, r in book_spans):
if colon_idx is not None and any(
l < colon_idx < r for l, r in book_spans
):
has_book_colon = True
break
if has_book_colon:
@@ -423,20 +537,25 @@ class Discord:
continue
# 若整行只是 URL/时间等自然包含":"的内容,则不当作字段
url_like_names = {"http", "https", "ftp", "ftps", "magnet"}
if all(m.group(1).lower() in url_like_names or m.group(1).isdigit() for m in matches):
if all(
m.group(1).lower() in url_like_names or m.group(1).isdigit()
for m in matches
):
desc_lines.append(line)
continue
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)
name = m.group(1).strip()
value = m.group(2).strip(" ,;;。、\t") or "-"
if name:
fields.append({"name": name, "value": value, "inline": False})
fields.append(
{"name": name, "value": value, "inline": False}
)
last_end = m.end()
# 匹配末尾后的文本
suffix = line[last_end:].strip(" ,;;。、")
@@ -451,7 +570,7 @@ class Discord:
title=title,
url=link or "https://github.com/jxxghp/MoviePilot",
description=description if description else None,
color=0xE67E22
color=0xE67E22,
)
for field in fields:
embed.add_field(name=field["name"], value=field["value"], inline=False)
@@ -465,14 +584,16 @@ class Discord:
for index, media in enumerate(medias[:10], start=1):
overview = media.get_overview_string(80)
desc_parts = [
f"{media.type.value} | {media.vote_star}" if media.vote_star else media.type.value,
overview
f"{media.type.value} | {media.vote_star}"
if media.vote_star
else media.type.value,
overview,
]
embed = discord.Embed(
title=f"{index}. {media.title_year}",
url=media.detail_link or discord.Embed.Empty,
description="\n".join([p for p in desc_parts if p]),
color=0x5865F2
color=0x5865F2,
)
if media.get_poster_image():
embed.set_thumbnail(url=media.get_poster_image())
@@ -482,7 +603,9 @@ class Discord:
return embeds
@staticmethod
def _build_torrent_embeds(torrents: List[Context], title: str) -> List[discord.Embed]:
def _build_torrent_embeds(
torrents: List[Context], title: str
) -> List[discord.Embed]:
embeds: List[discord.Embed] = []
for index, context in enumerate(torrents[:10], start=1):
torrent = context.torrent_info
@@ -492,13 +615,13 @@ class Discord:
detail = [
f"{torrent.site_name} | {StringUtils.str_filesize(torrent.size)} | {torrent.volume_factor} | {torrent.seeders}",
meta.resource_term,
meta.video_term
meta.video_term,
]
embed = discord.Embed(
title=f"{index}. {title_text or torrent.title}",
url=torrent.page_url or discord.Embed.Empty,
description="\n".join([d for d in detail if d]),
color=0x00A86B
color=0x00A86B,
)
poster = getattr(torrent, "poster", None)
if poster:
@@ -524,7 +647,9 @@ class Discord:
return buttons
@staticmethod
def _build_view(buttons: Optional[List[List[dict]]], link: Optional[str] = None) -> Optional[discord.ui.View]:
def _build_view(
buttons: Optional[List[List[dict]]], link: Optional[str] = None
) -> Optional[discord.ui.View]:
has_buttons = buttons and any(buttons)
if not has_buttons and not link:
return None
@@ -534,20 +659,34 @@ class Discord:
for row_index, button_row in enumerate(buttons[:5]):
for button in button_row[:5]:
if "url" in button:
btn = discord.ui.Button(label=button.get("text", "链接"),
url=button["url"],
style=discord.ButtonStyle.link)
btn = discord.ui.Button(
label=button.get("text", "链接"),
url=button["url"],
style=discord.ButtonStyle.link,
)
else:
custom_id = (button.get("callback_data") or button.get("text") or f"btn-{row_index}")[:99]
btn = discord.ui.Button(label=button.get("text", "选择")[:80],
custom_id=custom_id,
style=discord.ButtonStyle.primary)
custom_id = (
button.get("callback_data")
or button.get("text")
or f"btn-{row_index}"
)[:99]
btn = discord.ui.Button(
label=button.get("text", "选择")[:80],
custom_id=custom_id,
style=discord.ButtonStyle.primary,
)
view.add_item(btn)
elif link:
view.add_item(discord.ui.Button(label="查看详情", url=link, style=discord.ButtonStyle.link))
view.add_item(
discord.ui.Button(
label="查看详情", url=link, style=discord.ButtonStyle.link
)
)
return view
async def _resolve_channel(self, userid: Optional[str] = None, chat_id: Optional[str] = None):
async def _resolve_channel(
self, userid: Optional[str] = None, chat_id: Optional[str] = None
):
"""
Resolve the channel to send messages to.
Priority order:
@@ -557,8 +696,10 @@ class Discord:
4. Any available text channel in configured guild - fallback
5. `userid` (DM) - for private conversations as a final fallback
"""
logger.debug(f"[Discord] _resolve_channel: userid={userid}, chat_id={chat_id}, "
f"_channel_id={self._channel_id}, _guild_id={self._guild_id}")
logger.debug(
f"[Discord] _resolve_channel: userid={userid}, chat_id={chat_id}, "
f"_channel_id={self._channel_id}, _guild_id={self._guild_id}"
)
# Priority 1: Use explicit chat_id (reply to the same channel where user sent message)
if chat_id:
@@ -585,7 +726,9 @@ class Discord:
return channel
try:
channel = await self._client.fetch_channel(int(mapped_chat_id))
logger.debug(f"[Discord] 通过 fetch_channel 找到映射频道: {channel}")
logger.debug(
f"[Discord] 通过 fetch_channel 找到映射频道: {channel}"
)
return channel
except Exception as err:
logger.warn(f"通过映射的 chat_id 获取 Discord 频道失败:{err}")
@@ -595,7 +738,9 @@ class Discord:
logger.debug(f"[Discord] 使用缓存的广播频道: {self._broadcast_channel}")
return self._broadcast_channel
if self._channel_id:
logger.debug(f"[Discord] 尝试通过配置的 _channel_id={self._channel_id} 获取频道")
logger.debug(
f"[Discord] 尝试通过配置的 _channel_id={self._channel_id} 获取频道"
)
channel = self._client.get_channel(self._channel_id)
if not channel:
try:
@@ -641,7 +786,9 @@ class Discord:
async def _get_dm_channel(self, userid: str) -> Optional[discord.DMChannel]:
logger.debug(f"[Discord] _get_dm_channel: userid={userid}")
if userid in self._user_dm_cache:
logger.debug(f"[Discord] 从缓存获取私聊频道: {self._user_dm_cache.get(userid)}")
logger.debug(
f"[Discord] 从缓存获取私聊频道: {self._user_dm_cache.get(userid)}"
)
return self._user_dm_cache.get(userid)
try:
logger.debug(f"[Discord] 尝试获取/创建用户 {userid} 的私聊频道")
@@ -674,7 +821,9 @@ class Discord:
"""
if userid and chat_id:
self._user_chat_mapping[userid] = chat_id
logger.debug(f"[Discord] 更新用户频道映射: userid={userid} -> chat_id={chat_id}")
logger.debug(
f"[Discord] 更新用户频道映射: userid={userid} -> chat_id={chat_id}"
)
def _get_user_chat_id(self, userid: str) -> Optional[str]:
"""
@@ -708,7 +857,9 @@ class Discord:
proxy = None
if settings.PROXY:
proxy = settings.PROXY.get("https") or settings.PROXY.get("http")
async with httpx.AsyncClient(timeout=10, verify=False, proxy=proxy) as client:
async with httpx.AsyncClient(
timeout=10, verify=False, proxy=proxy
) as client:
await client.post(self._ds_url, json=payload)
except Exception as err:
logger.error(f"转发 Discord 消息失败:{err}")

View File

@@ -6,18 +6,16 @@ from app.core.context import MediaInfo, Context
from app.log import logger
from app.modules import _ModuleBase, _MessageBase
from app.modules.slack.slack import Slack
from app.schemas import MessageChannel, CommingMessage, Notification
from app.schemas import MessageChannel, CommingMessage, Notification, MessageResponse
from app.schemas.types import ModuleType
class SlackModule(_ModuleBase, _MessageBase[Slack]):
def init_module(self) -> None:
"""
初始化模块
"""
super().init_service(service_name=Slack.__name__.lower(),
service_type=Slack)
super().init_service(service_name=Slack.__name__.lower(), service_type=Slack)
self._channel = MessageChannel.Slack
@staticmethod
@@ -67,7 +65,9 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
def init_setting(self) -> Tuple[str, Union[str, bool]]:
pass
def message_parser(self, source: str, body: Any, form: Any, args: Any) -> Optional[CommingMessage]:
def message_parser(
self, source: str, body: Any, form: Any, args: Any
) -> Optional[CommingMessage]:
"""
解析消息内容,返回字典,注意以下约定值:
userid: 用户ID
@@ -213,10 +213,14 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
message_info = msg_json.get("message", {})
# Slack消息的时间戳作为消息ID
message_ts = message_info.get("ts")
channel_id = msg_json.get("channel", {}).get("id") or msg_json.get("container", {}).get("channel_id")
channel_id = msg_json.get("channel", {}).get("id") or msg_json.get(
"container", {}
).get("channel_id")
logger.info(f"收到来自 {client_config.name} 的Slack按钮回调"
f"userid={userid}, username={username}, callback_data={callback_data}")
logger.info(
f"收到来自 {client_config.name} 的Slack按钮回调"
f"userid={userid}, username={username}, callback_data={callback_data}"
)
# 创建包含回调信息的CommingMessage
return CommingMessage(
@@ -228,11 +232,16 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
is_callback=True,
callback_data=callback_data,
message_id=message_ts,
chat_id=channel_id
chat_id=channel_id,
)
elif msg_json.get("type") == "event_callback":
userid = msg_json.get('event', {}).get('user')
text = re.sub(r"<@[0-9A-Z]+>", "", msg_json.get("event", {}).get("text"), flags=re.IGNORECASE).strip()
userid = msg_json.get("event", {}).get("user")
text = re.sub(
r"<@[0-9A-Z]+>",
"",
msg_json.get("event", {}).get("text"),
flags=re.IGNORECASE,
).strip()
username = ""
elif msg_json.get("type") == "shortcut":
userid = msg_json.get("user", {}).get("id")
@@ -244,9 +253,16 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
username = msg_json.get("user_name")
else:
return None
logger.info(f"收到来自 {client_config.name} 的Slack消息userid={userid}, username={username}, text={text}")
return CommingMessage(channel=MessageChannel.Slack, source=client_config.name,
userid=userid, username=username, text=text)
logger.info(
f"收到来自 {client_config.name} 的Slack消息userid={userid}, username={username}, text={text}"
)
return CommingMessage(
channel=MessageChannel.Slack,
source=client_config.name,
userid=userid,
username=username,
text=text,
)
return None
def post_message(self, message: Notification, **kwargs) -> None:
@@ -261,19 +277,26 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
targets = message.targets
userid = message.userid
if not userid and targets is not None:
userid = targets.get('slack_userid')
userid = targets.get("slack_userid")
if not userid:
logger.warn(f"用户没有指定 Slack用户ID消息无法发送")
return
client: Slack = self.get_instance(conf.name)
if client:
client.send_msg(title=message.title, text=message.text,
image=message.image, userid=userid, link=message.link,
buttons=message.buttons,
original_message_id=message.original_message_id,
original_chat_id=message.original_chat_id)
client.send_msg(
title=message.title,
text=message.text,
image=message.image,
userid=userid,
link=message.link,
buttons=message.buttons,
original_message_id=message.original_message_id,
original_chat_id=message.original_chat_id,
)
def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None:
def post_medias_message(
self, message: Notification, medias: List[MediaInfo]
) -> None:
"""
发送媒体信息选择列表
:param message: 消息体
@@ -285,12 +308,18 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
continue
client: Slack = self.get_instance(conf.name)
if client:
client.send_medias_msg(title=message.title, medias=medias, userid=message.userid,
buttons=message.buttons,
original_message_id=message.original_message_id,
original_chat_id=message.original_chat_id)
client.send_medias_msg(
title=message.title,
medias=medias,
userid=message.userid,
buttons=message.buttons,
original_message_id=message.original_message_id,
original_chat_id=message.original_chat_id,
)
def post_torrents_message(self, message: Notification, torrents: List[Context]) -> None:
def post_torrents_message(
self, message: Notification, torrents: List[Context]
) -> None:
"""
发送种子信息选择列表
:param message: 消息体
@@ -302,13 +331,22 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
continue
client: Slack = self.get_instance(conf.name)
if client:
client.send_torrents_msg(title=message.title, torrents=torrents,
userid=message.userid, buttons=message.buttons,
original_message_id=message.original_message_id,
original_chat_id=message.original_chat_id)
client.send_torrents_msg(
title=message.title,
torrents=torrents,
userid=message.userid,
buttons=message.buttons,
original_message_id=message.original_message_id,
original_chat_id=message.original_chat_id,
)
def delete_message(self, channel: MessageChannel, source: str,
message_id: str, chat_id: Optional[str] = None) -> bool:
def delete_message(
self,
channel: MessageChannel,
source: str,
message_id: str,
chat_id: Optional[str] = None,
) -> bool:
"""
删除消息
:param channel: 消息渠道
@@ -329,3 +367,86 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
if result:
success = True
return success
def edit_message(
self,
channel: MessageChannel,
source: str,
message_id: Union[str, int],
chat_id: Union[str, int],
text: str,
title: Optional[str] = None,
) -> bool:
"""
编辑消息
:param channel: 消息渠道
:param source: 指定的消息源
:param message_id: 消息ID
:param chat_id: 聊天ID
:param text: 新的消息内容
:param title: 消息标题
:return: 编辑是否成功
"""
if channel != self._channel:
return False
for conf in self.get_configs().values():
if source != conf.name:
continue
client: Slack = self.get_instance(conf.name)
if client:
result = client.send_msg(
title=title or "",
text=text,
original_message_id=str(message_id),
original_chat_id=str(chat_id),
)
if result and result[0]:
return True
return False
def send_direct_message(self, message: Notification) -> Optional[MessageResponse]:
"""
直接发送消息并返回消息ID等信息
:param message: 消息体
:return: 消息响应包含message_id, chat_id等
"""
for conf in self.get_configs().values():
if not self.check_message(message, conf.name):
continue
targets = message.targets
userid = message.userid
if not userid and targets is not None:
userid = targets.get("slack_userid")
if not userid:
logger.warn("用户没有指定 Slack 用户ID消息无法发送")
return None
client: Slack = self.get_instance(conf.name)
if client:
result = client.send_msg(
title=message.title or "",
text=message.text,
userid=userid,
)
if result and result[0]:
# Slack 使用时间戳作为 message_idchat_id 是频道ID
# 注意:这里返回的是发送后的结果,需要获取实际的 message_id
# 由于 Slack API 返回的是 result[1],包含完整响应,我们需要从中提取
response_data = result[1]
message_id = (
response_data.get("ts")
if isinstance(response_data, dict)
else None
)
channel_id = (
response_data.get("channel")
if isinstance(response_data, dict)
else None
)
return MessageResponse(
message_id=message_id,
chat_id=channel_id,
channel=MessageChannel.Slack,
source=conf.name,
success=True,
)
return None

View File

@@ -1,6 +1,7 @@
import asyncio
import re
import threading
import time
from typing import Optional, List, Dict, Callable, Union
from urllib.parse import urljoin, quote
@@ -39,6 +40,8 @@ class Telegram:
str, str
] = {} # userid -> chat_id mapping for reply targeting
_bot_username: Optional[str] = None # Bot username for mention detection
_typing_tasks: Dict[str, threading.Thread] = {} # chat_id -> typing任务
_typing_stop_flags: Dict[str, bool] = {} # chat_id -> 停止标志
def __init__(
self,
@@ -105,11 +108,8 @@ class Telegram:
# Check if we should process this message
if self._should_process_message(message):
# 发送正在输入状态
try:
_bot.send_chat_action(message.chat.id, "typing")
except Exception as err:
logger.error(f"发送Telegram正在输入状态失败{err}")
# 启动持续发送正在输入状态
self._start_typing_task(message.chat.id)
RequestUtils(timeout=15).post_res(self._ds_url, json=message.json)
@_bot.callback_query_handler(func=lambda call: True)
@@ -147,11 +147,8 @@ class Telegram:
# 先确认回调避免用户看到loading状态
_bot.answer_callback_query(call.id)
# 发送正在输入状态
try:
_bot.send_chat_action(call.message.chat.id, "typing")
except Exception as e:
logger.error(f"发送Telegram正在输入状态失败{e}")
# 启动持续发送正在输入状态
self._start_typing_task(call.message.chat.id)
# 发送给主程序处理
RequestUtils(timeout=15).post_res(self._ds_url, json=callback_json)
@@ -256,6 +253,47 @@ class Telegram:
"""
return self._bot is not None
def _start_typing_task(self, chat_id: Union[str, int]) -> None:
"""
启动持续发送正在输入状态的任务
"""
chat_id_str = str(chat_id)
# 如果已有任务在运行,先停止
if chat_id_str in self._typing_tasks:
self._stop_typing_task(chat_id_str)
# 设置停止标志
self._typing_stop_flags[chat_id_str] = False
def typing_worker():
"""定期发送typing状态的后台线程"""
while not self._typing_stop_flags.get(chat_id_str, True):
try:
if self._bot:
self._bot.send_chat_action(chat_id, "typing")
except Exception as e:
logger.debug(f"发送typing状态失败: {e}")
# 每5秒发送一次Telegram客户端会在约5-6秒后消失状态
for _ in range(50):
if self._typing_stop_flags.get(chat_id_str, True):
break
time.sleep(0.1)
thread = threading.Thread(target=typing_worker, daemon=True)
thread.start()
self._typing_tasks[chat_id_str] = thread
def _stop_typing_task(self, chat_id: Union[str, int]) -> None:
"""
停止正在输入状态的任务
"""
chat_id_str = str(chat_id)
self._typing_stop_flags[chat_id_str] = True
if chat_id_str in self._typing_tasks:
task = self._typing_tasks.pop(chat_id_str, None)
if task and task.is_alive():
task.join(timeout=1)
def send_msg(
self,
title: str,
@@ -317,6 +355,7 @@ class Telegram:
result = self.__edit_message(
original_chat_id, original_message_id, caption, buttons, image
)
self._stop_typing_task(chat_id)
return {
"success": bool(result),
"message_id": original_message_id,
@@ -330,6 +369,7 @@ class Telegram:
caption=caption,
reply_markup=reply_markup,
)
self._stop_typing_task(chat_id)
if sent and hasattr(sent, "message_id"):
return {
"success": True,
@@ -342,6 +382,7 @@ class Telegram:
except Exception as msg_e:
logger.error(f"发送消息失败:{msg_e}")
self._stop_typing_task(chat_id)
return {"success": False}
def _determine_target_chat_id(
@@ -838,6 +879,9 @@ class Telegram:
"""
停止Telegram消息接收服务
"""
# 停止所有typing任务
for chat_id in list(self._typing_tasks.keys()):
self._stop_typing_task(chat_id)
if self._bot:
self._bot.stop_polling()
self._polling_thread.join()

View File

@@ -102,7 +102,7 @@ class TheMovieDbModule(_ModuleBase):
if meta and not tmdbid and settings.RECOGNIZE_SOURCE != "themoviedb":
return False
if meta and not meta.name:
if meta and not meta.name and not tmdbid:
logger.warn("识别媒体信息时未提供元数据名称")
return False
@@ -118,6 +118,98 @@ class TheMovieDbModule(_ModuleBase):
# 使用中英文名分别识别,去重去空,但要保持顺序
return list(dict.fromkeys([k for k in [meta.cn_name, zh_name, meta.en_name] if k]))
def _get_info_by_tmdbid(self, tmdbid: int, mtype: Optional[MediaType],
meta: Optional[MetaBase]) -> Optional[dict]:
"""
根据tmdbid查询媒体信息当类型未知且同时存在电影和电视剧时通过元数据消歧
"""
if mtype:
return self.tmdb.get_info(mtype=mtype, tmdbid=tmdbid)
# 类型未知,分别查询电影和电视剧
info_tv = self.tmdb.get_info(mtype=MediaType.TV, tmdbid=tmdbid)
info_movie = self.tmdb.get_info(mtype=MediaType.MOVIE, tmdbid=tmdbid)
if info_tv and info_movie:
# 同时存在,尝试通过元数据消歧
result = self._disambiguate_by_meta(info_tv, info_movie, meta)
if result:
return result
logger.warn(f"无法判断tmdb_id:{tmdbid} 是电影还是电视剧")
return None
return info_tv or info_movie or None
async def _async_get_info_by_tmdbid(self, tmdbid: int, mtype: Optional[MediaType],
meta: Optional[MetaBase]) -> Optional[dict]:
"""
根据tmdbid查询媒体信息当类型未知且同时存在电影和电视剧时通过元数据消歧异步版本
"""
if mtype:
return await self.tmdb.async_get_info(mtype=mtype, tmdbid=tmdbid)
# 类型未知,分别查询电影和电视剧
info_tv = await self.tmdb.async_get_info(mtype=MediaType.TV, tmdbid=tmdbid)
info_movie = await self.tmdb.async_get_info(mtype=MediaType.MOVIE, tmdbid=tmdbid)
if info_tv and info_movie:
# 同时存在,尝试通过元数据消歧
result = self._disambiguate_by_meta(info_tv, info_movie, meta)
if result:
return result
logger.warn(f"无法判断tmdb_id:{tmdbid} 是电影还是电视剧")
return None
return info_tv or info_movie or None
@staticmethod
def _disambiguate_by_meta(info_tv: dict, info_movie: dict,
meta: Optional[MetaBase]) -> Optional[dict]:
"""
通过元数据标题、年份、类型对同tmdbid的电影和电视剧进行消歧
"""
if not meta:
return None
def _collect_titles(info: dict) -> set:
titles = set()
for key in ('title', 'name', 'original_title', 'original_name'):
if info.get(key):
titles.add(info[key])
for name in (info.get('names') or []):
titles.add(name)
return titles
def _match_score(info: dict) -> int:
score = 0
# 标题匹配
titles = _collect_titles(info)
meta_names = [n for n in [meta.cn_name, meta.en_name] if n]
for meta_name in meta_names:
if any(meta_name in t or t in meta_name for t in titles):
score += 2
break
# 年份匹配
if meta.year:
release_date = info.get('release_date') or info.get('first_air_date') or ''
if release_date and release_date[:4] == meta.year:
score += 1
return score
score_tv = _match_score(info_tv)
score_movie = _match_score(info_movie)
if score_tv > score_movie:
logger.info(f"通过元数据消歧tmdb_id:{info_tv.get('id')} 识别为电视剧")
return info_tv
elif score_movie > score_tv:
logger.info(f"通过元数据消歧tmdb_id:{info_movie.get('id')} 识别为电影")
return info_movie
# 评分相同时参考meta.type
if meta.type == MediaType.TV:
logger.info(f"通过媒体类型提示消歧tmdb_id:{info_tv.get('id')} 识别为电视剧")
return info_tv
elif meta.type == MediaType.MOVIE:
logger.info(f"通过媒体类型提示消歧tmdb_id:{info_movie.get('id')} 识别为电影")
return info_movie
return None
def _search_by_name(self, name: str, meta: MetaBase, group_seasons: List[dict]) -> dict:
"""
根据名称搜索媒体信息
@@ -404,9 +496,9 @@ class TheMovieDbModule(_ModuleBase):
info = None
# 缓存没有或者强制不使用缓存
if tmdbid:
# 直接查询详情
info = self.tmdb.get_info(mtype=mtype, tmdbid=tmdbid)
if not info and meta:
# 直接查询详情支持同ID电影/电视剧消歧
info = self._get_info_by_tmdbid(tmdbid=tmdbid, mtype=mtype, meta=meta)
if not info and meta and not tmdbid:
# 准备搜索名称
names = self._prepare_search_names(meta)
for name in names:
@@ -422,7 +514,10 @@ class TheMovieDbModule(_ModuleBase):
info = self.tmdb.get_info(mtype=info.get("media_type"),
tmdbid=info.get("id"))
elif not info:
logger.error("识别媒体信息时未提供元数据或唯一且有效的tmdbid")
if tmdbid:
logger.warn(f"tmdb_id:{tmdbid} 无法确定媒体类型,识别失败")
else:
logger.error("识别媒体信息时未提供元数据或唯一且有效的tmdbid")
return None
# 保存到缓存
@@ -485,9 +580,9 @@ class TheMovieDbModule(_ModuleBase):
info = None
# 缓存没有或者强制不使用缓存
if tmdbid:
# 直接查询详情
info = await self.tmdb.async_get_info(mtype=mtype, tmdbid=tmdbid)
if not info and meta:
# 直接查询详情支持同ID电影/电视剧消歧
info = await self._async_get_info_by_tmdbid(tmdbid=tmdbid, mtype=mtype, meta=meta)
if not info and meta and not tmdbid:
# 准备搜索名称
names = self._prepare_search_names(meta)
for name in names:
@@ -503,7 +598,10 @@ class TheMovieDbModule(_ModuleBase):
info = await self.tmdb.async_get_info(mtype=info.get("media_type"),
tmdbid=info.get("id"))
elif not info:
logger.error("识别媒体信息时未提供元数据或唯一且有效的tmdbid")
if tmdbid:
logger.warn(f"tmdb_id:{tmdbid} 无法确定媒体类型,识别失败")
else:
logger.error("识别媒体信息时未提供元数据或唯一且有效的tmdbid")
return None
# 保存到缓存

View File

@@ -47,6 +47,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
"""
定时任务管理
"""
CONFIG_WATCH = {
"DEV",
"COOKIECLOUD_INTERVAL",
@@ -56,6 +57,8 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
"SUBSCRIBE_MODE",
"SUBSCRIBE_RSS_INTERVAL",
"SITEDATA_REFRESH_INTERVAL",
"AI_AGENT_ENABLE",
"AI_AGENT_JOB_INTERVAL",
}
def __init__(self):
@@ -98,133 +101,134 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
"cookiecloud": {
"name": "同步CookieCloud站点",
"func": SiteChain().sync_cookies,
"running": False
"running": False,
},
"mediaserver_sync": {
"name": "同步媒体服务器",
"func": MediaServerChain().sync,
"running": False
"running": False,
},
"subscribe_tmdb": {
"name": "订阅元数据更新",
"func": SubscribeChain().check,
"running": False
"running": False,
},
"subscribe_search": {
"name": "订阅搜索补全",
"func": SubscribeChain().search,
"running": False,
"kwargs": {
"state": "R"
}
"kwargs": {"state": "R"},
},
"new_subscribe_search": {
"name": "新增订阅搜索",
"func": SubscribeChain().search,
"running": False,
"kwargs": {
"state": "N"
}
"kwargs": {"state": "N"},
},
"subscribe_refresh": {
"name": "订阅刷新",
"func": SubscribeChain().refresh,
"running": False
"running": False,
},
"subscribe_follow": {
"name": "关注的订阅分享",
"func": SubscribeChain().follow,
"running": False
"running": False,
},
"transfer": {
"name": "下载文件整理",
"func": TransferChain().process,
"running": False
"running": False,
},
"clear_cache": {
"name": "缓存清理",
"func": self.clear_cache,
"running": False
"running": False,
},
"user_auth": {
"name": "用户认证检查",
"func": self.user_auth,
"running": False
"running": False,
},
"scheduler_job": {
"name": "公共定时服务",
"func": SchedulerChain().scheduler_job,
"running": False
"running": False,
},
"random_wallpager": {
"name": "壁纸缓存",
"func": WallpaperHelper().get_wallpapers,
"running": False
"running": False,
},
"sitedata_refresh": {
"name": "站点数据刷新",
"func": SiteChain().refresh_userdatas,
"running": False
"running": False,
},
"recommend_refresh": {
"name": "推荐缓存",
"func": RecommendChain().refresh_recommend,
"running": False
"running": False,
},
"plugin_market_refresh": {
"name": "插件市场缓存",
"func": PluginManager().async_get_online_plugins,
"running": False,
"kwargs": {
"force": True
}
"kwargs": {"force": True},
},
"subscribe_calendar_cache": {
"name": "订阅日历缓存",
"func": SubscribeChain().cache_calendar,
"running": False
"running": False,
},
"full_gc": {
"name": "主动内存回收",
"func": self.full_gc,
"running": False
}
"running": False,
},
"agent_heartbeat": {
"name": "智能体定时任务",
"func": self.agent_heartbeat,
"running": False,
},
}
# 创建定时服务
self._scheduler = BackgroundScheduler(timezone=settings.TZ,
executors={
'default': ThreadPoolExecutor(settings.CONF.scheduler)
})
self._scheduler = BackgroundScheduler(
timezone=settings.TZ,
executors={"default": ThreadPoolExecutor(settings.CONF.scheduler)},
)
# CookieCloud定时同步
if settings.COOKIECLOUD_INTERVAL \
and str(settings.COOKIECLOUD_INTERVAL).isdigit():
if (
settings.COOKIECLOUD_INTERVAL
and str(settings.COOKIECLOUD_INTERVAL).isdigit()
):
self._scheduler.add_job(
self.start,
"interval",
id="cookiecloud",
name="同步CookieCloud站点",
minutes=int(settings.COOKIECLOUD_INTERVAL),
next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(minutes=5),
kwargs={
'job_id': 'cookiecloud'
}
next_run_time=datetime.now(pytz.timezone(settings.TZ))
+ timedelta(minutes=5),
kwargs={"job_id": "cookiecloud"},
)
# 媒体服务器同步
if settings.MEDIASERVER_SYNC_INTERVAL \
and str(settings.MEDIASERVER_SYNC_INTERVAL).isdigit():
if (
settings.MEDIASERVER_SYNC_INTERVAL
and str(settings.MEDIASERVER_SYNC_INTERVAL).isdigit()
):
self._scheduler.add_job(
self.start,
"interval",
id="mediaserver_sync",
name="同步媒体服务器",
hours=int(settings.MEDIASERVER_SYNC_INTERVAL),
next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(minutes=10),
kwargs={
'job_id': 'mediaserver_sync'
}
next_run_time=datetime.now(pytz.timezone(settings.TZ))
+ timedelta(minutes=10),
kwargs={"job_id": "mediaserver_sync"},
)
# 新增订阅时搜索5分钟检查一次
@@ -234,9 +238,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
id="new_subscribe_search",
name="新增订阅搜索",
minutes=5,
kwargs={
'job_id': 'new_subscribe_search'
}
kwargs={"job_id": "new_subscribe_search"},
)
# 检查更新订阅TMDB数据每隔6小时
@@ -246,9 +248,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
id="subscribe_tmdb",
name="订阅元数据更新",
hours=6,
kwargs={
'job_id': 'subscribe_tmdb'
}
kwargs={"job_id": "subscribe_tmdb"},
)
# 订阅状态每隔24小时搜索一次
@@ -259,9 +259,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
id="subscribe_search",
name="订阅搜索补全",
hours=settings.SUBSCRIBE_SEARCH_INTERVAL,
kwargs={
'job_id': 'subscribe_search'
}
kwargs={"job_id": "subscribe_search"},
)
if settings.SUBSCRIBE_MODE == "spider":
@@ -275,13 +273,14 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
name="订阅刷新",
hour=trigger.hour,
minute=trigger.minute,
kwargs={
'job_id': 'subscribe_refresh'
})
kwargs={"job_id": "subscribe_refresh"},
)
else:
# RSS订阅模式
if not settings.SUBSCRIBE_RSS_INTERVAL \
or not str(settings.SUBSCRIBE_RSS_INTERVAL).isdigit():
if (
not settings.SUBSCRIBE_RSS_INTERVAL
or not str(settings.SUBSCRIBE_RSS_INTERVAL).isdigit()
):
settings.SUBSCRIBE_RSS_INTERVAL = 30
elif int(settings.SUBSCRIBE_RSS_INTERVAL) < 5:
settings.SUBSCRIBE_RSS_INTERVAL = 5
@@ -291,9 +290,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
id="subscribe_refresh",
name="RSS订阅刷新",
minutes=int(settings.SUBSCRIBE_RSS_INTERVAL),
kwargs={
'job_id': 'subscribe_refresh'
}
kwargs={"job_id": "subscribe_refresh"},
)
# 关注订阅分享每1小时
@@ -303,9 +300,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
id="subscribe_follow",
name="关注的订阅分享",
hours=1,
kwargs={
'job_id': 'subscribe_follow'
}
kwargs={"job_id": "subscribe_follow"},
)
# 下载器文件转移每5分钟
@@ -315,9 +310,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
id="transfer",
name="下载文件整理",
minutes=5,
kwargs={
'job_id': 'transfer'
}
kwargs={"job_id": "transfer"},
)
# 后台刷新TMDB壁纸
@@ -327,10 +320,9 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
id="random_wallpager",
name="壁纸缓存",
minutes=30,
next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(seconds=1),
kwargs={
'job_id': 'random_wallpager'
}
next_run_time=datetime.now(pytz.timezone(settings.TZ))
+ timedelta(seconds=1),
kwargs={"job_id": "random_wallpager"},
)
# 公共定时服务
@@ -340,9 +332,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
id="scheduler_job",
name="公共定时服务",
minutes=10,
kwargs={
'job_id': 'scheduler_job'
}
kwargs={"job_id": "scheduler_job"},
)
# 缓存清理服务每隔24小时
@@ -352,9 +342,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
id="clear_cache",
name="缓存清理",
hours=settings.CONF.meta / 3600,
kwargs={
'job_id': 'clear_cache'
}
kwargs={"job_id": "clear_cache"},
)
# 定时检查用户认证每隔10分钟
@@ -364,9 +352,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
id="user_auth",
name="用户认证检查",
minutes=10,
kwargs={
'job_id': 'user_auth'
}
kwargs={"job_id": "user_auth"},
)
# 站点数据刷新
@@ -377,9 +363,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
id="sitedata_refresh",
name="站点数据刷新",
minutes=settings.SITEDATA_REFRESH_INTERVAL * 60,
kwargs={
'job_id': 'sitedata_refresh'
}
kwargs={"job_id": "sitedata_refresh"},
)
# 推荐缓存
@@ -389,10 +373,9 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
id="recommend_refresh",
name="推荐缓存",
hours=24,
next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(seconds=5),
kwargs={
'job_id': 'recommend_refresh'
}
next_run_time=datetime.now(pytz.timezone(settings.TZ))
+ timedelta(seconds=5),
kwargs={"job_id": "recommend_refresh"},
)
# 插件市场缓存
@@ -402,9 +385,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
id="plugin_market_refresh",
name="插件市场缓存",
minutes=30,
kwargs={
'job_id': 'plugin_market_refresh'
}
kwargs={"job_id": "plugin_market_refresh"},
)
# 订阅日历缓存
@@ -414,10 +395,9 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
id="subscribe_calendar_cache",
name="订阅日历缓存",
hours=6,
next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(minutes=2),
kwargs={
'job_id': 'subscribe_calendar_cache'
}
next_run_time=datetime.now(pytz.timezone(settings.TZ))
+ timedelta(minutes=2),
kwargs={"job_id": "subscribe_calendar_cache"},
)
# 主动内存回收
@@ -428,9 +408,18 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
id="full_gc",
name="主动内存回收",
minutes=settings.MEMORY_GC_INTERVAL,
kwargs={
'job_id': 'full_gc'
}
kwargs={"job_id": "full_gc"},
)
# 智能体定时任务检查
if settings.AI_AGENT_ENABLE and settings.AI_AGENT_JOB_INTERVAL:
self._scheduler.add_job(
self.start,
"interval",
id="agent_heartbeat",
name="智能体定时任务",
hours=settings.AI_AGENT_JOB_INTERVAL,
kwargs={"job_id": "agent_heartbeat"},
)
# 初始化工作流服务
@@ -502,19 +491,21 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
# 普通函数
job["func"](*args, **kwargs)
except Exception as e:
logger.error(f"定时任务 {job.get('name')} 执行失败:{str(e)} - {traceback.format_exc()}")
MessageHelper().put(title=f"{job.get('name')} 执行失败",
message=str(e),
role="system")
logger.error(
f"定时任务 {job.get('name')} 执行失败:{str(e)} - {traceback.format_exc()}"
)
MessageHelper().put(
title=f"{job.get('name')} 执行失败", message=str(e), role="system"
)
eventmanager.send_event(
EventType.SystemError,
{
"type": "scheduler",
"scheduler_id": job_id,
"scheduler_name": job.get('name'),
"scheduler_name": job.get("name"),
"error": str(e),
"traceback": traceback.format_exc()
}
"traceback": traceback.format_exc(),
},
)
# 运行结束
self.__finish_job(job_id)
@@ -559,9 +550,11 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
logger.info(f"移除工作流服务:{service.get('name')}")
except Exception as e:
logger.error(f"移除工作流服务失败:{str(e)} - {job_id}: {service}")
SchedulerChain().messagehelper.put(title=f"工作流 {workflow.name} 服务移除失败",
message=str(e),
role="system")
SchedulerChain().messagehelper.put(
title=f"工作流 {workflow.name} 服务移除失败",
message=str(e),
role="system",
)
def remove_plugin_job(self, pid: str, job_id: Optional[str] = None):
"""
@@ -581,7 +574,9 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
else:
# 移除插件的所有服务
jobs_to_remove = [
(job_id, service) for job_id, service in self._jobs.items() if service.get("pid") == pid
(job_id, service)
for job_id, service in self._jobs.items()
if service.get("pid") == pid
]
for job_id, _ in jobs_to_remove:
self._jobs.pop(job_id, None)
@@ -602,12 +597,16 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
except JobLookupError:
pass
if job_removed:
logger.info(f"移除插件服务({plugin_name}){service.get('name')}") # noqa
logger.info(
f"移除插件服务({plugin_name}){service.get('name')}"
) # noqa
except Exception as e:
logger.error(f"移除插件服务失败:{str(e)} - {job_id}: {service}")
SchedulerChain().messagehelper.put(title=f"插件 {plugin_name} 服务移除失败",
message=str(e),
role="system")
SchedulerChain().messagehelper.put(
title=f"插件 {plugin_name} 服务移除失败",
message=str(e),
role="system",
)
def update_workflow_job(self, workflow: Workflow):
"""
@@ -633,14 +632,16 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
id=job_id,
name=workflow.name,
kwargs={"job_id": job_id, "workflow_id": workflow.id},
replace_existing=True
replace_existing=True,
)
logger.info(f"注册工作流服务:{workflow.name} - {workflow.timer}")
except Exception as e:
logger.error(f"注册工作流服务失败:{workflow.name} - {str(e)}")
SchedulerChain().messagehelper.put(title=f"工作流 {workflow.name} 服务注册失败",
message=str(e),
role="system")
SchedulerChain().messagehelper.put(
title=f"工作流 {workflow.name} 服务注册失败",
message=str(e),
role="system",
)
def update_plugin_job(self, pid: str):
"""
@@ -656,7 +657,9 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
try:
plugin_services = plugin_manager.get_plugin_services(pid=pid)
except Exception as e:
logger.error(f"运行插件 {pid} 服务失败:{str(e)} - {traceback.format_exc()}")
logger.error(
f"运行插件 {pid} 服务失败:{str(e)} - {traceback.format_exc()}"
)
return
# 获取插件名称
plugin_name = plugin_manager.get_plugin_attr(pid, "plugin_name")
@@ -681,14 +684,18 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
name=service["name"],
**(service.get("kwargs") or {}),
kwargs={"job_id": job_id},
replace_existing=True
replace_existing=True,
)
logger.info(
f"注册插件{plugin_name}服务:{service['name']} - {service['trigger']}"
)
logger.info(f"注册插件{plugin_name}服务:{service['name']} - {service['trigger']}")
except Exception as e:
logger.error(f"注册插件{plugin_name}服务失败:{str(e)} - {service}")
SchedulerChain().messagehelper.put(title=f"插件 {plugin_name} 服务注册失败",
message=str(e),
role="system")
SchedulerChain().messagehelper.put(
title=f"插件 {plugin_name} 服务注册失败",
message=str(e),
role="system",
)
def list(self) -> List[schemas.ScheduleInfo]:
"""
@@ -714,12 +721,14 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
if service.get("running") and name and provider_name:
if job_id not in added:
added.append(job_id)
schedulers.append(schemas.ScheduleInfo(
id=job_id,
name=name,
provider=provider_name,
status="正在运行",
))
schedulers.append(
schemas.ScheduleInfo(
id=job_id,
name=name,
provider=provider_name,
status="正在运行",
)
)
# 获取其他待执行任务
for job in jobs:
job_id = job.id.split("|")[0]
@@ -734,13 +743,15 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
status = "正在运行" if service.get("running") else "等待"
# 下次运行时间
next_run = TimerUtils.time_difference(job.next_run_time)
schedulers.append(schemas.ScheduleInfo(
id=job_id,
name=job.name,
provider=service.get("provider_name", "[系统]"),
status=status,
next_run=next_run
))
schedulers.append(
schemas.ScheduleInfo(
id=job_id,
name=job.name,
provider=service.get("provider_name", "[系统]"),
status=status,
next_run=next_run,
)
)
return schedulers
def stop(self):
@@ -776,7 +787,18 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
collected = gc.collect()
memory_after = get_memory_usage()
memory_freed = memory_before - memory_after
logger.info(f"主动内存回收完成,回收对象数: {collected},释放内存: {memory_freed:.2f} MB")
logger.info(
f"主动内存回收完成,回收对象数: {collected},释放内存: {memory_freed:.2f} MB"
)
@staticmethod
async def agent_heartbeat():
"""
智能体心跳唤醒:检查并执行待处理的定时任务
"""
from app.agent import agent_manager
await agent_manager.heartbeat_check_jobs()
def user_auth(self):
"""
@@ -788,9 +810,11 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
__max_try__ = 30
if self._auth_count > __max_try__:
if not self._auth_message:
SchedulerChain().messagehelper.put(title=f"用户认证失败",
message="用户认证失败次数过多,将不再尝试认证!",
role="system")
SchedulerChain().messagehelper.put(
title=f"用户认证失败",
message="用户认证失败次数过多,将不再尝试认证!",
role="system",
)
self._auth_message = True
return
logger.info("用户未认证,正在尝试认证...")
@@ -807,7 +831,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
mtype=NotificationType.Manual,
title="MoviePilot用户认证成功",
text=f"使用站点:{msg}如有插件使用异常请重启MoviePilot。",
link=settings.MP_DOMAIN('#/site')
link=settings.MP_DOMAIN("#/site"),
)
)
# 认证通过后重新初始化插件

View File

@@ -194,6 +194,8 @@ class ChannelCapabilities:
max_buttons_per_row: int = 5
max_button_rows: int = 10
max_button_text_length: int = 30
# 单条消息最大长度0 表示不限制),用于流式输出时自动分段
max_message_length: int = 0
fallback_enabled: bool = True
@@ -219,6 +221,8 @@ class ChannelCapabilityManager:
max_buttons_per_row=4,
max_button_rows=10,
max_button_text_length=30,
# Telegram 文本消息限制 4096 字符,预留空间给 MarkdownV2 转义和标题
max_message_length=3500,
),
MessageChannel.Wechat: ChannelCapabilities(
channel=MessageChannel.Wechat,
@@ -244,6 +248,8 @@ class ChannelCapabilityManager:
max_buttons_per_row=3,
max_button_rows=8,
max_button_text_length=25,
# Slack 消息限制 40000 字符,预留空间给格式化
max_message_length=39000,
fallback_enabled=True,
),
MessageChannel.Discord: ChannelCapabilities(
@@ -260,6 +266,8 @@ class ChannelCapabilityManager:
max_buttons_per_row=5,
max_button_rows=5,
max_button_text_length=80,
# Discord 消息限制 2000 字符
max_message_length=1800,
fallback_enabled=True,
),
MessageChannel.SynologyChat: ChannelCapabilities(
@@ -376,6 +384,14 @@ class ChannelCapabilityManager:
channel_caps = cls.get_capabilities(channel)
return channel_caps.max_button_text_length if channel_caps else 20
@classmethod
def get_max_message_length(cls, channel: MessageChannel) -> int:
"""
获取单条消息最大长度0 表示不限制)
"""
channel_caps = cls.get_capabilities(channel)
return channel_caps.max_message_length if channel_caps else 0
@classmethod
def should_use_fallback(cls, channel: MessageChannel) -> bool:
"""

View File

@@ -0,0 +1,544 @@
---
name: moviepilot-api
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.
---
# MoviePilot REST API
> All script paths are relative to this skill file.
Use `scripts/mp-api.py` to call any MoviePilot REST API endpoint directly.
## Setup
Configure the backend host and API key (persisted to `~/.config/moviepilot_api/config`):
```
python scripts/mp-api.py configure --host http://localhost:3000 --apikey <API_TOKEN>
```
The API key is the `API_TOKEN` value from MoviePilot settings.
## How to Call APIs
### General syntax
```
python scripts/mp-api.py <METHOD> <PATH> [key=value ...] [--json '<body>']
```
### Authentication
- By default, the key is sent via the `X-API-KEY` header.
- For endpoints suffixed with `2` (e.g. `/api/v1/dashboard/statistic2`), use `--token-param` to send the key as `?token=`.
- Both methods validate against the same `API_TOKEN` value.
### Examples
```bash
# GET with query params
python scripts/mp-api.py GET /api/v1/media/search title="Avatar" type="movie"
# POST with JSON body
python scripts/mp-api.py POST /api/v1/download/add --json '{"torrent_url":"abc1234:1"}'
# DELETE
python scripts/mp-api.py DELETE /api/v1/subscribe/123
# Endpoints that require ?token= auth
python scripts/mp-api.py GET /api/v1/dashboard/statistic2 --token-param
```
## Complete API Reference
All endpoints are under the base URL `{MP_HOST}`. Path parameters are shown as `{param}`.
---
### Media Search (13 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/media/search` | Search media/person by title. Params: `title` (required), `type`, `page`, `count` |
| GET | `/api/v1/media/recognize` | Recognize media from torrent title. Params: `title` (required), `subtitle` |
| GET | `/api/v1/media/recognize2` | Recognize media (API_TOKEN auth, use `--token-param`). Params: `title`, `subtitle` |
| GET | `/api/v1/media/recognize_file` | Recognize media from file path. Params: `path` (required) |
| GET | `/api/v1/media/recognize_file2` | Recognize file (API_TOKEN auth). Params: `path` |
| POST | `/api/v1/media/scrape/{storage}` | Scrape media metadata. Body: FileItem JSON |
| GET | `/api/v1/media/category/config` | Get category strategy config |
| POST | `/api/v1/media/category/config` | Save category strategy config. Body: CategoryConfig |
| GET | `/api/v1/media/category` | Get auto-categorization config |
| GET | `/api/v1/media/group/seasons/{episode_group}` | Get episode group seasons |
| GET | `/api/v1/media/groups/{tmdbid}` | Get media episode groups |
| GET | `/api/v1/media/seasons` | Get media season info. Params: `mediaid`, `title`, `year`, `season` |
| GET | `/api/v1/media/{mediaid}` | Get media detail. Params: `type_name` (required: movie/tv), `title`, `year` |
### TMDB (8 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/tmdb/seasons/{tmdbid}` | All seasons for a TMDB title |
| GET | `/api/v1/tmdb/similar/{tmdbid}/{type_name}` | Similar movies/TV shows |
| GET | `/api/v1/tmdb/recommend/{tmdbid}/{type_name}` | Recommended movies/TV shows |
| GET | `/api/v1/tmdb/collection/{collection_id}` | Collection details. Params: `page`, `count` |
| GET | `/api/v1/tmdb/credits/{tmdbid}/{type_name}` | Cast and crew. Params: `page` |
| GET | `/api/v1/tmdb/person/{person_id}` | Person details |
| GET | `/api/v1/tmdb/person/credits/{person_id}` | Person's filmography. Params: `page` |
| GET | `/api/v1/tmdb/{tmdbid}/{season}` | All episodes of a season. Params: `episode_group` |
### Douban (5 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/douban/{doubanid}` | Douban media detail |
| GET | `/api/v1/douban/person/{person_id}` | Person detail |
| GET | `/api/v1/douban/person/credits/{person_id}` | Person filmography. Params: `page` |
| GET | `/api/v1/douban/credits/{doubanid}/{type_name}` | Cast info (type_name: movie/tv) |
| GET | `/api/v1/douban/recommend/{doubanid}/{type_name}` | Recommendations |
### Bangumi (5 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/bangumi/{bangumiid}` | Bangumi detail |
| GET | `/api/v1/bangumi/credits/{bangumiid}` | Cast. Params: `page`, `count` |
| GET | `/api/v1/bangumi/recommend/{bangumiid}` | Recommendations. Params: `page`, `count` |
| GET | `/api/v1/bangumi/person/{person_id}` | Person detail |
| GET | `/api/v1/bangumi/person/credits/{person_id}` | Person filmography. Params: `page`, `count` |
### Search / Torrents (4 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/search/media/{mediaid}` | Search torrents by media ID (format: `tmdb:123` / `douban:123` / `bangumi:123`). Params: `mtype`, `area`, `title`, `year`, `season`, `sites` |
| GET | `/api/v1/search/title` | Fuzzy search torrents by keyword. Params: `keyword`, `page`, `sites` |
| GET | `/api/v1/search/last` | Get latest search results |
| POST | `/api/v1/search/recommend` | AI recommended resources. Body: `filtered_indices`, `check_only`, `force` |
### Download (7 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/download/` | List active downloads. Params: `name` (downloader name) |
| POST | `/api/v1/download/` | Add download (with media info). Body: JSON |
| POST | `/api/v1/download/add` | Add download (without media info). Body: JSON with `torrent_url` |
| GET | `/api/v1/download/start/{hashString}` | Resume download task |
| GET | `/api/v1/download/stop/{hashString}` | Pause download task |
| GET | `/api/v1/download/clients` | List available download clients |
| DELETE | `/api/v1/download/{hashString}` | Delete download task. Params: `name` |
### Subscribe (28 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/subscribe/` | List all subscriptions |
| POST | `/api/v1/subscribe/` | Add subscription. Body: Subscribe JSON |
| PUT | `/api/v1/subscribe/` | Update subscription. Body: Subscribe JSON |
| GET | `/api/v1/subscribe/list` | List subscriptions (API_TOKEN auth, use `--token-param`) |
| GET | `/api/v1/subscribe/{subscribe_id}` | Subscription detail |
| DELETE | `/api/v1/subscribe/{subscribe_id}` | Delete subscription |
| PUT | `/api/v1/subscribe/status/{subid}` | Update subscription status. Params: `state` (required) |
| GET | `/api/v1/subscribe/media/{mediaid}` | Query subscription by media ID. Params: `season`, `title` |
| DELETE | `/api/v1/subscribe/media/{mediaid}` | Delete subscription by media ID. Params: `season` |
| GET | `/api/v1/subscribe/refresh` | Refresh all subscriptions |
| GET | `/api/v1/subscribe/reset/{subid}` | Reset subscription |
| GET | `/api/v1/subscribe/check` | Refresh subscription TMDB info |
| GET | `/api/v1/subscribe/search` | Search all subscriptions |
| GET | `/api/v1/subscribe/search/{subscribe_id}` | Search specific subscription |
| POST | `/api/v1/subscribe/seerr` | Overseerr/Jellyseerr notification subscription |
| GET | `/api/v1/subscribe/history/{mtype}` | Subscription history. Params: `page`, `count` |
| DELETE | `/api/v1/subscribe/history/{history_id}` | Delete subscription history |
| GET | `/api/v1/subscribe/popular` | Popular subscriptions. Params: `stype` (required), `page`, `count`, `min_sub`, `genre_id`, `min_rating`, `max_rating`, `sort_type` |
| GET | `/api/v1/subscribe/user/{username}` | User's subscriptions |
| GET | `/api/v1/subscribe/files/{subscribe_id}` | Subscription related files |
| POST | `/api/v1/subscribe/share` | Share subscription. Body: SubscribeShare JSON |
| DELETE | `/api/v1/subscribe/share/{share_id}` | Delete shared subscription |
| POST | `/api/v1/subscribe/fork` | Fork shared subscription. Body: SubscribeShare JSON |
| GET | `/api/v1/subscribe/follow` | List followed share users |
| POST | `/api/v1/subscribe/follow` | Follow a share user. Params: `share_uid` |
| DELETE | `/api/v1/subscribe/follow` | Unfollow a share user. Params: `share_uid` |
| GET | `/api/v1/subscribe/shares` | List shared subscriptions. Params: `name`, `page`, `count`, `genre_id`, `min_rating`, `max_rating`, `sort_type` |
| GET | `/api/v1/subscribe/share/statistics` | Share statistics |
### Site (24 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/site/` | List all sites |
| POST | `/api/v1/site/` | Add site. Body: Site JSON |
| PUT | `/api/v1/site/` | Update site. Body: Site JSON |
| GET | `/api/v1/site/{site_id}` | Site detail by ID |
| DELETE | `/api/v1/site/{site_id}` | Delete site |
| GET | `/api/v1/site/domain/{site_url}` | Site detail by domain |
| GET | `/api/v1/site/cookiecloud` | Sync CookieCloud |
| GET | `/api/v1/site/reset` | Reset sites |
| POST | `/api/v1/site/priorities` | Batch update site priorities. Body: array |
| GET | `/api/v1/site/cookie/{site_id}` | Update site cookie & UA. Params: `username`, `password`, `code` |
| POST | `/api/v1/site/userdata/{site_id}` | Refresh site user data |
| GET | `/api/v1/site/userdata/{site_id}` | Get site user data. Params: `workdate` |
| GET | `/api/v1/site/userdata/latest` | All sites latest user data |
| GET | `/api/v1/site/test/{site_id}` | Test site connection |
| GET | `/api/v1/site/icon/{site_id}` | Site icon |
| GET | `/api/v1/site/category/{site_id}` | Site categories |
| GET | `/api/v1/site/resource/{site_id}` | Site resources. Params: `keyword`, `cat`, `page` |
| GET | `/api/v1/site/statistic/{site_url}` | Specific site statistics |
| GET | `/api/v1/site/statistic` | All site statistics |
| GET | `/api/v1/site/rss` | RSS subscription sites |
| GET | `/api/v1/site/auth` | Check authenticated sites |
| POST | `/api/v1/site/auth` | Authenticate a site. Body: SiteAuth |
| GET | `/api/v1/site/mapping` | Site domain-to-name mapping |
| GET | `/api/v1/site/supporting` | Supported site list |
### History (5 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/history/download` | Download history. Params: `page`, `count` |
| DELETE | `/api/v1/history/download` | Delete download history. Body: DownloadHistory JSON |
| GET | `/api/v1/history/transfer` | Transfer history. Params: `title`, `page`, `count`, `status` |
| DELETE | `/api/v1/history/transfer` | Delete transfer history. Params: `deletesrc`, `deletedest`. Body: TransferHistory |
| GET | `/api/v1/history/empty/transfer` | Clear all transfer history |
### Media Server (8 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/mediaserver/play/{itemid}` | Play media online |
| GET | `/api/v1/mediaserver/exists` | Check if media exists in library. Params: `title`, `year`, `mtype`, `tmdbid`, `season` |
| POST | `/api/v1/mediaserver/exists_remote` | Check existing episodes (remote). Body: MediaInfo JSON |
| POST | `/api/v1/mediaserver/notexists` | Check missing episodes (remote). Body: MediaInfo JSON |
| GET | `/api/v1/mediaserver/latest` | Latest library items. Params: `server` (required), `count` |
| GET | `/api/v1/mediaserver/playing` | Currently playing. Params: `server` (required), `count` |
| GET | `/api/v1/mediaserver/library` | Library list. Params: `server` (required), `hidden` |
| GET | `/api/v1/mediaserver/clients` | Available media servers |
### Storage / Files (13 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/v1/storage/list` | List directory contents. Params: `sort`. Body: FileItem JSON |
| POST | `/api/v1/storage/mkdir` | Create directory. Params: `name` (required). Body: FileItem |
| POST | `/api/v1/storage/delete` | Delete file or directory. Body: FileItem JSON |
| POST | `/api/v1/storage/download` | Download file. Body: FileItem JSON |
| POST | `/api/v1/storage/image` | Preview image. Body: FileItem JSON |
| POST | `/api/v1/storage/rename` | Rename file/dir. Params: `new_name` (required), `recursive`. Body: FileItem |
| GET | `/api/v1/storage/usage/{name}` | Storage usage info |
| GET | `/api/v1/storage/transtype/{name}` | Supported transfer types |
| GET | `/api/v1/storage/qrcode/{name}` | Generate QR code for auth |
| GET | `/api/v1/storage/auth_url/{name}` | Get OAuth2 auth URL |
| GET | `/api/v1/storage/check/{name}` | Confirm QR login. Params: `ck`, `t` |
| POST | `/api/v1/storage/save/{name}` | Save storage config. Body: JSON object |
| GET | `/api/v1/storage/reset/{name}` | Reset storage config |
### Transfer (5 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/transfer/name` | Preview transfer name. Params: `path` (required), `filetype` (required) |
| GET | `/api/v1/transfer/queue` | Transfer queue |
| DELETE | `/api/v1/transfer/queue` | Remove from transfer queue. Body: FileItem JSON |
| POST | `/api/v1/transfer/manual` | Manual transfer. Params: `background`. Body: ManualTransferItem JSON |
| GET | `/api/v1/transfer/now` | Run immediate transfer |
### Dashboard (16 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/dashboard/statistic` | Media statistics. Params: `name` |
| GET | `/api/v1/dashboard/statistic2` | Media statistics (API_TOKEN, use `--token-param`) |
| GET | `/api/v1/dashboard/storage` | Local storage space |
| GET | `/api/v1/dashboard/storage2` | Local storage space (API_TOKEN) |
| GET | `/api/v1/dashboard/processes` | Process info |
| GET | `/api/v1/dashboard/downloader` | Downloader info. Params: `name` |
| GET | `/api/v1/dashboard/downloader2` | Downloader info (API_TOKEN) |
| GET | `/api/v1/dashboard/schedule` | Scheduled services |
| GET | `/api/v1/dashboard/schedule2` | Scheduled services (API_TOKEN) |
| GET | `/api/v1/dashboard/transfer` | Transfer statistics. Params: `days` |
| GET | `/api/v1/dashboard/cpu` | CPU usage |
| GET | `/api/v1/dashboard/cpu2` | CPU usage (API_TOKEN) |
| GET | `/api/v1/dashboard/memory` | Memory usage |
| GET | `/api/v1/dashboard/memory2` | Memory usage (API_TOKEN) |
| GET | `/api/v1/dashboard/network` | Network traffic |
| GET | `/api/v1/dashboard/network2` | Network traffic (API_TOKEN) |
### Plugin (22 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/plugin/` | List plugins. Params: `state` (installed/market/all), `force` |
| GET | `/api/v1/plugin/installed` | List installed plugins |
| GET | `/api/v1/plugin/statistic` | Plugin install statistics |
| GET | `/api/v1/plugin/install/{plugin_id}` | Install plugin. Params: `repo_url`, `force` |
| GET | `/api/v1/plugin/reload/{plugin_id}` | Reload plugin |
| GET | `/api/v1/plugin/reset/{plugin_id}` | Reset plugin config & data |
| GET | `/api/v1/plugin/{plugin_id}` | Get plugin config |
| PUT | `/api/v1/plugin/{plugin_id}` | Update plugin config. Body: JSON object |
| DELETE | `/api/v1/plugin/{plugin_id}` | Uninstall plugin |
| POST | `/api/v1/plugin/clone/{plugin_id}` | Clone plugin. Body: JSON object |
| GET | `/api/v1/plugin/form/{plugin_id}` | Plugin form page |
| GET | `/api/v1/plugin/page/{plugin_id}` | Plugin data page |
| GET | `/api/v1/plugin/remotes` | Plugin federation list. Params: `token` (required) |
| GET | `/api/v1/plugin/dashboard/meta` | All plugin dashboard metadata |
| GET | `/api/v1/plugin/dashboard/{plugin_id}/{key}` | Plugin dashboard by key |
| GET | `/api/v1/plugin/dashboard/{plugin_id}` | Plugin dashboard |
| GET | `/api/v1/plugin/file/{plugin_id}/{filepath}` | Plugin static file |
| GET | `/api/v1/plugin/folders` | Plugin folder config |
| POST | `/api/v1/plugin/folders` | Save plugin folder config |
| POST | `/api/v1/plugin/folders/{folder_name}` | Create plugin folder |
| DELETE | `/api/v1/plugin/folders/{folder_name}` | Delete plugin folder |
| PUT | `/api/v1/plugin/folders/{folder_name}/plugins` | Update folder plugins. Body: array |
### Workflow (16 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/workflow/` | List all workflows |
| POST | `/api/v1/workflow/` | Create workflow. Body: Workflow JSON |
| GET | `/api/v1/workflow/{workflow_id}` | Workflow detail |
| PUT | `/api/v1/workflow/{workflow_id}` | Update workflow. Body: Workflow JSON |
| DELETE | `/api/v1/workflow/{workflow_id}` | Delete workflow |
| POST | `/api/v1/workflow/{workflow_id}/run` | Run workflow. Params: `from_begin` |
| POST | `/api/v1/workflow/{workflow_id}/start` | Enable workflow |
| POST | `/api/v1/workflow/{workflow_id}/pause` | Disable workflow |
| POST | `/api/v1/workflow/{workflow_id}/reset` | Reset workflow |
| GET | `/api/v1/workflow/actions` | List all actions |
| GET | `/api/v1/workflow/plugin/actions` | Plugin actions. Params: `plugin_id` |
| GET | `/api/v1/workflow/event_types` | List event types |
| POST | `/api/v1/workflow/share` | Share workflow. Body: WorkflowShare JSON |
| DELETE | `/api/v1/workflow/share/{share_id}` | Delete shared workflow |
| POST | `/api/v1/workflow/fork` | Fork shared workflow. Body: WorkflowShare JSON |
| GET | `/api/v1/workflow/shares` | List shared workflows. Params: `name`, `page`, `count` |
### System (20 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/system/env` | Get system configuration |
| POST | `/api/v1/system/env` | Update system configuration. Body: JSON object |
| GET | `/api/v1/system/setting/{key}` | Get system setting |
| POST | `/api/v1/system/setting/{key}` | Update system setting |
| GET | `/api/v1/system/global` | Non-sensitive settings. Params: `token` (required) |
| GET | `/api/v1/system/global/user` | User-related settings |
| GET | `/api/v1/system/restart` | Restart system |
| GET | `/api/v1/system/runscheduler` | Run scheduled service. Params: `jobid` (required) |
| GET | `/api/v1/system/runscheduler2` | Run scheduler (API_TOKEN, use `--token-param`). Params: `jobid` |
| GET | `/api/v1/system/modulelist` | List loaded modules |
| GET | `/api/v1/system/moduletest/{moduleid}` | Test module availability |
| GET | `/api/v1/system/versions` | List all GitHub releases |
| GET | `/api/v1/system/ruletest` | Test filter rule. Params: `title` (required), `rulegroup_name` (required), `subtitle` |
| GET | `/api/v1/system/nettest` | Test network connectivity. Params: `url` (required), `proxy` (required), `include` |
| GET | `/api/v1/system/llm-models` | List LLM models. Params: `provider` (required), `api_key` (required), `base_url` |
| GET | `/api/v1/system/progress/{process_type}` | Real-time progress (SSE) |
| GET | `/api/v1/system/message` | Real-time messages (SSE). Params: `role` |
| GET | `/api/v1/system/logging` | Real-time logs (SSE). Params: `length`, `logfile` |
| GET | `/api/v1/system/img/{proxy}` | Image proxy. Params: `imgurl` (required), `cache`, `use_cookies` |
| GET | `/api/v1/system/cache/image` | Cached image. Params: `url` (required) |
### Discover (6 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/discover/source` | Discover data sources |
| GET | `/api/v1/discover/bangumi` | Discover Bangumi. Params: `type`, `cat`, `sort`, `year`, `page`, `count` |
| GET | `/api/v1/discover/douban_movies` | Discover Douban movies. Params: `sort`, `tags`, `page`, `count` |
| GET | `/api/v1/discover/douban_tvs` | Discover Douban TV. Params: `sort`, `tags`, `page`, `count` |
| GET | `/api/v1/discover/tmdb_movies` | Discover TMDB movies. Params: `sort_by`, `with_genres`, `with_original_language`, `page` |
| GET | `/api/v1/discover/tmdb_tvs` | Discover TMDB TV. Params: same as movies |
### Recommend (14 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/recommend/source` | Recommendation data sources |
| GET | `/api/v1/recommend/bangumi_calendar` | Bangumi daily schedule. Params: `page`, `count` |
| GET | `/api/v1/recommend/douban_showing` | Douban now showing. Params: `page`, `count` |
| GET | `/api/v1/recommend/douban_movies` | Douban movies. Params: `sort`, `tags`, `page`, `count` |
| GET | `/api/v1/recommend/douban_tvs` | Douban TV. Params: `sort`, `tags`, `page`, `count` |
| GET | `/api/v1/recommend/douban_movie_top250` | Douban Top 250 movies. Params: `page`, `count` |
| GET | `/api/v1/recommend/douban_tv_weekly_chinese` | Douban Chinese TV weekly. Params: `page`, `count` |
| GET | `/api/v1/recommend/douban_tv_weekly_global` | Douban Global TV weekly. Params: `page`, `count` |
| GET | `/api/v1/recommend/douban_tv_animation` | Douban animation. Params: `page`, `count` |
| GET | `/api/v1/recommend/douban_movie_hot` | Douban hot movies. Params: `page`, `count` |
| GET | `/api/v1/recommend/douban_tv_hot` | Douban hot TV. Params: `page`, `count` |
| GET | `/api/v1/recommend/tmdb_movies` | TMDB movies. Params: `sort_by`, `with_genres`, `page` |
| GET | `/api/v1/recommend/tmdb_tvs` | TMDB TV. Params: `sort_by`, `with_genres`, `page` |
| GET | `/api/v1/recommend/tmdb_trending` | TMDB trending. Params: `page` |
### Torrent Cache (5 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/torrent/cache` | Get torrent cache |
| DELETE | `/api/v1/torrent/cache` | Clear torrent cache |
| DELETE | `/api/v1/torrent/cache/{domain}/{torrent_hash}` | Delete specific torrent cache |
| POST | `/api/v1/torrent/cache/refresh` | Refresh torrent cache |
| POST | `/api/v1/torrent/cache/reidentify/{domain}/{torrent_hash}` | Re-identify torrent. Params: `tmdbid`, `doubanid` |
### Message (6 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/v1/message/` | Receive user message. Params: `token`, `source` |
| GET | `/api/v1/message/` | Callback verification. Params: `token`, `echostr`, `msg_signature`, `timestamp`, `nonce`, `source` |
| POST | `/api/v1/message/web` | Send web message. Params: `text` (required) |
| GET | `/api/v1/message/web` | Get web messages. Params: `page`, `count` |
| POST | `/api/v1/message/webpush/subscribe` | WebPush subscribe. Body: Subscription JSON |
| POST | `/api/v1/message/webpush/send` | Send WebPush notification. Body: SubscriptionMessage JSON |
### User (10 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/user/` | List all users |
| POST | `/api/v1/user/` | Create user. Body: UserCreate JSON |
| PUT | `/api/v1/user/` | Update user. Body: UserUpdate JSON |
| GET | `/api/v1/user/current` | Current logged-in user |
| GET | `/api/v1/user/{username}` | User detail |
| DELETE | `/api/v1/user/id/{user_id}` | Delete user by ID |
| DELETE | `/api/v1/user/name/{user_name}` | Delete user by username |
| POST | `/api/v1/user/avatar/{user_id}` | Upload avatar. Body: multipart/form-data |
| GET | `/api/v1/user/config/{key}` | Get user config |
| POST | `/api/v1/user/config/{key}` | Update user config |
### Login (3 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/v1/login/access-token` | Get JWT access token. Body: form (username, password) |
| GET | `/api/v1/login/wallpaper` | Login page wallpaper |
| GET | `/api/v1/login/wallpapers` | Login page wallpaper list |
### MCP Tools (6 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/v1/mcp` | MCP JSON-RPC 2.0 endpoint |
| DELETE | `/api/v1/mcp` | Terminate MCP session |
| GET | `/api/v1/mcp/tools` | List all exposed tools |
| POST | `/api/v1/mcp/tools/call` | Call a tool. Body: `{"tool_name":"...","arguments":{...}}` |
| GET | `/api/v1/mcp/tools/{tool_name}` | Get tool definition |
| GET | `/api/v1/mcp/tools/{tool_name}/schema` | Get tool input schema |
### Webhook (2 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/webhook/` | Webhook message (GET). Params: `token`, `source` |
| POST | `/api/v1/webhook/` | Webhook message (POST). Params: `token`, `source` |
### Servarr Compatibility -- /api/v3 (16 endpoints)
Radarr/Sonarr compatible API for integration with external tools.
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v3/system/status` | System status |
| GET | `/api/v3/qualityProfile` | Quality profiles |
| GET | `/api/v3/rootfolder` | Root folders |
| GET | `/api/v3/tag` | Tags |
| GET | `/api/v3/languageprofile` | Languages |
| GET | `/api/v3/movie` | All subscribed movies |
| POST | `/api/v3/movie` | Add movie subscription. Body: RadarrMovie JSON |
| GET | `/api/v3/movie/lookup` | Search movie. Params: `term` (format: `tmdb:123`) |
| GET | `/api/v3/movie/{mid}` | Movie detail |
| DELETE | `/api/v3/movie/{mid}` | Delete movie subscription |
| GET | `/api/v3/series` | All TV series |
| POST | `/api/v3/series` | Add TV subscription. Body: SonarrSeries JSON |
| PUT | `/api/v3/series` | Update TV subscription. Body: SonarrSeries JSON |
| GET | `/api/v3/series/lookup` | Search TV. Params: `term` (format: `tvdb:123`) |
| GET | `/api/v3/series/{tid}` | TV detail |
| DELETE | `/api/v3/series/{tid}` | Delete TV subscription |
### CookieCloud -- /cookiecloud (5 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/cookiecloud/` | Root |
| POST | `/cookiecloud/` | Root |
| POST | `/cookiecloud/update` | Upload cookie data. Body: CookieData JSON |
| GET | `/cookiecloud/get/{uuid}` | Download encrypted data |
| POST | `/cookiecloud/get/{uuid}` | Download encrypted data (POST) |
---
## Common Workflows
### Search and download a movie
```bash
# 1. Search TMDB for the movie
python scripts/mp-api.py GET /api/v1/media/search title="Inception" type="movie"
# 2. Get media detail (replace {tmdbid} with actual ID)
python scripts/mp-api.py GET /api/v1/media/27205 type_name="movie"
# 3. Search torrents
python scripts/mp-api.py GET /api/v1/search/media/tmdb:27205 mtype="movie"
# 4. Get latest search results
python scripts/mp-api.py GET /api/v1/search/last
# 5. Add download
python scripts/mp-api.py POST /api/v1/download/add --json '{"torrent_url":"<url_from_search>"}'
```
### Add a subscription
```bash
# 1. Search for the show
python scripts/mp-api.py GET /api/v1/media/search title="Breaking Bad" type="tv"
# 2. Check if already subscribed
python scripts/mp-api.py GET /api/v1/subscribe/media/tmdb:1396
# 3. Check if already in library
python scripts/mp-api.py GET /api/v1/mediaserver/exists tmdbid=1396 mtype="tv"
# 4. Add subscription
python scripts/mp-api.py POST /api/v1/subscribe/ --json '{"name":"Breaking Bad","year":"2008","type":"tv","tmdbid":1396}'
```
### System monitoring
```bash
# CPU, memory, network
python scripts/mp-api.py GET /api/v1/dashboard/cpu
python scripts/mp-api.py GET /api/v1/dashboard/memory
python scripts/mp-api.py GET /api/v1/dashboard/network
# Storage
python scripts/mp-api.py GET /api/v1/dashboard/storage
# Active downloads
python scripts/mp-api.py GET /api/v1/download/
# Run a scheduled task
python scripts/mp-api.py GET /api/v1/system/runscheduler jobid="subscribe_search_all"
```
### Site management
```bash
# List all sites
python scripts/mp-api.py GET /api/v1/site/
# Test site connectivity
python scripts/mp-api.py GET /api/v1/site/test/1
# Get site user data
python scripts/mp-api.py GET /api/v1/site/userdata/1
# Sync CookieCloud
python scripts/mp-api.py GET /api/v1/site/cookiecloud
```
## Error Handling
| Scenario | Action |
|----------|--------|
| HTTP 401 | API key is invalid or missing. Re-run `configure` with correct `--apikey`. |
| HTTP 403 | Insufficient permissions. The API key grants superuser access; check if the endpoint requires special auth. |
| HTTP 404 | Endpoint or resource not found. Verify the path and path parameters. |
| HTTP 422 | Validation error. Check required parameters and JSON body format. |
| Connection error | Verify `--host` URL is reachable. Check if MoviePilot is running. |
| Missing config | Run `python scripts/mp-api.py configure --host <HOST> --apikey <KEY>` first. |

View File

@@ -0,0 +1,336 @@
#!/usr/bin/env python3
"""
MoviePilot REST API CLI -- a lightweight command-line client for calling
any MoviePilot API endpoint directly.
Usage:
python mp-api.py configure --host <HOST> --apikey <KEY>
python mp-api.py GET /api/v1/media/search title="Avatar" type="movie"
python mp-api.py POST /api/v1/download/add --json '{"torrent_url":"..."}'
python mp-api.py DELETE /api/v1/subscribe/123
Authentication:
The script sends the API key via the ``X-API-KEY`` header.
It can also fall back to ``?token=`` for endpoints that require it.
Configuration priority:
CLI flags > Environment variables (MP_HOST / MP_API_KEY) > Config file
Config file location: ~/.config/moviepilot_api/config
"""
from __future__ import annotations
import json
import os
import sys
import stat
import urllib.request
import urllib.error
import urllib.parse
import ssl
from pathlib import Path
SCRIPT_NAME = os.path.basename(sys.argv[0]) if sys.argv else "mp-api.py"
CONFIG_DIR = Path.home() / ".config" / "moviepilot_api"
CONFIG_FILE = CONFIG_DIR / "config"
# ---------------------------------------------------------------------------
# Configuration helpers
# ---------------------------------------------------------------------------
def read_config() -> tuple[str, str]:
"""Return (host, apikey) from the config file."""
host = ""
apikey = ""
if not CONFIG_FILE.exists():
return host, apikey
for line in CONFIG_FILE.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip()
if key == "MP_HOST":
host = value
elif key == "MP_API_KEY":
apikey = value
return host, apikey
def save_config(host: str, apikey: str) -> None:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
CONFIG_FILE.write_text(f"MP_HOST={host}\nMP_API_KEY={apikey}\n", encoding="utf-8")
CONFIG_FILE.chmod(stat.S_IRUSR | stat.S_IWUSR)
def resolve_config(
cli_host: str = "",
cli_key: str = "",
) -> tuple[str, str]:
"""Resolve effective host & key using priority: CLI > env > file."""
cfg_host, cfg_key = read_config()
host = cli_host or os.environ.get("MP_HOST", "") or cfg_host
apikey = cli_key or os.environ.get("MP_API_KEY", "") or cfg_key
return host, apikey
# ---------------------------------------------------------------------------
# HTTP helpers
# ---------------------------------------------------------------------------
# Allow self-signed certs (common in home-lab setups)
_SSL_CTX = ssl.create_default_context()
_SSL_CTX.check_hostname = False
_SSL_CTX.verify_mode = ssl.CERT_NONE
def http_request(
method: str,
url: str,
headers: dict[str, str] | None = None,
body: bytes | None = None,
timeout: int = 120,
) -> tuple[int, str]:
"""Perform an HTTP request and return (status_code, response_body)."""
headers = headers or {}
req = urllib.request.Request(url, data=body, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=timeout, context=_SSL_CTX) as resp:
return resp.status, resp.read().decode("utf-8", errors="replace")
except urllib.error.HTTPError as exc:
return exc.code, exc.read().decode("utf-8", errors="replace")
except urllib.error.URLError as exc:
return 0, f"Connection error: {exc.reason}"
def build_url(host: str, path: str, query_params: dict[str, str] | None = None) -> str:
"""Build a full URL from host + path + optional query parameters."""
base = host.rstrip("/")
if not path.startswith("/"):
path = "/" + path
url = base + path
if query_params:
url += "?" + urllib.parse.urlencode(query_params)
return url
# ---------------------------------------------------------------------------
# Core API call
# ---------------------------------------------------------------------------
def api_call(
host: str,
apikey: str,
method: str,
path: str,
query_params: dict[str, str] | None = None,
json_body: object | None = None,
use_token_param: bool = False,
timeout: int = 120,
) -> tuple[int, object]:
"""
Call a MoviePilot REST API endpoint.
Parameters
----------
host : str
MoviePilot base URL (e.g. ``http://localhost:3000``).
apikey : str
The API key (``settings.API_TOKEN`` value).
method : str
HTTP method: GET, POST, PUT, DELETE.
path : str
API path (e.g. ``/api/v1/media/search``).
query_params : dict, optional
Additional query-string parameters.
json_body : object, optional
A JSON-serialisable body for POST/PUT requests.
use_token_param : bool
If True, send the key as ``?token=`` instead of the header.
timeout : int
Request timeout in seconds.
Returns
-------
(status_code, parsed_json_or_text)
"""
headers: dict[str, str] = {}
qp = dict(query_params or {})
if use_token_param:
qp["token"] = apikey
else:
headers["X-API-KEY"] = apikey
body_bytes: bytes | None = None
if json_body is not None:
headers["Content-Type"] = "application/json"
body_bytes = json.dumps(json_body, ensure_ascii=False).encode("utf-8")
url = build_url(host, path, qp if qp else None)
status, raw = http_request(method, url, headers, body_bytes, timeout)
# Try to parse JSON
try:
data = json.loads(raw)
except (json.JSONDecodeError, ValueError):
data = raw
return status, data
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def print_json(obj: object) -> None:
"""Pretty-print a JSON-serialisable object to stdout."""
if isinstance(obj, str):
print(obj)
else:
print(json.dumps(obj, indent=2, ensure_ascii=False))
def print_usage() -> None:
print(f"""Usage: python {SCRIPT_NAME} [options] <METHOD> <PATH> [key=value ...] [--json '<body>']
python {SCRIPT_NAME} configure --host <HOST> --apikey <KEY>
Options:
--host HOST MoviePilot backend URL
--apikey KEY API key (API_TOKEN)
--token-param Send key as ?token= query param instead of X-API-KEY header
--timeout SECS Request timeout (default: 120)
--help Show this help message
Methods: GET POST PUT DELETE
Examples:
python {SCRIPT_NAME} configure --host http://localhost:3000 --apikey mytoken123
python {SCRIPT_NAME} GET /api/v1/media/search title="Avatar" type="movie"
python {SCRIPT_NAME} GET /api/v1/subscribe/
python {SCRIPT_NAME} POST /api/v1/download/add --json '{{"torrent_url":"abc:1"}}'
python {SCRIPT_NAME} DELETE /api/v1/subscribe/123
python {SCRIPT_NAME} GET /api/v1/dashboard/statistic2 --token-param
""")
def main() -> None:
argv = sys.argv[1:]
if not argv or "--help" in argv or "-h" in argv:
print_usage()
sys.exit(0)
# Parse options
cli_host = ""
cli_key = ""
use_token_param = False
timeout = 120
positional: list[str] = []
json_body_str: str | None = None
i = 0
while i < len(argv):
arg = argv[i]
if arg == "--host":
i += 1
cli_host = argv[i] if i < len(argv) else ""
elif arg == "--apikey":
i += 1
cli_key = argv[i] if i < len(argv) else ""
elif arg == "--token-param":
use_token_param = True
elif arg == "--timeout":
i += 1
timeout = int(argv[i]) if i < len(argv) else 120
elif arg == "--json":
i += 1
json_body_str = argv[i] if i < len(argv) else "{}"
else:
positional.append(arg)
i += 1
# Sub-command: configure
if positional and positional[0].lower() == "configure":
if not cli_host and not cli_key:
print(
"Error: --host and --apikey are required for configure", file=sys.stderr
)
sys.exit(1)
cfg_host, cfg_key = read_config()
save_config(cli_host or cfg_host, cli_key or cfg_key)
print("Configuration saved.")
sys.exit(0)
# Normal API call
if len(positional) < 2:
print("Error: expected <METHOD> <PATH>", file=sys.stderr)
print_usage()
sys.exit(1)
method = positional[0].upper()
path = positional[1]
# Remaining positional args are key=value query params
query_params: dict[str, str] = {}
for kv in positional[2:]:
if "=" in kv:
k, _, v = kv.partition("=")
query_params[k] = v
else:
print(f"Warning: ignoring argument without '=': {kv}", file=sys.stderr)
# Parse JSON body
json_body = None
if json_body_str:
try:
json_body = json.loads(json_body_str)
except json.JSONDecodeError as exc:
print(f"Error: invalid JSON body: {exc}", file=sys.stderr)
sys.exit(1)
# Resolve config
host, apikey = resolve_config(cli_host, cli_key)
if not host:
print("Error: backend host is not configured.", file=sys.stderr)
print(" Use: --host HOST or set MP_HOST environment variable", file=sys.stderr)
sys.exit(1)
if not apikey:
print("Error: API key is not configured.", file=sys.stderr)
print(
" Use: --apikey KEY or set MP_API_KEY environment variable",
file=sys.stderr,
)
sys.exit(1)
# Persist if CLI flags provided
if cli_host or cli_key:
save_config(host, apikey)
status, data = api_call(
host=host,
apikey=apikey,
method=method,
path=path,
query_params=query_params if query_params else None,
json_body=json_body,
use_token_param=use_token_param,
timeout=timeout,
)
if status and status not in (200, 201):
print(f"HTTP {status}", file=sys.stderr)
print_json(data)
if status and status >= 400:
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -18,7 +18,11 @@ class MetaInfoTest(TestCase):
if info.get("path"):
meta_info = MetaInfoPath(path=Path(info.get("path")))
else:
meta_info = MetaInfo(title=info.get("title"), subtitle=info.get("subtitle"), custom_words=["#"])
meta_info = MetaInfo(
title=info.get("title"),
subtitle=info.get("subtitle"),
custom_words=["#"],
)
target = {
"type": meta_info.type.value,
"cn_name": meta_info.cn_name or "",
@@ -31,14 +35,17 @@ class MetaInfoTest(TestCase):
"pix": meta_info.resource_pix or "",
"video_codec": meta_info.video_encode or "",
"audio_codec": meta_info.audio_encode or "",
"fps": meta_info.fps or None
"fps": meta_info.fps or None,
}
# 检查tmdbid
if info.get("target").get("tmdbid"):
target["tmdbid"] = meta_info.tmdbid
self.assertEqual(target, info.get("target"))
expected = info.get("target")
if "fps" not in expected:
target.pop("fps", None)
self.assertEqual(target, expected)
def test_emby_format_ids(self):
"""
@@ -47,21 +54,33 @@ class MetaInfoTest(TestCase):
# 测试文件路径
test_paths = [
# 文件名中包含tmdbid
("/movies/The Vampire Diaries (2009) [tmdbid=18165]/The.Vampire.Diaries.S01E01.1080p.mkv", 18165),
(
"/movies/The Vampire Diaries (2009) [tmdbid=18165]/The.Vampire.Diaries.S01E01.1080p.mkv",
18165,
),
# 目录名中包含tmdbid
("/movies/Inception (2010) [tmdbid-27205]/Inception.2010.1080p.mkv", 27205),
# 父目录名中包含tmdbid
("/movies/Breaking Bad (2008) [tmdb=1396]/Season 1/Breaking.Bad.S01E01.1080p.mkv", 1396),
(
"/movies/Breaking Bad (2008) [tmdb=1396]/Season 1/Breaking.Bad.S01E01.1080p.mkv",
1396,
),
# 祖父目录名中包含tmdbid
("/tv/Game of Thrones (2011) {tmdb=1399}/Season 1/Game.of.Thrones.S01E01.1080p.mkv", 1399),
(
"/tv/Game of Thrones (2011) {tmdb=1399}/Season 1/Game.of.Thrones.S01E01.1080p.mkv",
1399,
),
# 测试{tmdb-xxx}格式
("/movies/Avatar (2009) {tmdb-19995}/Avatar.2009.1080p.mkv", 19995),
]
for path_str, expected_tmdbid in test_paths:
meta = MetaInfoPath(Path(path_str))
self.assertEqual(meta.tmdbid, expected_tmdbid,
f"路径 {path_str} 期望的tmdbid为 {expected_tmdbid},实际识别为 {meta.tmdbid}")
self.assertEqual(
meta.tmdbid,
expected_tmdbid,
f"路径 {path_str} 期望的tmdbid为 {expected_tmdbid},实际识别为 {meta.tmdbid}",
)
def test_metainfopath_with_custom_words(self):
"""测试 MetaInfoPath 使用自定义识别词"""
@@ -93,7 +112,37 @@ class MetaInfoTest(TestCase):
title = "电影替换词.2024.mkv"
meta = MetaInfo(title=title, custom_words=custom_words)
# 验证 apply_words 属性存在
self.assertTrue(hasattr(meta, 'apply_words'))
self.assertTrue(hasattr(meta, "apply_words"))
# 如果替换词被应用,应该记录在 apply_words 中
if meta.apply_words:
self.assertIn("替换词 => 新词", meta.apply_words)
def test_metainfopath_auxiliary_chinese_stem_uses_parent_title(self):
"""
文件名为简英双语/特效等压制标签、父目录为拉丁片名时,应合并父目录标题与年份。
"""
path = Path(
"/Marty Supreme 2025 2160p DoVi HDR Atmos TrueHD 7.1 x265-PbK/简英双语特效.mp4"
)
meta = MetaInfoPath(path)
self.assertEqual(meta.en_name, "Marty Supreme")
self.assertEqual(meta.year, "2025")
def test_metainfopath_chinese_parent_not_replaced_by_auxiliary_rule(self):
"""
纯中文父目录(无拉丁字母)时不触发辅助文件名规则,避免误伤。
"""
path = Path("/movies/流浪地球 (2023)/简体中字.mkv")
meta = MetaInfoPath(path)
self.assertTrue(meta.cn_name)
self.assertIn("简体", meta.cn_name)
def test_metainfopath_cn_title_containing_keyword_not_cleared(self):
"""
中文片名恰好包含辅助关键词子串时(如"粤语残片""粤语"
不应被当作辅助标签清空。
"""
path = Path("/Some Movie 2024/粤语残片.mkv")
meta = MetaInfoPath(path)
# stem 含有非关键词汉字"残片",不应被全量匹配命中
self.assertIn("粤语残片", meta.cn_name)

View File

@@ -0,0 +1,150 @@
# -*- coding: utf-8 -*-
import asyncio
from unittest import TestCase
from app.core.metainfo import MetaInfo
from app.chain import ChainBase
from app.modules.themoviedb import TheMovieDbModule
from app.schemas.types import MediaType
class TmdbRecognizeModuleTest(TestCase):
"""
TMDB模块层识别测试
模块层的 async_recognize_media 不会自动从 meta.tmdbid 提取 tmdbid
该提取在 ChainBase 层完成,因此测试中需显式传入 tmdbid 参数。
"""
@classmethod
def setUpClass(cls):
cls.module = TheMovieDbModule()
cls.module.init_module()
@classmethod
def tearDownClass(cls):
cls.module.stop()
def _run(self, coro):
return asyncio.run(coro)
def test_tmdbid_priority_over_title(self):
"""
当标题中包含 {tmdbid=xxx} 时应优先使用tmdbid识别
而非回退到标题搜索
"""
meta = MetaInfo(title="空之境界 {tmdbid=938416}")
self.assertEqual(meta.tmdbid, 938416)
self.assertEqual(meta.cn_name, "空之境界")
result = self._run(
self.module.async_recognize_media(
meta=meta, tmdbid=meta.tmdbid, cache=False
)
)
self.assertIsNotNone(result, "应能识别到媒体信息")
self.assertEqual(result.tmdb_id, 938416)
def test_tmdbid_disambiguation_tv_vs_movie(self):
"""
当同一tmdbid同时存在电影和电视剧时应通过元数据消歧
tmdbid=23155 同时存在电影"空之境界 第五章 矛盾螺旋"和电视剧"TV Land Top 10"
标题包含"空之境界"应消歧为电影
"""
meta = MetaInfo(title="空之境界 第五章 矛盾螺旋 (2008) {tmdbid=23155}")
self.assertEqual(meta.tmdbid, 23155)
result = self._run(
self.module.async_recognize_media(
meta=meta, tmdbid=meta.tmdbid, cache=False
)
)
self.assertIsNotNone(result, "同ID存在电影和电视剧时应能通过元数据消歧")
self.assertEqual(result.tmdb_id, 23155)
self.assertEqual(result.type, MediaType.MOVIE)
def test_tmdbid_with_explicit_type(self):
"""
当标题中同时包含 tmdbid 和 type 时,应直接使用指定类型查询
"""
meta = MetaInfo(title="空之境界 {tmdbid=23155}")
result = self._run(
self.module.async_recognize_media(
meta=meta, tmdbid=meta.tmdbid, mtype=MediaType.TV, cache=False
)
)
self.assertIsNotNone(result)
self.assertEqual(result.tmdb_id, 23155)
self.assertEqual(result.type, MediaType.TV)
def test_tmdbid_only_movie_exists(self):
"""
tmdbid仅存在电影时即使meta.type推断为TV也应正确识别为电影
tmdbid=496891 仅存在电影"少女与战车 最终章 第2话"
"""
meta = MetaInfo(title="少女与战车 最终章 第2话 (2019) {tmdbid=496891}")
self.assertEqual(meta.tmdbid, 496891)
result = self._run(
self.module.async_recognize_media(
meta=meta, tmdbid=meta.tmdbid, cache=False
)
)
self.assertIsNotNone(result, "仅存在电影时应正确识别")
self.assertEqual(result.tmdb_id, 496891)
self.assertEqual(result.type, MediaType.MOVIE)
class TmdbRecognizeChainTest(TestCase):
"""
ChainBase层识别测试端到端
验证从 meta.tmdbid 提取到模块识别的完整流程
"""
@classmethod
def setUpClass(cls):
cls.chain = ChainBase()
def _run(self, coro):
return asyncio.run(coro)
def test_chain_tmdbid_movie(self):
"""
通过ChainBase识别tmdbid对应电影应正确识别
"""
meta = MetaInfo(title="空之境界 第五章 矛盾螺旋 (2008) {tmdbid=23155}")
result = self._run(
self.chain.async_recognize_media(meta=meta, cache=False)
)
self.assertIsNotNone(result)
self.assertEqual(result.tmdb_id, 23155)
self.assertEqual(result.type, MediaType.MOVIE)
def test_chain_tmdbid_ignores_inferred_type(self):
"""
当tmdbid存在时不应使用meta推断的类型
"第2话"会让meta.type推断为TV但tmdbid=496891仅存在电影
"""
meta = MetaInfo(title="少女与战车 最终章 第2话 (2019) {tmdbid=496891}")
self.assertEqual(meta.type, MediaType.TV, "meta.type应被推断为TV")
self.assertEqual(meta.tmdbid, 496891)
result = self._run(
self.chain.async_recognize_media(meta=meta, cache=False)
)
self.assertIsNotNone(result, "有tmdbid时不应因meta.type推断错误而识别失败")
self.assertEqual(result.tmdb_id, 496891)
self.assertEqual(result.type, MediaType.MOVIE)
def test_chain_no_tmdbid_uses_inferred_type(self):
"""
无tmdbid时应正常使用meta推断的类型进行标题搜索
"""
meta = MetaInfo(title="进击的巨人 S01E01")
self.assertEqual(meta.type, MediaType.TV)
result = self._run(
self.chain.async_recognize_media(meta=meta, cache=False)
)
self.assertIsNotNone(result)
self.assertEqual(result.type, MediaType.TV)

View File

@@ -1,2 +1,2 @@
APP_VERSION = 'v2.9.17'
FRONTEND_VERSION = 'v2.9.16'
APP_VERSION = 'v2.9.22'
FRONTEND_VERSION = 'v2.9.21'