mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-14 01:39:45 +08:00
Merge pull request #6915 from Guoyin-Wen/feat/milkie-site-adapter
feat(indexer): 适配 Milkie 站点搜索与连通测试
This commit is contained in:
@@ -203,6 +203,7 @@ class SiteChain(InteractionChainMixin, ChainBase):
|
||||
"hddolby.com": self.__hddolby_test,
|
||||
"rousi.pro": self.__rousi_test,
|
||||
"sunnypt.top": self.__sunnypt_test,
|
||||
"milkie.cc": self.__milkie_test,
|
||||
}
|
||||
|
||||
def refresh_userdata(self, site: dict = None) -> Optional[SiteUserData]:
|
||||
@@ -403,6 +404,33 @@ class SiteChain(InteractionChainMixin, ChainBase):
|
||||
return False, user_info.get("message", "鉴权已过期或无效")
|
||||
return False, f"错误:{res.status_code} {res.reason}"
|
||||
|
||||
def __milkie_test(self, site: SiteSnapshot) -> Tuple[bool, str]:
|
||||
"""
|
||||
通过种子搜索接口测试 Milkie API Key
|
||||
|
||||
:param site: Milkie 站点配置
|
||||
:return: 是否可用及状态信息
|
||||
"""
|
||||
domain = site.domain or site_rules.extract_domain(site.url)
|
||||
with _open_site_response(
|
||||
method="GET",
|
||||
url=f"https://{domain}/api/v1/torrents?ps=1",
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"User-Agent": site.ua or self.runtime_config.user_agent,
|
||||
"x-milkie-auth": site.apikey,
|
||||
},
|
||||
proxies=(self.runtime_config.proxy or {}) if site.proxy else {},
|
||||
timeout=site.timeout or 15,
|
||||
) as res:
|
||||
if res is None:
|
||||
return False, "无法打开网站!"
|
||||
if res.status_code == 200:
|
||||
return True, "连接成功"
|
||||
if res.status_code in (401, 403):
|
||||
return False, "API Key 无效或已过期"
|
||||
return False, f"错误:{res.status_code} {res.reason}"
|
||||
|
||||
def __sunnypt_test(self, site: SiteSnapshot) -> Tuple[bool, str]:
|
||||
"""
|
||||
通过 profile 接口测试 SunnyPT API Key 和下载权限
|
||||
|
||||
@@ -18,6 +18,7 @@ from app.modules.indexer.parser import SiteParserBase, SiteSchema
|
||||
from app.modules.indexer.spider import SiteSpider
|
||||
from app.modules.indexer.spider.haidan import HaiDanSpider
|
||||
from app.modules.indexer.spider.hddolby import HddolbySpider
|
||||
from app.modules.indexer.spider.milkie import MilkieSpider
|
||||
from app.modules.indexer.spider.mtorrent import MTorrentSpider
|
||||
from app.modules.indexer.spider.rousi import RousiSpider
|
||||
from app.modules.indexer.spider.sunnypt import SunnyPTSpider
|
||||
@@ -39,6 +40,7 @@ SPIDER_PARSER_CLASSES = {
|
||||
"HDDolby": HddolbySpider,
|
||||
"RousiPro": RousiSpider,
|
||||
"SunnyPT": SunnyPTSpider,
|
||||
"Milkie": MilkieSpider,
|
||||
}
|
||||
|
||||
_SPECIALIZED_SEARCH_ARGUMENTS = {
|
||||
@@ -50,6 +52,7 @@ _SPECIALIZED_SEARCH_ARGUMENTS = {
|
||||
"Haidan": ("keyword", "mtype"),
|
||||
"HDDolby": ("keyword", "mtype", "page"),
|
||||
"RousiPro": ("keyword", "mtype", "cat", "page"),
|
||||
"Milkie": ("keyword", "mtype", "page"),
|
||||
}
|
||||
|
||||
_SPECIALIZED_SUBTITLE_SEARCH_ARGUMENTS = {
|
||||
|
||||
178
app/modules/indexer/spider/milkie.py
Normal file
178
app/modules/indexer/spider/milkie.py
Normal file
@@ -0,0 +1,178 @@
|
||||
from typing import Any, List, Optional, Tuple
|
||||
from urllib.parse import quote
|
||||
|
||||
from app.adapters.network.http import AsyncRequestUtils, RequestUtils
|
||||
from app.foundation import temporal as time_tools
|
||||
from app.runtime.log import logger
|
||||
from app.runtime.settings import get_runtime_setting
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
|
||||
class MilkieSpider:
|
||||
"""Milkie JSON API 种子搜索适配器。"""
|
||||
|
||||
_size = 100
|
||||
_timeout = 15
|
||||
_proxy: Optional[dict[str, Any]] = None
|
||||
_searchurl = "%sapi/v1/torrents"
|
||||
_downloadurl = "%sapi/v1/torrents/%s/torrent?key=%s"
|
||||
_pageurl = "%sbrowse/%s"
|
||||
|
||||
# 电影分类
|
||||
_movie_category = "1"
|
||||
# 电视剧分类
|
||||
_tv_category = "2"
|
||||
# 音乐分类
|
||||
_music_category = "3"
|
||||
|
||||
# 站点分类到媒体类型的映射,未覆盖的分类不输出媒体类型
|
||||
_category_mapping = {
|
||||
1: MediaType.MOVIE,
|
||||
2: MediaType.TV,
|
||||
3: MediaType.MUSIC,
|
||||
}
|
||||
|
||||
def __init__(self, indexer: dict[str, Any]):
|
||||
"""使用站点配置初始化 Milkie 请求上下文。"""
|
||||
if indexer:
|
||||
self._indexerid = indexer.get('id')
|
||||
self._domain = indexer.get('domain')
|
||||
self._searchurl = self._searchurl % self._domain
|
||||
self._name = indexer.get('name')
|
||||
if indexer.get('proxy'):
|
||||
self._proxy = get_runtime_setting('PROXY')
|
||||
self._apikey = indexer.get('apikey')
|
||||
self._ua = indexer.get('ua')
|
||||
self._timeout = indexer.get('timeout') or 15
|
||||
|
||||
@classmethod
|
||||
def get_search_page_size(cls, keyword: Optional[str] = None) -> Optional[int]:
|
||||
"""
|
||||
获取搜索接口单页容量。
|
||||
"""
|
||||
return cls._size
|
||||
|
||||
def __get_params(self, keyword: Optional[str] = None, mtype: Optional[MediaType] = None,
|
||||
page: Optional[int] = 0) -> dict[str, Any]:
|
||||
"""
|
||||
获取搜索参数
|
||||
"""
|
||||
params = {
|
||||
"pi": int(page or 0),
|
||||
"ps": self._size,
|
||||
"oby": "created_at",
|
||||
"odir": "desc"
|
||||
}
|
||||
if keyword:
|
||||
params["query"] = keyword
|
||||
# 按媒体类型收敛分类,提高关键字搜索的准确度
|
||||
if mtype == MediaType.MOVIE:
|
||||
params["categories"] = self._movie_category
|
||||
elif mtype == MediaType.TV:
|
||||
params["categories"] = self._tv_category
|
||||
elif mtype == MediaType.MUSIC:
|
||||
params["categories"] = self._music_category
|
||||
return params
|
||||
|
||||
def __parse_created_at(self, created_at: str) -> str:
|
||||
"""将站点 ISO8601 发布时间转换为本地日期时间文本。"""
|
||||
parsed = time_tools.parse_datetime(created_at)
|
||||
if not parsed:
|
||||
return created_at or ""
|
||||
return parsed.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
def __parse_result(self, results: List[dict[str, Any]]) -> List[dict[str, Any]]:
|
||||
"""
|
||||
解析搜索结果
|
||||
"""
|
||||
torrents: List[dict[str, Any]] = []
|
||||
if not results:
|
||||
return torrents
|
||||
|
||||
for result in results:
|
||||
category = self._category_mapping.get(result.get('category') or 0)
|
||||
torrent = {
|
||||
'title': result.get('releaseName'),
|
||||
'description': result.get('id'),
|
||||
# API Key 含 URL 保留字符,必须编码后拼入下载链接
|
||||
'enclosure': self._downloadurl % (
|
||||
self._domain, result.get('id'), quote(self._apikey or "", safe="")
|
||||
),
|
||||
'pubdate': self.__parse_created_at(result.get('createdAt') or ""),
|
||||
'size': result.get('size'),
|
||||
'seeders': result.get('seeders'),
|
||||
'peers': result.get('leechers'),
|
||||
'grabs': result.get('downloaded'),
|
||||
# 站点无分享率考核,等效全站免费
|
||||
'downloadvolumefactor': 0,
|
||||
'uploadvolumefactor': 1,
|
||||
'page_url': self._pageurl % (self._domain, result.get('id')),
|
||||
'category': category.value if category else None
|
||||
}
|
||||
externals = result.get('externals') or {}
|
||||
if externals.get('imdb'):
|
||||
torrent['imdbid'] = externals.get('imdb')
|
||||
torrents.append(torrent)
|
||||
|
||||
return torrents
|
||||
|
||||
def __process_response(self, res: Any) -> Tuple[bool, List[dict[str, Any]]]:
|
||||
"""统一判定搜索响应状态并投影 Milkie 种子结果。"""
|
||||
if res and res.status_code == 200:
|
||||
results = res.json().get("torrents") or []
|
||||
return False, self.__parse_result(results)
|
||||
if res is not None:
|
||||
if res.status_code in (401, 403):
|
||||
logger.warn(f"{self._name} 搜索失败,API Key 无效或已过期")
|
||||
else:
|
||||
logger.warn(f"{self._name} 搜索失败,错误码:{res.status_code}")
|
||||
return True, []
|
||||
logger.warn(f"{self._name} 搜索失败,无法连接 {self._domain}")
|
||||
return True, []
|
||||
|
||||
def __get_headers(self) -> dict[str, str]:
|
||||
"""构造带 API Key 认证的请求头。"""
|
||||
return {
|
||||
"x-milkie-auth": self._apikey or "",
|
||||
"User-Agent": self._ua or ""
|
||||
}
|
||||
|
||||
def search(self, keyword: str, mtype: Optional[MediaType] = None,
|
||||
page: Optional[int] = 0) -> Tuple[bool, List[dict[str, Any]]]:
|
||||
"""
|
||||
搜索
|
||||
"""
|
||||
if not self._apikey:
|
||||
logger.warn(f"{self._name} 未配置 API Key,无法搜索")
|
||||
return True, []
|
||||
|
||||
# 获取请求参数
|
||||
params = self.__get_params(keyword, mtype, page)
|
||||
|
||||
# 发送请求
|
||||
res = RequestUtils(
|
||||
headers=self.__get_headers(),
|
||||
proxies=self._proxy,
|
||||
timeout=self._timeout
|
||||
).get_res(url=self._searchurl, params=params)
|
||||
return self.__process_response(res)
|
||||
|
||||
async def async_search(self, keyword: str, mtype: Optional[MediaType] = None,
|
||||
page: Optional[int] = 0) -> Tuple[bool, List[dict[str, Any]]]:
|
||||
"""
|
||||
异步搜索
|
||||
"""
|
||||
if not self._apikey:
|
||||
logger.warn(f"{self._name} 未配置 API Key,无法搜索")
|
||||
return True, []
|
||||
|
||||
# 获取请求参数
|
||||
params = self.__get_params(keyword, mtype, page)
|
||||
|
||||
# 发送请求
|
||||
res = await AsyncRequestUtils(
|
||||
headers=self.__get_headers(),
|
||||
proxies=self._proxy,
|
||||
timeout=self._timeout
|
||||
).get_res(url=self._searchurl, params=params)
|
||||
return self.__process_response(res)
|
||||
@@ -762,8 +762,8 @@ flowchart LR
|
||||
|
||||
| 指标 | 当前值 |
|
||||
|---|---:|
|
||||
| Python 模块 | 1112 |
|
||||
| 内部导入边 | 9,401 |
|
||||
| Python 模块 | 1113 |
|
||||
| 内部导入边 | 9,412 |
|
||||
| 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) |
|
||||
| Application / Chain 具体 Adapter 直连 | 0 / 0 |
|
||||
| Direct egress | 49(债务已清零,49 条精确 containment) |
|
||||
@@ -773,7 +773,7 @@ flowchart LR
|
||||
| Model/Oper 自动事务与自建 Session | 0 |
|
||||
| 组合根外 `SystemConfigOper()` | 0 |
|
||||
|
||||
整理失败反馈由 `app.application.transfer.feedback` 集中投影;已整理下载的站点字幕复用 `app.chain.transfer` 自动整理队列;网络连通性检测按 `app.application.nettest` 归档,并覆盖 HTTP 与 WebSocket 探测。未启用智能助手的消息音乐交互由 `app.chain.music_interaction` 包中的解析展示与订阅协调模块处理;音乐标签准入与批次共识由 `app.chain.transfer.music` 负责,预览证据由 `app.application.transfer.projection` 只读投影;宿主依赖边现为 9,401,音乐来源链与识别缓存复用领域身份核验,音乐交互包未新增 SCC,插件一次性任务 owner `app.scheduler.oncejob` 带来 1 个模块和 9 条依赖边。Agent 持久回执新增 Application 端口及 DB Model/Oper/Adapter 四个冷导入模块。缓存组合根只在启动时选用 Redis 缓存才导入 Redis 适配器,依赖 langchain_core 的对话记忆模型归属 `app.agent.memory`,文件缓存且未启用智能体的实例不再加载 redis 与 langchain_core。Telegram 渠道改用模块内 `app.modules.telegram.botapi` 精简 Bot API 客户端,经 `RequestUtils` 出站,宿主不再导入 pyTelegramBotAPI,启用 Telegram 的实例也不会因该 SDK 加载 redis;该依赖暂时保留给插件使用。Web Push 协议(aes128gcm 加密、VAPID 签名与经 `RequestUtils` 投递)由网络适配器 `app.adapters.network.webpush` 直接基于 http-ece 与 py-vapid 实现,消息端点与 `WebPushModule` 共用,宿主不再导入 pywebpush 及其连带的 aiohttp;该依赖同样暂时保留给插件使用。飞书渠道改用模块内 `app.modules.feishu.openapi` OpenAPI 客户端(经 `RequestUtils` 出站)与网络适配器 `app.adapters.network.feishu` 事件长连接(websocket-client 同步收发 pbbp2 帧),宿主不再导入 lark-oapi 及其上万个生成模块,长连接也不再依赖 SDK 的模块级事件循环;插件经 `app.sdk.feishu` 复用同一长连接,lark-oapi 暂时保留给尚未迁移的插件使用。API 文档路由由 `app.adapters.web.docs` 按 `API_DOCS_ENABLE` 逐请求开放,默认关闭,未开放时不生成文档。当前 `app.startup.lifecycle` 为 582、`app.factory` 为 595、`app.main` 为 597。音乐调用观察 `app.application.music.observation` 新增一个轻量模块,记录来源结果及请求预算;AcoustID及路径识别复用此上下文传递有界指纹候选,保留旧单ID模块合同。站点图片域名快照新增 `app.application.security.image`,各启动入口增加一个模块;性能基线只同步模块数量,原有耗时预算、历史采样和生命周期资源约束保持有效。
|
||||
整理失败反馈由 `app.application.transfer.feedback` 集中投影;已整理下载的站点字幕复用 `app.chain.transfer` 自动整理队列;网络连通性检测按 `app.application.nettest` 归档,并覆盖 HTTP 与 WebSocket 探测。未启用智能助手的消息音乐交互由 `app.chain.music_interaction` 包中的解析展示与订阅协调模块处理;音乐标签准入与批次共识由 `app.chain.transfer.music` 负责,预览证据由 `app.application.transfer.projection` 只读投影;宿主依赖边现为 9,412,音乐来源链与识别缓存复用领域身份核验,音乐交互包未新增 SCC,插件一次性任务 owner `app.scheduler.oncejob` 带来 1 个模块和 9 条依赖边。Agent 持久回执新增 Application 端口及 DB Model/Oper/Adapter 四个冷导入模块。缓存组合根只在启动时选用 Redis 缓存才导入 Redis 适配器,依赖 langchain_core 的对话记忆模型归属 `app.agent.memory`,文件缓存且未启用智能体的实例不再加载 redis 与 langchain_core。Telegram 渠道改用模块内 `app.modules.telegram.botapi` 精简 Bot API 客户端,经 `RequestUtils` 出站,宿主不再导入 pyTelegramBotAPI,启用 Telegram 的实例也不会因该 SDK 加载 redis;该依赖暂时保留给插件使用。Web Push 协议(aes128gcm 加密、VAPID 签名与经 `RequestUtils` 投递)由网络适配器 `app.adapters.network.webpush` 直接基于 http-ece 与 py-vapid 实现,消息端点与 `WebPushModule` 共用,宿主不再导入 pywebpush 及其连带的 aiohttp;该依赖同样暂时保留给插件使用。飞书渠道改用模块内 `app.modules.feishu.openapi` OpenAPI 客户端(经 `RequestUtils` 出站)与网络适配器 `app.adapters.network.feishu` 事件长连接(websocket-client 同步收发 pbbp2 帧),宿主不再导入 lark-oapi 及其上万个生成模块,长连接也不再依赖 SDK 的模块级事件循环;插件经 `app.sdk.feishu` 复用同一长连接,lark-oapi 暂时保留给尚未迁移的插件使用。API 文档路由由 `app.adapters.web.docs` 按 `API_DOCS_ENABLE` 逐请求开放,默认关闭,未开放时不生成文档。当前 `app.startup.lifecycle` 为 582、`app.factory` 为 595、`app.main` 为 597。音乐调用观察 `app.application.music.observation` 新增一个轻量模块,记录来源结果及请求预算;AcoustID及路径识别复用此上下文传递有界指纹候选,保留旧单ID模块合同。站点图片域名快照新增 `app.application.security.image`,各启动入口增加一个模块;性能基线只同步模块数量,原有耗时预算、历史采样和生命周期资源约束保持有效。
|
||||
|
||||
架构专项验证分为两个 CI 投影:`Check event semantic policy` 先运行依赖、Adapter、出口和 Event
|
||||
语义门禁,`Check host architecture snapshot` 再执行快照测试及一次
|
||||
|
||||
@@ -98,7 +98,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁
|
||||
|
||||
| 指标 | 当前值 | 解释 |
|
||||
|---|---:|---|
|
||||
| 宿主 Python 模块 / 内部依赖边 | 1112 / 9,401 | `dependency-baseline.json` 当前快照;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档;消息音乐交互由 `app.chain.music_interaction` 包中的音乐解析/展示与订阅协调模块处理;已整理下载的站点字幕经 `app.chain.transfer` 自动整理队列完成命名与归档;网络连通性检测按 `app.application.nettest` 归档;LLM 独占连接关闭复用宿主线程池 |
|
||||
| 宿主 Python 模块 / 内部依赖边 | 1113 / 9,412 | `dependency-baseline.json` 当前快照;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档;消息音乐交互由 `app.chain.music_interaction` 包中的音乐解析/展示与订阅协调模块处理;已整理下载的站点字幕经 `app.chain.transfer` 自动整理队列完成命名与归档;网络连通性检测按 `app.application.nettest` 归档;LLM 独占连接关闭复用宿主线程池 |
|
||||
| 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 |
|
||||
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
|
||||
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |
|
||||
|
||||
@@ -941,8 +941,8 @@
|
||||
"runtime_only": true
|
||||
}
|
||||
},
|
||||
"edge_count": 9401,
|
||||
"edge_sha256": "82e88feed7d0c88ac1617017a301ed2979be7da87e995463b5e22b61c77e9620",
|
||||
"edge_count": 9412,
|
||||
"edge_sha256": "12ffbe3a91e7314ded4ce7996bffeb7497ba0e2434bb57e58e428da20dfd0ed6",
|
||||
"edges": [
|
||||
"app -> app.foundation",
|
||||
"app -> app.foundation.environment",
|
||||
@@ -7405,6 +7405,7 @@
|
||||
"app.modules.indexer -> app.modules.indexer.spider",
|
||||
"app.modules.indexer -> app.modules.indexer.spider.haidan",
|
||||
"app.modules.indexer -> app.modules.indexer.spider.hddolby",
|
||||
"app.modules.indexer -> app.modules.indexer.spider.milkie",
|
||||
"app.modules.indexer -> app.modules.indexer.spider.mtorrent",
|
||||
"app.modules.indexer -> app.modules.indexer.spider.rousi",
|
||||
"app.modules.indexer -> app.modules.indexer.spider.sunnypt",
|
||||
@@ -7628,6 +7629,16 @@
|
||||
"app.modules.indexer.spider.hddolby -> app.runtime.settings",
|
||||
"app.modules.indexer.spider.hddolby -> app.schemas",
|
||||
"app.modules.indexer.spider.hddolby -> app.schemas.types",
|
||||
"app.modules.indexer.spider.milkie -> app.adapters",
|
||||
"app.modules.indexer.spider.milkie -> app.adapters.network",
|
||||
"app.modules.indexer.spider.milkie -> app.adapters.network.http",
|
||||
"app.modules.indexer.spider.milkie -> app.foundation",
|
||||
"app.modules.indexer.spider.milkie -> app.foundation.temporal",
|
||||
"app.modules.indexer.spider.milkie -> app.runtime",
|
||||
"app.modules.indexer.spider.milkie -> app.runtime.log",
|
||||
"app.modules.indexer.spider.milkie -> app.runtime.settings",
|
||||
"app.modules.indexer.spider.milkie -> app.schemas",
|
||||
"app.modules.indexer.spider.milkie -> app.schemas.types",
|
||||
"app.modules.indexer.spider.mtorrent -> app.adapters",
|
||||
"app.modules.indexer.spider.mtorrent -> app.adapters.network",
|
||||
"app.modules.indexer.spider.mtorrent -> app.adapters.network.http",
|
||||
@@ -10346,7 +10357,7 @@
|
||||
"app.workflow.actions.transfer_file -> app.workflow",
|
||||
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
||||
],
|
||||
"module_count": 1112,
|
||||
"module_count": 1113,
|
||||
"modules": [
|
||||
"app",
|
||||
"app.adapters",
|
||||
@@ -11121,6 +11132,7 @@
|
||||
"app.modules.indexer.spider",
|
||||
"app.modules.indexer.spider.haidan",
|
||||
"app.modules.indexer.spider.hddolby",
|
||||
"app.modules.indexer.spider.milkie",
|
||||
"app.modules.indexer.spider.mtorrent",
|
||||
"app.modules.indexer.spider.rousi",
|
||||
"app.modules.indexer.spider.sunnypt",
|
||||
|
||||
@@ -89,6 +89,10 @@ def _install_search_observers(monkeypatch, events: list[tuple]) -> None:
|
||||
"Yema",
|
||||
{"keyword": "clean keyword", "mtype": MediaType.MOVIE, "page": 3},
|
||||
),
|
||||
(
|
||||
"Milkie",
|
||||
{"keyword": "clean keyword", "mtype": MediaType.MOVIE, "page": 3},
|
||||
),
|
||||
(
|
||||
"Haidan",
|
||||
{"keyword": "clean keyword", "mtype": MediaType.MOVIE},
|
||||
|
||||
205
tests/test_milkie_spider.py
Normal file
205
tests/test_milkie_spider.py
Normal file
@@ -0,0 +1,205 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# pylint: disable=no-name-in-module
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.indexer.spider import milkie as milkie_module
|
||||
from app.modules.indexer.spider.milkie import MilkieSpider
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
|
||||
def _build_indexer() -> dict:
|
||||
"""构造 Milkie API Spider 所需的最小站点配置。"""
|
||||
return {
|
||||
"id": "milkie",
|
||||
"name": "Milkie",
|
||||
"domain": "https://milkie.cc/",
|
||||
"apikey": "milkie+secret",
|
||||
"ua": "MoviePilot-Test",
|
||||
"proxy": False,
|
||||
}
|
||||
|
||||
|
||||
def _sample_torrent() -> dict:
|
||||
"""构造 Milkie 搜索接口的一条真实形态结果。"""
|
||||
return {
|
||||
"id": "wrJoUrlySnVm",
|
||||
"releaseName": "Dune.Part.Two.2024.2160p.UHD.BluRay.H265-GAZPROM",
|
||||
"category": 1,
|
||||
"createdAt": "2026-10-08T08:46:25+02:00",
|
||||
"size": 1076476044,
|
||||
"downloaded": 12,
|
||||
"seeders": 11,
|
||||
"leechers": 0,
|
||||
"externals": {"imdb": "tt15239678", "tmdb": None, "tvmaze": None},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def milkie_spider():
|
||||
"""构造不依赖真实站点配置的 MilkieSpider。"""
|
||||
return MilkieSpider(_build_indexer())
|
||||
|
||||
|
||||
def test_search_page_size_matches_api_limit(milkie_spider):
|
||||
"""单页容量应与接口上限一致,供分页器正确切片。"""
|
||||
assert MilkieSpider.get_search_page_size("dune") == 100
|
||||
|
||||
|
||||
def test_keyword_search_maps_media_categories(milkie_spider):
|
||||
"""带关键字的搜索应按媒体类型收敛分类。"""
|
||||
params = milkie_spider._MilkieSpider__get_params(
|
||||
"Dune", MediaType.MOVIE, 0
|
||||
)
|
||||
|
||||
assert params["query"] == "Dune"
|
||||
assert params["categories"] == "1"
|
||||
assert params["pi"] == 0
|
||||
assert params["ps"] == 100
|
||||
|
||||
|
||||
def test_tv_and_music_category_mapping(milkie_spider):
|
||||
"""剧集与音乐分别映射到站点 2、3 分类。"""
|
||||
tv_params = milkie_spider._MilkieSpider__get_params("Dune", MediaType.TV, 1)
|
||||
music_params = milkie_spider._MilkieSpider__get_params("Dune", MediaType.MUSIC, 0)
|
||||
|
||||
assert tv_params["categories"] == "2"
|
||||
assert tv_params["pi"] == 1
|
||||
assert music_params["categories"] == "3"
|
||||
|
||||
|
||||
def test_keyword_search_without_media_type_omits_categories(milkie_spider):
|
||||
"""未指定媒体类型时不提交分类,避免站点接口过滤失效。"""
|
||||
params = milkie_spider._MilkieSpider__get_params("Dune", None, 0)
|
||||
|
||||
assert "categories" not in params
|
||||
|
||||
|
||||
def test_browse_mode_supports_pagination(milkie_spider):
|
||||
"""无关键字的浏览模式同样支持服务端翻页。"""
|
||||
first_page = milkie_spider._MilkieSpider__get_params(None, None, 0)
|
||||
second_page = milkie_spider._MilkieSpider__get_params(None, None, 1)
|
||||
|
||||
assert "query" not in first_page
|
||||
assert first_page["pi"] == 0
|
||||
assert second_page["pi"] == 1
|
||||
assert first_page["oby"] == "created_at"
|
||||
|
||||
|
||||
def test_parse_result_projects_torrent_fields(milkie_spider):
|
||||
"""结果字段应完整映射到站点搜索契约。"""
|
||||
torrents = milkie_spider._MilkieSpider__parse_result([_sample_torrent()])
|
||||
|
||||
assert len(torrents) == 1
|
||||
torrent = torrents[0]
|
||||
assert torrent["title"] == "Dune.Part.Two.2024.2160p.UHD.BluRay.H265-GAZPROM"
|
||||
assert torrent["enclosure"] == (
|
||||
"https://milkie.cc/api/v1/torrents/wrJoUrlySnVm"
|
||||
"/torrent?key=milkie%2Bsecret"
|
||||
)
|
||||
assert torrent["page_url"] == "https://milkie.cc/browse/wrJoUrlySnVm"
|
||||
assert torrent["size"] == 1076476044
|
||||
assert torrent["seeders"] == 11
|
||||
assert torrent["peers"] == 0
|
||||
assert torrent["grabs"] == 12
|
||||
assert torrent["pubdate"] == "2026-10-08 08:46:25"
|
||||
assert torrent["imdbid"] == "tt15239678"
|
||||
assert torrent["category"] == MediaType.MOVIE.value
|
||||
|
||||
|
||||
def test_parse_result_marks_site_as_freeleech(milkie_spider):
|
||||
"""站点无分享率考核,所有种子应视为免费。"""
|
||||
torrents = milkie_spider._MilkieSpider__parse_result([_sample_torrent()])
|
||||
|
||||
assert torrents[0]["downloadvolumefactor"] == 0
|
||||
assert torrents[0]["uploadvolumefactor"] == 1
|
||||
|
||||
|
||||
def test_parse_result_category_fallback(milkie_spider):
|
||||
"""游戏、软件等无对应媒体类型的分类不应输出媒体类型。"""
|
||||
raw = _sample_torrent()
|
||||
raw["category"] = 4
|
||||
|
||||
torrents = milkie_spider._MilkieSpider__parse_result([raw])
|
||||
|
||||
assert torrents[0]["category"] is None
|
||||
|
||||
|
||||
def test_search_requires_api_key(milkie_spider, monkeypatch):
|
||||
"""缺少 API Key 时应直接失败且不发起请求。"""
|
||||
request_utils = MagicMock()
|
||||
monkeypatch.setattr(milkie_module, "RequestUtils", request_utils)
|
||||
|
||||
milkie_spider._apikey = None
|
||||
error, results = milkie_spider.search("Dune")
|
||||
|
||||
assert error is True
|
||||
assert results == []
|
||||
request_utils.assert_not_called()
|
||||
|
||||
|
||||
def _install_json_response(monkeypatch, status_code: int, payload: dict) -> MagicMock:
|
||||
"""在模块边界替换同步 HTTP 客户端并返回调用记录。"""
|
||||
response = MagicMock()
|
||||
response.status_code = status_code
|
||||
response.json.return_value = payload
|
||||
request_utils = MagicMock()
|
||||
request_utils.return_value.get_res.return_value = response
|
||||
monkeypatch.setattr(milkie_module, "RequestUtils", request_utils)
|
||||
return request_utils
|
||||
|
||||
|
||||
def test_search_sends_auth_header_and_parses_response(milkie_spider, monkeypatch):
|
||||
"""搜索应携带 x-milkie-auth 请求头并解析 torrents 数组。"""
|
||||
request_utils = _install_json_response(
|
||||
monkeypatch, 200, {"hits": 1, "torrents": [_sample_torrent()]}
|
||||
)
|
||||
|
||||
error, results = milkie_spider.search("Dune", MediaType.MOVIE)
|
||||
|
||||
assert error is False
|
||||
assert len(results) == 1
|
||||
headers = request_utils.call_args.kwargs["headers"]
|
||||
assert headers["x-milkie-auth"] == "milkie+secret"
|
||||
|
||||
|
||||
def test_search_reports_invalid_api_key(milkie_spider, monkeypatch):
|
||||
"""401 响应应判定为 API Key 失效。"""
|
||||
_install_json_response(monkeypatch, 401, {"message": "Unauthorized"})
|
||||
|
||||
error, results = milkie_spider.search("Dune")
|
||||
|
||||
assert error is True
|
||||
assert results == []
|
||||
|
||||
|
||||
def test_search_reports_unreachable_site(milkie_spider, monkeypatch):
|
||||
"""网络不可达时应返回错误标志。"""
|
||||
request_utils = MagicMock()
|
||||
request_utils.return_value.get_res.return_value = None
|
||||
monkeypatch.setattr(milkie_module, "RequestUtils", request_utils)
|
||||
|
||||
error, results = milkie_spider.search("Dune")
|
||||
|
||||
assert error is True
|
||||
assert results == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_search_matches_sync_behavior(milkie_spider, monkeypatch):
|
||||
"""异步搜索应与同步搜索使用同样的认证与解析路径。"""
|
||||
response = MagicMock()
|
||||
response.status_code = 200
|
||||
response.json.return_value = {"hits": 1, "torrents": [_sample_torrent()]}
|
||||
async_request_utils = MagicMock()
|
||||
async_request_utils.return_value.get_res = AsyncMock(return_value=response)
|
||||
monkeypatch.setattr(milkie_module, "AsyncRequestUtils", async_request_utils)
|
||||
|
||||
error, results = await milkie_spider.async_search("Dune", MediaType.MOVIE)
|
||||
|
||||
assert error is False
|
||||
assert results[0]["title"] == "Dune.Part.Two.2024.2160p.UHD.BluRay.H265-GAZPROM"
|
||||
headers = async_request_utils.call_args.kwargs["headers"]
|
||||
assert headers["x-milkie-auth"] == "milkie+secret"
|
||||
Reference in New Issue
Block a user