mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 23:47:41 +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:
@@ -0,0 +1,15 @@
|
||||
"""模块业务样板基类包。
|
||||
|
||||
沉淀各内置模块逐字复制的业务样板,模块发现规则
|
||||
(`ModuleHelper.load`)会跳过 `_` 前缀的包与类,因此本包不会被识别为可实例化模块。
|
||||
"""
|
||||
|
||||
from app.modules._base.downloader import _DownloaderModuleBase
|
||||
from app.modules._base.mediaserver import _MediaServerModuleBase
|
||||
from app.modules._base.notification import _MessageChannelModuleBase
|
||||
|
||||
__all__ = [
|
||||
"_DownloaderModuleBase",
|
||||
"_MessageChannelModuleBase",
|
||||
"_MediaServerModuleBase",
|
||||
]
|
||||
@@ -0,0 +1,109 @@
|
||||
"""下载器模块业务样板基类。
|
||||
|
||||
沉淀三个内置下载器模块(qbittorrent/transmission/rtorrent)逐字复制的样板:
|
||||
连接测试、定时重连、种子信息读取与查询状态归一。差异化逻辑
|
||||
(任务添加、原始状态映射、任务列表构建)仍留在各模块。
|
||||
"""
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple, Union
|
||||
|
||||
from torrentool.torrent import Torrent
|
||||
|
||||
from app.domain import torrent as torrent_rules
|
||||
from app.modules import _DownloaderBase, _ModuleBase, TService
|
||||
from app.runtime.cache import FileCache
|
||||
from app.runtime.log import logger
|
||||
from app.schemas.types import TorrentQueryStatus, TorrentStatus
|
||||
|
||||
|
||||
class _DownloaderModuleBase(_ModuleBase, _DownloaderBase[TService]):
|
||||
"""
|
||||
下载器模块业务样板基类。
|
||||
"""
|
||||
|
||||
def test(self) -> Optional[Tuple[bool, str]]:
|
||||
"""
|
||||
测试模块连接性
|
||||
"""
|
||||
if not self.get_instances():
|
||||
return None
|
||||
for name, server in self.get_instances().items():
|
||||
if server.is_inactive():
|
||||
server.reconnect()
|
||||
if not server.transfer_info():
|
||||
return False, f"无法连接{self.get_name()}下载器:{name}"
|
||||
return True, ""
|
||||
|
||||
def scheduler_job(self) -> None:
|
||||
"""
|
||||
定时任务,每10分钟调用一次
|
||||
"""
|
||||
for name, server in self.get_instances().items():
|
||||
if server.is_inactive():
|
||||
logger.info(f"{self.get_name()}下载器 {name} 连接断开,尝试重连 ...")
|
||||
server.reconnect()
|
||||
|
||||
def _get_torrent_info(self, content: Union[Path, str, bytes]) \
|
||||
-> Tuple[Optional[Torrent], Optional[bytes]]:
|
||||
"""
|
||||
读取种子内容,返回解析后的种子信息与原始内容,磁力链接不解析
|
||||
"""
|
||||
torrent_info, torrent_content = None, None
|
||||
try:
|
||||
if isinstance(content, Path):
|
||||
if content.exists():
|
||||
torrent_content = content.read_bytes()
|
||||
else:
|
||||
# 读取缓存的种子文件
|
||||
torrent_content = FileCache().get(
|
||||
content.as_posix(), region="torrents"
|
||||
)
|
||||
else:
|
||||
torrent_content = content
|
||||
|
||||
if torrent_content:
|
||||
# 检查是否为磁力链接
|
||||
if torrent_rules.is_magnet_link(torrent_content):
|
||||
return None, torrent_content
|
||||
else:
|
||||
torrent_info = Torrent.from_string(torrent_content)
|
||||
|
||||
return torrent_info, torrent_content
|
||||
except Exception as e:
|
||||
logger.error(f"获取种子名称失败:{e}")
|
||||
return None, None
|
||||
|
||||
@staticmethod
|
||||
def _normalize_query_status(
|
||||
status: Optional[Union[TorrentStatus, TorrentQueryStatus, str]]
|
||||
) -> TorrentQueryStatus:
|
||||
"""
|
||||
归一任务查询状态。
|
||||
"""
|
||||
status_value = getattr(status, "value", status)
|
||||
status_text = str(status_value or "").strip().lower()
|
||||
if not status_text or status_text in {"all", "全部"}:
|
||||
return TorrentQueryStatus.ALL
|
||||
if status_text in {
|
||||
TorrentStatus.TRANSFER.value,
|
||||
TorrentQueryStatus.TRANSFER.value,
|
||||
"transfer",
|
||||
}:
|
||||
return TorrentQueryStatus.TRANSFER
|
||||
if status_text in {
|
||||
TorrentStatus.DOWNLOADING.value,
|
||||
TorrentQueryStatus.DOWNLOADING.value,
|
||||
"downloading",
|
||||
}:
|
||||
return TorrentQueryStatus.DOWNLOADING
|
||||
if status_text in {
|
||||
TorrentQueryStatus.COMPLETED.value,
|
||||
"complete",
|
||||
"seeding",
|
||||
"完成",
|
||||
"已完成",
|
||||
}:
|
||||
return TorrentQueryStatus.COMPLETED
|
||||
if status_text in {TorrentQueryStatus.PAUSED.value, "pause", "暂停", "已暂停"}:
|
||||
return TorrentQueryStatus.PAUSED
|
||||
return TorrentQueryStatus.ALL
|
||||
@@ -0,0 +1,192 @@
|
||||
"""媒体服务器模块业务样板基类。
|
||||
|
||||
沉淀各媒体服务器模块逐字复制的样板:用户辅助认证、媒体存在性检查、
|
||||
定时重连与连接测试。服务器差异(认证 API、存在性检查端点、连接探测方式)
|
||||
通过类属性与钩子方法保留在各模块。
|
||||
"""
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from app import schemas
|
||||
from app.application.mediaserver import MusicMediaServerHelper
|
||||
from app.domain.context import MediaInfo
|
||||
from app.modules import _MediaServerBase, _ModuleBase, TService
|
||||
from app.runtime.events import eventmanager
|
||||
from app.runtime.log import logger
|
||||
from app.schemas.types import ChainEventType, MediaType
|
||||
|
||||
|
||||
class _MediaServerModuleBase(_ModuleBase, _MediaServerBase[TService]):
|
||||
"""
|
||||
媒体服务器模块业务样板基类。
|
||||
"""
|
||||
|
||||
# 媒体库标识(用于 ExistMediaInfo.server_type,如 "emby"),子类覆写
|
||||
_server_type_value: str = ""
|
||||
|
||||
def user_authenticate(
|
||||
self,
|
||||
credentials: schemas.AuthCredentials,
|
||||
service_name: Optional[str] = None,
|
||||
) -> Optional[schemas.AuthCredentials]:
|
||||
"""
|
||||
使用媒体服务器用户辅助完成用户认证
|
||||
|
||||
:param credentials: 认证数据
|
||||
:param service_name: 指定要认证的媒体服务器名称,若为 None 则认证所有服务器
|
||||
:return: 认证数据
|
||||
"""
|
||||
if not credentials or credentials.grant_type != "password":
|
||||
return None
|
||||
# 确定要认证的服务器列表
|
||||
if service_name:
|
||||
# 如果指定了服务名,获取该服务实例
|
||||
servers = (
|
||||
[(service_name, server)]
|
||||
if (server := self.get_instance(service_name))
|
||||
else []
|
||||
)
|
||||
else:
|
||||
# 如果没有指定服务名,遍历所有服务
|
||||
servers = self.get_instances().items()
|
||||
# 遍历要认证的服务器
|
||||
for name, server in servers:
|
||||
# 触发认证拦截事件
|
||||
intercept_event = eventmanager.send_event(
|
||||
etype=ChainEventType.AuthIntercept,
|
||||
data=schemas.AuthInterceptCredentials(
|
||||
username=credentials.username,
|
||||
channel=self.get_name(),
|
||||
service=name,
|
||||
status="triggered",
|
||||
),
|
||||
)
|
||||
if intercept_event and intercept_event.event_data:
|
||||
intercept_data: schemas.AuthInterceptCredentials = intercept_event.event_data
|
||||
if intercept_data.cancel:
|
||||
continue
|
||||
token = server.authenticate(credentials.username, credentials.password)
|
||||
if token:
|
||||
credentials.channel = self.get_name()
|
||||
credentials.service = name
|
||||
credentials.token = token
|
||||
return credentials
|
||||
return None
|
||||
|
||||
def media_exists(
|
||||
self,
|
||||
mediainfo: MediaInfo,
|
||||
itemid: Optional[str] = None,
|
||||
server: Optional[str] = None,
|
||||
) -> Optional[schemas.ExistMediaInfo]:
|
||||
"""
|
||||
判断媒体文件是否存在
|
||||
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:param itemid: 媒体服务器ItemID
|
||||
:param server: 媒体服务器名称
|
||||
:return: 如不存在返回None,存在时返回信息,包括每季已存在所有集{type: movie/tv, seasons: {season: [episodes]}}
|
||||
"""
|
||||
if server:
|
||||
servers = [(server, self.get_instance(server))]
|
||||
else:
|
||||
servers = self.get_instances().items()
|
||||
for name, s in servers:
|
||||
if not s:
|
||||
continue
|
||||
if mediainfo.type == MediaType.MUSIC:
|
||||
# 部分服务器未实现音乐查询,退化为空列表
|
||||
matches = getattr(s, "get_music", lambda **_: [])(
|
||||
**MusicMediaServerHelper.search_params(mediainfo)
|
||||
)
|
||||
match = MusicMediaServerHelper.find_match(mediainfo, matches)
|
||||
if match:
|
||||
return schemas.ExistMediaInfo(
|
||||
type=MediaType.MUSIC,
|
||||
server_type=self._server_type_value,
|
||||
server=name,
|
||||
itemid=match.item_id,
|
||||
)
|
||||
continue
|
||||
if mediainfo.type == MediaType.MOVIE:
|
||||
if itemid:
|
||||
movie = s.get_iteminfo(itemid)
|
||||
if movie:
|
||||
logger.info(f"媒体库 {name} 中找到了 {movie}")
|
||||
return schemas.ExistMediaInfo(
|
||||
type=MediaType.MOVIE,
|
||||
server_type=self._server_type_value,
|
||||
server=name,
|
||||
itemid=movie.item_id
|
||||
)
|
||||
movies = s.get_movies(title=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
media_source=mediainfo.media_source,
|
||||
media_id=mediainfo.media_id)
|
||||
if not movies:
|
||||
logger.info(f"{mediainfo.title_year} 没有在媒体库 {name} 中")
|
||||
continue
|
||||
else:
|
||||
logger.info(f"媒体库 {name} 中找到了 {movies}")
|
||||
return schemas.ExistMediaInfo(
|
||||
type=MediaType.MOVIE,
|
||||
server_type=self._server_type_value,
|
||||
server=name,
|
||||
itemid=movies[0].item_id
|
||||
)
|
||||
else:
|
||||
itemid, tvs = s.get_tv_episodes(title=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
media_source=mediainfo.media_source,
|
||||
media_id=mediainfo.media_id,
|
||||
item_id=itemid)
|
||||
if not tvs:
|
||||
logger.info(f"{mediainfo.title_year} 没有在媒体库 {name} 中")
|
||||
continue
|
||||
else:
|
||||
logger.info(f"{mediainfo.title_year} 在媒体库 {name} 中找到 了这些季集:{tvs}")
|
||||
return schemas.ExistMediaInfo(
|
||||
type=MediaType.TV,
|
||||
seasons=tvs,
|
||||
server_type=self._server_type_value,
|
||||
server=name,
|
||||
itemid=itemid
|
||||
)
|
||||
return None
|
||||
|
||||
def scheduler_job(self) -> None:
|
||||
"""
|
||||
定时任务,每10分钟调用一次
|
||||
"""
|
||||
# 定时重连
|
||||
for name, server in self.get_instances().items():
|
||||
if self._is_inactive(server):
|
||||
logger.info(f"{self.get_name()}服务器 {name} 连接断开,尝试重连 ...")
|
||||
server.reconnect()
|
||||
|
||||
def _is_inactive(self, server) -> bool:
|
||||
"""
|
||||
定时重连的失活判断钩子,子类可覆写(如增加配置完整性检查)。
|
||||
"""
|
||||
return server.is_inactive()
|
||||
|
||||
def test(self) -> Optional[Tuple[bool, str]]:
|
||||
"""
|
||||
测试模块连接性
|
||||
"""
|
||||
if not self.get_instances():
|
||||
return None
|
||||
for name, server in self.get_instances().items():
|
||||
error = self._test_server(server, name)
|
||||
if error:
|
||||
return False, error
|
||||
return True, ""
|
||||
|
||||
def _test_server(self, server, name: str) -> Optional[str]:
|
||||
"""
|
||||
连接测试钩子,返回失败信息,None 表示就绪,子类可覆写。
|
||||
"""
|
||||
if server.is_inactive():
|
||||
server.reconnect()
|
||||
if not server.get_user():
|
||||
return f"无法连接{self.get_name()}服务器:{name}"
|
||||
return None
|
||||
@@ -0,0 +1,149 @@
|
||||
"""消息渠道模块业务样板基类。
|
||||
|
||||
沉淀各消息渠道模块逐字复制的样板:管理员判断、连接测试、
|
||||
斜杠命令注册。渠道差异(客户端类型、菜单 API、前置条件)通过
|
||||
类属性与钩子方法保留在各模块。
|
||||
"""
|
||||
import copy
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
|
||||
from app.application.messaging.agent import (
|
||||
matches_channel_admin,
|
||||
resolve_config_principal_ids,
|
||||
)
|
||||
from app.foundation.collections import DictUtils
|
||||
from app.modules import _MessageBase, _ModuleBase, TService
|
||||
from app.runtime.events import eventmanager
|
||||
from app.runtime.log import logger
|
||||
from app.schemas import CommandRegisterEventData
|
||||
from app.schemas.types import ChainEventType
|
||||
|
||||
|
||||
class _MessageChannelModuleBase(_ModuleBase, _MessageBase[TService]):
|
||||
"""
|
||||
消息渠道模块业务样板基类。
|
||||
"""
|
||||
|
||||
# 管理员配置键,子类覆写(如 "TELEGRAM_ADMINS")
|
||||
_admin_config_key: str = ""
|
||||
# 命令注册事件源标识,默认取模块名,子类可覆写
|
||||
_command_origin: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def _get_admins(cls, config: Optional[dict]) -> List[str]:
|
||||
"""
|
||||
解析渠道管理员配置,兼容逗号分隔和首尾空白。
|
||||
"""
|
||||
return sorted(resolve_config_principal_ids(config, cls._admin_config_key))
|
||||
|
||||
def _should_reject_admin_command(
|
||||
self,
|
||||
config: Optional[dict],
|
||||
*user_ids: Optional[Union[str, int]],
|
||||
) -> bool:
|
||||
"""
|
||||
判断命令或命令型按钮回调是否应因非管理员身份被拒绝。
|
||||
"""
|
||||
if not self._get_admins(config):
|
||||
return False
|
||||
# 模块实例未初始化时 self._channel 为空,退回静态子类型声明
|
||||
channel = self._channel or self.get_subtype()
|
||||
return not matches_channel_admin(
|
||||
channel,
|
||||
config,
|
||||
*user_ids,
|
||||
)
|
||||
|
||||
def test(self) -> Optional[Tuple[bool, str]]:
|
||||
"""
|
||||
测试模块连接性
|
||||
"""
|
||||
if not self.get_instances():
|
||||
return None
|
||||
for name, client in self.get_instances().items():
|
||||
state, message = self._test_connection(client)
|
||||
if not state:
|
||||
suffix = f":{message}" if message else ""
|
||||
return False, f"{self.get_name()} {name} 未就绪{suffix}"
|
||||
return True, ""
|
||||
|
||||
def _test_connection(self, client) -> Tuple[bool, str]:
|
||||
"""
|
||||
连接测试钩子,返回 (是否就绪, 失败信息),子类可覆写。
|
||||
"""
|
||||
return bool(client.get_state()), ""
|
||||
|
||||
def register_commands(self, commands: Dict[str, dict]) -> None:
|
||||
"""
|
||||
注册命令,实现这个函数接收系统可用的命令菜单
|
||||
|
||||
:param commands: 命令字典
|
||||
"""
|
||||
for client_config in self.get_configs().values():
|
||||
if not self._commands_enabled(client_config.config):
|
||||
continue
|
||||
|
||||
client = self.get_instance(client_config.name)
|
||||
if not client:
|
||||
continue
|
||||
|
||||
# 触发事件,允许调整命令数据,这里需要进行深复制,避免实例共享
|
||||
scoped_commands = copy.deepcopy(commands)
|
||||
event = eventmanager.send_event(
|
||||
ChainEventType.CommandRegister,
|
||||
CommandRegisterEventData(
|
||||
commands=scoped_commands,
|
||||
origin=self._command_origin or self.get_name(),
|
||||
service=client_config.name,
|
||||
),
|
||||
)
|
||||
|
||||
# 如果事件返回有效的 event_data,使用事件中调整后的命令
|
||||
if event and event.event_data:
|
||||
event_data: CommandRegisterEventData = event.event_data
|
||||
# 如果事件被取消,跳过命令注册,并清理菜单
|
||||
if event_data.cancel:
|
||||
self._delete_commands(client)
|
||||
logger.debug(
|
||||
f"Command registration for {client_config.name} canceled by event: {event_data.source}"
|
||||
)
|
||||
continue
|
||||
scoped_commands = event_data.commands or {}
|
||||
if not scoped_commands:
|
||||
logger.debug("Filtered commands are empty, skipping registration.")
|
||||
self._delete_commands(client)
|
||||
|
||||
# scoped_commands 必须是 commands 的子集
|
||||
filtered_scoped_commands = DictUtils.filter_keys_to_subset(
|
||||
scoped_commands,
|
||||
commands,
|
||||
)
|
||||
# 如果 filtered_scoped_commands 为空,则跳过注册
|
||||
if not filtered_scoped_commands:
|
||||
logger.debug("Filtered commands are empty, skipping registration.")
|
||||
self._delete_commands(client)
|
||||
continue
|
||||
# 对比调整后的命令与当前命令
|
||||
if filtered_scoped_commands != commands:
|
||||
logger.debug(
|
||||
f"Command set has changed, Updating new commands: {filtered_scoped_commands}"
|
||||
)
|
||||
self._apply_commands(client, filtered_scoped_commands)
|
||||
|
||||
def _commands_enabled(self, config: Optional[dict]) -> bool:
|
||||
"""
|
||||
命令注册前置条件钩子,返回 False 时跳过该实例,子类可覆写。
|
||||
"""
|
||||
return True
|
||||
|
||||
def _delete_commands(self, client) -> None:
|
||||
"""
|
||||
清理已注册命令的钩子,子类可覆写(如改用菜单 API)。
|
||||
"""
|
||||
client.delete_commands()
|
||||
|
||||
def _apply_commands(self, client, commands: Dict[str, dict]) -> None:
|
||||
"""
|
||||
应用命令集合的钩子,子类可覆写(如改用菜单 API)。
|
||||
"""
|
||||
client.register_commands(commands)
|
||||
Reference in New Issue
Block a user