mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-06 07:56:52 +08:00
refactor(chain): 处理链功能域 mixin 化,清理未使用导入并根治兼容层循环导入
- ChainBase 拆分为 RecognitionMixin/MessageProcessingMixin/NotificationMixin - TransferChain 拆分为 7 个功能 mixin(_mixins.py),SubscribeChain 音乐订阅域拆出 _music.py - 斜杠命令交互四件套收敛为 InteractionChainMixin 委托,会话管理器移至 application 层,chain 层不再 re-export - 模块基础类收敛到 app/modules/_base(notification/mediaserver 语义重命名) - 清理 app/chain/__init__.py 24 个未使用导入,修正 49 处测试 patch 目标到实际命名空间 - 兼容层 legacy 符号不再并入 __all__,根治 schemas 初始化反向拉起 application.transfer 的循环导入 - 修复 bangumi 集数为字符串时 set_bangumi_info 抛 TypeError - 新增重复代码等架构门禁测试;capability 清单校验排除下划线内部目录
This commit is contained in:
+15
-976
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,86 @@
|
||||
from typing import Optional, Tuple, Union
|
||||
|
||||
from app.schemas.types import MessageChannel
|
||||
|
||||
|
||||
class InteractionChainMixin:
|
||||
"""
|
||||
斜杠命令交互四件套委托:remote_list / parse_callback /
|
||||
handle_callback_interaction / handle_text_interaction。
|
||||
|
||||
subscribe、site 等业务链的交互入口完全同构,唯一差异是各自的
|
||||
交互处理器构造参数。本 mixin 将四件套委托提取为公共实现,
|
||||
子类只需注入处理器类并实现 _interaction_handler 构造器。
|
||||
|
||||
子类注入约定:
|
||||
- `_interaction_handler_type`:交互处理器类,提供静态 parse_callback;
|
||||
- `_interaction_handler()`:按各链业务动作构造处理器实例。
|
||||
"""
|
||||
|
||||
# 交互处理器类,子类注入(如 SubscribeInteractionHandler / SiteInteractionHandler)
|
||||
_interaction_handler_type: type = None
|
||||
|
||||
def _interaction_handler(self):
|
||||
"""
|
||||
构造交互处理器实例,由子类按各自业务动作注入实现。
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def remote_list(
|
||||
self,
|
||||
arg_str: str = "",
|
||||
channel: MessageChannel = None,
|
||||
userid: Union[str, int] = None,
|
||||
source: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
斜杠命令统一入口,委托交互处理器。
|
||||
"""
|
||||
return self._interaction_handler().remote_list(
|
||||
arg_str=arg_str, channel=channel, userid=userid, source=source
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def parse_callback(cls, callback_data: str) -> Optional[Tuple[str, str]]:
|
||||
"""
|
||||
解析斜杠命令按钮回调。
|
||||
"""
|
||||
return cls._interaction_handler_type.parse_callback(callback_data)
|
||||
|
||||
def handle_callback_interaction(
|
||||
self,
|
||||
callback_data: str,
|
||||
channel: MessageChannel,
|
||||
source: str,
|
||||
userid: Union[str, int],
|
||||
username: str,
|
||||
original_message_id: Optional[Union[str, int]] = None,
|
||||
original_chat_id: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""委托交互处理器处理按钮回调。"""
|
||||
return self._interaction_handler().handle_callback_interaction(
|
||||
callback_data=callback_data,
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=username,
|
||||
original_message_id=original_message_id,
|
||||
original_chat_id=original_chat_id,
|
||||
)
|
||||
|
||||
def handle_text_interaction(
|
||||
self,
|
||||
channel: MessageChannel,
|
||||
source: str,
|
||||
userid: Union[str, int],
|
||||
username: str,
|
||||
text: str,
|
||||
) -> bool:
|
||||
"""委托交互处理器处理文本输入。"""
|
||||
return self._interaction_handler().handle_text_interaction(
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=username,
|
||||
text=text,
|
||||
)
|
||||
@@ -0,0 +1,486 @@
|
||||
"""消息处理与通知发送 mixin。
|
||||
|
||||
从 ChainBase 拆出的消息域:渠道输入状态机、通知派发规范化、消息渲染、
|
||||
隔离路由与队列发送。方法经 MRO 解析,依赖 ChainBase 实例的 run_module、
|
||||
eventmanager、messageoper、messagequeue 等协作对象。
|
||||
"""
|
||||
import copy
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
from app.db.oper.user import UserOper
|
||||
from app.domain.context import Context, MediaInfo, MusicInfo, TorrentInfo
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.foundation.identity import normalize_internal_user_id
|
||||
from app.application.messaging.message import MessageTemplateHelper
|
||||
from app.runtime.config import settings
|
||||
from app.runtime.extensions.service_registry import ServiceConfigHelper
|
||||
from app.runtime.log import logger
|
||||
from app.schemas import MessageResponse, Notification, TransferInfo
|
||||
from app.schemas.message import ChannelCapability, ChannelCapabilityManager
|
||||
from app.schemas.types import EventType, MessageChannel
|
||||
|
||||
|
||||
class MessageProcessingMixin:
|
||||
"""消息输入/处理状态机与通知派发规范化。"""
|
||||
|
||||
def start_message_processing_status(
|
||||
self,
|
||||
channel: MessageChannel,
|
||||
source: Optional[str],
|
||||
userid: Optional[Union[str, int]] = None,
|
||||
message_id: Optional[Union[str, int]] = None,
|
||||
chat_id: Optional[Union[str, int]] = None,
|
||||
text: Optional[str] = None,
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
启动渠道侧消息输入/处理状态。
|
||||
具体表现由消息模块实现,例如 typing 保活或消息 reaction。
|
||||
"""
|
||||
if not channel or not ChannelCapabilityManager.supports_capability(
|
||||
channel, ChannelCapability.PROCESSING_STATUS
|
||||
):
|
||||
return None
|
||||
try:
|
||||
status = self.run_module(
|
||||
"mark_message_processing_started",
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
message_id=message_id,
|
||||
chat_id=chat_id,
|
||||
text=text,
|
||||
)
|
||||
except Exception as err:
|
||||
logger.debug(f"启动消息处理状态失败: {err}")
|
||||
return None
|
||||
return status if isinstance(status, dict) else None
|
||||
|
||||
def finish_message_processing_status(
|
||||
self,
|
||||
status: Optional[dict] = None,
|
||||
channel: Optional[MessageChannel] = None,
|
||||
source: Optional[str] = None,
|
||||
userid: Optional[Union[str, int]] = None,
|
||||
message_id: Optional[Union[str, int]] = None,
|
||||
chat_id: Optional[Union[str, int]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
结束渠道侧消息输入/处理状态。
|
||||
优先使用 start 返回的 status,缺失时使用显式渠道和消息定位参数。
|
||||
"""
|
||||
target_channel = channel
|
||||
if status:
|
||||
try:
|
||||
target_channel = MessageChannel(status.get("channel"))
|
||||
except Exception:
|
||||
target_channel = channel
|
||||
if not target_channel or not ChannelCapabilityManager.supports_capability(
|
||||
target_channel, ChannelCapability.PROCESSING_STATUS
|
||||
):
|
||||
return
|
||||
try:
|
||||
self.run_module(
|
||||
"mark_message_processing_finished",
|
||||
channel=target_channel,
|
||||
source=(status or {}).get("source") or source,
|
||||
userid=(status or {}).get("userid") or userid,
|
||||
message_id=(status or {}).get("message_id") or message_id,
|
||||
chat_id=(status or {}).get("chat_id") or chat_id,
|
||||
status=status,
|
||||
)
|
||||
except Exception as err:
|
||||
logger.debug(f"结束消息处理状态失败: {err}")
|
||||
|
||||
@staticmethod
|
||||
def _normalize_notification_for_dispatch(
|
||||
message: Notification
|
||||
) -> Notification:
|
||||
"""
|
||||
规范化待发送的通知消息。
|
||||
后台任务会复用内部占位用户ID作为会话身份,这里在真正发送前清空,
|
||||
让消息重新走默认通知路由或基于 targets 的目标解析。
|
||||
"""
|
||||
dispatch_message = copy.deepcopy(message)
|
||||
dispatch_message.userid = normalize_internal_user_id(
|
||||
dispatch_message.userid
|
||||
)
|
||||
return dispatch_message
|
||||
|
||||
@staticmethod
|
||||
def _build_notice_message_data(message: Notification) -> dict:
|
||||
"""
|
||||
构造消息通知事件数据。
|
||||
"""
|
||||
return {**message.model_dump(exclude={"save_history"}), "type": message.mtype}
|
||||
|
||||
|
||||
class NotificationMixin:
|
||||
"""通知消息发送域:渲染、隔离路由、队列发送与消息编辑。"""
|
||||
|
||||
def post_message(
|
||||
self,
|
||||
message: Optional[Notification] = None,
|
||||
meta: Optional[MetaBase] = None,
|
||||
mediainfo: Optional[Union[MediaInfo, MusicInfo]] = None,
|
||||
torrentinfo: Optional[TorrentInfo] = None,
|
||||
transferinfo: Optional[TransferInfo] = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""
|
||||
发送消息
|
||||
:param message: Notification实例
|
||||
:param meta: 元数据
|
||||
:param mediainfo: 媒体信息
|
||||
:param torrentinfo: 种子信息
|
||||
:param transferinfo: 文件整理信息
|
||||
:param kwargs: 其他参数(覆盖业务对象属性值)
|
||||
:return: 成功或失败
|
||||
"""
|
||||
# 添加格式化的时间参数
|
||||
kwargs.setdefault("current_time", datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
||||
# 渲染消息
|
||||
message = MessageTemplateHelper.render(
|
||||
message=message,
|
||||
meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
torrentinfo=torrentinfo,
|
||||
transferinfo=transferinfo,
|
||||
**kwargs,
|
||||
)
|
||||
# 检查消息是否有效
|
||||
if not message:
|
||||
logger.warning("消息为空,跳过发送")
|
||||
return
|
||||
if message.save_history:
|
||||
self.messageoper.add(**message.model_dump())
|
||||
dispatch_message = self._normalize_notification_for_dispatch(message)
|
||||
# 发送消息按设置隔离
|
||||
if not dispatch_message.userid and dispatch_message.mtype:
|
||||
# 消息隔离设置
|
||||
notify_action = ServiceConfigHelper.get_notification_switch(
|
||||
dispatch_message.mtype
|
||||
)
|
||||
if notify_action:
|
||||
# 'admin' 'user,admin' 'user' 'all'
|
||||
actions = notify_action.split(",")
|
||||
# 是否已发送管理员标志
|
||||
admin_sended = False
|
||||
send_orignal = False
|
||||
useroper = UserOper()
|
||||
for action in actions:
|
||||
send_message = copy.deepcopy(dispatch_message)
|
||||
if action == "admin" and not admin_sended:
|
||||
# 仅发送管理员
|
||||
logger.info(f"{send_message.mtype} 的消息已设置发送给管理员")
|
||||
# 读取管理员消息IDS
|
||||
send_message.targets = useroper.get_settings(settings.SUPERUSER)
|
||||
admin_sended = True
|
||||
elif action == "user" and send_message.username:
|
||||
# 发送对应用户
|
||||
logger.info(
|
||||
f"{send_message.mtype} 的消息已设置发送给用户 {send_message.username}"
|
||||
)
|
||||
# 读取用户消息IDS
|
||||
send_message.targets = useroper.get_settings(
|
||||
send_message.username
|
||||
)
|
||||
if send_message.targets is None:
|
||||
# 没有找到用户
|
||||
if not admin_sended:
|
||||
# 回滚发送管理员
|
||||
logger.info(
|
||||
f"用户 {send_message.username} 不存在,消息将发送给管理员"
|
||||
)
|
||||
# 读取管理员消息IDS
|
||||
send_message.targets = useroper.get_settings(
|
||||
settings.SUPERUSER
|
||||
)
|
||||
admin_sended = True
|
||||
else:
|
||||
# 管理员发过了,此消息不发了
|
||||
logger.info(
|
||||
f"用户 {send_message.username} 不存在,消息无法发送到对应用户"
|
||||
)
|
||||
continue
|
||||
elif send_message.username == settings.SUPERUSER:
|
||||
# 管理员同名已发送
|
||||
admin_sended = True
|
||||
else:
|
||||
# 按原消息发送全体
|
||||
if not admin_sended:
|
||||
send_orignal = True
|
||||
break
|
||||
# 按设定发送
|
||||
self.eventmanager.send_event(
|
||||
etype=EventType.NoticeMessage,
|
||||
data=self._build_notice_message_data(send_message),
|
||||
)
|
||||
self.messagequeue.send_message(
|
||||
"post_message", message=send_message, **kwargs
|
||||
)
|
||||
if not send_orignal:
|
||||
return
|
||||
# 发送消息事件
|
||||
self.eventmanager.send_event(
|
||||
etype=EventType.NoticeMessage,
|
||||
data=self._build_notice_message_data(dispatch_message),
|
||||
)
|
||||
# 按原消息发送
|
||||
self.messagequeue.send_message(
|
||||
"post_message",
|
||||
message=dispatch_message,
|
||||
immediately=True if dispatch_message.userid else False,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
async def async_post_message(
|
||||
self,
|
||||
message: Optional[Notification] = None,
|
||||
meta: Optional[MetaBase] = None,
|
||||
mediainfo: Optional[Union[MediaInfo, MusicInfo]] = None,
|
||||
torrentinfo: Optional[TorrentInfo] = None,
|
||||
transferinfo: Optional[TransferInfo] = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""
|
||||
异步发送消息
|
||||
:param message: Notification实例
|
||||
:param meta: 元数据
|
||||
:param mediainfo: 媒体信息
|
||||
:param torrentinfo: 种子信息
|
||||
:param transferinfo: 文件整理信息
|
||||
:param kwargs: 其他参数(覆盖业务对象属性值)
|
||||
:return: 成功或失败
|
||||
"""
|
||||
# 添加格式化的时间参数
|
||||
kwargs.setdefault("current_time", datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
||||
# 渲染消息
|
||||
message = MessageTemplateHelper.render(
|
||||
message=message,
|
||||
meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
torrentinfo=torrentinfo,
|
||||
transferinfo=transferinfo,
|
||||
**kwargs,
|
||||
)
|
||||
# 检查消息是否有效
|
||||
if not message:
|
||||
logger.warning("消息为空,跳过发送")
|
||||
return
|
||||
if message.save_history:
|
||||
await self.messageoper.async_add(**message.model_dump())
|
||||
dispatch_message = self._normalize_notification_for_dispatch(message)
|
||||
# 发送消息按设置隔离
|
||||
if not dispatch_message.userid and dispatch_message.mtype:
|
||||
# 消息隔离设置
|
||||
notify_action = ServiceConfigHelper.get_notification_switch(
|
||||
dispatch_message.mtype
|
||||
)
|
||||
if notify_action:
|
||||
# 'admin' 'user,admin' 'user' 'all'
|
||||
actions = notify_action.split(",")
|
||||
# 是否已发送管理员标志
|
||||
admin_sended = False
|
||||
send_orignal = False
|
||||
useroper = UserOper()
|
||||
for action in actions:
|
||||
send_message = copy.deepcopy(dispatch_message)
|
||||
if action == "admin" and not admin_sended:
|
||||
# 仅发送管理员
|
||||
logger.info(f"{send_message.mtype} 的消息已设置发送给管理员")
|
||||
# 读取管理员消息IDS
|
||||
send_message.targets = useroper.get_settings(settings.SUPERUSER)
|
||||
admin_sended = True
|
||||
elif action == "user" and send_message.username:
|
||||
# 发送对应用户
|
||||
logger.info(
|
||||
f"{send_message.mtype} 的消息已设置发送给用户 {send_message.username}"
|
||||
)
|
||||
# 读取用户消息IDS
|
||||
send_message.targets = useroper.get_settings(
|
||||
send_message.username
|
||||
)
|
||||
if send_message.targets is None:
|
||||
# 没有找到用户
|
||||
if not admin_sended:
|
||||
# 回滚发送管理员
|
||||
logger.info(
|
||||
f"用户 {send_message.username} 不存在,消息将发送给管理员"
|
||||
)
|
||||
# 读取管理员消息IDS
|
||||
send_message.targets = useroper.get_settings(
|
||||
settings.SUPERUSER
|
||||
)
|
||||
admin_sended = True
|
||||
else:
|
||||
# 管理员发过了,此消息不发了
|
||||
logger.info(
|
||||
f"用户 {send_message.username} 不存在,消息无法发送到对应用户"
|
||||
)
|
||||
continue
|
||||
elif send_message.username == settings.SUPERUSER:
|
||||
# 管理员同名已发送
|
||||
admin_sended = True
|
||||
else:
|
||||
# 按原消息发送全体
|
||||
if not admin_sended:
|
||||
send_orignal = True
|
||||
break
|
||||
# 按设定发送
|
||||
await self.eventmanager.async_send_event(
|
||||
etype=EventType.NoticeMessage,
|
||||
data=self._build_notice_message_data(send_message),
|
||||
)
|
||||
await self.messagequeue.async_send_message(
|
||||
"post_message", message=send_message, **kwargs
|
||||
)
|
||||
if not send_orignal:
|
||||
return
|
||||
# 发送消息事件
|
||||
await self.eventmanager.async_send_event(
|
||||
etype=EventType.NoticeMessage,
|
||||
data=self._build_notice_message_data(dispatch_message),
|
||||
)
|
||||
# 按原消息发送
|
||||
await self.messagequeue.async_send_message(
|
||||
"post_message",
|
||||
message=dispatch_message,
|
||||
immediately=True if dispatch_message.userid else False,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def post_medias_message(
|
||||
self, message: Notification, medias: List[MediaInfo]
|
||||
) -> None:
|
||||
"""
|
||||
发送媒体信息选择列表
|
||||
:param message: 消息体
|
||||
:param medias: 媒体列表
|
||||
:return: 成功或失败
|
||||
"""
|
||||
note_list = [media.to_dict() for media in medias]
|
||||
if message.save_history:
|
||||
self.messageoper.add(**message.model_dump(), note=note_list)
|
||||
dispatch_message = self._normalize_notification_for_dispatch(message)
|
||||
return self.messagequeue.send_message(
|
||||
"post_medias_message",
|
||||
message=dispatch_message,
|
||||
medias=medias,
|
||||
immediately=True if dispatch_message.userid else False,
|
||||
)
|
||||
|
||||
def post_torrents_message(
|
||||
self, message: Notification, torrents: List[Context]
|
||||
) -> None:
|
||||
"""
|
||||
发送种子信息选择列表
|
||||
:param message: 消息体
|
||||
:param torrents: 种子列表
|
||||
:return: 成功或失败
|
||||
"""
|
||||
note_list = [torrent.torrent_info.to_dict() for torrent in torrents]
|
||||
if message.save_history:
|
||||
self.messageoper.add(**message.model_dump(), note=note_list)
|
||||
dispatch_message = self._normalize_notification_for_dispatch(message)
|
||||
return self.messagequeue.send_message(
|
||||
"post_torrents_message",
|
||||
message=dispatch_message,
|
||||
torrents=torrents,
|
||||
immediately=True if dispatch_message.userid else False,
|
||||
)
|
||||
|
||||
def delete_message(
|
||||
self,
|
||||
channel: MessageChannel,
|
||||
source: str,
|
||||
message_id: Union[str, int],
|
||||
chat_id: Optional[Union[str, int]] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
删除消息
|
||||
:param channel: 消息渠道
|
||||
:param source: 消息源(指定特定的消息模块)
|
||||
:param message_id: 消息ID
|
||||
:param chat_id: 聊天ID(如群组ID)
|
||||
:return: 删除是否成功
|
||||
"""
|
||||
return self.run_module(
|
||||
"delete_message",
|
||||
channel=channel,
|
||||
source=source,
|
||||
message_id=message_id,
|
||||
chat_id=chat_id,
|
||||
)
|
||||
|
||||
def edit_message(
|
||||
self,
|
||||
channel: MessageChannel,
|
||||
source: str,
|
||||
message_id: Union[str, int],
|
||||
chat_id: Union[str, int],
|
||||
text: str,
|
||||
title: Optional[str] = None,
|
||||
buttons: Optional[List[List[dict]]] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
编辑已发送的消息
|
||||
:param channel: 消息渠道
|
||||
:param source: 消息源(指定特定的消息模块)
|
||||
:param message_id: 消息ID
|
||||
:param chat_id: 聊天ID
|
||||
:param text: 新的消息内容
|
||||
:param title: 消息标题
|
||||
:param buttons: 更新后的按钮列表
|
||||
:param metadata: 其他消息元数据
|
||||
:return: 编辑是否成功
|
||||
"""
|
||||
if channel == MessageChannel.WebAgent:
|
||||
try:
|
||||
from app.application.messaging.agent import edit_web_agent_message
|
||||
|
||||
return edit_web_agent_message(
|
||||
user_id=str((metadata or {}).get("userid") or ""),
|
||||
message_id=message_id,
|
||||
title=title,
|
||||
text=text,
|
||||
buttons=buttons,
|
||||
)
|
||||
except Exception as err:
|
||||
logger.debug(f"编辑 WebAgent 消息失败: {err}")
|
||||
return False
|
||||
|
||||
return self.run_module(
|
||||
"edit_message",
|
||||
channel=channel,
|
||||
source=source,
|
||||
message_id=message_id,
|
||||
chat_id=chat_id,
|
||||
text=text,
|
||||
title=title,
|
||||
buttons=buttons,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
def send_direct_message(self, message: Notification) -> Optional[MessageResponse]:
|
||||
"""
|
||||
直接发送消息并返回消息ID等信息(用于后续编辑消息的场景)
|
||||
不经过消息队列、不保存消息历史
|
||||
:param message: 消息体
|
||||
:return: 消息响应(包含message_id, chat_id等)
|
||||
"""
|
||||
return self.run_module(
|
||||
"send_direct_message",
|
||||
message=self._normalize_notification_for_dispatch(message),
|
||||
)
|
||||
|
||||
def finalize_message(
|
||||
self,
|
||||
response: MessageResponse,
|
||||
) -> bool:
|
||||
"""
|
||||
对已发送消息执行渠道收尾动作。
|
||||
例如关闭流式卡片状态;无特殊收尾的渠道直接返回 False。
|
||||
"""
|
||||
return self.run_module("finalize_message", response=response)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,420 @@
|
||||
import copy
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
from app.application.torrent import TorrentHelper
|
||||
from app.chain.download import DownloadChain
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.search import SearchChain
|
||||
from app.db.models.subscribe import Subscribe
|
||||
from app.db.oper.subscribe import SubscribeOper
|
||||
from app.db.oper.systemconfig import SystemConfigOper
|
||||
from app.domain.context import Context, MediaInfo, MusicInfo
|
||||
from app.domain.media import MUSIC_SUBSCRIBABLE_TYPES
|
||||
from app.domain.meta.metamusic import MetaMusic
|
||||
from app.runtime.log import logger
|
||||
from app.schemas.types import (
|
||||
MUSIC_ENTITY_ALBUM,
|
||||
MUSIC_ENTITY_RECORDING,
|
||||
MediaType,
|
||||
SystemConfigKey,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_music_total_tracks(value: Any) -> Optional[int]:
|
||||
"""将专辑曲目总数归一为正整数,无效或未知值返回 None。"""
|
||||
try:
|
||||
total_tracks = int(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return total_tracks if total_tracks > 0 else None
|
||||
|
||||
|
||||
class MusicSubscribeMixin:
|
||||
"""
|
||||
音乐订阅功能域 mixin:单曲/专辑目标识别、实体快照同步、候选筛选、
|
||||
择优下载与完成推进。
|
||||
|
||||
该域方法通过 self 复用 SubscribeChain 主体的 get_sub_sites / get_params /
|
||||
filter_torrents / check_and_handle_existing_media / finish_subscribe_or_not /
|
||||
get_subscribe_source_keyword 等编排能力,因此仅作为 mixin 混入 SubscribeChain,
|
||||
不独立成链。build_subscribe_meta / _subscribe_media_key 等订阅通用辅助仍保留在
|
||||
subscribe.py,方法内延迟导入以避免 _music ↔ subscribe 的模块级循环。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _validate_music_subscribe_target(
|
||||
mediainfo: MediaInfo,
|
||||
requested_music_type: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""校验音乐订阅实体一致性,并确保专辑具备可验证的曲目总数。"""
|
||||
if mediainfo.type != MediaType.MUSIC:
|
||||
return "识别结果不是音乐"
|
||||
music_type = getattr(mediainfo, "music_type", None)
|
||||
if requested_music_type and requested_music_type not in MUSIC_SUBSCRIBABLE_TYPES:
|
||||
return "音乐订阅仅支持单曲或专辑"
|
||||
if music_type not in MUSIC_SUBSCRIBABLE_TYPES:
|
||||
return "音乐订阅仅支持单曲或专辑"
|
||||
if requested_music_type and requested_music_type != music_type:
|
||||
return f"音乐订阅类型不匹配:请求 {requested_music_type},识别为 {music_type}"
|
||||
if music_type == MUSIC_ENTITY_ALBUM \
|
||||
and _normalize_music_total_tracks(getattr(mediainfo, "total_tracks", None)) is None:
|
||||
return "专辑总曲目数未知,无法校验整张专辑资源"
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _ensure_music_subscribe_entity(
|
||||
subscribe: Subscribe,
|
||||
mediainfo: Optional[MusicInfo],
|
||||
) -> Optional[MusicInfo]:
|
||||
"""保持已持久化的单曲/专辑实体边界,拒绝远端详情把订阅类型改写。"""
|
||||
if not mediainfo:
|
||||
return None
|
||||
expected_type = getattr(subscribe, "music_type", None)
|
||||
actual_type = getattr(mediainfo, "music_type", None)
|
||||
if expected_type and expected_type not in MUSIC_SUBSCRIBABLE_TYPES:
|
||||
logger.warning(f"音乐订阅 {subscribe.name} 的实体类型无效:{expected_type}")
|
||||
return None
|
||||
if actual_type not in MUSIC_SUBSCRIBABLE_TYPES:
|
||||
logger.warning(
|
||||
f"音乐订阅 {subscribe.name} 识别为不可订阅实体:{actual_type}"
|
||||
)
|
||||
if expected_type in MUSIC_SUBSCRIBABLE_TYPES:
|
||||
return MusicSubscribeMixin._music_info_from_subscribe(subscribe)
|
||||
return None
|
||||
if expected_type and actual_type != expected_type:
|
||||
logger.warning(
|
||||
f"音乐订阅 {subscribe.name} 实体不匹配:"
|
||||
f"订阅为 {expected_type},远端识别为 {actual_type},使用订阅快照"
|
||||
)
|
||||
return MusicSubscribeMixin._music_info_from_subscribe(subscribe)
|
||||
if actual_type == MUSIC_ENTITY_ALBUM:
|
||||
remote_total = _normalize_music_total_tracks(getattr(mediainfo, "total_tracks", None))
|
||||
stored_total = _normalize_music_total_tracks(getattr(subscribe, "total_tracks", None))
|
||||
resolved_total = remote_total or stored_total
|
||||
if resolved_total is not None and mediainfo.total_tracks != resolved_total:
|
||||
# 识别模块结果可能来自共享缓存,补齐订阅快照时不得原地修改。
|
||||
mediainfo = copy.copy(mediainfo)
|
||||
mediainfo.total_tracks = resolved_total
|
||||
return mediainfo
|
||||
|
||||
@staticmethod
|
||||
def _recognize_music_subscribe(subscribe: Subscribe) -> Optional[MusicInfo]:
|
||||
"""按订阅身份恢复音乐目标,远端暂不可用时使用已持久化的稳定快照。"""
|
||||
# 延迟导入订阅通用辅助,避免 _music ↔ subscribe 模块级循环
|
||||
from app.chain.subscribe import build_subscribe_meta
|
||||
if subscribe.media_source and subscribe.media_id:
|
||||
# 与影视共用统一识别入口,按媒体源和原生 ID 恢复音乐详情
|
||||
mediainfo = MediaChain().recognize_media(
|
||||
media_source=subscribe.media_source,
|
||||
media_id=str(subscribe.media_id),
|
||||
mtype=MediaType.MUSIC,
|
||||
music_type=getattr(subscribe, "music_type", None),
|
||||
)
|
||||
if mediainfo:
|
||||
return MusicSubscribeMixin._ensure_music_subscribe_entity(subscribe, mediainfo)
|
||||
if getattr(subscribe, "music_type", None) in {MUSIC_ENTITY_RECORDING, MUSIC_ENTITY_ALBUM}:
|
||||
return MusicSubscribeMixin._music_info_from_subscribe(subscribe)
|
||||
# 旧订阅没有保存实体类型时不能猜测为单曲,否则可能误把专辑按单曲完成。
|
||||
return None
|
||||
if getattr(subscribe, "music_type", None) == MUSIC_ENTITY_ALBUM:
|
||||
# 缺少远端 ID 的专辑不能退化为单曲识别,使用已保存专辑快照更可靠。
|
||||
return MusicSubscribeMixin._music_info_from_subscribe(subscribe)
|
||||
# 旧订阅没有实体类型时只允许走 Recording 识别,不能从全局混合搜索中猜成专辑或艺术家。
|
||||
mediainfo = MediaChain().recognize_media(
|
||||
meta=build_subscribe_meta(subscribe),
|
||||
mtype=MediaType.MUSIC,
|
||||
media_source=subscribe.media_source,
|
||||
music_type=MUSIC_ENTITY_RECORDING,
|
||||
)
|
||||
return MusicSubscribeMixin._ensure_music_subscribe_entity(subscribe, mediainfo)
|
||||
|
||||
@staticmethod
|
||||
async def _async_recognize_music_subscribe(subscribe: Subscribe) -> Optional[MusicInfo]:
|
||||
"""异步按订阅身份恢复音乐目标,远端暂不可用时使用已持久化的稳定快照。"""
|
||||
# 延迟导入订阅通用辅助,避免 _music ↔ subscribe 模块级循环
|
||||
from app.chain.subscribe import build_subscribe_meta
|
||||
if subscribe.media_source and subscribe.media_id:
|
||||
# 与影视共用统一识别入口,按媒体源和原生 ID 恢复音乐详情
|
||||
mediainfo = await MediaChain().async_recognize_media(
|
||||
media_source=subscribe.media_source,
|
||||
media_id=str(subscribe.media_id),
|
||||
mtype=MediaType.MUSIC,
|
||||
music_type=getattr(subscribe, "music_type", None),
|
||||
)
|
||||
if mediainfo:
|
||||
return MusicSubscribeMixin._ensure_music_subscribe_entity(subscribe, mediainfo)
|
||||
if getattr(subscribe, "music_type", None) in {MUSIC_ENTITY_RECORDING, MUSIC_ENTITY_ALBUM}:
|
||||
return MusicSubscribeMixin._music_info_from_subscribe(subscribe)
|
||||
return None
|
||||
if getattr(subscribe, "music_type", None) == MUSIC_ENTITY_ALBUM:
|
||||
return MusicSubscribeMixin._music_info_from_subscribe(subscribe)
|
||||
mediainfo = await MediaChain().async_recognize_media(
|
||||
meta=build_subscribe_meta(subscribe),
|
||||
mtype=MediaType.MUSIC,
|
||||
media_source=subscribe.media_source,
|
||||
music_type=MUSIC_ENTITY_RECORDING,
|
||||
)
|
||||
return MusicSubscribeMixin._ensure_music_subscribe_entity(subscribe, mediainfo)
|
||||
|
||||
@staticmethod
|
||||
def _music_info_from_subscribe(subscribe: Subscribe) -> MusicInfo:
|
||||
"""从订阅行恢复不依赖远端请求的最小音乐目标,保留专辑完成判断所需字段。"""
|
||||
year_text = str(subscribe.year or "")[:4]
|
||||
music_type = getattr(subscribe, "music_type", None)
|
||||
# 音乐订阅的 description 由标准 MusicInfo.overview 生成,首段固定为艺术家。
|
||||
artist_text = str(getattr(subscribe, "description", None) or "") \
|
||||
.split(" · ", maxsplit=1)[0].strip()
|
||||
artists = [
|
||||
artist.strip() for artist in artist_text.split(" / ") if artist.strip()
|
||||
]
|
||||
return MusicInfo(
|
||||
media_source=subscribe.media_source,
|
||||
media_id=str(subscribe.media_id) if subscribe.media_id is not None else None,
|
||||
music_type=music_type,
|
||||
title=subscribe.name,
|
||||
artists=artists,
|
||||
album=subscribe.name if music_type == MUSIC_ENTITY_ALBUM else None,
|
||||
year=int(year_text) if year_text.isdigit() else None,
|
||||
total_tracks=getattr(subscribe, "total_tracks", None)
|
||||
if music_type == MUSIC_ENTITY_ALBUM else None,
|
||||
cover_url=getattr(subscribe, "poster", None) or getattr(subscribe, "backdrop", None),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _sync_music_subscribe_target(subscribe: Subscribe, mediainfo: MusicInfo) -> None:
|
||||
"""把远端识别得到的专辑类型和总曲目数同步到订阅,供搜索失败与完成历史复用。"""
|
||||
update_data = {}
|
||||
if mediainfo.music_type and getattr(subscribe, "music_type", None) != mediainfo.music_type:
|
||||
update_data["music_type"] = mediainfo.music_type
|
||||
if mediainfo.music_type == MUSIC_ENTITY_ALBUM:
|
||||
# 远端详情可能暂时不返回曲目数;已确认的订阅快照不能因此被清空。
|
||||
total_tracks = _normalize_music_total_tracks(mediainfo.total_tracks) \
|
||||
or _normalize_music_total_tracks(getattr(subscribe, "total_tracks", None))
|
||||
else:
|
||||
total_tracks = None
|
||||
if getattr(subscribe, "total_tracks", None) != total_tracks:
|
||||
update_data["total_tracks"] = total_tracks
|
||||
if not update_data:
|
||||
return
|
||||
SubscribeOper().update(subscribe.id, update_data)
|
||||
for key, value in update_data.items():
|
||||
setattr(subscribe, key, value)
|
||||
|
||||
@staticmethod
|
||||
def _is_music_download_complete(
|
||||
subscribe: Subscribe,
|
||||
mediainfo: MusicInfo,
|
||||
downloads: Optional[List[Context]],
|
||||
) -> bool:
|
||||
"""判断音乐下载是否满足订阅完成条件;专辑必须由下载层确认整专曲目覆盖。"""
|
||||
if not downloads:
|
||||
return False
|
||||
music_type = getattr(subscribe, "music_type", None) or mediainfo.music_type
|
||||
if music_type != MUSIC_ENTITY_ALBUM:
|
||||
return True
|
||||
return any(context.confirmed_full_coverage for context in downloads)
|
||||
|
||||
def _prepare_music_subscribe(
|
||||
self,
|
||||
subscribe: Subscribe,
|
||||
) -> Optional[Tuple[MusicInfo, MetaMusic]]:
|
||||
"""识别音乐订阅目标、同步实体快照,并在搜索前处理已完整入库的目标。"""
|
||||
# 延迟导入订阅通用辅助,避免 _music ↔ subscribe 模块级循环
|
||||
from app.chain.subscribe import _subscribe_media_key
|
||||
mediainfo = self._recognize_music_subscribe(subscribe)
|
||||
if not mediainfo:
|
||||
logger.warning(
|
||||
f"未识别到音乐订阅目标:{subscribe.name},"
|
||||
f"媒体源:{subscribe.media_source},媒体ID:{subscribe.media_id}"
|
||||
)
|
||||
return None
|
||||
validation_error = self._validate_music_subscribe_target(
|
||||
mediainfo,
|
||||
getattr(subscribe, "music_type", None),
|
||||
)
|
||||
if validation_error:
|
||||
logger.warning(f"音乐订阅 {subscribe.name} 无法继续:{validation_error}")
|
||||
return None
|
||||
self._sync_music_subscribe_target(subscribe, mediainfo)
|
||||
meta = MetaMusic.from_music_info(mediainfo)
|
||||
exists, _ = self.check_and_handle_existing_media(
|
||||
subscribe=subscribe,
|
||||
meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
mediakey=_subscribe_media_key(subscribe),
|
||||
)
|
||||
if exists:
|
||||
return None
|
||||
return mediainfo, meta
|
||||
|
||||
def _filter_music_subscribe_contexts(
|
||||
self,
|
||||
subscribe: Subscribe,
|
||||
mediainfo: MusicInfo,
|
||||
contexts: List[Context],
|
||||
) -> List[Context]:
|
||||
"""按站点、音乐实体、订阅参数和优先级规则筛选并绑定下载上下文。"""
|
||||
sites = self.get_sub_sites(subscribe)
|
||||
default_rule_key = SystemConfigKey.BestVersionFilterRuleGroups \
|
||||
if subscribe.best_version else SystemConfigKey.SubscribeFilterRuleGroups
|
||||
rule_groups = subscribe.filter_groups or SystemConfigOper().get(default_rule_key) or []
|
||||
torrent_helper = TorrentHelper()
|
||||
matched: List[Context] = []
|
||||
for source_context in contexts or []:
|
||||
source_torrent = source_context.torrent_info
|
||||
if not source_torrent or source_torrent.category not in (MediaType.MUSIC, MediaType.MUSIC.value):
|
||||
continue
|
||||
# 过滤模块会就地写入 pri_order;RSS 缓存会被多个订阅复用,必须隔离候选副本。
|
||||
torrent = copy.copy(source_torrent)
|
||||
if sites and torrent.site not in sites:
|
||||
continue
|
||||
if not SearchChain.matches_music_resource(
|
||||
mediainfo,
|
||||
torrent.title,
|
||||
torrent.description,
|
||||
):
|
||||
continue
|
||||
if not torrent_helper.filter_torrent(torrent, self.get_params(subscribe)):
|
||||
continue
|
||||
filtered = self.filter_torrents(
|
||||
rule_groups=rule_groups,
|
||||
torrent_list=[torrent],
|
||||
mediainfo=mediainfo,
|
||||
)
|
||||
if filtered is not None:
|
||||
if not filtered:
|
||||
continue
|
||||
torrent = filtered[0]
|
||||
|
||||
context = copy.copy(source_context)
|
||||
context.torrent_info = torrent
|
||||
meta = MetaMusic.from_music_info(mediainfo)
|
||||
meta.org_string = torrent.title
|
||||
meta.apply_audio_quality(f"{torrent.title} {torrent.description or ''}", overwrite=True)
|
||||
if subscribe.best_version:
|
||||
# 用户规则组可用格式、码率等内置规则定义洗版顺序;未命中规则
|
||||
# 优先级时再回退到规范化音质分数,确保零配置也能自动升级。
|
||||
music_priority = torrent.pri_order or meta.audio_quality_score
|
||||
if music_priority <= (subscribe.current_priority or 0):
|
||||
logger.info(
|
||||
f"{torrent.title} 音质优先级 {music_priority} "
|
||||
f"未高于当前版本 {subscribe.current_priority or 0}"
|
||||
)
|
||||
continue
|
||||
torrent.pri_order = music_priority
|
||||
context.meta_info = meta
|
||||
context.media_info = mediainfo
|
||||
context.match_source = str(mediainfo.media_source or "title")
|
||||
context.candidate_recognized = False
|
||||
context.media_info_is_target = True
|
||||
if subscribe.media_category:
|
||||
context.media_info.category = subscribe.media_category
|
||||
matched.append(context)
|
||||
return matched
|
||||
|
||||
def _download_music_subscribe(
|
||||
self,
|
||||
subscribe: Subscribe,
|
||||
mediainfo: MusicInfo,
|
||||
contexts: List[Context],
|
||||
) -> None:
|
||||
"""批量择优下载音乐候选,并按单曲或整专完成语义推进订阅。"""
|
||||
if not contexts:
|
||||
return
|
||||
downloads, _ = DownloadChain().batch_download(
|
||||
contexts=contexts,
|
||||
username=subscribe.username,
|
||||
save_path=subscribe.save_path,
|
||||
downloader=subscribe.downloader,
|
||||
source=self.get_subscribe_source_keyword(subscribe),
|
||||
custom_words=subscribe.custom_words,
|
||||
)
|
||||
successful = [
|
||||
context for context in downloads or []
|
||||
if context and context.meta_info and context.torrent_info
|
||||
]
|
||||
quality_downloads = successful
|
||||
if getattr(subscribe, "music_type", None) == MUSIC_ENTITY_ALBUM:
|
||||
quality_downloads = [
|
||||
context for context in successful
|
||||
if context.confirmed_full_coverage
|
||||
]
|
||||
if subscribe.best_version and quality_downloads:
|
||||
best_context = max(quality_downloads, key=lambda item: item.torrent_info.pri_order)
|
||||
best_meta = best_context.meta_info
|
||||
quality_data = {
|
||||
"current_priority": best_context.torrent_info.pri_order,
|
||||
"current_audio_format": best_meta.audio_format,
|
||||
"current_bitrate": best_meta.bitrate,
|
||||
"current_bit_depth": best_meta.bit_depth,
|
||||
"current_sample_rate": best_meta.sample_rate,
|
||||
}
|
||||
SubscribeOper().update(subscribe.id, quality_data)
|
||||
for key, value in quality_data.items():
|
||||
setattr(subscribe, key, value)
|
||||
current_subscribe = SubscribeOper().get(subscribe.id)
|
||||
if current_subscribe:
|
||||
self.finish_subscribe_or_not(
|
||||
subscribe=current_subscribe,
|
||||
meta=MetaMusic.from_music_info(mediainfo),
|
||||
mediainfo=mediainfo,
|
||||
downloads=downloads,
|
||||
)
|
||||
|
||||
def _search_music_subscribe(self, subscribe: Subscribe) -> None:
|
||||
"""复用站点标题搜索、订阅过滤和批量下载完成单个音乐订阅。"""
|
||||
target = self._prepare_music_subscribe(subscribe)
|
||||
if not target:
|
||||
return
|
||||
mediainfo, _ = target
|
||||
|
||||
sites = self.get_sub_sites(subscribe)
|
||||
default_rule_key = SystemConfigKey.BestVersionFilterRuleGroups \
|
||||
if subscribe.best_version else SystemConfigKey.SubscribeFilterRuleGroups
|
||||
rule_groups = subscribe.filter_groups or SystemConfigOper().get(default_rule_key) or []
|
||||
keywords = [subscribe.keyword] if subscribe.keyword else SearchChain.music_site_keywords(mediainfo)
|
||||
if not keywords:
|
||||
keywords = [subscribe.name]
|
||||
|
||||
searchchain = SearchChain()
|
||||
contexts: List[Context] = []
|
||||
for keyword in keywords:
|
||||
contexts = searchchain.search_by_title(
|
||||
title=keyword,
|
||||
sites=sites,
|
||||
mtype=MediaType.MUSIC,
|
||||
rule_groups=rule_groups,
|
||||
)
|
||||
contexts = self._filter_music_subscribe_contexts(
|
||||
subscribe=subscribe,
|
||||
mediainfo=mediainfo,
|
||||
contexts=contexts,
|
||||
)
|
||||
if contexts:
|
||||
break
|
||||
|
||||
if not contexts:
|
||||
logger.warning(f"音乐订阅 {subscribe.keyword or subscribe.name} 未搜索到符合条件的资源")
|
||||
return
|
||||
|
||||
self._download_music_subscribe(subscribe, mediainfo, contexts)
|
||||
|
||||
def _match_music_subscribe(
|
||||
self,
|
||||
subscribe: Subscribe,
|
||||
contexts: List[Context],
|
||||
) -> None:
|
||||
"""直接匹配本轮 RSS 缓存中的音乐资源,避免再次调用站点搜索接口。"""
|
||||
target = self._prepare_music_subscribe(subscribe)
|
||||
if not target:
|
||||
return
|
||||
mediainfo, _ = target
|
||||
matched = self._filter_music_subscribe_contexts(
|
||||
subscribe=subscribe,
|
||||
mediainfo=mediainfo,
|
||||
contexts=contexts,
|
||||
)
|
||||
if not matched:
|
||||
logger.info(f"音乐订阅 {subscribe.name} 未匹配到符合条件的 RSS 资源")
|
||||
return
|
||||
self._download_music_subscribe(subscribe, mediainfo, matched)
|
||||
@@ -0,0 +1,518 @@
|
||||
"""媒体识别管线 mixin。
|
||||
|
||||
从 ChainBase 拆出的识别域:原生模块识别路由、识别缓存回填、共享识别、
|
||||
插件补充识别。方法经 MRO 解析,依赖 ChainBase 实例的 run_module/eventmanager
|
||||
等协作对象。
|
||||
"""
|
||||
import copy
|
||||
from typing import Optional
|
||||
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
|
||||
from app.adapters.external.server import MoviePilotServerHelper
|
||||
from app.db.oper.systemconfig import SystemConfigOper
|
||||
from app.domain.context import MediaInfo, MusicInfo
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.domain.meta.metamusic import MetaMusic
|
||||
from app.runtime.cache import fresh, async_fresh
|
||||
from app.runtime.config import settings
|
||||
from app.runtime.events import Event
|
||||
from app.runtime.log import logger
|
||||
from app.schemas.media import normalize_media_source, resolve_media_identity
|
||||
from app.schemas.types import ChainEventType, MediaSource, MediaType, SystemConfigKey
|
||||
|
||||
|
||||
class RecognitionMixin:
|
||||
|
||||
@staticmethod
|
||||
def _can_use_media_recognize_share(
|
||||
meta: Optional[MetaBase],
|
||||
media_source: Optional[MediaSource],
|
||||
media_id: Optional[str],
|
||||
) -> bool:
|
||||
"""
|
||||
仅在名称识别场景下使用共享识别,显式ID识别不再重复回查
|
||||
"""
|
||||
return bool(
|
||||
settings.MEDIA_RECOGNIZE_SHARE
|
||||
and meta
|
||||
and not media_source
|
||||
and not media_id
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _snapshot_recognize_cache_meta(meta: Optional[MetaBase]) -> Optional[MetaBase]:
|
||||
"""
|
||||
保存共享识别前的本地缓存关键元数据,用于共享成功后回填正缓存覆盖负缓存。
|
||||
"""
|
||||
if not meta:
|
||||
return None
|
||||
return copy.deepcopy(meta)
|
||||
|
||||
def _update_local_recognize_cache(
|
||||
self,
|
||||
meta: Optional[MetaBase],
|
||||
mediainfo: Optional[MediaInfo],
|
||||
) -> None:
|
||||
"""
|
||||
共享识别成功后回填本地识别缓存,避免名称负缓存导致后续重复回查共享。
|
||||
"""
|
||||
if not meta or not mediainfo:
|
||||
return
|
||||
self.run_module(
|
||||
"update_recognize_cache",
|
||||
meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
)
|
||||
|
||||
async def _async_update_local_recognize_cache(
|
||||
self,
|
||||
meta: Optional[MetaBase],
|
||||
mediainfo: Optional[MediaInfo],
|
||||
) -> None:
|
||||
"""
|
||||
异步回填本地识别缓存。
|
||||
"""
|
||||
if not meta or not mediainfo:
|
||||
return
|
||||
await self.async_run_module(
|
||||
"async_update_recognize_cache",
|
||||
meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _record_media_recognize_share_hit() -> None:
|
||||
"""记录一次共享媒体识别成功命中,统计失败不影响识别结果。"""
|
||||
try:
|
||||
SystemConfigOper().increment(SystemConfigKey.MediaRecognizeShareCount)
|
||||
except Exception as err:
|
||||
logger.error(f"记录共享媒体识别命中次数失败:{str(err)}")
|
||||
|
||||
def _run_native_media_recognize(
|
||||
self,
|
||||
module_kwargs: dict,
|
||||
cache: bool,
|
||||
) -> Optional[MediaInfo]:
|
||||
"""执行同步原生媒体模块识别,具体媒体领域可覆写该路由钩子。"""
|
||||
with fresh(not cache):
|
||||
return self.run_module("recognize_media", **module_kwargs)
|
||||
|
||||
async def _async_run_native_media_recognize(
|
||||
self,
|
||||
module_kwargs: dict,
|
||||
cache: bool,
|
||||
) -> Optional[MediaInfo]:
|
||||
"""执行异步原生媒体模块识别,具体媒体领域可覆写该路由钩子。"""
|
||||
async with async_fresh(not cache):
|
||||
return await self.async_run_module(
|
||||
"async_recognize_media", **module_kwargs
|
||||
)
|
||||
|
||||
def recognize_media(
|
||||
self,
|
||||
meta: MetaBase = None,
|
||||
mtype: Optional[MediaType] = None,
|
||||
media_source: Optional[MediaSource] = None,
|
||||
media_id: Optional[str] = None,
|
||||
episode_group: Optional[str] = None,
|
||||
cache: bool = True,
|
||||
share_meta: MetaBase = None,
|
||||
music_type: Optional[str] = None,
|
||||
) -> Optional[MediaInfo]:
|
||||
"""
|
||||
识别媒体信息,不含Fanart图片
|
||||
:param meta: 识别的元数据
|
||||
:param share_meta: 共享识别查询/上报使用的原始元数据
|
||||
:param mtype: 识别的媒体类型
|
||||
:param media_source: 请求级识别数据源
|
||||
:param media_id: 数据源原生ID,必须与media_source成对提供
|
||||
:param episode_group: 剧集组
|
||||
:param cache: 是否使用缓存
|
||||
:param music_type: 音乐实体类型,显式音乐 ID 必须据此区分单曲与专辑
|
||||
:return: 识别的媒体信息,包括剧集信息
|
||||
"""
|
||||
# 仅传数据源是请求级识别源约束(按名称识别限定数据源),显式 media_id 才要求来源成对
|
||||
explicit_identity = media_id is not None
|
||||
requested_source = normalize_media_source(media_source) or media_source
|
||||
media_source, media_id = resolve_media_identity(
|
||||
media=meta,
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
)
|
||||
if explicit_identity and (not media_source or not media_id):
|
||||
logger.warning("媒体识别需要同时提供有效的 media_source 和 media_id")
|
||||
return None
|
||||
if not media_id and requested_source is not None:
|
||||
media_source = requested_source
|
||||
# meta 自带同源身份(如 {tmdbid=} 标题)时直接按身份识别,避免退化为名称搜索
|
||||
meta_source, meta_id = resolve_media_identity(media=meta)
|
||||
if meta_id and meta_source == requested_source:
|
||||
media_source, media_id = meta_source, meta_id
|
||||
if not episode_group and hasattr(meta, "episode_group"):
|
||||
episode_group = meta.episode_group
|
||||
if not mtype and not (media_source and media_id) and meta and meta.type in [
|
||||
MediaType.TV, MediaType.MOVIE, MediaType.MUSIC
|
||||
]:
|
||||
mtype = meta.type
|
||||
share_query_meta = share_meta or meta
|
||||
module_kwargs = {
|
||||
"meta": meta,
|
||||
"mtype": mtype,
|
||||
"media_source": media_source,
|
||||
"media_id": media_id,
|
||||
"episode_group": episode_group,
|
||||
"cache": cache,
|
||||
}
|
||||
if music_type is not None:
|
||||
module_kwargs["music_type"] = music_type
|
||||
mediainfo = self._run_native_media_recognize(module_kwargs, cache)
|
||||
# 原生识别未取得远端身份时,允许插件按已知要素补充匹配媒体信息(影视与音乐统一)
|
||||
mediainfo = self._supplement_media_recognize(
|
||||
meta=meta, mtype=mtype, media_source=media_source,
|
||||
media_id=media_id, mediainfo=mediainfo,
|
||||
music_type=music_type,
|
||||
)
|
||||
fallback_mediainfo = (
|
||||
mediainfo
|
||||
if mediainfo and not self._media_info_has_identity(mediainfo)
|
||||
else None
|
||||
)
|
||||
if mediainfo and self._media_info_has_identity(mediainfo):
|
||||
# 电影、电视剧、音乐统一上报;音乐的 tmdb 等字段恒为 None,身份取数据源原生 ID
|
||||
if not getattr(mediainfo, "recognize_cache_hit", False):
|
||||
MoviePilotServerHelper.report_recognize_share(
|
||||
meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
keyword_meta=share_query_meta,
|
||||
)
|
||||
return mediainfo
|
||||
|
||||
if self._can_use_media_recognize_share(
|
||||
share_query_meta, media_source, media_id
|
||||
):
|
||||
shared_cache_meta = self._snapshot_recognize_cache_meta(meta)
|
||||
share_query_kwargs = {
|
||||
"meta": meta,
|
||||
"mtype": mtype,
|
||||
"keyword_meta": share_query_meta,
|
||||
}
|
||||
if music_type is not None:
|
||||
share_query_kwargs["music_type"] = music_type
|
||||
shared_item = MoviePilotServerHelper.query_recognize_share(
|
||||
**share_query_kwargs,
|
||||
)
|
||||
shared_params = MoviePilotServerHelper.to_recognize_params(shared_item)
|
||||
if shared_params:
|
||||
shared_module_kwargs = {
|
||||
"meta": meta,
|
||||
"mtype": shared_params.get("mtype") or mtype,
|
||||
"media_source": shared_params.get("media_source"),
|
||||
"media_id": shared_params.get("media_id"),
|
||||
"episode_group": episode_group,
|
||||
"cache": cache,
|
||||
}
|
||||
shared_music_type = shared_params.get("music_type") or music_type
|
||||
if shared_music_type is not None:
|
||||
shared_module_kwargs["music_type"] = shared_music_type
|
||||
mediainfo = self._run_native_media_recognize(
|
||||
shared_module_kwargs,
|
||||
cache,
|
||||
)
|
||||
if mediainfo and self._media_info_has_identity(mediainfo):
|
||||
self._update_local_recognize_cache(shared_cache_meta, mediainfo)
|
||||
self._record_media_recognize_share_hit()
|
||||
return mediainfo
|
||||
if mediainfo and not fallback_mediainfo:
|
||||
fallback_mediainfo = mediainfo
|
||||
return fallback_mediainfo
|
||||
|
||||
async def async_recognize_media(
|
||||
self,
|
||||
meta: MetaBase = None,
|
||||
mtype: Optional[MediaType] = None,
|
||||
media_source: Optional[MediaSource] = None,
|
||||
media_id: Optional[str] = None,
|
||||
episode_group: Optional[str] = None,
|
||||
cache: bool = True,
|
||||
share_meta: MetaBase = None,
|
||||
music_type: Optional[str] = None,
|
||||
) -> Optional[MediaInfo]:
|
||||
"""
|
||||
识别媒体信息,不含Fanart图片(异步版本)
|
||||
:param meta: 识别的元数据
|
||||
:param share_meta: 共享识别查询/上报使用的原始元数据
|
||||
:param mtype: 识别的媒体类型
|
||||
:param media_source: 请求级识别数据源
|
||||
:param media_id: 数据源原生ID,必须与media_source成对提供
|
||||
:param episode_group: 剧集组
|
||||
:param cache: 是否使用缓存
|
||||
:param music_type: 音乐实体类型,显式音乐 ID 必须据此区分单曲与专辑
|
||||
:return: 识别的媒体信息,包括剧集信息
|
||||
"""
|
||||
# 仅传数据源是请求级识别源约束(按名称识别限定数据源),显式 media_id 才要求来源成对
|
||||
explicit_identity = media_id is not None
|
||||
requested_source = normalize_media_source(media_source) or media_source
|
||||
media_source, media_id = resolve_media_identity(
|
||||
media=meta,
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
)
|
||||
if explicit_identity and (not media_source or not media_id):
|
||||
logger.warning("媒体识别需要同时提供有效的 media_source 和 media_id")
|
||||
return None
|
||||
if not media_id and requested_source is not None:
|
||||
media_source = requested_source
|
||||
# meta 自带同源身份(如 {tmdbid=} 标题)时直接按身份识别,避免退化为名称搜索
|
||||
meta_source, meta_id = resolve_media_identity(media=meta)
|
||||
if meta_id and meta_source == requested_source:
|
||||
media_source, media_id = meta_source, meta_id
|
||||
if not episode_group and hasattr(meta, "episode_group"):
|
||||
episode_group = meta.episode_group
|
||||
if not mtype and not (media_source and media_id) and meta and meta.type in [
|
||||
MediaType.TV, MediaType.MOVIE, MediaType.MUSIC
|
||||
]:
|
||||
mtype = meta.type
|
||||
share_query_meta = share_meta or meta
|
||||
module_kwargs = {
|
||||
"meta": meta,
|
||||
"mtype": mtype,
|
||||
"media_source": media_source,
|
||||
"media_id": media_id,
|
||||
"episode_group": episode_group,
|
||||
"cache": cache,
|
||||
}
|
||||
if music_type is not None:
|
||||
module_kwargs["music_type"] = music_type
|
||||
mediainfo = await self._async_run_native_media_recognize(module_kwargs, cache)
|
||||
# 原生识别未取得远端身份时,允许插件按已知要素补充匹配媒体信息(影视与音乐统一)
|
||||
mediainfo = await self._async_supplement_media_recognize(
|
||||
meta=meta, mtype=mtype, media_source=media_source,
|
||||
media_id=media_id, mediainfo=mediainfo,
|
||||
music_type=music_type,
|
||||
)
|
||||
fallback_mediainfo = (
|
||||
mediainfo
|
||||
if mediainfo and not self._media_info_has_identity(mediainfo)
|
||||
else None
|
||||
)
|
||||
if mediainfo and self._media_info_has_identity(mediainfo):
|
||||
# 电影、电视剧、音乐统一上报;音乐的 tmdb 等字段恒为 None,身份取数据源原生 ID
|
||||
if not getattr(mediainfo, "recognize_cache_hit", False):
|
||||
await MoviePilotServerHelper.async_report_recognize_share(
|
||||
meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
keyword_meta=share_query_meta,
|
||||
)
|
||||
return mediainfo
|
||||
|
||||
if self._can_use_media_recognize_share(
|
||||
share_query_meta, media_source, media_id
|
||||
):
|
||||
shared_cache_meta = self._snapshot_recognize_cache_meta(meta)
|
||||
share_query_kwargs = {
|
||||
"meta": meta,
|
||||
"mtype": mtype,
|
||||
"keyword_meta": share_query_meta,
|
||||
}
|
||||
if music_type is not None:
|
||||
share_query_kwargs["music_type"] = music_type
|
||||
shared_item = await MoviePilotServerHelper.async_query_recognize_share(
|
||||
**share_query_kwargs,
|
||||
)
|
||||
shared_params = MoviePilotServerHelper.to_recognize_params(shared_item)
|
||||
if shared_params:
|
||||
shared_module_kwargs = {
|
||||
"meta": meta,
|
||||
"mtype": shared_params.get("mtype") or mtype,
|
||||
"media_source": shared_params.get("media_source"),
|
||||
"media_id": shared_params.get("media_id"),
|
||||
"episode_group": episode_group,
|
||||
"cache": cache,
|
||||
}
|
||||
shared_music_type = shared_params.get("music_type") or music_type
|
||||
if shared_music_type is not None:
|
||||
shared_module_kwargs["music_type"] = shared_music_type
|
||||
mediainfo = await self._async_run_native_media_recognize(
|
||||
shared_module_kwargs,
|
||||
cache,
|
||||
)
|
||||
if mediainfo and self._media_info_has_identity(mediainfo):
|
||||
await self._async_update_local_recognize_cache(shared_cache_meta, mediainfo)
|
||||
await run_in_threadpool(self._record_media_recognize_share_hit)
|
||||
return mediainfo
|
||||
if mediainfo and not fallback_mediainfo:
|
||||
fallback_mediainfo = mediainfo
|
||||
return fallback_mediainfo
|
||||
|
||||
@staticmethod
|
||||
def _media_recognize_plugin_payload(
|
||||
meta: Optional[MetaBase],
|
||||
mtype: Optional[MediaType],
|
||||
media_source: Optional[MediaSource],
|
||||
media_id: Optional[str],
|
||||
is_music: bool,
|
||||
music_type: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
构造媒体识别链式事件的已知要素载荷,供插件匹配媒体信息;影视与音乐统一协议,
|
||||
仅要素字段随媒体类型不同
|
||||
"""
|
||||
if is_music:
|
||||
return {
|
||||
"title": getattr(meta, "title", None),
|
||||
"artists": list(getattr(meta, "artists", None) or []),
|
||||
"album": getattr(meta, "album", None),
|
||||
"year": getattr(meta, "year", None),
|
||||
"isrc": getattr(meta, "isrc", None),
|
||||
"media_source": media_source,
|
||||
"media_id": media_id,
|
||||
"music_type": music_type,
|
||||
}
|
||||
return {
|
||||
"title": getattr(meta, "title", None) or getattr(meta, "name", None),
|
||||
"year": getattr(meta, "year", None),
|
||||
"season": getattr(meta, "begin_season", None),
|
||||
"type": mtype.value if isinstance(mtype, MediaType) else None,
|
||||
"media_source": media_source,
|
||||
"media_id": media_id,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _media_info_from_plugin(
|
||||
cls,
|
||||
event_data: dict,
|
||||
is_music: bool,
|
||||
mtype: Optional[MediaType] = None,
|
||||
music_type: Optional[str] = None,
|
||||
) -> Optional[MediaInfo]:
|
||||
"""
|
||||
解析插件返回的媒体信息,缺少数据源或身份字段的结果不采信;
|
||||
音乐构造 MusicInfo,影视构造 MediaInfo
|
||||
"""
|
||||
if not isinstance(event_data, dict):
|
||||
return None
|
||||
plugin_info = event_data.get("mediainfo")
|
||||
if not isinstance(plugin_info, dict):
|
||||
return None
|
||||
if not plugin_info.get("media_source"):
|
||||
logger.warn("插件返回的媒体信息缺少数据源,忽略 ...")
|
||||
return None
|
||||
try:
|
||||
if is_music:
|
||||
if not plugin_info.get("media_id"):
|
||||
logger.warn("插件返回的音乐媒体信息缺少媒体ID,忽略 ...")
|
||||
return None
|
||||
info: MediaInfo = MusicInfo.from_dict(plugin_info)
|
||||
if not info.media_source or not info.media_id:
|
||||
return None
|
||||
if music_type and info.music_type != music_type:
|
||||
logger.warn(
|
||||
f"插件返回的音乐实体类型为 {info.music_type},"
|
||||
f"与请求的 {music_type} 不一致,忽略 ..."
|
||||
)
|
||||
return None
|
||||
return info
|
||||
# 影视:插件未提供类型时使用请求推断的类型
|
||||
if not plugin_info.get("type") and mtype:
|
||||
plugin_info = {**plugin_info, "type": mtype}
|
||||
info = MediaInfo()
|
||||
info.from_dict(plugin_info)
|
||||
except Exception as err:
|
||||
logger.warn(f"插件返回的媒体信息格式错误:{err}")
|
||||
return None
|
||||
# 影视与音乐统一要求远端身份,无身份的结果不采信,避免未验证结果进入识别管线
|
||||
if not info.media_source or not cls._media_info_has_identity(info):
|
||||
logger.warn("插件返回的媒体信息缺少远端身份,忽略 ...")
|
||||
return None
|
||||
return info
|
||||
|
||||
@staticmethod
|
||||
def _media_info_has_identity(mediainfo) -> bool:
|
||||
"""判断媒体信息是否具备完整的规范媒体身份。"""
|
||||
media_source, media_id = resolve_media_identity(media=mediainfo)
|
||||
return bool(media_source and media_id)
|
||||
|
||||
def _supplement_media_recognize(
|
||||
self,
|
||||
meta: Optional[MetaBase],
|
||||
mtype: Optional[MediaType],
|
||||
media_source: Optional[MediaSource],
|
||||
media_id: Optional[str],
|
||||
mediainfo,
|
||||
music_type: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
媒体识别插件补充(影视与音乐统一):原生模块未给出带远端身份的结果时,
|
||||
广播媒体识别链式事件,允许插件(如第三方媒体源)按已知要素匹配并返回标准信息
|
||||
"""
|
||||
is_music = (
|
||||
isinstance(meta, MetaMusic)
|
||||
or mtype == MediaType.MUSIC
|
||||
or isinstance(mediainfo, MusicInfo)
|
||||
)
|
||||
# 已有远端身份时无需插件介入
|
||||
if mediainfo and self._media_info_has_identity(mediainfo):
|
||||
return mediainfo
|
||||
etype = ChainEventType.MusicMediaRecognize if is_music else ChainEventType.MediaRecognize
|
||||
if not self.eventmanager.check(etype):
|
||||
return mediainfo
|
||||
result: Event = self.eventmanager.send_event(
|
||||
etype,
|
||||
self._media_recognize_plugin_payload(
|
||||
meta, mtype, media_source, media_id, is_music, music_type
|
||||
),
|
||||
)
|
||||
if not result:
|
||||
return mediainfo
|
||||
plugin_info = self._media_info_from_plugin(
|
||||
result.event_data or {}, is_music, mtype, music_type
|
||||
)
|
||||
if not plugin_info:
|
||||
return mediainfo
|
||||
logger.info(
|
||||
f"插件补充媒体识别成功:{plugin_info.title}"
|
||||
f"({plugin_info.media_source}:{plugin_info.media_id})"
|
||||
)
|
||||
return plugin_info
|
||||
|
||||
async def _async_supplement_media_recognize(
|
||||
self,
|
||||
meta: Optional[MetaBase],
|
||||
mtype: Optional[MediaType],
|
||||
media_source: Optional[MediaSource],
|
||||
media_id: Optional[str],
|
||||
mediainfo,
|
||||
music_type: Optional[str] = None,
|
||||
):
|
||||
"""媒体识别插件补充的异步版本,影视与音乐统一流程"""
|
||||
is_music = (
|
||||
isinstance(meta, MetaMusic)
|
||||
or mtype == MediaType.MUSIC
|
||||
or isinstance(mediainfo, MusicInfo)
|
||||
)
|
||||
# 已有远端身份时无需插件介入
|
||||
if mediainfo and self._media_info_has_identity(mediainfo):
|
||||
return mediainfo
|
||||
etype = ChainEventType.MusicMediaRecognize if is_music else ChainEventType.MediaRecognize
|
||||
if not self.eventmanager.check(etype):
|
||||
return mediainfo
|
||||
result: Event = await self.eventmanager.async_send_event(
|
||||
etype,
|
||||
self._media_recognize_plugin_payload(
|
||||
meta, mtype, media_source, media_id, is_music, music_type
|
||||
),
|
||||
)
|
||||
if not result:
|
||||
return mediainfo
|
||||
plugin_info = self._media_info_from_plugin(
|
||||
result.event_data or {}, is_music, mtype, music_type
|
||||
)
|
||||
if not plugin_info:
|
||||
return mediainfo
|
||||
logger.info(
|
||||
f"插件补充媒体识别成功:{plugin_info.title}"
|
||||
f"({plugin_info.media_source}:{plugin_info.media_id})"
|
||||
)
|
||||
return plugin_info
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Agent 业务处理链。
|
||||
|
||||
AgentChain 是 agent 编排在链层的入口:Agent 运行时会话需要复用
|
||||
ChainBase 提供的消息处理状态机(渠道处理状态、直发消息等),
|
||||
因此继承关系归属链层;具体 Agent 运行时(MoviePilotAgent 等)留在 app.agent。
|
||||
"""
|
||||
|
||||
from app.chain import ChainBase
|
||||
|
||||
|
||||
class AgentChain(ChainBase):
|
||||
"""Agent 业务处理链。"""
|
||||
|
||||
pass
|
||||
+15
-11
@@ -11,8 +11,12 @@ from pathlib import Path
|
||||
from typing import Any, Optional, Dict, Union, List, Tuple
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
from app.agent.orchestrator import agent_manager
|
||||
from app.agent.llm import AgentCapabilityManager, LLMHelper
|
||||
from app.application.agent import (
|
||||
get_agent_manager,
|
||||
is_audio_input_available,
|
||||
supports_image_input,
|
||||
transcribe_audio,
|
||||
)
|
||||
from app.chain import ChainBase
|
||||
from app.chain.download import DownloadChain
|
||||
from app.chain.media import MediaChain
|
||||
@@ -68,7 +72,7 @@ class MessageChain(ChainBase):
|
||||
return
|
||||
clear_task = None
|
||||
try:
|
||||
clear_task = agent_manager.clear_session(session_id=session_id, user_id=str(userid))
|
||||
clear_task = get_agent_manager().clear_session(session_id=session_id, user_id=str(userid))
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
clear_task,
|
||||
global_vars.loop,
|
||||
@@ -346,7 +350,7 @@ class MessageChain(ChainBase):
|
||||
if not session_info:
|
||||
return False
|
||||
session_id, _ = session_info
|
||||
if not agent_manager.matches_secret_confirmation(
|
||||
if not get_agent_manager().matches_secret_confirmation(
|
||||
session_id,
|
||||
str(userid),
|
||||
channel=channel.value,
|
||||
@@ -966,7 +970,7 @@ class MessageChain(ChainBase):
|
||||
if session_id:
|
||||
clear_task = None
|
||||
try:
|
||||
clear_task = agent_manager.clear_session(
|
||||
clear_task = get_agent_manager().clear_session(
|
||||
session_id=session_id, user_id=str(userid)
|
||||
)
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
@@ -1015,7 +1019,7 @@ class MessageChain(ChainBase):
|
||||
session_id, _ = session_info
|
||||
try:
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
agent_manager.stop_current_task(session_id=session_id),
|
||||
get_agent_manager().stop_current_task(session_id=session_id),
|
||||
global_vars.loop,
|
||||
)
|
||||
stopped = future.result(timeout=10)
|
||||
@@ -1180,7 +1184,7 @@ class MessageChain(ChainBase):
|
||||
return
|
||||
|
||||
session_id, _ = session_info
|
||||
status = agent_manager.get_session_status(session_id=session_id)
|
||||
status = get_agent_manager().get_session_status(session_id=session_id)
|
||||
self.post_message(
|
||||
Notification(
|
||||
channel=channel,
|
||||
@@ -1254,7 +1258,7 @@ class MessageChain(ChainBase):
|
||||
# 将可直接输入给 LLM 的附件统一转换为 data URL
|
||||
original_images = images
|
||||
all_files = list(files or [])
|
||||
if images and LLMHelper.supports_image_input(
|
||||
if images and supports_image_input(
|
||||
provider=settings.LLM_PROVIDER,
|
||||
model=settings.LLM_MODEL,
|
||||
):
|
||||
@@ -1333,7 +1337,7 @@ class MessageChain(ChainBase):
|
||||
process_kwargs["has_audio_input"] = True
|
||||
# 在事件循环中处理
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
agent_manager.process_message(**process_kwargs),
|
||||
get_agent_manager().process_message(**process_kwargs),
|
||||
global_vars.loop,
|
||||
)
|
||||
return True
|
||||
@@ -1353,7 +1357,7 @@ class MessageChain(ChainBase):
|
||||
"""
|
||||
if not audio_refs:
|
||||
return None
|
||||
if not AgentCapabilityManager.is_audio_input_available():
|
||||
if not is_audio_input_available():
|
||||
logger.warning("音频输入能力未配置或未启用,跳过语音识别")
|
||||
return None
|
||||
|
||||
@@ -1460,7 +1464,7 @@ class MessageChain(ChainBase):
|
||||
)
|
||||
continue
|
||||
|
||||
transcript = AgentCapabilityManager.transcribe_audio(
|
||||
transcript = transcribe_audio(
|
||||
content=content, filename=filename
|
||||
)
|
||||
if transcript:
|
||||
|
||||
+4
-4
@@ -509,10 +509,10 @@ class SearchChain(ChainBase):
|
||||
"""
|
||||
通过统一后台提示词机制执行资源推荐。
|
||||
"""
|
||||
from app.agent.orchestrator import ReplyMode, agent_manager
|
||||
from app.agent.prompt import prompt_manager
|
||||
from app.application.agent import get_agent_manager, get_prompt_manager
|
||||
from app.schemas.agent import ReplyMode
|
||||
|
||||
prompt = prompt_manager.render_system_task_message(
|
||||
prompt = get_prompt_manager().render_system_task_message(
|
||||
"search_recommend",
|
||||
template_context={"search_results": search_results_text},
|
||||
)
|
||||
@@ -521,7 +521,7 @@ class SearchChain(ChainBase):
|
||||
def on_output(text: str):
|
||||
full_output[0] = text
|
||||
|
||||
await agent_manager.run_background_prompt(
|
||||
await get_agent_manager().run_background_prompt(
|
||||
message=prompt,
|
||||
session_prefix="__agent_search_recommend",
|
||||
output_callback=on_output,
|
||||
|
||||
+6
-67
@@ -1,13 +1,14 @@
|
||||
import base64
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Callable, List, Optional, Tuple, Union, Dict
|
||||
from typing import Callable, Optional, Tuple, Union, Dict
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from app.application.site.sites import SitesHelper # pylint: disable=no-name-in-module
|
||||
from lxml import etree
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.chain._interaction import InteractionChainMixin
|
||||
from app.runtime.config import global_vars, settings
|
||||
from app.runtime.events import Event, eventmanager
|
||||
from app.db.models.site import Site
|
||||
@@ -17,10 +18,7 @@ from app.adapters.network.browser import PlaywrightHelper
|
||||
from app.adapters.network.cloudflare import under_challenge
|
||||
from app.application.security.cookie import CookieHelper
|
||||
from app.adapters.external.cookiecloud import CookieCloudHelper
|
||||
from app.application.messaging.site import (
|
||||
SiteInteractionHandler,
|
||||
site_interaction_manager,
|
||||
)
|
||||
from app.application.messaging.site import SiteInteractionHandler
|
||||
from app.application.rss import RssHelper
|
||||
from app.runtime.log import logger
|
||||
from app.schemas import MessageChannel, Notification, SiteUserData
|
||||
@@ -33,12 +31,13 @@ from app.foundation import url as url_tools
|
||||
from app.foundation.dom import DomUtils
|
||||
|
||||
|
||||
|
||||
class SiteChain(ChainBase):
|
||||
class SiteChain(InteractionChainMixin, ChainBase):
|
||||
"""
|
||||
站点管理处理链
|
||||
"""
|
||||
|
||||
# 交互处理器类注入,供 InteractionChainMixin 的 parse_callback 委托
|
||||
_interaction_handler_type = SiteInteractionHandler
|
||||
|
||||
def __init__(self):
|
||||
"""初始化站点管理处理链及特殊站点测试器"""
|
||||
@@ -752,66 +751,6 @@ class SiteChain(ChainBase):
|
||||
"""构造 /sites 交互处理器,Cookie 更新动作由本链提供。"""
|
||||
return SiteInteractionHandler(messenger=self, cookie_updater=self.update_cookie)
|
||||
|
||||
def remote_list(
|
||||
self,
|
||||
arg_str: str = "",
|
||||
channel: MessageChannel = None,
|
||||
userid: Union[str, int] = None,
|
||||
source: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
/sites 统一入口,委托交互处理器。
|
||||
"""
|
||||
return self._interaction_handler().remote_list(
|
||||
arg_str=arg_str, channel=channel, userid=userid, source=source
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def parse_callback(callback_data: str) -> Optional[Tuple[str, str]]:
|
||||
"""
|
||||
解析 /sites 按钮回调。
|
||||
"""
|
||||
return SiteInteractionHandler.parse_callback(callback_data)
|
||||
|
||||
def handle_callback_interaction(
|
||||
self,
|
||||
callback_data: str,
|
||||
channel: MessageChannel,
|
||||
source: str,
|
||||
userid: Union[str, int],
|
||||
username: str,
|
||||
original_message_id: Optional[Union[str, int]] = None,
|
||||
original_chat_id: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""委托交互处理器处理按钮回调。"""
|
||||
return self._interaction_handler().handle_callback_interaction(
|
||||
callback_data=callback_data,
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=username,
|
||||
original_message_id=original_message_id,
|
||||
original_chat_id=original_chat_id,
|
||||
)
|
||||
|
||||
def handle_text_interaction(
|
||||
self,
|
||||
channel: MessageChannel,
|
||||
source: str,
|
||||
userid: Union[str, int],
|
||||
username: str,
|
||||
text: str,
|
||||
) -> bool:
|
||||
"""委托交互处理器处理文本输入。"""
|
||||
return self._interaction_handler().handle_text_interaction(
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=username,
|
||||
text=text,
|
||||
)
|
||||
|
||||
|
||||
def remote_disable(self, arg_str: str, channel: MessageChannel,
|
||||
userid: Union[str, int] = None, source: Optional[str] = None):
|
||||
"""
|
||||
|
||||
+8
-451
@@ -1,7 +1,6 @@
|
||||
import copy
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
@@ -9,6 +8,8 @@ from typing import Any, Callable, Dict, List, Optional, Union, Tuple
|
||||
|
||||
from app import schemas
|
||||
from app.chain import ChainBase
|
||||
from app.chain._interaction import InteractionChainMixin
|
||||
from app.chain._music import MusicSubscribeMixin
|
||||
from app.chain.download import DownloadChain
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.mediaserver import MediaServerChain
|
||||
@@ -19,7 +20,6 @@ from app.runtime.config import settings, global_vars
|
||||
from app.domain.context import (
|
||||
Context,
|
||||
MediaInfo,
|
||||
MusicInfo,
|
||||
TorrentInfo,
|
||||
)
|
||||
from app.runtime.events import eventmanager, Event
|
||||
@@ -32,10 +32,7 @@ from app.db.models.subscribe import Subscribe
|
||||
from app.db.oper.site import SiteOper
|
||||
from app.db.oper.subscribe import SubscribeOper
|
||||
from app.db.oper.systemconfig import SystemConfigOper
|
||||
from app.application.messaging.subscribe import (
|
||||
SubscribeInteractionHandler,
|
||||
subscribe_interaction_manager,
|
||||
)
|
||||
from app.application.messaging.subscribe import SubscribeInteractionHandler
|
||||
from app.application.mediaserver import MediaServerHelper
|
||||
from app.application.subscribe import add_subscribe, async_add_subscribe
|
||||
from app.adapters.external.server import MoviePilotServerHelper
|
||||
@@ -43,22 +40,11 @@ from app.application.torrent import TorrentHelper
|
||||
from app.runtime.log import logger
|
||||
from app.schemas import (SubscribeEpisodesRefreshEventData,
|
||||
SubscribeCompletionCheckEventData)
|
||||
from app.schemas.types import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MediaSource, MediaType, SystemConfigKey, MessageChannel, NotificationType, EventType, ChainEventType, \
|
||||
from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaSource, MediaType, SystemConfigKey, MessageChannel, NotificationType, EventType, ChainEventType, \
|
||||
ContentType
|
||||
from app.domain.media import MUSIC_SUBSCRIBABLE_TYPES
|
||||
from app.schemas.media import build_media_key, normalize_media_source, resolve_media_identity
|
||||
|
||||
|
||||
|
||||
def _normalize_music_total_tracks(value: Any) -> Optional[int]:
|
||||
"""将专辑曲目总数归一为正整数,无效或未知值返回 None。"""
|
||||
try:
|
||||
total_tracks = int(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return total_tracks if total_tracks > 0 else None
|
||||
|
||||
|
||||
def build_subscribe_meta(subscribe: Subscribe) -> MetaBase:
|
||||
"""
|
||||
按订阅对象构造主程序链路共用的媒体元数据。
|
||||
@@ -116,7 +102,7 @@ def _subscribe_media_keys(subscribe: Subscribe) -> List[Union[str, int]]:
|
||||
return [candidate for candidate in candidates if candidate not in (None, "")]
|
||||
|
||||
|
||||
class SubscribeChain(ChainBase):
|
||||
class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
|
||||
"""
|
||||
订阅管理处理链。
|
||||
|
||||
@@ -133,6 +119,9 @@ class SubscribeChain(ChainBase):
|
||||
电影下载优先级 writer 单独维护。
|
||||
"""
|
||||
|
||||
# 交互处理器类注入,供 InteractionChainMixin 的 parse_callback 委托
|
||||
_interaction_handler_type = SubscribeInteractionHandler
|
||||
|
||||
_rlock = threading.RLock()
|
||||
# 避免莫名原因导致长时间持有锁
|
||||
_LOCK_TIMOUT = 3600 * 2
|
||||
@@ -1261,378 +1250,6 @@ class SubscribeChain(ChainBase):
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _validate_music_subscribe_target(
|
||||
mediainfo: MediaInfo,
|
||||
requested_music_type: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""校验音乐订阅实体一致性,并确保专辑具备可验证的曲目总数。"""
|
||||
if mediainfo.type != MediaType.MUSIC:
|
||||
return "识别结果不是音乐"
|
||||
music_type = getattr(mediainfo, "music_type", None)
|
||||
if requested_music_type and requested_music_type not in MUSIC_SUBSCRIBABLE_TYPES:
|
||||
return "音乐订阅仅支持单曲或专辑"
|
||||
if music_type not in MUSIC_SUBSCRIBABLE_TYPES:
|
||||
return "音乐订阅仅支持单曲或专辑"
|
||||
if requested_music_type and requested_music_type != music_type:
|
||||
return f"音乐订阅类型不匹配:请求 {requested_music_type},识别为 {music_type}"
|
||||
if music_type == MUSIC_ENTITY_ALBUM \
|
||||
and _normalize_music_total_tracks(getattr(mediainfo, "total_tracks", None)) is None:
|
||||
return "专辑总曲目数未知,无法校验整张专辑资源"
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _ensure_music_subscribe_entity(
|
||||
subscribe: Subscribe,
|
||||
mediainfo: Optional[MusicInfo],
|
||||
) -> Optional[MusicInfo]:
|
||||
"""保持已持久化的单曲/专辑实体边界,拒绝远端详情把订阅类型改写。"""
|
||||
if not mediainfo:
|
||||
return None
|
||||
expected_type = getattr(subscribe, "music_type", None)
|
||||
actual_type = getattr(mediainfo, "music_type", None)
|
||||
if expected_type and expected_type not in MUSIC_SUBSCRIBABLE_TYPES:
|
||||
logger.warning(f"音乐订阅 {subscribe.name} 的实体类型无效:{expected_type}")
|
||||
return None
|
||||
if actual_type not in MUSIC_SUBSCRIBABLE_TYPES:
|
||||
logger.warning(
|
||||
f"音乐订阅 {subscribe.name} 识别为不可订阅实体:{actual_type}"
|
||||
)
|
||||
if expected_type in MUSIC_SUBSCRIBABLE_TYPES:
|
||||
return SubscribeChain._music_info_from_subscribe(subscribe)
|
||||
return None
|
||||
if expected_type and actual_type != expected_type:
|
||||
logger.warning(
|
||||
f"音乐订阅 {subscribe.name} 实体不匹配:"
|
||||
f"订阅为 {expected_type},远端识别为 {actual_type},使用订阅快照"
|
||||
)
|
||||
return SubscribeChain._music_info_from_subscribe(subscribe)
|
||||
if actual_type == MUSIC_ENTITY_ALBUM:
|
||||
remote_total = _normalize_music_total_tracks(getattr(mediainfo, "total_tracks", None))
|
||||
stored_total = _normalize_music_total_tracks(getattr(subscribe, "total_tracks", None))
|
||||
resolved_total = remote_total or stored_total
|
||||
if resolved_total is not None and mediainfo.total_tracks != resolved_total:
|
||||
# 识别模块结果可能来自共享缓存,补齐订阅快照时不得原地修改。
|
||||
mediainfo = copy.copy(mediainfo)
|
||||
mediainfo.total_tracks = resolved_total
|
||||
return mediainfo
|
||||
|
||||
@staticmethod
|
||||
def _recognize_music_subscribe(subscribe: Subscribe) -> Optional[MusicInfo]:
|
||||
"""按订阅身份恢复音乐目标,远端暂不可用时使用已持久化的稳定快照。"""
|
||||
if subscribe.media_source and subscribe.media_id:
|
||||
# 与影视共用统一识别入口,按媒体源和原生 ID 恢复音乐详情
|
||||
mediainfo = MediaChain().recognize_media(
|
||||
media_source=subscribe.media_source,
|
||||
media_id=str(subscribe.media_id),
|
||||
mtype=MediaType.MUSIC,
|
||||
music_type=getattr(subscribe, "music_type", None),
|
||||
)
|
||||
if mediainfo:
|
||||
return SubscribeChain._ensure_music_subscribe_entity(subscribe, mediainfo)
|
||||
if getattr(subscribe, "music_type", None) in {MUSIC_ENTITY_RECORDING, MUSIC_ENTITY_ALBUM}:
|
||||
return SubscribeChain._music_info_from_subscribe(subscribe)
|
||||
# 旧订阅没有保存实体类型时不能猜测为单曲,否则可能误把专辑按单曲完成。
|
||||
return None
|
||||
if getattr(subscribe, "music_type", None) == MUSIC_ENTITY_ALBUM:
|
||||
# 缺少远端 ID 的专辑不能退化为单曲识别,使用已保存专辑快照更可靠。
|
||||
return SubscribeChain._music_info_from_subscribe(subscribe)
|
||||
# 旧订阅没有实体类型时只允许走 Recording 识别,不能从全局混合搜索中猜成专辑或艺术家。
|
||||
mediainfo = MediaChain().recognize_media(
|
||||
meta=build_subscribe_meta(subscribe),
|
||||
mtype=MediaType.MUSIC,
|
||||
media_source=subscribe.media_source,
|
||||
music_type=MUSIC_ENTITY_RECORDING,
|
||||
)
|
||||
return SubscribeChain._ensure_music_subscribe_entity(subscribe, mediainfo)
|
||||
|
||||
@staticmethod
|
||||
async def _async_recognize_music_subscribe(subscribe: Subscribe) -> Optional[MusicInfo]:
|
||||
"""异步按订阅身份恢复音乐目标,远端暂不可用时使用已持久化的稳定快照。"""
|
||||
if subscribe.media_source and subscribe.media_id:
|
||||
# 与影视共用统一识别入口,按媒体源和原生 ID 恢复音乐详情
|
||||
mediainfo = await MediaChain().async_recognize_media(
|
||||
media_source=subscribe.media_source,
|
||||
media_id=str(subscribe.media_id),
|
||||
mtype=MediaType.MUSIC,
|
||||
music_type=getattr(subscribe, "music_type", None),
|
||||
)
|
||||
if mediainfo:
|
||||
return SubscribeChain._ensure_music_subscribe_entity(subscribe, mediainfo)
|
||||
if getattr(subscribe, "music_type", None) in {MUSIC_ENTITY_RECORDING, MUSIC_ENTITY_ALBUM}:
|
||||
return SubscribeChain._music_info_from_subscribe(subscribe)
|
||||
return None
|
||||
if getattr(subscribe, "music_type", None) == MUSIC_ENTITY_ALBUM:
|
||||
return SubscribeChain._music_info_from_subscribe(subscribe)
|
||||
mediainfo = await MediaChain().async_recognize_media(
|
||||
meta=build_subscribe_meta(subscribe),
|
||||
mtype=MediaType.MUSIC,
|
||||
media_source=subscribe.media_source,
|
||||
music_type=MUSIC_ENTITY_RECORDING,
|
||||
)
|
||||
return SubscribeChain._ensure_music_subscribe_entity(subscribe, mediainfo)
|
||||
|
||||
@staticmethod
|
||||
def _music_info_from_subscribe(subscribe: Subscribe) -> MusicInfo:
|
||||
"""从订阅行恢复不依赖远端请求的最小音乐目标,保留专辑完成判断所需字段。"""
|
||||
year_text = str(subscribe.year or "")[:4]
|
||||
music_type = getattr(subscribe, "music_type", None)
|
||||
# 音乐订阅的 description 由标准 MusicInfo.overview 生成,首段固定为艺术家。
|
||||
artist_text = str(getattr(subscribe, "description", None) or "") \
|
||||
.split(" · ", maxsplit=1)[0].strip()
|
||||
artists = [
|
||||
artist.strip() for artist in artist_text.split(" / ") if artist.strip()
|
||||
]
|
||||
return MusicInfo(
|
||||
media_source=subscribe.media_source,
|
||||
media_id=str(subscribe.media_id) if subscribe.media_id is not None else None,
|
||||
music_type=music_type,
|
||||
title=subscribe.name,
|
||||
artists=artists,
|
||||
album=subscribe.name if music_type == MUSIC_ENTITY_ALBUM else None,
|
||||
year=int(year_text) if year_text.isdigit() else None,
|
||||
total_tracks=getattr(subscribe, "total_tracks", None)
|
||||
if music_type == MUSIC_ENTITY_ALBUM else None,
|
||||
cover_url=getattr(subscribe, "poster", None) or getattr(subscribe, "backdrop", None),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _sync_music_subscribe_target(subscribe: Subscribe, mediainfo: MusicInfo) -> None:
|
||||
"""把远端识别得到的专辑类型和总曲目数同步到订阅,供搜索失败与完成历史复用。"""
|
||||
update_data = {}
|
||||
if mediainfo.music_type and getattr(subscribe, "music_type", None) != mediainfo.music_type:
|
||||
update_data["music_type"] = mediainfo.music_type
|
||||
if mediainfo.music_type == MUSIC_ENTITY_ALBUM:
|
||||
# 远端详情可能暂时不返回曲目数;已确认的订阅快照不能因此被清空。
|
||||
total_tracks = _normalize_music_total_tracks(mediainfo.total_tracks) \
|
||||
or _normalize_music_total_tracks(getattr(subscribe, "total_tracks", None))
|
||||
else:
|
||||
total_tracks = None
|
||||
if getattr(subscribe, "total_tracks", None) != total_tracks:
|
||||
update_data["total_tracks"] = total_tracks
|
||||
if not update_data:
|
||||
return
|
||||
SubscribeOper().update(subscribe.id, update_data)
|
||||
for key, value in update_data.items():
|
||||
setattr(subscribe, key, value)
|
||||
|
||||
@staticmethod
|
||||
def _is_music_download_complete(
|
||||
subscribe: Subscribe,
|
||||
mediainfo: MusicInfo,
|
||||
downloads: Optional[List[Context]],
|
||||
) -> bool:
|
||||
"""判断音乐下载是否满足订阅完成条件;专辑必须由下载层确认整专曲目覆盖。"""
|
||||
if not downloads:
|
||||
return False
|
||||
music_type = getattr(subscribe, "music_type", None) or mediainfo.music_type
|
||||
if music_type != MUSIC_ENTITY_ALBUM:
|
||||
return True
|
||||
return any(context.confirmed_full_coverage for context in downloads)
|
||||
|
||||
def _prepare_music_subscribe(
|
||||
self,
|
||||
subscribe: Subscribe,
|
||||
) -> Optional[Tuple[MusicInfo, MetaMusic]]:
|
||||
"""识别音乐订阅目标、同步实体快照,并在搜索前处理已完整入库的目标。"""
|
||||
mediainfo = self._recognize_music_subscribe(subscribe)
|
||||
if not mediainfo:
|
||||
logger.warning(
|
||||
f"未识别到音乐订阅目标:{subscribe.name},"
|
||||
f"媒体源:{subscribe.media_source},媒体ID:{subscribe.media_id}"
|
||||
)
|
||||
return None
|
||||
validation_error = self._validate_music_subscribe_target(
|
||||
mediainfo,
|
||||
getattr(subscribe, "music_type", None),
|
||||
)
|
||||
if validation_error:
|
||||
logger.warning(f"音乐订阅 {subscribe.name} 无法继续:{validation_error}")
|
||||
return None
|
||||
self._sync_music_subscribe_target(subscribe, mediainfo)
|
||||
meta = MetaMusic.from_music_info(mediainfo)
|
||||
exists, _ = self.check_and_handle_existing_media(
|
||||
subscribe=subscribe,
|
||||
meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
mediakey=_subscribe_media_key(subscribe),
|
||||
)
|
||||
if exists:
|
||||
return None
|
||||
return mediainfo, meta
|
||||
|
||||
def _filter_music_subscribe_contexts(
|
||||
self,
|
||||
subscribe: Subscribe,
|
||||
mediainfo: MusicInfo,
|
||||
contexts: List[Context],
|
||||
) -> List[Context]:
|
||||
"""按站点、音乐实体、订阅参数和优先级规则筛选并绑定下载上下文。"""
|
||||
sites = self.get_sub_sites(subscribe)
|
||||
default_rule_key = SystemConfigKey.BestVersionFilterRuleGroups \
|
||||
if subscribe.best_version else SystemConfigKey.SubscribeFilterRuleGroups
|
||||
rule_groups = subscribe.filter_groups or SystemConfigOper().get(default_rule_key) or []
|
||||
torrent_helper = TorrentHelper()
|
||||
matched: List[Context] = []
|
||||
for source_context in contexts or []:
|
||||
source_torrent = source_context.torrent_info
|
||||
if not source_torrent or source_torrent.category not in (MediaType.MUSIC, MediaType.MUSIC.value):
|
||||
continue
|
||||
# 过滤模块会就地写入 pri_order;RSS 缓存会被多个订阅复用,必须隔离候选副本。
|
||||
torrent = copy.copy(source_torrent)
|
||||
if sites and torrent.site not in sites:
|
||||
continue
|
||||
if not SearchChain.matches_music_resource(
|
||||
mediainfo,
|
||||
torrent.title,
|
||||
torrent.description,
|
||||
):
|
||||
continue
|
||||
if not torrent_helper.filter_torrent(torrent, self.get_params(subscribe)):
|
||||
continue
|
||||
filtered = self.filter_torrents(
|
||||
rule_groups=rule_groups,
|
||||
torrent_list=[torrent],
|
||||
mediainfo=mediainfo,
|
||||
)
|
||||
if filtered is not None:
|
||||
if not filtered:
|
||||
continue
|
||||
torrent = filtered[0]
|
||||
|
||||
context = copy.copy(source_context)
|
||||
context.torrent_info = torrent
|
||||
meta = MetaMusic.from_music_info(mediainfo)
|
||||
meta.org_string = torrent.title
|
||||
meta.apply_audio_quality(f"{torrent.title} {torrent.description or ''}", overwrite=True)
|
||||
if subscribe.best_version:
|
||||
# 用户规则组可用格式、码率等内置规则定义洗版顺序;未命中规则
|
||||
# 优先级时再回退到规范化音质分数,确保零配置也能自动升级。
|
||||
music_priority = torrent.pri_order or meta.audio_quality_score
|
||||
if music_priority <= (subscribe.current_priority or 0):
|
||||
logger.info(
|
||||
f"{torrent.title} 音质优先级 {music_priority} "
|
||||
f"未高于当前版本 {subscribe.current_priority or 0}"
|
||||
)
|
||||
continue
|
||||
torrent.pri_order = music_priority
|
||||
context.meta_info = meta
|
||||
context.media_info = mediainfo
|
||||
context.match_source = str(mediainfo.media_source or "title")
|
||||
context.candidate_recognized = False
|
||||
context.media_info_is_target = True
|
||||
if subscribe.media_category:
|
||||
context.media_info.category = subscribe.media_category
|
||||
matched.append(context)
|
||||
return matched
|
||||
|
||||
def _download_music_subscribe(
|
||||
self,
|
||||
subscribe: Subscribe,
|
||||
mediainfo: MusicInfo,
|
||||
contexts: List[Context],
|
||||
) -> None:
|
||||
"""批量择优下载音乐候选,并按单曲或整专完成语义推进订阅。"""
|
||||
if not contexts:
|
||||
return
|
||||
downloads, _ = DownloadChain().batch_download(
|
||||
contexts=contexts,
|
||||
username=subscribe.username,
|
||||
save_path=subscribe.save_path,
|
||||
downloader=subscribe.downloader,
|
||||
source=self.get_subscribe_source_keyword(subscribe),
|
||||
custom_words=subscribe.custom_words,
|
||||
)
|
||||
successful = [
|
||||
context for context in downloads or []
|
||||
if context and context.meta_info and context.torrent_info
|
||||
]
|
||||
quality_downloads = successful
|
||||
if getattr(subscribe, "music_type", None) == MUSIC_ENTITY_ALBUM:
|
||||
quality_downloads = [
|
||||
context for context in successful
|
||||
if context.confirmed_full_coverage
|
||||
]
|
||||
if subscribe.best_version and quality_downloads:
|
||||
best_context = max(quality_downloads, key=lambda item: item.torrent_info.pri_order)
|
||||
best_meta = best_context.meta_info
|
||||
quality_data = {
|
||||
"current_priority": best_context.torrent_info.pri_order,
|
||||
"current_audio_format": best_meta.audio_format,
|
||||
"current_bitrate": best_meta.bitrate,
|
||||
"current_bit_depth": best_meta.bit_depth,
|
||||
"current_sample_rate": best_meta.sample_rate,
|
||||
}
|
||||
SubscribeOper().update(subscribe.id, quality_data)
|
||||
for key, value in quality_data.items():
|
||||
setattr(subscribe, key, value)
|
||||
current_subscribe = SubscribeOper().get(subscribe.id)
|
||||
if current_subscribe:
|
||||
self.finish_subscribe_or_not(
|
||||
subscribe=current_subscribe,
|
||||
meta=MetaMusic.from_music_info(mediainfo),
|
||||
mediainfo=mediainfo,
|
||||
downloads=downloads,
|
||||
)
|
||||
|
||||
def _search_music_subscribe(self, subscribe: Subscribe) -> None:
|
||||
"""复用站点标题搜索、订阅过滤和批量下载完成单个音乐订阅。"""
|
||||
target = self._prepare_music_subscribe(subscribe)
|
||||
if not target:
|
||||
return
|
||||
mediainfo, _ = target
|
||||
|
||||
sites = self.get_sub_sites(subscribe)
|
||||
default_rule_key = SystemConfigKey.BestVersionFilterRuleGroups \
|
||||
if subscribe.best_version else SystemConfigKey.SubscribeFilterRuleGroups
|
||||
rule_groups = subscribe.filter_groups or SystemConfigOper().get(default_rule_key) or []
|
||||
keywords = [subscribe.keyword] if subscribe.keyword else SearchChain.music_site_keywords(mediainfo)
|
||||
if not keywords:
|
||||
keywords = [subscribe.name]
|
||||
|
||||
searchchain = SearchChain()
|
||||
contexts: List[Context] = []
|
||||
for keyword in keywords:
|
||||
contexts = searchchain.search_by_title(
|
||||
title=keyword,
|
||||
sites=sites,
|
||||
mtype=MediaType.MUSIC,
|
||||
rule_groups=rule_groups,
|
||||
)
|
||||
contexts = self._filter_music_subscribe_contexts(
|
||||
subscribe=subscribe,
|
||||
mediainfo=mediainfo,
|
||||
contexts=contexts,
|
||||
)
|
||||
if contexts:
|
||||
break
|
||||
|
||||
if not contexts:
|
||||
logger.warning(f"音乐订阅 {subscribe.keyword or subscribe.name} 未搜索到符合条件的资源")
|
||||
return
|
||||
|
||||
self._download_music_subscribe(subscribe, mediainfo, contexts)
|
||||
|
||||
def _match_music_subscribe(
|
||||
self,
|
||||
subscribe: Subscribe,
|
||||
contexts: List[Context],
|
||||
) -> None:
|
||||
"""直接匹配本轮 RSS 缓存中的音乐资源,避免再次调用站点搜索接口。"""
|
||||
target = self._prepare_music_subscribe(subscribe)
|
||||
if not target:
|
||||
return
|
||||
mediainfo, _ = target
|
||||
matched = self._filter_music_subscribe_contexts(
|
||||
subscribe=subscribe,
|
||||
mediainfo=mediainfo,
|
||||
contexts=contexts,
|
||||
)
|
||||
if not matched:
|
||||
logger.info(f"音乐订阅 {subscribe.name} 未匹配到符合条件的 RSS 资源")
|
||||
return
|
||||
self._download_music_subscribe(subscribe, mediainfo, matched)
|
||||
|
||||
def search(
|
||||
self,
|
||||
sid: Optional[int] = None,
|
||||
@@ -3247,66 +2864,6 @@ class SubscribeChain(ChainBase):
|
||||
"""构造 /subscribes 交互处理器,业务动作由本链提供。"""
|
||||
return SubscribeInteractionHandler(messenger=self, actions=self)
|
||||
|
||||
def remote_list(
|
||||
self,
|
||||
arg_str: str = "",
|
||||
channel: MessageChannel = None,
|
||||
userid: Union[str, int] = None,
|
||||
source: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
/subscribes 统一入口,委托交互处理器。
|
||||
"""
|
||||
return self._interaction_handler().remote_list(
|
||||
arg_str=arg_str, channel=channel, userid=userid, source=source
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def parse_callback(callback_data: str) -> Optional[Tuple[str, str]]:
|
||||
"""
|
||||
解析 /subscribes 按钮回调。
|
||||
"""
|
||||
return SubscribeInteractionHandler.parse_callback(callback_data)
|
||||
|
||||
def handle_callback_interaction(
|
||||
self,
|
||||
callback_data: str,
|
||||
channel: MessageChannel,
|
||||
source: str,
|
||||
userid: Union[str, int],
|
||||
username: str,
|
||||
original_message_id: Optional[Union[str, int]] = None,
|
||||
original_chat_id: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""委托交互处理器处理按钮回调。"""
|
||||
return self._interaction_handler().handle_callback_interaction(
|
||||
callback_data=callback_data,
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=username,
|
||||
original_message_id=original_message_id,
|
||||
original_chat_id=original_chat_id,
|
||||
)
|
||||
|
||||
def handle_text_interaction(
|
||||
self,
|
||||
channel: MessageChannel,
|
||||
source: str,
|
||||
userid: Union[str, int],
|
||||
username: str,
|
||||
text: str,
|
||||
) -> bool:
|
||||
"""委托交互处理器处理文本输入。"""
|
||||
return self._interaction_handler().handle_text_interaction(
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=username,
|
||||
text=text,
|
||||
)
|
||||
|
||||
|
||||
def remote_delete(self, arg_str: str, channel: MessageChannel,
|
||||
userid: Union[str, int] = None, source: Optional[str] = None):
|
||||
"""
|
||||
|
||||
+73
-2436
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user