From c6e7658133aa8b3ca687020f29fa950d0535bc6e Mon Sep 17 00:00:00 2001 From: stomtian Date: Sat, 9 Aug 2025 09:27:54 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E5=92=8C=E6=95=B4=E7=90=86?= =?UTF-8?q?=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 180 +-- package.v2.json | 13 +- .../__init__.py | 1123 ++++++----------- plugins.v2/nullbr_search/cms_client.py | 110 ++ plugins.v2/nullbr_search/nullbr_client.py | 202 +++ .../requirements.txt | 0 6 files changed, 762 insertions(+), 866 deletions(-) rename plugins.v2/{nullbrsearch => nullbr_search}/__init__.py (63%) create mode 100644 plugins.v2/nullbr_search/cms_client.py create mode 100644 plugins.v2/nullbr_search/nullbr_client.py rename plugins.v2/{nullbrsearch => nullbr_search}/requirements.txt (100%) diff --git a/README.md b/README.md index debfa3c..a09fd12 100644 --- a/README.md +++ b/README.md @@ -2,141 +2,70 @@ ## 📝 插件简介 -Nullbr资源搜索插件是为MoviePilot-v2设计的资源搜索增强插件,通过集成Nullbr API,为用户提供优先级资源搜索功能。 +Nullbr资源搜索插件是为MoviePilot设计的智能资源搜索插件,通过集成Nullbr API,支持115网盘、磁力、ed2k、m3u8等多种资源类型的搜索和自动转存。 -**主要特点:** -- 🚀 **优先搜索**: 在MoviePilot搜索其他资源站之前,优先使用Nullbr API查找资源 -- 🔍 **智能跳过**: 如果Nullbr找到资源,自动跳过后续搜索,提高效率 -- 🎯 **多种资源**: 支持115网盘、磁力链接、ed2k、m3u8等多种资源类型 -- 📊 **统计监控**: 提供详细的使用统计和状态监控 -- ⚙️ **灵活配置**: 支持自定义资源类型和搜索参数 +## 🛠️ 插件安装方法 -## 🛠️ 安装方法 +复制本仓库地址到插件源配置即可在市场搜到本插件: -### 方法1: 直接复制文件 -1. 将`nullbrsearch`目录复制到MoviePilot的`app/plugins/`目录下 -2. 重启MoviePilot服务 +1. 在MoviePilot设置中找到「插件」→「插件源」 +2. 添加插件源:`https://github.com/Hqyel/MoviePilot-Plugins` +3. 保存后在插件市场搜索「Nullbr资源搜索」 +4. 点击安装并配置相关参数 -### 方法2: Git克隆 -```bash -cd /path/to/moviepilot/app/plugins/ -git clone nullbrsearch -``` +## 📖 使用方法 + +**重要:与本插件交互必须以问号结尾!** + +### 基本搜索 +在MoviePilot的任何消息界面发送搜索请求时,必须以问号结尾: + +- ✅ `武林外传?` +- ✅ `权力的游戏?` +- ✅ `复仇者联盟?` +- ❌ `武林外传` (不会触发搜索) +- ❌ `权力的游戏` (不会触发搜索) + +### 资源获取流程 +1. **搜索影片**:发送 `影片名?` +2. **选择影片**:回复数字(如 `1?`)查看资源类型 +3. **获取资源**: + - 自动获取(按优先级):直接发送数字 `2?` + - 指定类型:发送 `2.115?` 或 `3.magnet?` + +### CloudSyncMedia转存 +如果配置了CMS系统,获取115网盘资源后: +- 发送资源编号即可转存:`1?`、`2?`、`3?`... + +## ⚠️ 重要注意事项 + +**本插件与ChatGPT插件冲突!** + +如果你同时安装了ChatGPT插件,需要: +1. 禁用ChatGPT插件,或 +2. 调整插件优先级,确保Nullbr插件优先级更高 + +这是因为两个插件都会监听用户消息,可能产生冲突。 ## 🔧 配置说明 ### 必需配置 -- **APP_ID**: Nullbr API的应用ID,用于基本搜索功能(必填) +- **APP_ID**: Nullbr API的应用ID(必填) +- **API_KEY**: Nullbr API的密钥(可选,用于获取下载链接) ### 可选配置 -- **API_KEY**: Nullbr API的密钥,用于获取具体下载链接(可选) -- **资源类型**: 可以选择启用/禁用不同的资源类型 - - 115网盘分享 - - 磁力链接 - - M3U8在线视频 - - ED2K链接 - -### 高级设置 -- **搜索超时**: 设置API请求的超时时间(10-120秒) - -## 📖 使用方法 - -1. **获取API密钥**: 从Nullbr官方获取APP_ID和API_KEY -2. **配置插件**: 在MoviePilot插件设置中填入相关信息 -3. **启用插件**: 打开插件开关,插件开始工作 -4. **正常使用**: 通过MoviePilot的任何搜索功能,插件会自动优先搜索Nullbr资源 - -## 🌐 支持的API接口 - -插件提供了以下REST API接口: - -### `/nullbr/search` -- **方法**: GET -- **参数**: `keyword`, `page` -- **功能**: 搜索影视资源 - -### `/nullbr/resources` -- **方法**: POST -- **参数**: `media_type`, `tmdbid`, `resource_type` -- **功能**: 获取具体资源链接 - -### `/nullbr/test` -- **方法**: GET -- **功能**: 测试API连接状态 - -## 📊 工作原理 - -``` -用户搜索请求 - ↓ -MoviePilot接收 - ↓ -Nullbr插件拦截 ← 优先级最高 - ↓ -调用Nullbr API - ↓ -找到资源? → 是 → 返回结果给用户 → 结束搜索 - ↓ - 否 - ↓ -继续搜索其他资源站 -``` - -## 🔍 支持的媒体类型 - -- **电影** (movie): 支持获取电影资源 -- **剧集** (tv): 支持获取完整剧集资源 -- **合集** (collection): 支持搜索系列合集 -- **人物** (person): 支持人物相关搜索 - -## 📈 状态监控 - -插件提供详细的使用统计: -- 总搜索次数 -- 成功搜索次数 -- 失败搜索次数 -- 最后搜索时间 -- API连接状态 -- 资源类型启用状态 - -## ⚠️ 注意事项 - -1. **API限制**: 请遵守Nullbr API的使用限制和频率限制 -2. **网络连接**: 确保MoviePilot服务器能够访问`api.nullbr.eu.org` -3. **权限要求**: API_KEY的权限级别决定了能获取的资源类型 -4. **日志监控**: 如遇问题请检查MoviePilot日志中的相关错误信息 - -## 🐛 故障排除 - -### 常见问题 - -**Q: 插件显示已启用但不工作** -A: 检查APP_ID是否正确配置,查看日志中的错误信息 - -**Q: 能搜索但无法获取下载链接** -A: 需要配置有效的API_KEY才能获取具体资源链接 - -**Q: API请求超时** -A: 可能是网络问题,尝试增加超时时间或检查网络连接 - -**Q: 搜索结果为空** -A: 检查搜索关键词,或者查看Nullbr API是否有该资源 - -### 日志排查 -在MoviePilot日志中搜索以下关键词: -- `Nullbr` -- `nullbr` -- `NullbrSearch` +- **资源类型**: 启用/禁用不同资源类型(115网盘、磁力、M3U8、ED2K) +- **资源优先级**: 设置资源获取的优先顺序 +- **CloudSyncMedia**: 配置115网盘资源的自动转存功能 ## 📝 更新日志 -### v1.0.0 (2024-08-06) -- 🎉 首次发布 -- ✅ 支持基本搜索功能 -- ✅ 支持多种资源类型 -- ✅ 提供完整的配置界面 -- ✅ 集成API接口 -- ✅ 添加使用统计功能 +### v2.0.4 (2024-08-08) +- 🎉 移除了数据统计页面,简化插件结构 +- ✅ 优化了用户交互流程 +- ✅ 修复了与其他插件的兼容性问题 +- ✅ 改进了微信消息格式兼容性 +- ✅ 完善了CloudSyncMedia转存功能 ## 🤝 贡献 @@ -144,9 +73,4 @@ A: 检查搜索关键词,或者查看Nullbr API是否有该资源 ## 📄 许可证 -本插件基于GPL-3.0许可证开源。 - -## 🙏 致谢 - -- 感谢MoviePilot项目提供的优秀插件框架 -- 感谢Nullbr提供的资源API服务 \ No newline at end of file +本插件基于GPL-3.0许可证开源。 \ No newline at end of file diff --git a/package.v2.json b/package.v2.json index fdde5e0..f2d6cf3 100644 --- a/package.v2.json +++ b/package.v2.json @@ -1,17 +1,14 @@ { - "nullbrsearch": { + "nullbr_search": { "name": "Nullbr资源搜索", - "description": "优先使用Nullbr API搜索影视资源,支持115网盘、磁力、ed2k、m3u8等多种资源类型。在MoviePilot搜索其他资源站之前优先查找Nullbr资源,提高搜索效率。", + "description": "支持nullbr api接口直接搜索影视资源。支持115网盘、磁力、ed2k、m3u8等多种资源类型。", "labels": "资源", - "version": "1.0.7", + "version": "2.0.0", "icon": "https://raw.githubusercontent.com/Hqyel/MoviePilot-Plugins/main/icons/nullbr.png", "author": "Hqyel", "level": 1, "history": { - "v1.0.4": "初始上架版本", - "v1.0.5": "增加资源优先度设置", - "v1.0.6": "增加CloudSyncMedia转存配置", - "v1.0.7": "请求时的代理问题" - } + "v2.0.0": "重构整理代码。" + } } } \ No newline at end of file diff --git a/plugins.v2/nullbrsearch/__init__.py b/plugins.v2/nullbr_search/__init__.py similarity index 63% rename from plugins.v2/nullbrsearch/__init__.py rename to plugins.v2/nullbr_search/__init__.py index 1821e85..eb39d4d 100644 --- a/plugins.v2/nullbrsearch/__init__.py +++ b/plugins.v2/nullbr_search/__init__.py @@ -1,336 +1,29 @@ import re import time -from typing import Any, List, Dict, Tuple, Optional -import requests -from requests.adapters import HTTPAdapter -from urllib3.util.retry import Retry +from typing import Any, List, Dict, Tuple -from app.core.config import settings 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 CloudSyncMediaClient: - """CloudSyncMedia客户端""" - - def __init__(self, base_url: str, username: str, password: str): - self.base_url = base_url.rstrip('/') - self.username = username - self.password = password - self.token = None - self.token_expiry = 0 - - # 配置请求会话 - self.session = requests.Session() - self.session.headers.update({ - 'Content-Type': 'application/json', - 'Accept': 'application/json' - }) - - # CMS一般为内网服务,禁用代理访问 - self.session.proxies = { - 'http': None, - 'https': None - } - - # 初始化时获取token - self._ensure_valid_token() - - def _login(self) -> dict: - """登录CMS系统获取token""" - try: - response = self.session.post( - f'{self.base_url}/api/auth/login', - json={ - 'username': self.username, - 'password': self.password - }, - timeout=(10, 30) - ) - response.raise_for_status() - data = response.json() - - if data.get('code') != 200 or 'data' not in data: - raise ValueError(f'CMS登录失败: {data}') - - return data['data'] - - except requests.exceptions.RequestException as e: - logger.error(f'CMS登录失败: {str(e)}') - raise - - def _ensure_valid_token(self): - """确保有效的token""" - current_time = time.time() - - # 如果token不存在或距离过期时间不到1小时,重新获取token - if not self.token or current_time >= (self.token_expiry - 3600): - login_data = self._login() - self.token = login_data['token'] - - # 设置token过期时间为24小时后 - self.token_expiry = current_time + 86400 - - # 更新session的Authorization header - self.session.headers.update({ - 'Authorization': f'Bearer {self.token}' - }) - - logger.info("CMS token已更新") - - def add_share_down(self, url: str) -> dict: - """添加分享链接到CMS系统进行转存""" - if not url: - raise ValueError('转存链接不能为空') - - try: - self._ensure_valid_token() - - response = self.session.post( - f'{self.base_url}/api/cloud/add_share_down', - json={'url': url}, - timeout=(10, 30) - ) - response.raise_for_status() - result = response.json() - - logger.info(f"CMS转存请求已发送: {url}") - return result - - except requests.exceptions.HTTPError as e: - if e.response.status_code == 401: - # token可能过期,强制重新获取 - self.token = None - self._ensure_valid_token() - - # 重试请求 - response = self.session.post( - f'{self.base_url}/api/cloud/add_share_down', - json={'url': url}, - timeout=(10, 30) - ) - response.raise_for_status() - return response.json() - raise - except Exception as e: - logger.error(f'CMS转存请求失败: {str(e)}') - raise - - -class NullbrApiClient: - """Nullbr API客户端""" - - def __init__(self, app_id: str, api_key: str = None): - self._app_id = app_id - self._api_key = api_key - self._base_url = "https://api.nullbr.eu.org" - - # 配置请求会话 - self._session = requests.Session() - self._session.headers.update({ - 'User-Agent': 'MoviePilot-NullbrSearch/1.0.4', - 'Content-Type': 'application/json' - }) - - # 根据配置使用系统代理(Nullbr在中国大陆需要代理访问) - # 不设置proxies,使用系统默认代理配置 - - # 配置重试策略,增加超时相关的状态码 - try: - retry_strategy = Retry( - total=3, - status_forcelist=[429, 500, 502, 503, 504, 408], # 添加408 Request Timeout - allowed_methods=["HEAD", "GET", "OPTIONS"], - backoff_factor=1 - ) - except TypeError: - try: - retry_strategy = Retry( - total=3, - status_forcelist=[429, 500, 502, 503, 504, 408], - method_whitelist=["HEAD", "GET", "OPTIONS"], - backoff_factor=1 - ) - except Exception: - retry_strategy = Retry(total=3, backoff_factor=1) - - adapter = HTTPAdapter(max_retries=retry_strategy) - self._session.mount("http://", adapter) - self._session.mount("https://", adapter) - - def _make_request(self, url: str, params: dict, headers: dict, use_proxy: bool = True) -> requests.Response: - """发起HTTP请求,支持代理重试机制""" - session = self._session - - # 如果不使用代理,创建临时session - if not use_proxy: - session = requests.Session() - session.headers.update(self._session.headers) - session.proxies = {'http': None, 'https': None} - - timeout = 5 if use_proxy else (10, 30) # 使用代理时超时5s,无代理时用更长超时 - - return session.get(url, params=params, headers=headers, timeout=timeout) - - def search(self, query: str, page: int = 1) -> Optional[Dict]: - """搜索媒体资源""" - try: - # 根据API文档,APP_ID应该放在Header中 - headers = {'X-APP-ID': self._app_id} - - # API_KEY如果存在,也放在Header中 - if self._api_key: - headers['X-API-KEY'] = self._api_key - - params = { - 'query': query, - 'page': page - } - - logger.info(f"请求参数: {params}") - logger.info(f"请求头: X-APP-ID={self._app_id}, X-API-KEY={'已设置' if self._api_key else '未设置'}") - - url = f"{self._base_url}/search" - - # 首先尝试使用系统代理,5秒超时 - try: - logger.debug("尝试使用系统代理访问Nullbr API") - response = self._make_request(url, params, headers, use_proxy=True) - logger.info(f"使用系统代理请求成功,响应状态码: {response.status_code}") - - except (requests.exceptions.Timeout, requests.exceptions.ConnectTimeout, - requests.exceptions.ReadTimeout, requests.exceptions.ConnectionError) as e: - logger.warning(f"使用系统代理访问超时/连接失败: {str(e)},尝试直连") - try: - # 代理失败,尝试不使用代理直连 - response = self._make_request(url, params, headers, use_proxy=False) - logger.info(f"直连请求成功,响应状态码: {response.status_code}") - except Exception as direct_e: - logger.error(f"直连也失败: {str(direct_e)}") - return None - - if response.status_code == 200: - return response.json() - elif response.status_code == 403: - logger.error("Nullbr API认证失败,请检查APP_ID") - return None - else: - logger.warning(f"Nullbr API搜索失败: {response.status_code}, 响应内容: {response.text}") - return None - - except Exception as e: - logger.error(f"Nullbr API请求异常: {str(e)}") - return None - - def get_movie_resources(self, tmdbid: int, resource_type: str = "115") -> Optional[Dict]: - """获取电影资源链接""" - if not self._api_key: - logger.warning("获取资源链接需要API_KEY") - return None - - try: - headers = {'X-APP-ID': self._app_id, 'X-API-KEY': self._api_key} - url = f"{self._base_url}/movie/{tmdbid}/{resource_type}" - - # 首先尝试使用系统代理,5秒超时 - try: - logger.debug("尝试使用系统代理获取电影资源") - response = self._make_request(url, {}, headers, use_proxy=True) - logger.info(f"使用系统代理请求成功,响应状态码: {response.status_code}") - - except (requests.exceptions.Timeout, requests.exceptions.ConnectTimeout, - requests.exceptions.ReadTimeout, requests.exceptions.ConnectionError) as e: - logger.warning(f"使用系统代理访问超时/连接失败: {str(e)},尝试直连") - try: - # 代理失败,尝试不使用代理直连 - response = self._make_request(url, {}, headers, use_proxy=False) - logger.info(f"直连请求成功,响应状态码: {response.status_code}") - except Exception as direct_e: - logger.error(f"直连也失败: {str(direct_e)}") - return None - - if response.status_code == 200: - return response.json() - elif response.status_code == 401: - logger.error("API_KEY权限不足") - return None - elif response.status_code == 403: - logger.error("API认证失败") - return None - elif response.status_code == 429: - logger.warning("API请求过快,请稍后重试") - return None - else: - logger.warning(f"获取电影资源失败: {response.status_code}, 响应: {response.text}") - return None - - except Exception as e: - logger.error(f"获取电影资源异常: {str(e)}") - return None - - def get_tv_resources(self, tmdbid: int, resource_type: str = "115") -> Optional[Dict]: - """获取剧集资源链接""" - if not self._api_key: - logger.warning("获取资源链接需要API_KEY") - return None - - try: - headers = {'X-APP-ID': self._app_id, 'X-API-KEY': self._api_key} - url = f"{self._base_url}/tv/{tmdbid}/{resource_type}" - - # 首先尝试使用系统代理,5秒超时 - try: - logger.debug("尝试使用系统代理获取剧集资源") - response = self._make_request(url, {}, headers, use_proxy=True) - logger.info(f"使用系统代理请求成功,响应状态码: {response.status_code}") - - except (requests.exceptions.Timeout, requests.exceptions.ConnectTimeout, - requests.exceptions.ReadTimeout, requests.exceptions.ConnectionError) as e: - logger.warning(f"使用系统代理访问超时/连接失败: {str(e)},尝试直连") - try: - # 代理失败,尝试不使用代理直连 - response = self._make_request(url, {}, headers, use_proxy=False) - logger.info(f"直连请求成功,响应状态码: {response.status_code}") - except Exception as direct_e: - logger.error(f"直连也失败: {str(direct_e)}") - return None - - if response.status_code == 200: - return response.json() - elif response.status_code == 401: - logger.error("API_KEY权限不足") - return None - elif response.status_code == 403: - logger.error("API认证失败") - return None - elif response.status_code == 429: - logger.warning("API请求过快,请稍后重试") - return None - else: - logger.warning(f"获取剧集资源失败: {response.status_code}, 响应: {response.text}") - return None - - except Exception as e: - logger.error(f"获取剧集资源异常: {str(e)}") - return None - - -class NullbrSearch(_PluginBase): +class nullbr_search(_PluginBase): # 插件基本信息 plugin_name = "Nullbr资源搜索" - plugin_desc = "优先使用Nullbr API搜索影视资源,支持多种资源类型(115网盘、磁力、ed2k、m3u8)" + plugin_desc = "支持nullbr api接口直接搜索影视资源。支持115网盘、磁力、ed2k、m3u8等多种资源类型。)" plugin_icon = "https://raw.githubusercontent.com/Hqyel/MoviePilot-Plugins/main/icons/nullbr.png" - plugin_version = "1.0.7" + plugin_version = "2.0.0" plugin_author = "Hqyel" author_url = "https://github.com/Hqyel" - plugin_config_prefix = "nullbr_" + 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 @@ -340,24 +33,105 @@ class NullbrSearch(_PluginBase): self._enable_video = True self._enable_ed2k = True self._search_timeout = 30 - self._client = None # CloudSyncMedia配置 self._cms_enabled = False self._cms_url = "" self._cms_username = "" self._cms_password = "" - self._cms_client = None + # 客户端实例 + 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") @@ -395,10 +169,11 @@ class NullbrSearch(_PluginBase): # 初始化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资源搜索插件已启动") + logger.info("Nullbr API客户端初始化成功") except Exception as e: - logger.error(f"Nullbr插件初始化失败: {str(e)}") + logger.error(f"Nullbr API客户端初始化失败: {str(e)}") self._enabled = False else: if not self._app_id: @@ -408,6 +183,7 @@ class NullbrSearch(_PluginBase): # 初始化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, @@ -422,13 +198,16 @@ class NullbrSearch(_PluginBase): 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]]: @@ -872,241 +651,25 @@ class NullbrSearch(_PluginBase): } def get_page(self) -> List[dict]: - stats = {"total_searches": 0, "success_searches": 0, "failed_searches": 0, "last_search": "从未"} - return [ - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': {'cols': 12}, - 'content': [ - { - 'component': 'VCard', - 'props': {'class': 'mb-4'}, - 'content': [ - { - 'component': 'VCardTitle', - 'props': {'text': '🌟 Nullbr资源搜索状态'} - }, - { - 'component': 'VCardText', - 'content': [ - { - 'component': 'VList', - 'content': [ - { - 'component': 'VListItem', - 'content': [ - { - 'component': 'VListItemTitle', - 'props': {'text': f"插件状态: {'🟢 运行中' if self._enabled else '🔴 已停止'}"} - } - ] - }, - { - 'component': 'VListItem', - 'content': [ - { - 'component': 'VListItemTitle', - 'props': {'text': f"API认证: {'✅ 已配置' if self._app_id else '❌ 未配置'}"} - } - ] - }, - { - 'component': 'VListItem', - 'content': [ - { - 'component': 'VListItemTitle', - 'props': {'text': f"资源获取: {'✅ 可用' if self._api_key else '❌ 仅搜索'}"} - } - ] - } - ] - } - ] - } - ] - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': {'cols': 12, 'md': 6}, - 'content': [ - { - 'component': 'VCard', - 'content': [ - { - 'component': 'VCardTitle', - 'props': {'text': '📊 支持的资源类型'} - }, - { - 'component': 'VCardText', - 'content': [ - { - 'component': 'VList', - 'content': [ - { - 'component': 'VListItem', - 'content': [ - { - 'component': 'VListItemTitle', - 'props': {'text': f"115网盘: {'✅ 启用' if self._enable_115 else '❌ 禁用'}"} - } - ] - }, - { - 'component': 'VListItem', - 'content': [ - { - 'component': 'VListItemTitle', - 'props': {'text': f"磁力链接: {'✅ 启用' if self._enable_magnet else '❌ 禁用'}"} - } - ] - }, - { - 'component': 'VListItem', - 'content': [ - { - 'component': 'VListItemTitle', - 'props': {'text': f"M3U8视频: {'✅ 启用' if self._enable_video else '❌ 禁用'}"} - } - ] - }, - { - 'component': 'VListItem', - 'content': [ - { - 'component': 'VListItemTitle', - 'props': {'text': f"ED2K链接: {'✅ 启用' if self._enable_ed2k else '❌ 禁用'}"} - } - ] - } - ] - } - ] - } - ] - } - ] - }, - { - 'component': 'VCol', - 'props': {'cols': 12, 'md': 6}, - 'content': [ - { - 'component': 'VCard', - 'content': [ - { - 'component': 'VCardTitle', - 'props': {'text': '📈 使用统计'} - }, - { - 'component': 'VCardText', - 'content': [ - { - 'component': 'VList', - 'content': [ - { - 'component': 'VListItem', - 'content': [ - { - 'component': 'VListItemTitle', - 'props': {'text': f"总搜索次数: {stats.get('total_searches', 0)}"} - } - ] - }, - { - 'component': 'VListItem', - 'content': [ - { - 'component': 'VListItemTitle', - 'props': {'text': f"成功次数: {stats.get('success_searches', 0)}"} - } - ] - }, - { - 'component': 'VListItem', - 'content': [ - { - 'component': 'VListItemTitle', - 'props': {'text': f"失败次数: {stats.get('failed_searches', 0)}"} - } - ] - }, - { - 'component': 'VListItem', - 'content': [ - { - 'component': 'VListItemTitle', - 'props': {'text': f"最后搜索: {stats.get('last_search', '从未')}"} - } - ] - } - ] - } - ] - } - ] - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': {'cols': 12}, - 'content': [ - { - 'component': 'VCard', - 'content': [ - { - 'component': 'VCardTitle', - 'props': {'text': '💡 使用说明'} - }, - { - 'component': 'VCardText', - 'props': { - 'text': '''🔑 配置步骤: - 1. 在插件设置中填入您的 Nullbr API APP_ID (必填) - 2. 如需获取下载链接,请填入 API_KEY (可选) - 3. 根据需要启用不同的资源类型 - 4. 保存配置并启用插件 + """ + 拼装插件详情页面,需要返回页面配置,同时附带数据 + 插件详情页面使用Vuetify组件拼装,参考:https://vuetifyjs.com/ - ⚡ 工作原理: - • 插件通过API接口提供Nullbr资源搜索服务 - • 可在MoviePilot中手动调用搜索功能 - • 支持电影、剧集、合集等多种媒体类型 - • 支持115网盘、磁力、ed2k、m3u8等多种资源格式 - - 📞 技术支持: - 如遇问题请检查 MoviePilot 日志中的错误信息''' - } - } - ] - } - ] - } - ] - } - ] + :return: 页面配置(vuetify模式)或 None(vue模式) + """ + pass @eventmanager.register(EventType.UserMessage) def talk(self, event: Event): """ 监听用户消息,识别搜索请求和编号选择 """ - if not self._enabled or not self._client: + 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") @@ -1135,15 +698,44 @@ class NullbrSearch(_PluginBase): elif clean_text.isdigit(): number = int(clean_text) - # 先检查是否有资源缓存(用于CMS转存) - if self._cms_enabled and self._cms_client and userid in self._user_resource_cache: + # 先检查是否有资源缓存(直接进行转存) + 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']): - logger.info(f"检测到资源转存请求: {number}") - self.handle_resource_transfer(number, channel, userid) + 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) @@ -1155,16 +747,39 @@ class NullbrSearch(_PluginBase): 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="切换搜索", @@ -1172,47 +787,48 @@ class NullbrSearch(_PluginBase): userid=userid ) - # 调用MoviePilot的原始搜索功能 self.fallback_to_moviepilot_search(keyword, channel, userid) return - items = result.get('items', [])[:10] # 最多显示10个结果 + # 搜索成功,更新统计 + self._stats['successful_searches'] += 1 # 缓存搜索结果 self._user_search_cache[userid] = { - 'results': items, - 'keyword': keyword, + 'results': result.get('items', []), 'timestamp': time.time() } - # 格式化搜索结果 - reply_text = f"🔍 找到「{keyword}」的资源:\n\n" + # 构建回复消息 + reply_text = f"🎬 找到 {len(result.get('items', []))} 个「{keyword}」相关资源:\n\n" - for i, item in enumerate(items, 1): + # 显示前10个结果 + for i, item in enumerate(result.get('items', [])[:10], 1): title = item.get('title', '未知标题') - media_type = item.get('media_type', 'unknown') 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', '未知') - # 检查可用的资源类型 - available_types = [] - if item.get('115-flg') and self._enable_115: - available_types.append('115') - if item.get('magnet-flg') and self._enable_magnet: - available_types.append('磁力') - if item.get('video-flg') and self._enable_video: - available_types.append('在线') - if item.get('ed2k-flg') and self._enable_ed2k: - available_types.append('ed2k') - - type_text = '、'.join(available_types) if available_types else '无' - media_text = '电影' if media_type == 'movie' else '剧集' if media_type == 'tv' else media_type - - reply_text += f"{i}. {title}" + reply_text += f"【{i}】{title}" if year: reply_text += f" ({year})" - reply_text += f" - {media_text}\n" - reply_text += f" 资源: {type_text}\n\n" + 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" @@ -1230,6 +846,7 @@ class NullbrSearch(_PluginBase): userid=userid ) + except Exception as e: logger.error(f"搜索处理异常: {str(e)}") self.post_message( @@ -1238,7 +855,7 @@ class NullbrSearch(_PluginBase): text=f"搜索「{keyword}」时出现错误: {str(e)}", userid=userid ) - + def handle_resource_selection(self, number: int, channel: str, userid: str): """处理用户的编号选择""" try: @@ -1325,7 +942,7 @@ class NullbrSearch(_PluginBase): text=f"处理选择时出现错误: {str(e)}", userid=userid ) - + def handle_get_resources(self, number: int, resource_type: str, channel: str, userid: str): """处理获取具体资源链接的请求""" try: @@ -1404,8 +1021,8 @@ class NullbrSearch(_PluginBase): self.fallback_to_moviepilot_search(title, channel, userid) return - # 格式化资源链接 - self._format_and_send_resources(resources, resource_type, title, channel, userid) + # 格式化资源链接(第4步完善) + self.format_and_send_resources(resources, resource_type, title, channel, userid) except Exception as e: logger.error(f"获取资源链接异常: {str(e)}") @@ -1415,98 +1032,7 @@ class NullbrSearch(_PluginBase): text=f"获取资源链接时出现错误: {str(e)}", 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 - - # 缓存资源到用户缓存中,用于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\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\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\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: - 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 get_resources_by_priority(self, selected: dict, channel: str, userid: str): """按优先级获取资源""" try: @@ -1568,7 +1094,7 @@ class NullbrSearch(_PluginBase): ) # 格式化并发送资源链接 - self._format_and_send_resources(resources, priority_type, title, channel, userid) + self.format_and_send_resources(resources, priority_type, title, channel, userid) return else: logger.info(f"{priority_type} 资源不可用,尝试下一优先级") @@ -1592,7 +1118,7 @@ class NullbrSearch(_PluginBase): text=f"获取资源时出现错误: {str(e)}", userid=userid ) - + def handle_resource_transfer(self, resource_id: int, channel: str, userid: str): """处理资源转存请求""" try: @@ -1606,91 +1132,230 @@ class NullbrSearch(_PluginBase): ) return - # 检查资源缓存 + # 获取用户资源缓存 cache = self._user_resource_cache.get(userid) if not cache or time.time() - cache['timestamp'] > 3600: self.post_message( channel=channel, title="缓存过期", - text="资源缓存已过期,请重新获取资源。", + 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="无效编号", + title="编号错误", text=f"请输入有效的资源编号 (1-{len(resources)})。", userid=userid ) return - # 获取指定的资源 + # 获取要转存的资源 selected_resource = resources[resource_id - 1] - title = selected_resource['title'] - url = selected_resource['url'] - size = selected_resource['size'] - resource_type = selected_resource['type'] + resource_url = selected_resource['url'] + resource_title = selected_resource['title'] + resource_size = selected_resource['size'] - logger.info(f"开始转存资源: {title} ({resource_type}) -> {url}") + # 只有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"🚀 正在转存资源到CloudSyncMedia:\n\n" - f"📁 {title}\n" - f"💾 大小: {size}\n" - f"🔗 类型: {resource_type}\n\n" - f"请稍等...", + 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(url) + # 调用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=f"✅ 资源转存成功!\n\n" - f"📁 {title}\n" - f"💾 大小: {size}\n" - f"🚀 {result.get('msg', '已添加到转存队列')}\n\n" - f"请到CloudSyncMedia查看转存进度。", - userid=userid - ) - else: - error_msg = result.get('msg', '转存失败') - self.post_message( - channel=channel, - title="转存失败", - text=f"❌ 资源转存失败:\n\n" - f"📁 {title}\n" - f"🚫 错误: {error_msg}\n\n" - f"请检查CMS配置或稍后重试。", + 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"转存过程中发生错误: {str(e)}", + 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: @@ -1730,14 +1395,13 @@ class NullbrSearch(_PluginBase): # 如果上面的方法也失败,发送通用建议 if not success: - self._send_manual_search_suggestion(title, channel, userid) + 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.send_manual_search_suggestion(title, channel, userid) + + def send_manual_search_suggestion(self, title: str, channel: str, userid: str): """发送手动搜索建议""" self.post_message( channel=channel, @@ -1751,19 +1415,18 @@ class NullbrSearch(_PluginBase): ) def stop_service(self): - """ - 退出插件 - """ + """停止插件服务""" try: - # 清理Nullbr客户端 - if self._client and hasattr(self._client, '_session'): - self._client._session.close() - self._client = None + # 清理客户端连接 + if self._client: + logger.info("清理Nullbr客户端") + self._client = None - # 清理CMS客户端 - if self._cms_client and hasattr(self._cms_client, 'session'): - self._cms_client.session.close() - self._cms_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() @@ -1775,5 +1438,5 @@ class NullbrSearch(_PluginBase): logger.error(f"插件停止异常: {str(e)}") -# 导出插件类,确保插件系统能正确识别 -__all__ = ['NullbrSearch'] +# 导出插件类 +__all__ = ['nullbr_search'] \ No newline at end of file diff --git a/plugins.v2/nullbr_search/cms_client.py b/plugins.v2/nullbr_search/cms_client.py new file mode 100644 index 0000000..f111b92 --- /dev/null +++ b/plugins.v2/nullbr_search/cms_client.py @@ -0,0 +1,110 @@ +import requests +import time +from app.log import logger + + +class CloudSyncMediaClient: + """CloudSyncMedia客户端""" + + def __init__(self, base_url: str, username: str, password: str): + self.base_url = base_url.rstrip('/') + self.username = username + self.password = password + self.token = None + self.token_expiry = 0 + + # 配置请求会话 + self.session = requests.Session() + self.session.headers.update({ + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }) + + # CMS一般为内网服务,禁用代理访问 + self.session.proxies = { + 'http': None, + 'https': None + } + + # 初始化时获取token + self._ensure_valid_token() + + def _login(self) -> dict: + """登录CMS系统获取token""" + try: + response = self.session.post( + f'{self.base_url}/api/auth/login', + json={ + 'username': self.username, + 'password': self.password + }, + timeout=(10, 30) + ) + response.raise_for_status() + data = response.json() + + if data.get('code') != 200 or 'data' not in data: + raise ValueError(f'CMS登录失败: {data}') + + return data['data'] + + except requests.exceptions.RequestException as e: + logger.error(f'CMS登录失败: {str(e)}') + raise + + def _ensure_valid_token(self): + """确保有效的token""" + current_time = time.time() + + # 如果token不存在或距离过期时间不到1小时,重新获取token + if not self.token or current_time >= (self.token_expiry - 3600): + login_data = self._login() + self.token = login_data['token'] + + # 设置token过期时间为24小时后 + self.token_expiry = current_time + 86400 + + # 更新session的Authorization header + self.session.headers.update({ + 'Authorization': f'Bearer {self.token}' + }) + + logger.info("CMS token已更新") + + def add_share_down(self, url: str) -> dict: + """添加分享链接到CMS系统进行转存""" + if not url: + raise ValueError('转存链接不能为空') + + try: + self._ensure_valid_token() + + response = self.session.post( + f'{self.base_url}/api/cloud/add_share_down', + json={'url': url}, + timeout=(10, 30) + ) + response.raise_for_status() + result = response.json() + + logger.info(f"CMS转存请求已发送: {url}") + return result + + except requests.exceptions.HTTPError as e: + if e.response.status_code == 401: + # token可能过期,强制重新获取 + self.token = None + self._ensure_valid_token() + + # 重试请求 + response = self.session.post( + f'{self.base_url}/api/cloud/add_share_down', + json={'url': url}, + timeout=(10, 30) + ) + response.raise_for_status() + return response.json() + raise + except Exception as e: + logger.error(f'CMS转存请求失败: {str(e)}') + raise \ No newline at end of file diff --git a/plugins.v2/nullbr_search/nullbr_client.py b/plugins.v2/nullbr_search/nullbr_client.py new file mode 100644 index 0000000..a53dee6 --- /dev/null +++ b/plugins.v2/nullbr_search/nullbr_client.py @@ -0,0 +1,202 @@ +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry +from typing import Dict, Optional +from app.log import logger + + +class NullbrApiClient: + """Nullbr API客户端""" + + def __init__(self, app_id: str, api_key: str = None): + self._app_id = app_id + self._api_key = api_key + self._base_url = "https://api.nullbr.eu.org" + + # 配置请求会话 + self._session = requests.Session() + self._session.headers.update({ + 'User-Agent': 'MoviePilot-NullbrSearch/1.0.4', + 'Content-Type': 'application/json' + }) + + # 配置重试策略 + try: + retry_strategy = Retry( + total=3, + status_forcelist=[429, 500, 502, 503, 504, 408], + backoff_factor=1, + allowed_methods=["HEAD", "GET", "OPTIONS"] + ) + + adapter = HTTPAdapter(max_retries=retry_strategy) + self._session.mount("http://", adapter) + self._session.mount("https://", adapter) + except Exception as e: + logger.warning(f"重试策略配置失败: {str(e)}") + + def _make_request(self, url: str, params: dict, headers: dict, use_proxy: bool = True) -> requests.Response: + """发起HTTP请求,支持代理重试机制""" + session = self._session + + # 如果不使用代理,创建临时session + if not use_proxy: + session = requests.Session() + session.headers.update(self._session.headers) + session.proxies = {'http': None, 'https': None} + + timeout = 5 if use_proxy else (10, 30) + + return session.get(url, params=params, headers=headers, timeout=timeout) + + def search(self, query: str, page: int = 1) -> Optional[Dict]: + """搜索媒体资源""" + try: + headers = {'X-APP-ID': self._app_id} + + if self._api_key: + headers['X-API-KEY'] = self._api_key + + params = { + 'query': query, + 'page': page + } + + logger.info(f"搜索请求: {query}") + logger.debug(f"请求参数: {params}") + logger.debug(f"请求头: X-APP-ID={self._app_id}, X-API-KEY={'已设置' if self._api_key else '未设置'}") + + url = f"{self._base_url}/search" + + # 首先尝试使用系统代理 + try: + logger.debug("尝试使用系统代理访问Nullbr API") + response = self._make_request(url, params, headers, use_proxy=True) + logger.info(f"使用系统代理请求成功,状态码: {response.status_code}") + + except (requests.exceptions.Timeout, requests.exceptions.ConnectTimeout, + requests.exceptions.ReadTimeout, requests.exceptions.ConnectionError) as e: + logger.warning(f"系统代理访问失败: {str(e)},尝试直连") + try: + response = self._make_request(url, params, headers, use_proxy=False) + logger.info(f"直连请求成功,状态码: {response.status_code}") + + except Exception as direct_error: + logger.error(f"直连也失败: {str(direct_error)}") + raise direct_error + + # 检查响应状态 + response.raise_for_status() + + # 解析JSON响应 + result = response.json() + logger.info(f"搜索完成,找到 {len(result.get('items', []))} 个结果") + + return result + + except requests.exceptions.HTTPError as e: + if response.status_code == 401: + logger.error("API认证失败,请检查APP_ID和API_KEY") + elif response.status_code == 403: + logger.error("API访问被禁止,请检查权限") + elif response.status_code == 429: + logger.error("API请求频率超限,请稍后再试") + else: + logger.error(f"HTTP错误: {e}") + return None + + except requests.exceptions.RequestException as e: + logger.error(f"网络请求失败: {str(e)}") + return None + + except Exception as e: + logger.error(f"搜索异常: {str(e)}") + return None + + def get_movie_resources(self, tmdbid: int, resource_type: str = "115") -> Optional[Dict]: + """获取电影资源链接""" + if not self._api_key: + logger.warning("获取资源链接需要API_KEY") + return None + + try: + headers = {'X-APP-ID': self._app_id, 'X-API-KEY': self._api_key} + url = f"{self._base_url}/movie/{tmdbid}/{resource_type}" + + # 首先尝试使用系统代理 + try: + logger.debug("尝试使用系统代理获取电影资源") + response = self._make_request(url, {}, headers, use_proxy=True) + logger.info(f"使用系统代理请求成功,状态码: {response.status_code}") + + except (requests.exceptions.Timeout, requests.exceptions.ConnectTimeout, + requests.exceptions.ReadTimeout, requests.exceptions.ConnectionError) as e: + logger.warning(f"系统代理访问失败: {str(e)},尝试直连") + try: + response = self._make_request(url, {}, headers, use_proxy=False) + logger.info(f"直连请求成功,状态码: {response.status_code}") + + except Exception as direct_error: + logger.error(f"直连也失败: {str(direct_error)}") + raise direct_error + + response.raise_for_status() + result = response.json() + + logger.info(f"获取电影资源成功: TMDB={tmdbid}, 类型={resource_type}") + return result + + except requests.exceptions.HTTPError as e: + if response.status_code == 404: + logger.warning(f"未找到电影资源: TMDB={tmdbid}, 类型={resource_type}") + else: + logger.error(f"获取电影资源失败: {e}") + return None + + except Exception as e: + logger.error(f"获取电影资源异常: {str(e)}") + return None + + def get_tv_resources(self, tmdbid: int, resource_type: str = "115") -> Optional[Dict]: + """获取剧集资源链接""" + if not self._api_key: + logger.warning("获取资源链接需要API_KEY") + return None + + try: + headers = {'X-APP-ID': self._app_id, 'X-API-KEY': self._api_key} + url = f"{self._base_url}/tv/{tmdbid}/{resource_type}" + + # 首先尝试使用系统代理 + try: + logger.debug("尝试使用系统代理获取剧集资源") + response = self._make_request(url, {}, headers, use_proxy=True) + logger.info(f"使用系统代理请求成功,状态码: {response.status_code}") + + except (requests.exceptions.Timeout, requests.exceptions.ConnectTimeout, + requests.exceptions.ReadTimeout, requests.exceptions.ConnectionError) as e: + logger.warning(f"系统代理访问失败: {str(e)},尝试直连") + try: + response = self._make_request(url, {}, headers, use_proxy=False) + logger.info(f"直连请求成功,状态码: {response.status_code}") + + except Exception as direct_error: + logger.error(f"直连也失败: {str(direct_error)}") + raise direct_error + + response.raise_for_status() + result = response.json() + + logger.info(f"获取剧集资源成功: TMDB={tmdbid}, 类型={resource_type}") + return result + + except requests.exceptions.HTTPError as e: + if response.status_code == 404: + logger.warning(f"未找到剧集资源: TMDB={tmdbid}, 类型={resource_type}") + else: + logger.error(f"获取剧集资源失败: {e}") + return None + + except Exception as e: + logger.error(f"获取剧集资源异常: {str(e)}") + return None \ No newline at end of file diff --git a/plugins.v2/nullbrsearch/requirements.txt b/plugins.v2/nullbr_search/requirements.txt similarity index 100% rename from plugins.v2/nullbrsearch/requirements.txt rename to plugins.v2/nullbr_search/requirements.txt