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:
jxxghp
2026-08-16 16:30:16 +08:00
parent 24671f8f18
commit 7e851dbfa7
102 changed files with 6041 additions and 5888 deletions
+15 -976
View File
File diff suppressed because it is too large Load Diff
+86
View File
@@ -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,
)
+486
View File
@@ -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)
+1559
View File
File diff suppressed because it is too large Load Diff
+420
View File
@@ -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)
+518
View File
@@ -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
+14
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff