Compare commits

..

10 Commits

Author SHA1 Message Date
3db7e3c222 搜索资源是清理上次搜索结果的缓存 2025-08-15 10:52:21 +08:00
a1a106a955 readme增加致谢 2025-08-09 10:39:32 +08:00
c563846017 修改readme中CloudMediaSync名称 2025-08-09 10:32:07 +08:00
58a8d6052d 修改readme中CloudMediaSync名称 2025-08-09 10:31:03 +08:00
Hqyel
8b3e6a4b5c
Update README.md 2025-08-09 09:42:10 +08:00
Hqyel
ddee295fa3
Update README.md 2025-08-09 09:41:41 +08:00
c6e7658133 重构和整理代码 2025-08-09 09:27:54 +08:00
Hqyel
9ca80684b6
Update __init__.py
修订插件版本
2025-08-08 13:23:33 +08:00
507f01d8d0 update:v1.0.7请求时的代理问题 2025-08-08 13:06:06 +08:00
7694511601 add:增加CloudSyncMedia转存任务配置 2025-08-07 10:26:03 +08:00
6 changed files with 1016 additions and 641 deletions

182
README.md
View File

@ -2,151 +2,81 @@
## 📝 插件简介
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-Third`
3. 保存后在插件市场搜索「Nullbr资源搜索」
4. 点击安装并配置相关参数
### 方法2: Git克隆
```bash
cd /path/to/moviepilot/app/plugins/
git clone <repository-url> nullbrsearch
```
## 📖 使用方法
**重要:与本插件交互必须以问号结尾!**
### 基本搜索
在MoviePilot的任何消息界面发送搜索请求时必须以问号结尾
- ✅ `武林外传?`
- ✅ `权力的游戏?`
- ✅ `复仇者联盟?`
- ❌ `武林外传` (不会触发搜索)
- ❌ `权力的游戏` (不会触发搜索)
### 资源获取流程
1. **搜索影片**:发送 `影片名?`
2. **选择影片**:回复数字(如 `1`)查看资源类型
3. **获取资源**
- 自动获取(按优先级):直接发送数字 `2`
- 指定类型:发送 `2.115``3.magnet`
### CloudMediaSync转存
如果配置了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
- **资源优先级**: 设置资源获取的优先顺序
- **CloudMediaSync**: 配置115网盘资源的自动转存功能
## 📝 更新日志
### v1.0.0 (2024-08-06)
- 🎉 首次发布
- ✅ 支持基本搜索功能
- ✅ 支持多种资源类型
- ✅ 提供完整的配置界面
- ✅ 集成API接口
- ✅ 添加使用统计功能
### v2.0.4 (2024-08-08)
- 🎉 移除了数据统计页面,简化插件结构
- ✅ 优化了用户交互流程
- ✅ 修复了与其他插件的兼容性问题
- ✅ 改进了微信消息格式兼容性
- ✅ 完善了CloudMediaSync转存功能
## 🤝 贡献
欢迎提交Issue和Pull Request来改进这个插件
## 📄 许可证
本插件基于GPL-3.0许可证开源。
## 🙏 致谢
- 感谢MoviePilot项目提供的优秀插件框架
- 感谢Nullbr提供的资源API服务
- 感谢iLay1678大佬CMS接口部分参考了他项目[nullbr_cms_bot](https://github.com/iLay1678/nullbr_cms_bot)
## 📄 许可证
本插件基于GPL-3.0许可证开源。

View File

@ -1,15 +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.5",
"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": "增加资源优先度设置"
"v2.0.0": "重构整理代码。"
}
}
}

View File

@ -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

View File

@ -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