mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-21 16:23:34 +08:00
feat: support SunnyPT API indexer
This commit is contained in:
@@ -13,6 +13,7 @@ from app.modules.indexer.spider.haidan import HaiDanSpider
|
||||
from app.modules.indexer.spider.hddolby import HddolbySpider
|
||||
from app.modules.indexer.spider.mtorrent import MTorrentSpider
|
||||
from app.modules.indexer.spider.rousi import RousiSpider
|
||||
from app.modules.indexer.spider.sunnypt import SunnyPTSpider
|
||||
from app.modules.indexer.spider.tnode import TNodeSpider
|
||||
from app.modules.indexer.spider.torrentleech import TorrentLeech
|
||||
from app.modules.indexer.spider.yema import YemaSpider
|
||||
@@ -28,6 +29,7 @@ SPIDER_PARSER_CLASSES = {
|
||||
"Haidan": HaiDanSpider,
|
||||
"HDDolby": HddolbySpider,
|
||||
"RousiPro": RousiSpider,
|
||||
"SunnyPT": SunnyPTSpider,
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +41,7 @@ class IndexerModule(_ModuleBase):
|
||||
_site_schemas = []
|
||||
|
||||
def init_module(self) -> None:
|
||||
"""加载站点用户数据解析器"""
|
||||
# 加载模块
|
||||
self._site_schemas = ModuleHelper.load(
|
||||
'app.modules.indexer.parser',
|
||||
@@ -47,6 +50,7 @@ class IndexerModule(_ModuleBase):
|
||||
|
||||
@staticmethod
|
||||
def get_name() -> str:
|
||||
"""获取模块名称"""
|
||||
return "站点索引"
|
||||
|
||||
@staticmethod
|
||||
@@ -71,6 +75,7 @@ class IndexerModule(_ModuleBase):
|
||||
return 0
|
||||
|
||||
def stop(self):
|
||||
"""停止索引模块"""
|
||||
pass
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
@@ -83,6 +88,7 @@ class IndexerModule(_ModuleBase):
|
||||
return True, ""
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
"""索引模块无需独立开关配置"""
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
@@ -240,6 +246,13 @@ class IndexerModule(_ModuleBase):
|
||||
mtype=mtype,
|
||||
page=page
|
||||
)
|
||||
elif site.get('parser') == "SunnyPT":
|
||||
error_flag, result = SunnyPTSpider(site).search(
|
||||
keyword=search_word,
|
||||
mtype=mtype,
|
||||
cat=cat,
|
||||
page=page
|
||||
)
|
||||
elif site.get('parser') == "Yema":
|
||||
error_flag, result = YemaSpider(site).search(
|
||||
keyword=search_word,
|
||||
@@ -376,6 +389,13 @@ class IndexerModule(_ModuleBase):
|
||||
mtype=mtype,
|
||||
page=page
|
||||
)
|
||||
elif site.get('parser') == "SunnyPT":
|
||||
error_flag, result = await SunnyPTSpider(site).async_search(
|
||||
keyword=search_word,
|
||||
mtype=mtype,
|
||||
cat=cat,
|
||||
page=page
|
||||
)
|
||||
elif site.get('parser') == "Yema":
|
||||
error_flag, result = await YemaSpider(site).async_search(
|
||||
keyword=search_word,
|
||||
@@ -573,7 +593,8 @@ class IndexerModule(_ModuleBase):
|
||||
apikey=site.get("apikey"),
|
||||
token=site.get("token"),
|
||||
ua=site.get("ua"),
|
||||
proxy=site.get("proxy"))
|
||||
proxy=site.get("proxy"),
|
||||
api_url=site.get("api_url"))
|
||||
return None
|
||||
|
||||
site_obj = __get_site_obj()
|
||||
|
||||
@@ -18,6 +18,8 @@ from app.utils.string import StringUtils
|
||||
|
||||
# 站点框架
|
||||
class SiteSchema(Enum):
|
||||
"""站点用户数据解析框架类型"""
|
||||
|
||||
DiscuzX = "DiscuzX"
|
||||
Gazelle = "Gazelle"
|
||||
Ipt = "IPTorrents"
|
||||
@@ -37,9 +39,12 @@ class SiteSchema(Enum):
|
||||
Zhixing = "Zhixing"
|
||||
Bitpt = "Bitpt"
|
||||
RousiPro = "RousiPro"
|
||||
SunnyPT = "SunnyPT"
|
||||
|
||||
|
||||
class SiteParserBase(metaclass=ABCMeta):
|
||||
"""站点用户数据解析器基类"""
|
||||
|
||||
# 站点模版
|
||||
schema = None
|
||||
# 请求模式 cookie/apikey
|
||||
@@ -53,7 +58,22 @@ class SiteParserBase(metaclass=ABCMeta):
|
||||
session: Session = None,
|
||||
ua: Optional[str] = None,
|
||||
emulate: bool = False,
|
||||
proxy: bool = None):
|
||||
proxy: bool = None,
|
||||
api_url: Optional[str] = None):
|
||||
"""
|
||||
初始化站点用户数据解析器
|
||||
|
||||
:param site_name: 站点名称
|
||||
:param url: 站点前端地址
|
||||
:param site_cookie: 站点 Cookie
|
||||
:param apikey: 站点 API Key
|
||||
:param token: 站点 Token
|
||||
:param session: 可复用的 HTTP 会话
|
||||
:param ua: 请求 User-Agent
|
||||
:param emulate: 是否使用浏览器仿真
|
||||
:param proxy: 是否使用系统代理
|
||||
:param api_url: 站点独立 API Base URL
|
||||
"""
|
||||
super().__init__()
|
||||
|
||||
# 站点信息
|
||||
@@ -65,6 +85,7 @@ class SiteParserBase(metaclass=ABCMeta):
|
||||
self._site_domain = __split_url.netloc
|
||||
self._base_url = f"{__split_url.scheme}://{__split_url.netloc}"
|
||||
self._site_cookie = site_cookie
|
||||
self._api_url = api_url
|
||||
self._session = session if session else None
|
||||
self._ua = ua
|
||||
self._emulate = emulate
|
||||
|
||||
168
app/modules/indexer/parser/sunnypt.py
Normal file
168
app/modules/indexer/parser/sunnypt.py
Normal file
@@ -0,0 +1,168 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import json
|
||||
from typing import Optional, Tuple
|
||||
from urllib.parse import urlencode, urljoin
|
||||
|
||||
from app.log import logger
|
||||
from app.modules.indexer.parser import SiteParserBase, SiteSchema
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
class SunnyPTSiteUserInfo(SiteParserBase):
|
||||
"""
|
||||
SunnyPT MoviePilot API 用户数据解析器
|
||||
"""
|
||||
|
||||
schema = SiteSchema.SunnyPT
|
||||
request_mode = "apikey"
|
||||
|
||||
def _parse_site_page(self, html_text: str) -> None:
|
||||
"""
|
||||
配置 SunnyPT 用户数据接口地址和认证请求头
|
||||
|
||||
:param html_text: API Key 模式下的空首页数据
|
||||
"""
|
||||
self._base_url = f"{str(self._api_url or 'https://api.sunnypt.top/api/v1/mp').rstrip('/')}/"
|
||||
self._user_basic_page = "profile"
|
||||
self._user_detail_page = None
|
||||
self._user_traffic_page = None
|
||||
self._torrent_seeding_page = None
|
||||
self._user_mail_unread_page = "messages"
|
||||
self._sys_mail_unread_page = None
|
||||
self._addition_headers = {
|
||||
"Accept": "application/json",
|
||||
"User-Agent": self._ua,
|
||||
"X-API-Key": self.apikey,
|
||||
}
|
||||
|
||||
def _load_api_payload(self, html_text: str, operation: str) -> Optional[dict]:
|
||||
"""
|
||||
解析并校验 SunnyPT 通用 JSON 响应
|
||||
|
||||
:param html_text: API 响应文本
|
||||
:param operation: 错误信息中的操作名称
|
||||
:return: 成功时返回响应 data,失败时返回 None
|
||||
"""
|
||||
if not html_text:
|
||||
self.err_msg = f"{operation}失败,未收到 API 响应"
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(html_text)
|
||||
except (TypeError, json.JSONDecodeError) as err:
|
||||
self.err_msg = f"{operation}失败,API 响应不是有效 JSON"
|
||||
logger.warning(f"{self._site_name} {self.err_msg}:{str(err)}")
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
self.err_msg = f"{operation}失败,API 响应结构无效"
|
||||
logger.warning(f"{self._site_name} {self.err_msg}")
|
||||
return None
|
||||
if str(payload.get("code")) != "0":
|
||||
self.err_msg = payload.get("msg") or f"{operation}失败"
|
||||
logger.warning(f"{self._site_name} {operation}失败:{self.err_msg}")
|
||||
return None
|
||||
data = payload.get("data")
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
def _parse_user_base_info(self, html_text: str) -> None:
|
||||
"""
|
||||
解析 SunnyPT 用户资料、流量和做种统计
|
||||
|
||||
:param html_text: profile 接口响应文本
|
||||
"""
|
||||
user_info = self._load_api_payload(html_text, "获取用户信息")
|
||||
if not user_info:
|
||||
return
|
||||
self.userid = user_info.get("id")
|
||||
self.username = user_info.get("username")
|
||||
self.user_level = user_info.get("level") or str(user_info.get("class") or "")
|
||||
self.join_at = StringUtils.unify_datetime_str(user_info.get("registered_at"))
|
||||
self.upload = int(user_info.get("uploaded") or 0)
|
||||
self.download = int(user_info.get("downloaded") or 0)
|
||||
self.ratio = float(user_info.get("ratio") or 0)
|
||||
self.bonus = float(user_info.get("bonus") or 0)
|
||||
self.seeding = int(user_info.get("seeding_count") or 0)
|
||||
self.seeding_size = int(user_info.get("seeding_size") or 0)
|
||||
self.leeching = int(user_info.get("leeching_count") or 0)
|
||||
self.leeching_size = int(user_info.get("leeching_size") or 0)
|
||||
self.message_unread = int(user_info.get("unread_messages") or 0)
|
||||
|
||||
def _pase_unread_msgs(self) -> None:
|
||||
"""
|
||||
分页读取 SunnyPT 未读消息;解析阶段不标记已读,避免投递失败后丢失通知
|
||||
"""
|
||||
page = 1
|
||||
while True:
|
||||
query = urlencode({
|
||||
"unread_only": "true",
|
||||
"page": page,
|
||||
"page_size": 100,
|
||||
})
|
||||
html_text = self._get_page_content(
|
||||
url=urljoin(self._base_url, f"messages?{query}")
|
||||
)
|
||||
has_more = self._parse_message_unread_links(html_text, [])
|
||||
if not has_more:
|
||||
return
|
||||
page += 1
|
||||
|
||||
def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]:
|
||||
"""
|
||||
解析 SunnyPT 未读消息列表并直接保存消息正文
|
||||
|
||||
:param html_text: messages 接口响应文本
|
||||
:param msg_links: 兼容解析器基类签名的消息链接容器
|
||||
:return: 存在下一页时返回占位字符串,否则返回 None
|
||||
"""
|
||||
messages_data = self._load_api_payload(html_text, "获取未读消息")
|
||||
if not messages_data:
|
||||
return None
|
||||
self.message_unread = int(messages_data.get("unread_count") or 0)
|
||||
for message in messages_data.get("items") or []:
|
||||
if not isinstance(message, dict) or not message.get("unread"):
|
||||
continue
|
||||
title = message.get("title")
|
||||
content = message.get("content")
|
||||
created_at = StringUtils.unify_datetime_str(message.get("created_at"))
|
||||
if title and content and created_at:
|
||||
self.message_unread_contents.append((title, created_at, content))
|
||||
return "next" if messages_data.get("has_more") else None
|
||||
|
||||
def _parse_user_traffic_info(self, html_text: str) -> None:
|
||||
"""
|
||||
跳过独立流量页面,profile 接口已经返回完整统计
|
||||
|
||||
:param html_text: 未使用的页面文本
|
||||
"""
|
||||
|
||||
def _parse_user_detail_info(self, html_text: str) -> None:
|
||||
"""
|
||||
跳过独立用户详情页面,profile 接口已经返回完整资料
|
||||
|
||||
:param html_text: 未使用的页面文本
|
||||
"""
|
||||
|
||||
def _parse_user_torrent_seeding_info(
|
||||
self,
|
||||
html_text: str,
|
||||
multi_page: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
跳过独立做种列表,profile 接口已经返回做种数量和体积
|
||||
|
||||
:param html_text: 未使用的页面文本
|
||||
:param multi_page: 是否为后续分页
|
||||
:return: 始终返回 None
|
||||
"""
|
||||
return None
|
||||
|
||||
def _parse_message_content(
|
||||
self,
|
||||
html_text: str,
|
||||
) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
||||
"""
|
||||
跳过消息详情请求,messages 接口已经返回完整正文
|
||||
|
||||
:param html_text: 未使用的页面文本
|
||||
:return: 三个空值
|
||||
"""
|
||||
return None, None, None
|
||||
385
app/modules/indexer/spider/sunnypt.py
Normal file
385
app/modules/indexer/spider/sunnypt.py
Normal file
@@ -0,0 +1,385 @@
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from app.core.config import settings
|
||||
from app.log import logger
|
||||
from app.schemas import MediaType
|
||||
from app.utils.http import AsyncRequestUtils, RequestUtils
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
class SunnyPTSpider:
|
||||
"""
|
||||
SunnyPT MoviePilot API 索引器
|
||||
"""
|
||||
|
||||
_size = 100
|
||||
_category_cache_ttl = 3600
|
||||
_category_cache = {}
|
||||
|
||||
def __init__(self, indexer: dict):
|
||||
"""
|
||||
初始化 SunnyPT API 索引器
|
||||
|
||||
:param indexer: 合并站点认证信息后的索引配置
|
||||
"""
|
||||
indexer = indexer or {}
|
||||
self._indexer_id = indexer.get("id")
|
||||
self._name = indexer.get("name") or "SunnyPT"
|
||||
self._site_url = indexer.get("domain") or "https://sunnypt.top/"
|
||||
self._api_url = str(
|
||||
indexer.get("api_url") or "https://api.sunnypt.top/api/v1/mp"
|
||||
).rstrip("/")
|
||||
self._proxy = settings.PROXY if indexer.get("proxy") else None
|
||||
self._use_proxy = bool(indexer.get("proxy"))
|
||||
self._user_agent = indexer.get("ua") or settings.USER_AGENT
|
||||
self._api_key = indexer.get("apikey")
|
||||
self._timeout = indexer.get("timeout") or 15
|
||||
self._configured_categories = self._parse_configured_categories(
|
||||
indexer.get("category") or {}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_search_page_size(cls, keyword: Optional[str] = None) -> Optional[int]:
|
||||
"""
|
||||
获取搜索接口单页容量
|
||||
|
||||
:param keyword: 搜索关键字,SunnyPT 不按关键字改变分页容量
|
||||
:return: 搜索接口单页容量
|
||||
"""
|
||||
return cls._size
|
||||
|
||||
@staticmethod
|
||||
def _parse_configured_categories(category_config: dict) -> dict:
|
||||
"""
|
||||
从站点索引配置提取电影和电视剧分类 ID,作为分类接口不可用时的兜底
|
||||
|
||||
:param category_config: Build 站点配置中的分类段
|
||||
:return: 按 API media_type 索引的分类 ID
|
||||
"""
|
||||
category_map = {"movie": [], "tv": []}
|
||||
for media_type in category_map:
|
||||
for item in category_config.get(media_type) or []:
|
||||
category_id = item.get("id") if isinstance(item, dict) else item
|
||||
if category_id is not None:
|
||||
category_map[media_type].append(str(category_id))
|
||||
return category_map
|
||||
|
||||
@staticmethod
|
||||
def _parse_category_items(items: list) -> dict:
|
||||
"""
|
||||
将分类接口结果转换为按媒体类型索引的分类 ID
|
||||
|
||||
:param items: SunnyPT 分类接口 data 数组
|
||||
:return: 按 API media_type 索引的分类 ID
|
||||
"""
|
||||
category_map = {"movie": [], "tv": []}
|
||||
for item in items or []:
|
||||
if not isinstance(item, dict) or item.get("id") is None:
|
||||
continue
|
||||
category_id = str(item["id"])
|
||||
for media_type in item.get("media_types") or []:
|
||||
if media_type in category_map and category_id not in category_map[media_type]:
|
||||
category_map[media_type].append(category_id)
|
||||
return category_map
|
||||
|
||||
@classmethod
|
||||
def _get_cached_categories(cls, api_url: str) -> Optional[dict]:
|
||||
"""
|
||||
获取未过期的站点分类缓存
|
||||
|
||||
:param api_url: SunnyPT API Base URL
|
||||
:return: 分类映射,缓存不存在或过期时返回 None
|
||||
"""
|
||||
cached = cls._category_cache.get(api_url)
|
||||
if not cached:
|
||||
return None
|
||||
cached_at, category_map = cached
|
||||
if time.monotonic() - cached_at >= cls._category_cache_ttl:
|
||||
cls._category_cache.pop(api_url, None)
|
||||
return None
|
||||
return category_map
|
||||
|
||||
@classmethod
|
||||
def _set_cached_categories(cls, api_url: str, category_map: dict) -> None:
|
||||
"""
|
||||
缓存站点分类映射
|
||||
|
||||
:param api_url: SunnyPT API Base URL
|
||||
:param category_map: 按媒体类型索引的分类 ID
|
||||
"""
|
||||
cls._category_cache[api_url] = (time.monotonic(), category_map)
|
||||
|
||||
def _request_headers(self) -> dict:
|
||||
"""
|
||||
构造 SunnyPT API 请求头
|
||||
|
||||
:return: 不包含 Cookie 和 Authorization 的 API Key 请求头
|
||||
"""
|
||||
return {
|
||||
"Accept": "application/json",
|
||||
"User-Agent": self._user_agent,
|
||||
"X-API-Key": self._api_key,
|
||||
}
|
||||
|
||||
def _response_data(self, response, operation: str):
|
||||
"""
|
||||
校验 SunnyPT 通用响应并返回 data 字段
|
||||
|
||||
:param response: RequestUtils 返回的响应对象
|
||||
:param operation: 日志中使用的操作名称
|
||||
:return: 接口成功时返回 data,失败时返回 None
|
||||
"""
|
||||
if response is None:
|
||||
logger.warning(f"{self._name} {operation}失败,无法连接 API 服务")
|
||||
return None
|
||||
if response.status_code != 200:
|
||||
logger.warning(f"{self._name} {operation}失败,HTTP 错误码:{response.status_code}")
|
||||
return None
|
||||
try:
|
||||
payload = response.json() or {}
|
||||
except (TypeError, ValueError) as err:
|
||||
logger.warning(f"{self._name} {operation}响应不是有效 JSON:{str(err)}")
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
logger.warning(f"{self._name} {operation}响应结构无效")
|
||||
return None
|
||||
if str(payload.get("code")) != "0":
|
||||
logger.warning(f"{self._name} {operation}失败:{payload.get('msg') or '未知错误'}")
|
||||
return None
|
||||
return payload.get("data")
|
||||
|
||||
def _load_categories(self) -> dict:
|
||||
"""
|
||||
同步读取并缓存 SunnyPT 分类映射,接口失败时使用 Build 配置兜底
|
||||
|
||||
:return: 按媒体类型索引的分类 ID
|
||||
"""
|
||||
cached = self._get_cached_categories(self._api_url)
|
||||
if cached is not None:
|
||||
return cached
|
||||
response = RequestUtils(
|
||||
headers=self._request_headers(),
|
||||
proxies=self._proxy,
|
||||
timeout=self._timeout,
|
||||
).get_res(url=f"{self._api_url}/categories")
|
||||
items = self._response_data(response, "获取分类")
|
||||
category_map = self._parse_category_items(items) if isinstance(items, list) else {}
|
||||
if not category_map or not any(category_map.values()):
|
||||
return self._configured_categories
|
||||
self._set_cached_categories(self._api_url, category_map)
|
||||
return category_map
|
||||
|
||||
async def _async_load_categories(self) -> dict:
|
||||
"""
|
||||
异步读取并缓存 SunnyPT 分类映射,接口失败时使用 Build 配置兜底
|
||||
|
||||
:return: 按媒体类型索引的分类 ID
|
||||
"""
|
||||
cached = self._get_cached_categories(self._api_url)
|
||||
if cached is not None:
|
||||
return cached
|
||||
response = await AsyncRequestUtils(
|
||||
headers=self._request_headers(),
|
||||
proxies=self._proxy,
|
||||
timeout=self._timeout,
|
||||
).get_res(url=f"{self._api_url}/categories")
|
||||
items = self._response_data(response, "获取分类")
|
||||
category_map = self._parse_category_items(items) if isinstance(items, list) else {}
|
||||
if not category_map or not any(category_map.values()):
|
||||
return self._configured_categories
|
||||
self._set_cached_categories(self._api_url, category_map)
|
||||
return category_map
|
||||
|
||||
@staticmethod
|
||||
def _media_type_value(media_type: MediaType) -> Optional[str]:
|
||||
"""
|
||||
将 MoviePilot 媒体类型转换为 SunnyPT API 枚举
|
||||
|
||||
:param media_type: MoviePilot 媒体类型
|
||||
:return: movie、tv 或 None
|
||||
"""
|
||||
if media_type == MediaType.MOVIE:
|
||||
return "movie"
|
||||
if media_type == MediaType.TV:
|
||||
return "tv"
|
||||
return None
|
||||
|
||||
def _build_params(
|
||||
self,
|
||||
keyword: Optional[str],
|
||||
media_type: MediaType,
|
||||
category: Optional[str],
|
||||
page: Optional[int],
|
||||
category_map: dict,
|
||||
) -> dict:
|
||||
"""
|
||||
构造 SunnyPT 种子搜索参数
|
||||
|
||||
:param keyword: 搜索关键字或完整 IMDb ID
|
||||
:param media_type: MoviePilot 媒体类型
|
||||
:param category: 用户显式选择的分类 ID
|
||||
:param page: MoviePilot 从 0 开始的页码
|
||||
:param category_map: SunnyPT 分类接口返回的映射
|
||||
:return: SunnyPT API 查询参数
|
||||
"""
|
||||
params = {
|
||||
"page": int(page or 0) + 1,
|
||||
"page_size": self._size,
|
||||
"sort": "created_at",
|
||||
"order": "desc",
|
||||
}
|
||||
if keyword:
|
||||
params["keyword"] = keyword
|
||||
api_media_type = self._media_type_value(media_type)
|
||||
if api_media_type:
|
||||
params["media_type"] = api_media_type
|
||||
categories = category
|
||||
if not categories and api_media_type:
|
||||
categories = ",".join(category_map.get(api_media_type) or [])
|
||||
if categories:
|
||||
params["categories"] = categories
|
||||
return params
|
||||
|
||||
def _parse_result(self, results: List[dict]) -> List[dict]:
|
||||
"""
|
||||
将 SunnyPT API 种子数据转换为 MoviePilot 标准字段
|
||||
|
||||
:param results: SunnyPT 种子接口 data.items 数组
|
||||
:return: MoviePilot 标准种子字典列表
|
||||
"""
|
||||
torrents = []
|
||||
for result in results or []:
|
||||
if not isinstance(result, dict):
|
||||
continue
|
||||
media_type = result.get("media_type")
|
||||
if media_type == "movie":
|
||||
category = MediaType.MOVIE.value
|
||||
elif media_type == "tv":
|
||||
category = MediaType.TV.value
|
||||
else:
|
||||
category = MediaType.UNKNOWN.value
|
||||
|
||||
promotion = result.get("promotion") or {}
|
||||
promotion_active = bool(promotion.get("is_active"))
|
||||
download_factor = float(promotion.get("down_multiplier", 1.0)) \
|
||||
if promotion_active else 1.0
|
||||
upload_factor = float(promotion.get("up_multiplier", 1.0)) \
|
||||
if promotion_active else 1.0
|
||||
freedate = StringUtils.unify_datetime_str(promotion.get("until")) \
|
||||
if promotion_active and promotion.get("until") else None
|
||||
torrent_id = result.get("id")
|
||||
torrents.append({
|
||||
"title": result.get("title"),
|
||||
"description": result.get("subtitle"),
|
||||
"enclosure": self._build_download_url(torrent_id),
|
||||
"pubdate": StringUtils.unify_datetime_str(result.get("created_at")),
|
||||
"size": int(result.get("size") or 0),
|
||||
"seeders": int(result.get("seeders") or 0),
|
||||
"peers": int(result.get("leechers") or 0),
|
||||
"grabs": int(result.get("completed") or 0),
|
||||
"downloadvolumefactor": download_factor,
|
||||
"uploadvolumefactor": upload_factor,
|
||||
"freedate": freedate,
|
||||
"page_url": result.get("details_url"),
|
||||
"imdbid": result.get("imdb_id"),
|
||||
"labels": result.get("tags") or [],
|
||||
"hit_and_run": bool(result.get("hit_and_run")),
|
||||
"category": category,
|
||||
})
|
||||
return torrents
|
||||
|
||||
def _process_search_response(self, response) -> Tuple[bool, List[dict]]:
|
||||
"""
|
||||
处理 SunnyPT 种子搜索响应
|
||||
|
||||
:param response: RequestUtils 返回的响应对象
|
||||
:return: 是否失败及标准种子列表
|
||||
"""
|
||||
data = self._response_data(response, "搜索")
|
||||
if not isinstance(data, dict):
|
||||
return True, []
|
||||
return False, self._parse_result(data.get("items") or [])
|
||||
|
||||
def search(
|
||||
self,
|
||||
keyword: Optional[str],
|
||||
mtype: MediaType = None,
|
||||
cat: Optional[str] = None,
|
||||
page: Optional[int] = 0,
|
||||
) -> Tuple[bool, List[dict]]:
|
||||
"""
|
||||
同步搜索 SunnyPT 种子
|
||||
|
||||
:param keyword: 搜索关键字或完整 IMDb ID
|
||||
:param mtype: MoviePilot 媒体类型
|
||||
:param cat: 用户显式选择的分类 ID
|
||||
:param page: MoviePilot 从 0 开始的页码
|
||||
:return: 是否失败及标准种子列表
|
||||
"""
|
||||
if not self._api_key:
|
||||
logger.warning(f"{self._name} 未配置 API Key")
|
||||
return True, []
|
||||
category_map = self._load_categories() if mtype and not cat else self._configured_categories
|
||||
params = self._build_params(keyword, mtype, cat, page, category_map)
|
||||
response = RequestUtils(
|
||||
headers=self._request_headers(),
|
||||
proxies=self._proxy,
|
||||
timeout=self._timeout,
|
||||
).get_res(url=f"{self._api_url}/torrents", params=params)
|
||||
return self._process_search_response(response)
|
||||
|
||||
async def async_search(
|
||||
self,
|
||||
keyword: Optional[str],
|
||||
mtype: MediaType = None,
|
||||
cat: Optional[str] = None,
|
||||
page: Optional[int] = 0,
|
||||
) -> Tuple[bool, List[dict]]:
|
||||
"""
|
||||
异步搜索 SunnyPT 种子
|
||||
|
||||
:param keyword: 搜索关键字或完整 IMDb ID
|
||||
:param mtype: MoviePilot 媒体类型
|
||||
:param cat: 用户显式选择的分类 ID
|
||||
:param page: MoviePilot 从 0 开始的页码
|
||||
:return: 是否失败及标准种子列表
|
||||
"""
|
||||
if not self._api_key:
|
||||
logger.warning(f"{self._name} 未配置 API Key")
|
||||
return True, []
|
||||
category_map = await self._async_load_categories() \
|
||||
if mtype and not cat else self._configured_categories
|
||||
params = self._build_params(keyword, mtype, cat, page, category_map)
|
||||
response = await AsyncRequestUtils(
|
||||
headers=self._request_headers(),
|
||||
proxies=self._proxy,
|
||||
timeout=self._timeout,
|
||||
).get_res(url=f"{self._api_url}/torrents", params=params)
|
||||
return self._process_search_response(response)
|
||||
|
||||
def _build_download_url(self, torrent_id: int) -> str:
|
||||
"""
|
||||
构造先换取短时下载地址再下载种子文件的两段式链接
|
||||
|
||||
:param torrent_id: SunnyPT 种子 ID
|
||||
:return: Base64 请求配置与 download-token 接口 URL
|
||||
"""
|
||||
request_config = {
|
||||
"method": "post",
|
||||
"cookie": False,
|
||||
"header": {
|
||||
"X-API-Key": self._api_key,
|
||||
"Accept": "application/json",
|
||||
},
|
||||
"proxy": self._use_proxy,
|
||||
"result": "data.download_url",
|
||||
"result_base_url": self._api_url,
|
||||
}
|
||||
encoded_config = base64.b64encode(
|
||||
json.dumps(request_config).encode("utf-8")
|
||||
).decode("ascii")
|
||||
token_url = f"{self._api_url}/torrents/{torrent_id}/download-token"
|
||||
return f"[{encoded_config}]{token_url}"
|
||||
Reference in New Issue
Block a user