import re import time from typing import Any, List, Dict, Tuple from app.core.event import eventmanager, Event from app.log import logger from app.plugins import _PluginBase from app.schemas.types import EventType from app.db.systemconfig_oper import SystemConfigOper class nullbr_search(_PluginBase): # 插件基本信息 plugin_name = "Nullbr资源搜索" plugin_desc = "支持nullbr api接口直接搜索影视资源。支持115网盘、磁力、ed2k、m3u8等多种资源类型。)" plugin_icon = "https://raw.githubusercontent.com/Hqyel/MoviePilot-Plugins/main/icons/nullbr.png" plugin_version = "2.0.0" plugin_author = "Hqyel" author_url = "https://github.com/Hqyel" plugin_config_prefix = "nullbr_search_" plugin_order = 1 auth_level = 1 def __init__(self): super().__init__() # 基本配置 self._enabled = False self._app_id = None self._api_key = None self._resource_priority = ["115", "magnet", "ed2k", "video"] # 默认优先级 self._enable_115 = True self._enable_magnet = True self._enable_video = True self._enable_ed2k = True self._search_timeout = 30 # CloudSyncMedia配置 self._cms_enabled = False self._cms_url = "" self._cms_username = "" self._cms_password = "" # 客户端实例 self._client = None self._cms_client = None # 用户搜索结果缓存和资源缓存 self._user_search_cache = {} # {userid: {'results': [...], 'timestamp': time.time()}} self._user_resource_cache = {} # {userid: {'resources': [...], 'title': str, 'timestamp': time.time()}} # 统计数据 self._stats = { 'total_searches': 0, # 总搜索次数 'successful_searches': 0, # 成功搜索次数 'failed_searches': 0, # 失败搜索次数 'total_resources': 0, # 获取的总资源数 'cms_transfers': 0, # CMS转存次数 'successful_transfers': 0, # 成功转存次数 'failed_transfers': 0, # 失败转存次数 'last_search_time': None, # 最后搜索时间 'last_transfer_time': None, # 最后转存时间 'api_status': 'unknown', # API状态 'cms_status': 'unknown', # CMS状态 'popular_resources': {} # 热门搜索统计 {keyword: count} } def _format_message_for_wechat(self, text: str) -> str: """格式化消息以兼容微信企业应用显示""" # 微信企业应用需要特殊处理换行符和格式 # 将连续的换行符合并,并在关键位置添加分隔符 lines = text.split('\n') formatted_lines = [] for i, line in enumerate(lines): stripped_line = line.strip() # 空行处理:连续空行只保留一个 if not stripped_line: if formatted_lines and formatted_lines[-1] != '': formatted_lines.append('') continue # 对于标题行(包含emoji和中文冒号),前后加空行 if ('🎬' in stripped_line or '🎯' in stripped_line or '✅' in stripped_line or '❌' in stripped_line) and ':' in stripped_line: if formatted_lines and formatted_lines[-1] != '': formatted_lines.append('') formatted_lines.append(stripped_line) formatted_lines.append('') # 对于编号列表项 elif re.match(r'^\d+\.', stripped_line) or re.match(r'^【\d+】', stripped_line): if formatted_lines and formatted_lines[-1] != '': formatted_lines.append('') formatted_lines.append(stripped_line) # 对于缩进的详情行 elif stripped_line.startswith(' ') or stripped_line.startswith(' '): formatted_lines.append(stripped_line) # 对于分隔符和提示信息 elif stripped_line.startswith('---') or stripped_line.startswith('💡') or stripped_line.startswith('📋'): if formatted_lines and formatted_lines[-1] != '': formatted_lines.append('') formatted_lines.append(stripped_line) else: formatted_lines.append(stripped_line) return '\n'.join(formatted_lines) def post_message(self, channel, title: str, text: str, userid: str = None): """发送消息,自动处理微信格式兼容""" # 检测是否为微信通知渠道 try: # channel可能是字符串或MessageChannel对象 if hasattr(channel, 'name'): channel_name = str(channel.name).lower() elif hasattr(channel, 'type'): channel_name = str(channel.type).lower() else: channel_name = str(channel).lower() # 检测微信相关渠道 if 'wechat' in channel_name or 'wecom' in channel_name or 'wework' in channel_name: formatted_text = self._format_message_for_wechat(text) else: formatted_text = text except Exception: # 如果检测失败,使用原文本 formatted_text = text # 调用父类的post_message方法 super().post_message(channel=channel, title=title, text=formatted_text, userid=userid) def init_plugin(self, config: dict = None): """初始化插件""" logger.info(f"正在初始化 {self.plugin_name} v{self.plugin_version}") config_oper = SystemConfigOper() if config: self._enabled = config.get("enabled", False) self._app_id = config.get("app_id") self._api_key = config.get("api_key") # 构建资源优先级列表 priority_list = [] for i in range(1, 5): priority = config.get(f"priority_{i}") if priority and priority not in priority_list: priority_list.append(priority) # 如果配置不完整,使用默认优先级 if len(priority_list) < 4: self._resource_priority = ["115", "magnet", "ed2k", "video"] else: self._resource_priority = priority_list self._enable_115 = config.get("enable_115", True) self._enable_magnet = config.get("enable_magnet", True) self._enable_video = config.get("enable_video", True) self._enable_ed2k = config.get("enable_ed2k", True) self._search_timeout = config.get("search_timeout", 30) # CloudSyncMedia配置 self._cms_enabled = config.get("cms_enabled", False) self._cms_url = config.get("cms_url", "") self._cms_username = config.get("cms_username", "") self._cms_password = config.get("cms_password", "") logger.info(f"Nullbr资源优先级设置: {' > '.join(self._resource_priority)}") if self._cms_enabled: logger.info(f"CloudSyncMedia已启用: {self._cms_url}") # 初始化API客户端 if self._enabled and self._app_id: try: from .nullbr_client import NullbrApiClient self._client = NullbrApiClient(self._app_id, self._api_key) logger.info("Nullbr API客户端初始化成功") except Exception as e: logger.error(f"Nullbr API客户端初始化失败: {str(e)}") self._enabled = False else: if not self._app_id: logger.warning("Nullbr插件配置错误: 缺少APP_ID") self._client = None # 初始化CloudSyncMedia客户端 if self._cms_enabled and self._cms_url and self._cms_username and self._cms_password: try: from .cms_client import CloudSyncMediaClient self._cms_client = CloudSyncMediaClient( self._cms_url, self._cms_username, self._cms_password ) logger.info("CloudSyncMedia客户端已初始化") except Exception as e: logger.error(f"CloudSyncMedia初始化失败: {str(e)}") self._cms_enabled = False self._cms_client = None else: self._cms_client = None def get_state(self) -> bool: """获取插件状态""" return self._enabled @staticmethod def get_command() -> List[Dict[str, Any]]: """获取插件命令""" pass def get_api(self) -> List[Dict[str, Any]]: """获取插件API""" pass def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: """ 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 """ return [ { 'component': 'VForm', 'content': [ { 'component': 'VRow', 'content': [ { 'component': 'VCol', 'props': {'cols': 12}, 'content': [ { 'component': 'VAlert', 'props': { 'type': 'info', 'variant': 'tonal', 'text': '🌟 Nullbr资源搜索插件将优先使用Nullbr API查找资源。支持115网盘、磁力、ed2k、m3u8等多种资源类型。' } } ] } ] }, { 'component': 'VRow', 'content': [ { 'component': 'VCol', 'props': {'cols': 12, 'md': 6}, 'content': [ { 'component': 'VSwitch', 'props': { 'model': 'enabled', 'label': '启用插件', 'hint': '开启后插件将开始工作,优先搜索Nullbr资源', 'persistent-hint': True } } ] } ] }, { 'component': 'VRow', 'content': [ { 'component': 'VCol', 'props': {'cols': 12, 'md': 6}, 'content': [ { 'component': 'VTextField', 'props': { 'model': 'app_id', 'label': 'APP_ID *', 'placeholder': '请输入Nullbr API的APP_ID', 'hint': '必填:用于API认证的应用ID', 'persistent-hint': True, 'clearable': True } } ] }, { 'component': 'VCol', 'props': {'cols': 12, 'md': 6}, 'content': [ { 'component': 'VTextField', 'props': { 'model': 'api_key', 'label': 'API_KEY', 'placeholder': '请输入Nullbr API的API_KEY', 'hint': '可选:用于获取资源链接,没有则只能搜索不能获取下载链接', 'persistent-hint': True, 'clearable': True, 'type': 'password' } } ] } ] }, { 'component': 'VRow', 'content': [ { 'component': 'VCol', 'props': {'cols': 12}, 'content': [ { 'component': 'VExpansionPanels', 'content': [ { 'component': 'VExpansionPanel', 'props': {'title': '⚙️ 高级设置'}, 'content': [ { 'component': 'VExpansionPanelText', 'content': [ { 'component': 'VRow', 'content': [ { 'component': 'VCol', 'props': {'cols': 12, 'md': 3}, 'content': [ { 'component': 'VSwitch', 'props': { 'model': 'enable_115', 'label': '115网盘', 'hint': '搜索115网盘分享资源', 'persistent-hint': True } } ] }, { 'component': 'VCol', 'props': {'cols': 12, 'md': 3}, 'content': [ { 'component': 'VSwitch', 'props': { 'model': 'enable_magnet', 'label': '磁力链接', 'hint': '搜索磁力链接资源', 'persistent-hint': True } } ] }, { 'component': 'VCol', 'props': {'cols': 12, 'md': 3}, 'content': [ { 'component': 'VSwitch', 'props': { 'model': 'enable_video', 'label': 'M3U8视频', 'hint': '搜索在线观看资源', 'persistent-hint': True } } ] }, { 'component': 'VCol', 'props': {'cols': 12, 'md': 3}, 'content': [ { 'component': 'VSwitch', 'props': { 'model': 'enable_ed2k', 'label': 'ED2K链接', 'hint': '搜索ED2K链接资源', 'persistent-hint': True } } ] } ] }, { 'component': 'VRow', 'content': [ { 'component': 'VCol', 'props': {'cols': 12}, 'content': [ { 'component': 'VAlert', 'props': { 'type': 'info', 'variant': 'tonal' }, 'content': [ { 'component': 'span', 'text': '🎯 资源优先级设置 - 自动按优先级获取资源(可拖拽排序)' } ] } ] } ] }, { 'component': 'VRow', 'content': [ { 'component': 'VCol', 'props': {'cols': 12, 'md': 6}, 'content': [ { 'component': 'VSelect', 'props': { 'model': 'priority_1', 'label': '第一优先级', 'items': [ {'title': '115网盘', 'value': '115'}, {'title': '磁力链接', 'value': 'magnet'}, {'title': 'ED2K链接', 'value': 'ed2k'}, {'title': 'M3U8视频', 'value': 'video'} ], 'hint': '优先获取的资源类型', 'persistent-hint': True } } ] }, { 'component': 'VCol', 'props': {'cols': 12, 'md': 6}, 'content': [ { 'component': 'VSelect', 'props': { 'model': 'priority_2', 'label': '第二优先级', 'items': [ {'title': '115网盘', 'value': '115'}, {'title': '磁力链接', 'value': 'magnet'}, {'title': 'ED2K链接', 'value': 'ed2k'}, {'title': 'M3U8视频', 'value': 'video'} ], 'hint': '第二选择的资源类型', 'persistent-hint': True } } ] } ] }, { 'component': 'VRow', 'content': [ { 'component': 'VCol', 'props': {'cols': 12, 'md': 6}, 'content': [ { 'component': 'VSelect', 'props': { 'model': 'priority_3', 'label': '第三优先级', 'items': [ {'title': '115网盘', 'value': '115'}, {'title': '磁力链接', 'value': 'magnet'}, {'title': 'ED2K链接', 'value': 'ed2k'}, {'title': 'M3U8视频', 'value': 'video'} ], 'hint': '第三选择的资源类型', 'persistent-hint': True } } ] }, { 'component': 'VCol', 'props': {'cols': 12, 'md': 6}, 'content': [ { 'component': 'VSelect', 'props': { 'model': 'priority_4', 'label': '第四优先级', 'items': [ {'title': '115网盘', 'value': '115'}, {'title': '磁力链接', 'value': 'magnet'}, {'title': 'ED2K链接', 'value': 'ed2k'}, {'title': 'M3U8视频', 'value': 'video'} ], 'hint': '最后选择的资源类型', 'persistent-hint': True } } ] } ] }, { 'component': 'VRow', 'content': [ { 'component': 'VCol', 'props': {'cols': 12}, 'content': [ { 'component': 'VAlert', 'props': { 'type': 'info', 'variant': 'tonal' }, 'content': [ { 'component': 'span', 'text': '🚀 CloudSyncMedia转存配置 - 自动转存资源到CMS系统' } ] } ] } ] }, { 'component': 'VRow', 'content': [ { 'component': 'VCol', 'props': {'cols': 12, 'md': 6}, 'content': [ { 'component': 'VSwitch', 'props': { 'model': 'cms_enabled', 'label': '启用CloudSyncMedia', 'hint': '开启后支持自动转存资源到CMS系统', 'persistent-hint': True } } ] } ] }, { 'component': 'VRow', 'content': [ { 'component': 'VCol', 'props': {'cols': 12, 'md': 6}, 'content': [ { 'component': 'VTextField', 'props': { 'model': 'cms_url', 'label': 'CMS服务器地址', 'placeholder': 'http://your-cms-domain.com', 'hint': 'CloudSyncMedia服务器的完整URL地址', 'persistent-hint': True } } ] }, { 'component': 'VCol', 'props': {'cols': 12, 'md': 6}, 'content': [ { 'component': 'VTextField', 'props': { 'model': 'cms_username', 'label': 'CMS用户名', 'placeholder': '请输入CMS登录用户名', 'hint': '用于登录CMS系统的用户名', 'persistent-hint': True } } ] } ] }, { 'component': 'VRow', 'content': [ { 'component': 'VCol', 'props': {'cols': 12, 'md': 6}, 'content': [ { 'component': 'VTextField', 'props': { 'model': 'cms_password', 'label': 'CMS密码', 'placeholder': '请输入CMS登录密码', 'hint': '用于登录CMS系统的密码', 'persistent-hint': True, 'type': 'password' } } ] }, { 'component': 'VCol', 'props': {'cols': 12, 'md': 6}, 'content': [ { 'component': 'VTextField', 'props': { 'model': 'search_timeout', 'label': '搜索超时时间(秒)', 'placeholder': '30', 'hint': '单次API请求的超时时间', 'persistent-hint': True, 'type': 'number', 'min': 10, 'max': 120 } } ] } ] } ] } ] } ] } ] } ] } ] } ], { "enabled": False, "app_id": "", "api_key": "", "enable_115": True, "enable_magnet": True, "enable_video": True, "enable_ed2k": True, "priority_1": "115", "priority_2": "magnet", "priority_3": "ed2k", "priority_4": "video", "cms_enabled": False, "cms_url": "", "cms_username": "", "cms_password": "", "search_timeout": 30 } def get_page(self) -> List[dict]: """ 拼装插件详情页面,需要返回页面配置,同时附带数据 插件详情页面使用Vuetify组件拼装,参考:https://vuetifyjs.com/ :return: 页面配置(vuetify模式)或 None(vue模式) """ pass @eventmanager.register(EventType.UserMessage) def talk(self, event: Event): """ 监听用户消息,识别搜索请求和编号选择 """ if not self._enabled: return # 第3步测试阶段:即使没有client也要响应,用于测试交互逻辑 if not self._client: logger.info("API客户端未初始化,但继续处理用户消息进行测试") text = event.event_data.get("text") userid = event.event_data.get("userid") channel = event.event_data.get("channel") if not text: return logger.info(f"收到用户消息: {text}") # 检查是否为回退搜索触发的消息,避免无限循环 if event.event_data.get('source') == 'nullbr_fallback': logger.info("检测到回退搜索消息,跳过处理避免循环") return # 先检查是否为获取资源的请求(包含问号的情况,如 "1.115?" "2.magnet?") clean_text = text.rstrip('??').strip() if re.match(r'^\d+\.(115|magnet|video|ed2k)$', clean_text): parts = clean_text.split('.') number = int(parts[0]) resource_type = parts[1] logger.info(f"检测到资源获取请求: {number}.{resource_type}") self.handle_get_resources(number, resource_type, channel, userid) # 检查是否为编号选择(纯数字,包含问号的情况) elif clean_text.isdigit(): number = int(clean_text) # 先检查是否有资源缓存(直接进行转存) if userid in self._user_resource_cache: cache = self._user_resource_cache[userid] if time.time() - cache['timestamp'] < 3600: # 1小时内有效 if 1 <= number <= len(cache['resources']): if self._cms_enabled and self._cms_client: logger.info(f"检测到资源转存请求: {number}") self.handle_resource_transfer(number, channel, userid) else: # 有资源缓存但CMS未启用,显示资源详情和提示 selected_resource = cache['resources'][number - 1] resource_detail = f"🎯 选择的资源:\n\n" resource_detail += f"🎬 影片: 「{cache['title']}」\n" resource_detail += f"📂 名称: {selected_resource['title']}\n" resource_detail += f"💾 大小: {selected_resource['size']}\n" resource_detail += f"🔗 链接: {selected_resource['url']}\n" resource_detail += f"{'─' * 15}\n" resource_detail += f"💡 CloudSyncMedia转存功能未启用\n" resource_detail += f"⚙️ 如需转存功能,请在插件设置中配置CloudSyncMedia" self.post_message( channel=channel, title="资源详情", text=resource_detail, userid=userid ) return else: # 数字超出资源范围,提示用户 self.post_message( channel=channel, title="编号错误", text=f"请输入有效的资源编号 (1-{len(cache['resources'])})。", userid=userid ) return # 如果没有资源缓存,检查是否有搜索结果缓存 logger.info(f"检测到编号选择: {number}") self.handle_resource_selection(number, channel, userid) # 检查是否为搜索请求(以?结尾,但不是数字或资源请求) elif text.endswith('?') or text.endswith('?'): # 提取搜索关键词(去掉问号) keyword = clean_text if keyword: logger.info(f"检测到搜索请求: {keyword}") self.search_and_reply(keyword, channel, userid) def search_and_reply(self, keyword: str, channel: str, userid: str): """执行搜索并回复结果""" try: # 更新搜索统计 self._stats['total_searches'] += 1 self._stats['last_search_time'] = time.time() # 更新热门搜索统计 if keyword in self._stats['popular_resources']: self._stats['popular_resources'][keyword] += 1 else: self._stats['popular_resources'][keyword] = 1 # 检查API客户端是否可用 if not self._client: logger.warning("API客户端未初始化,无法搜索") self._stats['failed_searches'] += 1 self.post_message( channel=channel, title="配置错误", text="❌ API客户端未初始化\n\n请检查插件配置中的APP_ID设置", userid=userid ) return # 调用Nullbr API搜索 result = self._client.search(keyword) if not result or not result.get('items'): # Nullbr没有搜索结果,回退到MoviePilot原始搜索 logger.info(f"Nullbr未找到「{keyword}」的搜索结果,回退到MoviePilot搜索") self._stats['failed_searches'] += 1 self.post_message( channel=channel, title="切换搜索", text=f"Nullbr没有找到「{keyword}」的资源,正在使用MoviePilot原始搜索...", userid=userid ) self.fallback_to_moviepilot_search(keyword, channel, userid) return # 搜索成功,更新统计 self._stats['successful_searches'] += 1 # 缓存搜索结果 self._user_search_cache[userid] = { 'results': result.get('items', []), 'timestamp': time.time() } # 构建回复消息 reply_text = f"🎬 找到 {len(result.get('items', []))} 个「{keyword}」相关资源:\n\n" # 显示前10个结果 for i, item in enumerate(result.get('items', [])[:10], 1): title = item.get('title', '未知标题') year = item.get('release_date', item.get('first_air_date', ''))[:4] if item.get('release_date') or item.get('first_air_date') else '' media_type = '电影' if item.get('media_type') == 'movie' else '剧集' if item.get('media_type') == 'tv' else item.get('media_type', '未知') reply_text += f"【{i}】{title}" if year: reply_text += f" ({year})" reply_text += f"\n🎭 类型: {media_type}\n" # 显示可用的资源类型标记 resource_flags = [] if item.get('115-flg') and self._enable_115: resource_flags.append('💾115') if item.get('magnet-flg') and self._enable_magnet: resource_flags.append('🧲磁力') if item.get('video-flg') and self._enable_video: resource_flags.append('🎬在线') if item.get('ed2k-flg') and self._enable_ed2k: resource_flags.append('📎ed2k') if resource_flags: reply_text += f"📂 资源: {' | '.join(resource_flags)}\n" reply_text += f"{'─' * 15}\n" # 如果结果超过10个,显示提示 if len(result.get('items', [])) > 10: reply_text += f"... 还有 {len(result.get('items', [])) - 10} 个结果\n\n" if self._api_key: reply_text += "📋 使用方法:\n" reply_text += f"• 发送数字自动获取资源: 如 \"1\" (优先级: {' > '.join(self._resource_priority)})\n" reply_text += "• 手动指定资源类型: 如 \"1.115\" \"2.magnet\" (可选)" else: reply_text += "💡 提示: 请配置API_KEY以获取下载链接" self.post_message( channel=channel, title="Nullbr搜索结果", text=reply_text, userid=userid ) except Exception as e: logger.error(f"搜索处理异常: {str(e)}") self.post_message( channel=channel, title="搜索错误", text=f"搜索「{keyword}」时出现错误: {str(e)}", userid=userid ) def handle_resource_selection(self, number: int, channel: str, userid: str): """处理用户的编号选择""" try: # 检查缓存 cache = self._user_search_cache.get(userid) if not cache or time.time() - cache['timestamp'] > 3600: # 缓存1小时 self.post_message( channel=channel, title="提示", text="搜索结果已过期,请重新搜索。", userid=userid ) return results = cache['results'] if number < 1 or number > len(results): self.post_message( channel=channel, title="提示", text=f"请输入有效的编号 (1-{len(results)})。", userid=userid ) return # 获取选中的项目 selected = results[number - 1] title = selected.get('title', '未知标题') media_type = selected.get('media_type', 'unknown') year = selected.get('release_date', selected.get('first_air_date', ''))[:4] if selected.get('release_date') or selected.get('first_air_date') else '' tmdbid = selected.get('tmdbid') if not self._api_key: # 如果没有API_KEY,显示详细信息 reply_text = f"📺 选择的资源: {title}" if year: reply_text += f" ({year})" reply_text += f"\n类型: {'电影' if media_type == 'movie' else '剧集' if media_type == 'tv' else media_type}" reply_text += f"\nTMDB ID: {tmdbid}" if selected.get('overview'): reply_text += f"\n简介: {selected.get('overview')[:100]}..." # 显示可用的资源类型 reply_text += f"\n\n🔗 可用资源类型:" resource_options = [] if selected.get('115-flg') and self._enable_115: resource_options.append(f"• 115网盘") if selected.get('magnet-flg') and self._enable_magnet: resource_options.append(f"• 磁力链接") if selected.get('video-flg') and self._enable_video: resource_options.append(f"• 在线观看") if selected.get('ed2k-flg') and self._enable_ed2k: resource_options.append(f"• ED2K链接") if resource_options: reply_text += f"\n" + "\n".join(resource_options) reply_text += "\n\n⚠️ 注意: 需要配置API_KEY才能获取具体下载链接" else: reply_text += f"\n暂无可用资源类型" self.post_message( channel=channel, title="资源详情", text=reply_text, userid=userid ) else: # 如果有API_KEY,直接按优先级获取资源 self.post_message( channel=channel, title="获取中", text=f"正在按优先级获取「{title}」的资源...", userid=userid ) self.get_resources_by_priority(selected, channel, userid) except Exception as e: logger.error(f"处理资源选择异常: {str(e)}") self.post_message( channel=channel, title="错误", text=f"处理选择时出现错误: {str(e)}", userid=userid ) def handle_get_resources(self, number: int, resource_type: str, channel: str, userid: str): """处理获取具体资源链接的请求""" try: # 检查API_KEY if not self._api_key: self.post_message( channel=channel, title="配置错误", text="获取下载链接需要配置API_KEY,请在插件设置中添加。", userid=userid ) return # 检查缓存 cache = self._user_search_cache.get(userid) if not cache or time.time() - cache['timestamp'] > 3600: self.post_message( channel=channel, title="提示", text="搜索结果已过期,请重新搜索。", userid=userid ) return results = cache['results'] if number < 1 or number > len(results): self.post_message( channel=channel, title="提示", text=f"请输入有效的编号 (1-{len(results)})。", userid=userid ) return # 获取选中的项目 selected = results[number - 1] title = selected.get('title', '未知标题') media_type = selected.get('media_type', 'unknown') tmdbid = selected.get('tmdbid') if not tmdbid: self.post_message( channel=channel, title="错误", text="该资源缺少TMDB ID,无法获取下载链接。", userid=userid ) return # 发送获取中的提示 self.post_message( channel=channel, title="获取中", text=f"正在获取「{title}」的{resource_type}资源...", userid=userid ) # 调用相应的API获取资源 resources = None if media_type == 'movie': resources = self._client.get_movie_resources(tmdbid, resource_type) elif media_type == 'tv': resources = self._client.get_tv_resources(tmdbid, resource_type) if not resources: # Nullbr没有找到资源,回退到MoviePilot原始搜索 logger.info(f"Nullbr未找到「{title}」的{resource_type}资源,回退到MoviePilot搜索") self.post_message( channel=channel, title="切换搜索", text=f"Nullbr没有找到「{title}」的{resource_type}资源,正在使用MoviePilot原始搜索...", userid=userid ) # 调用MoviePilot的原始搜索功能 self.fallback_to_moviepilot_search(title, channel, userid) return # 格式化资源链接(第4步完善) self.format_and_send_resources(resources, resource_type, title, channel, userid) except Exception as e: logger.error(f"获取资源链接异常: {str(e)}") self.post_message( channel=channel, title="错误", text=f"获取资源链接时出现错误: {str(e)}", userid=userid ) def get_resources_by_priority(self, selected: dict, channel: str, userid: str): """按优先级获取资源""" try: title = selected.get('title', '未知标题') media_type = selected.get('media_type', 'unknown') tmdbid = selected.get('tmdbid') if not tmdbid: self.post_message( channel=channel, title="错误", text="该资源缺少TMDB ID,无法获取下载链接。", userid=userid ) return logger.info(f"按优先级获取资源: {title} (TMDB: {tmdbid})") logger.info(f"优先级顺序: {' > '.join(self._resource_priority)}") # 按优先级尝试获取资源 for priority_type in self._resource_priority: # 检查该资源类型是否可用 flag_key = f"{priority_type}-flg" if not selected.get(flag_key): logger.info(f"跳过 {priority_type}: 资源不可用") continue # 检查该资源类型是否启用 enable_key = f"_enable_{priority_type}" if not getattr(self, enable_key, True): logger.info(f"跳过 {priority_type}: 已在配置中禁用") continue logger.info(f"尝试获取 {priority_type} 资源...") # 调用相应的API获取资源 resources = None if media_type == 'movie': resources = self._client.get_movie_resources(tmdbid, priority_type) elif media_type == 'tv': resources = self._client.get_tv_resources(tmdbid, priority_type) if resources and resources.get(priority_type): # 找到资源,发送结果并结束 resource_name = { '115': '115网盘', 'magnet': '磁力链接', 'ed2k': 'ED2K链接', 'video': 'M3U8视频' }.get(priority_type, priority_type) logger.info(f"成功获取 {priority_type} 资源,共 {len(resources[priority_type])} 个") self.post_message( channel=channel, title="获取成功", text=f"✅ 已获取「{title}」的{resource_name}资源", userid=userid ) # 格式化并发送资源链接 self.format_and_send_resources(resources, priority_type, title, channel, userid) return else: logger.info(f"{priority_type} 资源不可用,尝试下一优先级") # 所有优先级都没有找到资源,回退到MoviePilot搜索 logger.info(f"所有优先级资源都不可用,回退到MoviePilot搜索") self.post_message( channel=channel, title="切换搜索", text=f"Nullbr没有找到「{title}」的任何资源,正在使用MoviePilot原始搜索...", userid=userid ) self.fallback_to_moviepilot_search(title, channel, userid) except Exception as e: logger.error(f"按优先级获取资源异常: {str(e)}") self.post_message( channel=channel, title="错误", text=f"获取资源时出现错误: {str(e)}", userid=userid ) def handle_resource_transfer(self, resource_id: int, channel: str, userid: str): """处理资源转存请求""" try: # 检查CMS是否启用 if not self._cms_enabled or not self._cms_client: self.post_message( channel=channel, title="功能未启用", text="CloudSyncMedia转存功能未启用,请在设置中配置。", userid=userid ) return # 获取用户资源缓存 cache = self._user_resource_cache.get(userid) if not cache or time.time() - cache['timestamp'] > 3600: self.post_message( channel=channel, title="缓存过期", text="资源缓存已过期,请重新获取资源后再试。", userid=userid ) return resources = cache['resources'] title = cache['title'] resource_type = cache['resource_type'] if resource_id < 1 or resource_id > len(resources): self.post_message( channel=channel, title="编号错误", text=f"请输入有效的资源编号 (1-{len(resources)})。", userid=userid ) return # 获取要转存的资源 selected_resource = resources[resource_id - 1] resource_url = selected_resource['url'] resource_title = selected_resource['title'] resource_size = selected_resource['size'] # 只有115网盘资源支持CMS转存 if resource_type != "115": self.post_message( channel=channel, title="不支持转存", text=f"暂不支持{resource_type}资源转存,只支持115网盘资源转存。", userid=userid ) return logger.info(f"开始CMS转存: 用户={userid}, 资源={resource_title}, URL={resource_url}") # 更新转存统计 self._stats['cms_transfers'] += 1 self._stats['last_transfer_time'] = time.time() # 发送转存中提示 self.post_message( channel=channel, title="转存中", text=f"🚀 正在转存「{title}」中的资源:\n\n" f"📁 {resource_title}\n" f"📊 大小: {resource_size}\n\n" f"⏳ 请稍等,正在处理中...", userid=userid ) # 调用CMS API进行转存 result = self._cms_client.add_share_down(resource_url) # 处理转存结果 if result.get('code') == 200: # 转存成功统计 self._stats['successful_transfers'] += 1 success_msg = f"✅ 转存成功!\n" success_msg += f"{'─' * 15}\n" success_msg += f"🎬 影片: 「{title}」\n" success_msg += f"📁 资源: {resource_title}\n" success_msg += f"📊 大小: {resource_size}\n" # 检查返回数据中是否有任务信息 if result.get('data'): data = result['data'] if data.get('task_id'): success_msg += f"🆔 任务ID: {data['task_id']}\n" if data.get('status'): success_msg += f"📋 状态: {data['status']}\n" success_msg += f"{'─' * 15}\n" success_msg += "💡 可在CloudSyncMedia管理界面查看转存进度" self.post_message( channel=channel, title="转存成功", text=success_msg, userid=userid ) logger.info(f"CMS转存成功: {resource_title} -> 任务ID: {result.get('data', {}).get('task_id', 'N/A')}") else: # 转存失败统计 self._stats['failed_transfers'] += 1 # 转存失败 error_msg = result.get('message', '未知错误') failure_msg = f"❌ 转存失败\n" failure_msg += f"{'─' * 15}\n" failure_msg += f"📁 资源: {resource_title}\n" failure_msg += f"🚨 错误: {error_msg}\n" failure_msg += f"{'─' * 15}\n" failure_msg += "💡 请检查CloudSyncMedia服务状态" self.post_message( channel=channel, title="转存失败", text=failure_msg, userid=userid ) logger.warning(f"CMS转存失败: {resource_title} -> {error_msg}") except Exception as e: logger.error(f"资源转存异常: {str(e)}") self.post_message( channel=channel, title="转存错误", text=f"❌ 转存过程中发生错误:\n\n{str(e)}\n\n💡 请检查CloudSyncMedia配置和网络连接", userid=userid ) def format_and_send_resources(self, resources: dict, resource_type: str, title: str, channel: str, userid: str): """格式化并发送资源链接""" try: resource_list = resources.get(resource_type, []) if not resource_list: self.post_message( channel=channel, title="无资源", text=f"没有找到「{title}」的{resource_type}资源。", userid=userid ) return # 更新资源统计 self._stats['total_resources'] += len(resource_list) # 缓存资源到用户缓存中,用于CMS转存 resource_cache = [] for res in resource_list[:10]: # 最多缓存10个 if resource_type == "115": url = res.get('share_link', '') elif resource_type == "magnet": url = res.get('magnet', '') elif resource_type in ["video", "ed2k"]: url = res.get('url', res.get('link', '')) else: url = '' if url: resource_cache.append({ 'url': url, 'title': res.get('title', res.get('name', '未知')), 'size': res.get('size', '未知'), 'type': resource_type }) # 保存到用户资源缓存 self._user_resource_cache[userid] = { 'resources': resource_cache, 'title': title, 'resource_type': resource_type, 'timestamp': time.time() } # 格式化显示文本 reply_text = f"🎯 「{title}」的{resource_type}资源:\n\n" if resource_type == "115": for i, res in enumerate(resource_list[:10], 1): reply_text += f"【{i}】{res.get('title', '未知')}\n" reply_text += f"💾 大小: {res.get('size', '未知')}\n" reply_text += f"🔗 链接: {res.get('share_link', '无')}\n" reply_text += f"{'─' * 15}\n" elif resource_type == "magnet": for i, res in enumerate(resource_list[:10], 1): reply_text += f"【{i}】{res.get('name', '未知')}\n" reply_text += f"💾 大小: {res.get('size', '未知')}\n" reply_text += f"📺 分辨率: {res.get('resolution', '未知')}\n" reply_text += f"🈴 中文字幕: {'✅' if res.get('zh_sub') else '❌'}\n" reply_text += f"🧲 磁力: {res.get('magnet', '无')}\n" reply_text += f"{'─' * 15}\n" elif resource_type in ["video", "ed2k"]: for i, res in enumerate(resource_list[:10], 1): reply_text += f"【{i}】{res.get('name', res.get('title', '未知'))}\n" if res.get('size'): reply_text += f"💾 大小: {res.get('size')}\n" reply_text += f"🔗 链接: {res.get('url', res.get('link', '无'))}\n" reply_text += f"{'─' * 15}\n" if len(reply_text) > 3500: # 留出空间给CMS提示 reply_text = reply_text[:3400] + "...\n\n(内容过长已截断)\n\n" reply_text += f"📊 共找到 {len(resource_list)} 个资源\n\n" # 如果启用了CloudSyncMedia,添加转存提示 if self._cms_enabled and self._cms_client and resource_type == "115": reply_text += "🚀 CloudSyncMedia转存:\n" reply_text += "发送资源编号进行转存,如: 1、2、3..." self.post_message( channel=channel, title=f"{resource_type.upper()}资源", text=reply_text, userid=userid ) except Exception as e: logger.error(f"格式化资源异常: {str(e)}") self.post_message( channel=channel, title="错误", text=f"处理资源信息时出现错误: {str(e)}", userid=userid ) def fallback_to_moviepilot_search(self, title: str, channel: str, userid: str): """回退到MoviePilot原始搜索功能""" logger.info(f"启动MoviePilot原始搜索: {title}") # 尝试其他搜索方式 self.try_alternative_search(title, channel, userid) def try_alternative_search(self, title: str, channel: str, userid: str): """尝试其他搜索方式""" try: logger.info(f"尝试MoviePilot原始搜索: {title}") # 简化策略:直接发送搜索建议和提示 # 避免复杂的模块调用导致的错误 success = False # 方法1: 尝试调用站点助手的简单方法 try: from app.helper.sites import SitesHelper sites_helper = SitesHelper() # 只是检查是否有配置的站点 if hasattr(sites_helper, 'get_indexers'): indexers = sites_helper.get_indexers() if indexers: logger.info(f"检测到 {len(indexers)} 个配置的站点") self.post_message( channel=channel, title="搜索提示", text=f"🔍 Nullbr未找到「{title}」的资源\n\n" + f"💡 系统检测到您已配置 {len(indexers)} 个搜索站点\n" + f"建议通过以下方式继续搜索:\n\n" + f"🌐 MoviePilot Web界面搜索\n" + f"📱 其他搜索渠道\n" + f"⚙️ 检查站点配置状态", userid=userid ) success = True except Exception as e: logger.warning(f"站点检测失败: {str(e)}") # 如果上面的方法也失败,发送通用建议 if not success: self.send_manual_search_suggestion(title, channel, userid) except Exception as e: logger.error(f"备用搜索失败: {str(e)}") self.send_manual_search_suggestion(title, channel, userid) def send_manual_search_suggestion(self, title: str, channel: str, userid: str): """发送手动搜索建议""" self.post_message( channel=channel, title="搜索建议", text=f"📋 「{title}」未找到资源,建议:\n\n" + f"🔍 在MoviePilot Web界面搜索\n" + f"⚙️ 检查资源站点配置\n" + f"🔄 尝试其他关键词\n" + f"📱 使用其他搜索渠道", userid=userid ) def stop_service(self): """停止插件服务""" try: # 清理客户端连接 if self._client: logger.info("清理Nullbr客户端") self._client = None if self._cms_client: logger.info("清理CMS客户端连接") if hasattr(self._cms_client, 'session'): self._cms_client.session.close() self._cms_client = None # 清理缓存 self._user_search_cache.clear() self._user_resource_cache.clear() self._enabled = False logger.info("Nullbr资源搜索插件已停止") except Exception as e: logger.error(f"插件停止异常: {str(e)}") # 导出插件类 __all__ = ['nullbr_search']