feat: 支持站点音乐分类搜索

This commit is contained in:
jxxghp
2026-08-13 19:45:09 +08:00
parent c86307ddd1
commit 23a90991fc
14 changed files with 464 additions and 38 deletions
+2
View File
@@ -252,6 +252,7 @@ class IndexerModule(_ModuleBase):
elif site.get('parser') == "TorrentLeech": elif site.get('parser') == "TorrentLeech":
error_flag, result = TorrentLeech(site).search( error_flag, result = TorrentLeech(site).search(
keyword=search_word, keyword=search_word,
mtype=mtype,
page=page page=page
) )
elif site.get('parser') == "mTorrent": elif site.get('parser') == "mTorrent":
@@ -395,6 +396,7 @@ class IndexerModule(_ModuleBase):
elif site.get('parser') == "TorrentLeech": elif site.get('parser') == "TorrentLeech":
error_flag, result = await TorrentLeech(site).async_search( error_flag, result = await TorrentLeech(site).async_search(
keyword=search_word, keyword=search_word,
mtype=mtype,
page=page page=page
) )
elif site.get('parser') == "mTorrent": elif site.get('parser') == "mTorrent":
+32 -2
View File
@@ -121,6 +121,10 @@ class SiteSpider:
self.list = self.browse.get('list') or self.list self.list = self.browse.get('list') or self.list
self.fields = self.browse.get('fields') or self.fields self.fields = self.browse.get('fields') or self.fields
result_num = indexer.get('result_num') result_num = indexer.get('result_num')
self.result_media_type_from_request = (
(self.search or {}).get("result_media_type") == "requested"
)
self.requested_result_media_type = None
self._field_templates = self.__build_field_templates() self._field_templates = self.__build_field_templates()
self.domain = indexer.get('domain') self.domain = indexer.get('domain')
self.result_num = int(result_num or self.default_result_num()) self.result_num = int(result_num or self.default_result_num())
@@ -167,6 +171,7 @@ class SiteSpider:
torrentspath = "" torrentspath = ""
# 是否选中了媒体类型专用路径,浏览模式下专用路径优先于 browse 配置 # 是否选中了媒体类型专用路径,浏览模式下专用路径优先于 browse 配置
typed_path_selected = False typed_path_selected = False
category_filter_selected = False
if len(paths) == 1: if len(paths) == 1:
torrentspath = paths[0].get('path', '') torrentspath = paths[0].get('path', '')
else: else:
@@ -243,6 +248,7 @@ class SiteSpider:
if allowed_cats and str(cat.get('id')) not in allowed_cats: if allowed_cats and str(cat.get('id')) not in allowed_cats:
continue continue
if self.category.get("field"): if self.category.get("field"):
category_filter_selected = True
value = params.get(self.category.get("field"), "") value = params.get(self.category.get("field"), "")
params.update({ params.update({
"%s" % self.category.get("field"): value + self.category.get("delimiter", "%s" % self.category.get("field"): value + self.category.get("delimiter",
@@ -251,6 +257,7 @@ class SiteSpider:
else: else:
category_param = cat.get("param") or self.category.get("param") category_param = cat.get("param") or self.category.get("param")
if category_param: if category_param:
category_filter_selected = True
# 某些站点(例如憨憨)使用重复的 cat[] 参数,字典值列表可由 # 某些站点(例如憨憨)使用重复的 cat[] 参数,字典值列表可由
# UrlUtils.combine_url 以 doseq=True 正确展开。 # UrlUtils.combine_url 以 doseq=True 正确展开。
category_id = cat.get("value", cat.get("id")) category_id = cat.get("value", cat.get("id"))
@@ -262,6 +269,7 @@ class SiteSpider:
else: else:
params[category_param] = [current_value, category_id] params[category_param] = [current_value, category_id]
else: else:
category_filter_selected = True
params.update({ params.update({
"cat%s" % cat.get("id"): 1 "cat%s" % cat.get("id"): 1
}) })
@@ -300,6 +308,12 @@ class SiteSpider:
# 搜索Url # 搜索Url
searchurl = self.domain + str(torrentspath).format(**inputs_dict) searchurl = self.domain + str(torrentspath).format(**inputs_dict)
if self.result_media_type_from_request \
and self.mtype \
and (typed_path_selected or category_filter_selected):
self.requested_result_media_type = self.mtype
else:
self.requested_result_media_type = None
return searchurl return searchurl
def __format_search_word(self, search_word: str) -> str: def __format_search_word(self, search_word: str) -> str:
@@ -774,6 +788,9 @@ class SiteSpider:
def __get_category(self, torrent: Any): def __get_category(self, torrent: Any):
# category 电影/电视剧/音乐 # category 电影/电视剧/音乐
if self.requested_result_media_type:
self.torrents_info['category'] = self.requested_result_media_type.value
return
if 'category' not in self.fields: if 'category' not in self.fields:
if self.site_media_type: if self.site_media_type:
self.torrents_info['category'] = self.site_media_type.value self.torrents_info['category'] = self.site_media_type.value
@@ -786,6 +803,19 @@ class SiteSpider:
resolved_type = self.site_media_type resolved_type = self.site_media_type
self.torrents_info['category'] = resolved_type.value self.torrents_info['category'] = resolved_type.value
def __apply_requested_result_media_type(self, torrents: Optional[List[dict]]) -> List[dict]:
"""
为已由站点查询条件约束类型的结果补充统一媒体类型。
仅在站点配置显式声明 result_media_type=requested 时生效,避免把普通混合搜索结果误分类。
"""
results = torrents or []
if not self.requested_result_media_type:
return results
for torrent in results:
torrent["category"] = self.requested_result_media_type.value
return results
def __get_subtitle_field(self, torrent: Any, field_name: str): def __get_subtitle_field(self, torrent: Any, field_name: str):
""" """
按配置读取字幕字段。 按配置读取字幕字段。
@@ -1080,7 +1110,7 @@ class SiteSpider:
result_num=self.result_num result_num=self.result_num
) )
if rust_torrents is not None: if rust_torrents is not None:
return rust_torrents return self.__apply_requested_result_media_type(rust_torrents)
# 清空旧结果 # 清空旧结果
self.torrents_info_array = [] self.torrents_info_array = []
@@ -1111,7 +1141,7 @@ class SiteSpider:
torrent_query.clear() torrent_query.clear()
del torrent_query del torrent_query
# 返回数组的副本,防止被后续清理操作影响 # 返回数组的副本,防止被后续清理操作影响
return self.torrents_info_array.copy() return self.__apply_requested_result_media_type(self.torrents_info_array.copy())
except Exception as err: except Exception as err:
self.is_error = True self.is_error = True
logger.warn(f"错误:{self.indexername} {str(err)}") logger.warn(f"错误:{self.indexername} {str(err)}")
+2 -2
View File
@@ -28,8 +28,8 @@ class HaiDanSpider:
# 电影分类 # 电影分类
_movie_category = ['401', '404', '405'] _movie_category = ['401', '404', '405']
_tv_category = ['402', '403', '404', '405'] _tv_category = ['402', '403', '404', '405']
# 音乐分类:408 为海胆 HQ Audio(音乐) 分区;406 MV 属于视频,不计入音乐 # 音乐搜索同时覆盖高品质音频和音乐视频/演唱会分区。
_music_category = ['408'] _music_category = ['406', '408']
# 足销状态 1-普通,2-免费,3-2X4-2X免费,5-50%6-2X50%7-30% # 足销状态 1-普通,2-免费,3-2X4-2X免费,5-50%6-2X50%7-30%
_dl_state = { _dl_state = {
+2 -2
View File
@@ -28,8 +28,8 @@ class HddolbySpider:
# 分类 # 分类
_movie_category = [401, 405] _movie_category = [401, 405]
_tv_category = [402, 403, 404, 405] _tv_category = [402, 403, 404, 405]
# 音乐分类:408 为杜比音乐唱片分区;406 演唱会蓝光属于视频,不计入音乐 # 音乐搜索同时覆盖高品质音频和音乐视频/演唱会分区。
_music_category = [408] _music_category = [406, 408]
# 标签 # 标签
_labels = { _labels = {
+2 -2
View File
@@ -36,8 +36,8 @@ class MTorrentSpider:
_movie_category = ['401', '419', '420', '421', '439', '405', '404'] _movie_category = ['401', '419', '420', '421', '439', '405', '404']
# 电视剧分类 # 电视剧分类
_tv_category = ['403', '402', '435', '438', '404', '405'] _tv_category = ['403', '402', '435', '438', '404', '405']
# 音乐分类:406 为馒头 Music(音樂) 分区 # 音乐分类:434 为无损音乐,406 为演唱分区
_music_category = ['406'] _music_category = ['434', '406']
# API KEY # API KEY
_apikey = None _apikey = None
+55 -7
View File
@@ -3,11 +3,14 @@ from urllib.parse import quote
from app.core.config import settings from app.core.config import settings
from app.log import logger from app.log import logger
from app.schemas import MediaType
from app.utils.http import RequestUtils, AsyncRequestUtils from app.utils.http import RequestUtils, AsyncRequestUtils
from app.utils.string import StringUtils from app.utils.string import StringUtils
class TorrentLeech: class TorrentLeech:
"""TorrentLeech JSON 搜索接口索引器。"""
_indexer = None _indexer = None
_proxy = None _proxy = None
_size = 100 _size = 100
@@ -25,12 +28,40 @@ class TorrentLeech:
return None if keyword else cls._size return None if keyword else cls._size
def __init__(self, indexer: dict): def __init__(self, indexer: dict):
"""初始化站点认证信息和媒体分类配置。"""
self._indexer = indexer self._indexer = indexer
if indexer.get('proxy'): if indexer.get('proxy'):
self._proxy = settings.PROXY self._proxy = settings.PROXY
self._timeout = indexer.get('timeout') or 15 self._timeout = indexer.get('timeout') or 15
def __parse_result(self, results: List[dict]) -> List[dict]: def __category_ids(self, mtype: MediaType = None) -> List[str]:
"""读取资源包中与请求媒体类型对应的 TorrentLeech 分类 ID。"""
category_key = {
MediaType.MOVIE: "movie",
MediaType.TV: "tv",
MediaType.MUSIC: "music",
}.get(mtype)
if not category_key:
return []
return [
str(item.get("id"))
for item in ((self._indexer.get("category") or {}).get(category_key) or [])
if isinstance(item, dict) and item.get("id") is not None
]
def __get_search_url(self, keyword: str, mtype: MediaType = None) -> str:
"""按媒体类型构造 TorrentLeech 搜索接口地址。"""
domain = self._indexer.get('domain')
encoded_keyword = quote(keyword)
category_ids = self.__category_ids(mtype)
if category_ids:
return (
f"{domain}torrents/browse/list/categories/{','.join(category_ids)}"
f"/exact/1/query/{encoded_keyword}"
)
return self._searchurl % (domain, encoded_keyword)
def __parse_result(self, results: List[dict], mtype: MediaType = None) -> List[dict]:
""" """
解析搜索结果 解析搜索结果
""" """
@@ -38,6 +69,7 @@ class TorrentLeech:
if not results: if not results:
return torrents return torrents
requested_category_ids = set(self.__category_ids(mtype))
for result in results: for result in results:
torrent = { torrent = {
'title': result.get('name'), 'title': result.get('name'),
@@ -54,10 +86,21 @@ class TorrentLeech:
'page_url': self._pageurl % (self._indexer.get('domain'), result.get('fid')), 'page_url': self._pageurl % (self._indexer.get('domain'), result.get('fid')),
'imdbid': result.get('imdbID') 'imdbid': result.get('imdbID')
} }
if requested_category_ids:
torrent['category'] = (
mtype.value
if str(result.get('categoryID')) in requested_category_ids
else MediaType.UNKNOWN.value
)
torrents.append(torrent) torrents.append(torrent)
return torrents return torrents
def search(self, keyword: str, page: Optional[int] = 0) -> Tuple[bool, List[dict]]: def search(
self,
keyword: str,
mtype: MediaType = None,
page: Optional[int] = 0,
) -> Tuple[bool, List[dict]]:
""" """
搜索种子 搜索种子
""" """
@@ -66,7 +109,7 @@ class TorrentLeech:
return True, [] return True, []
if keyword: if keyword:
url = self._searchurl % (self._indexer.get('domain'), quote(keyword)) url = self.__get_search_url(keyword, mtype)
else: else:
url = self._browseurl % (self._indexer.get('domain'), int(page) + 1) url = self._browseurl % (self._indexer.get('domain'), int(page) + 1)
@@ -81,7 +124,7 @@ class TorrentLeech:
).get_res(url) ).get_res(url)
if res and res.status_code == 200: if res and res.status_code == 200:
results = res.json().get('torrentList') or [] results = res.json().get('torrentList') or []
return False, self.__parse_result(results) return False, self.__parse_result(results, mtype)
elif res is not None: elif res is not None:
logger.warn(f"{self._indexer.get('name')} 搜索失败,错误码:{res.status_code}") logger.warn(f"{self._indexer.get('name')} 搜索失败,错误码:{res.status_code}")
return True, [] return True, []
@@ -89,7 +132,12 @@ class TorrentLeech:
logger.warn(f"{self._indexer.get('name')} 搜索失败,无法连接 {self._indexer.get('domain')}") logger.warn(f"{self._indexer.get('name')} 搜索失败,无法连接 {self._indexer.get('domain')}")
return True, [] return True, []
async def async_search(self, keyword: str, page: Optional[int] = 0) -> Tuple[bool, List[dict]]: async def async_search(
self,
keyword: str,
mtype: MediaType = None,
page: Optional[int] = 0,
) -> Tuple[bool, List[dict]]:
""" """
异步搜索种子 异步搜索种子
""" """
@@ -98,7 +146,7 @@ class TorrentLeech:
return True, [] return True, []
if keyword: if keyword:
url = self._searchurl % (self._indexer.get('domain'), quote(keyword)) url = self.__get_search_url(keyword, mtype)
else: else:
url = self._browseurl % (self._indexer.get('domain'), int(page) + 1) url = self._browseurl % (self._indexer.get('domain'), int(page) + 1)
@@ -113,7 +161,7 @@ class TorrentLeech:
).get_res(url) ).get_res(url)
if res and res.status_code == 200: if res and res.status_code == 200:
results = res.json().get('torrentList') or [] results = res.json().get('torrentList') or []
return False, self.__parse_result(results) return False, self.__parse_result(results, mtype)
elif res is not None: elif res is not None:
logger.warn(f"{self._indexer.get('name')} 搜索失败,错误码:{res.status_code}") logger.warn(f"{self._indexer.get('name')} 搜索失败,错误码:{res.status_code}")
return True, [] return True, []
+65 -13
View File
@@ -17,7 +17,8 @@ class YemaSpider:
# YemaPT 开放 API 使用更大的分页容量时会返回空结果。 # YemaPT 开放 API 使用更大的分页容量时会返回空结果。
_size = 40 _size = 40
_movie_category = [4] _movie_category = [4]
_tv_category = [5, 6, 13, 14, 15, 16, 17] _tv_category = [5, 6, 13, 14, 15, 17]
_music_category = [8, 16]
_labels = { _labels = {
"1": "禁转", "1": "禁转",
@@ -78,12 +79,14 @@ class YemaSpider:
self, self,
keyword: Optional[str], keyword: Optional[str],
page: Optional[int], page: Optional[int],
category_id: Optional[int] = None,
) -> dict: ) -> dict:
""" """
构造公开种子列表查询参数 构造公开种子列表查询参数
:param keyword: 搜索关键字 :param keyword: 搜索关键字
:param page: MoviePilot 从 0 开始的页码 :param page: MoviePilot 从 0 开始的页码
:param category_id: 可选的站点分类 ID
:return: YemaPT 开放 API 请求体 :return: YemaPT 开放 API 请求体
""" """
params = { params = {
@@ -95,8 +98,39 @@ class YemaSpider:
} }
if keyword: if keyword:
params["keyword"] = keyword params["keyword"] = keyword
if category_id is not None:
params["categoryId"] = category_id
return params return params
def _search_category_ids(self, mtype: MediaType = None) -> List[Optional[int]]:
"""
返回当前媒体类型需要分别查询的站点分类。
YemaPT 分类参数不支持数组,音乐需要分别查询音频音乐和 MV/演唱会分类。
"""
if mtype == MediaType.MOVIE:
return list(self._movie_category)
if mtype == MediaType.TV:
return list(self._tv_category)
if mtype == MediaType.MUSIC:
return list(self._music_category)
return [None]
@staticmethod
def _merge_search_results(result_groups: List[List[dict]]) -> List[dict]:
"""按种子详情链接合并多个分类查询结果并保持首次出现顺序。"""
merged = []
seen = set()
for results in result_groups:
for torrent in results:
identity = torrent.get("page_url") or torrent.get("enclosure")
if identity and identity in seen:
continue
if identity:
seen.add(identity)
merged.append(torrent)
return merged
def _parse_result(self, results: List[dict]) -> List[dict]: def _parse_result(self, results: List[dict]) -> List[dict]:
""" """
将开放 API 种子数据转换为 MoviePilot 标准字段 将开放 API 种子数据转换为 MoviePilot 标准字段
@@ -113,6 +147,8 @@ class YemaSpider:
category = MediaType.TV.value category = MediaType.TV.value
elif category_value in self._movie_category: elif category_value in self._movie_category:
category = MediaType.MOVIE.value category = MediaType.MOVIE.value
elif category_value in self._music_category:
category = MediaType.MUSIC.value
else: else:
category = MediaType.UNKNOWN.value category = MediaType.UNKNOWN.value
@@ -181,22 +217,30 @@ class YemaSpider:
同步搜索 YemaPT 公开种子 同步搜索 YemaPT 公开种子
:param keyword: 搜索关键字 :param keyword: 搜索关键字
:param mtype: MoviePilot 媒体类型,开放 API 不支持直接按媒体类型查询 :param mtype: MoviePilot 媒体类型,指定后按真实站点分类分别查询
:param page: MoviePilot 从 0 开始的页码 :param page: MoviePilot 从 0 开始的页码
:return: 是否失败及标准种子列表 :return: 是否失败及标准种子列表
""" """
if not self._api_key: if not self._api_key:
logger.warning(f"{self._name} 未配置 API AuthKey") logger.warning(f"{self._name} 未配置 API AuthKey")
return True, [] return True, []
response = RequestUtils( request = RequestUtils(
headers=self._request_headers(), headers=self._request_headers(),
proxies=self._proxy, proxies=self._proxy,
timeout=self._timeout, timeout=self._timeout,
).post_res(
url=self._search_url,
json=self._build_params(keyword, page),
) )
return self._process_search_response(response) result_groups = []
errors = []
for category_id in self._search_category_ids(mtype):
response = request.post_res(
url=self._search_url,
json=self._build_params(keyword, page, category_id),
)
error, results = self._process_search_response(response)
errors.append(error)
if not error:
result_groups.append(results)
return all(errors), self._merge_search_results(result_groups)
async def async_search( async def async_search(
self, self,
@@ -208,22 +252,30 @@ class YemaSpider:
异步搜索 YemaPT 公开种子 异步搜索 YemaPT 公开种子
:param keyword: 搜索关键字 :param keyword: 搜索关键字
:param mtype: MoviePilot 媒体类型,开放 API 不支持直接按媒体类型查询 :param mtype: MoviePilot 媒体类型,指定后按真实站点分类分别查询
:param page: MoviePilot 从 0 开始的页码 :param page: MoviePilot 从 0 开始的页码
:return: 是否失败及标准种子列表 :return: 是否失败及标准种子列表
""" """
if not self._api_key: if not self._api_key:
logger.warning(f"{self._name} 未配置 API AuthKey") logger.warning(f"{self._name} 未配置 API AuthKey")
return True, [] return True, []
response = await AsyncRequestUtils( request = AsyncRequestUtils(
headers=self._request_headers(), headers=self._request_headers(),
proxies=self._proxy, proxies=self._proxy,
timeout=self._timeout, timeout=self._timeout,
).post_res(
url=self._search_url,
json=self._build_params(keyword, page),
) )
return self._process_search_response(response) result_groups = []
errors = []
for category_id in self._search_category_ids(mtype):
response = await request.post_res(
url=self._search_url,
json=self._build_params(keyword, page, category_id),
)
error, results = self._process_search_response(response)
errors.append(error)
if not error:
result_groups.append(results)
return all(errors), self._merge_search_results(result_groups)
@staticmethod @staticmethod
def _download_factor(promotion: str) -> float: def _download_factor(promotion: str) -> float:
+6 -4
View File
@@ -26,10 +26,10 @@ def haidan_spider(monkeypatch):
def test_music_search_uses_music_categories(haidan_spider): def test_music_search_uses_music_categories(haidan_spider):
"""音乐搜索应提交海胆 HQ Audio 分区分类""" """音乐搜索应提交海胆 HQ Audio 和音乐视频分区。"""
params = haidan_spider._HaiDanSpider__get_params("张学友", MediaType.MUSIC) params = haidan_spider._HaiDanSpider__get_params("张学友", MediaType.MUSIC)
assert "cat=408" in params assert "cat=406%2C408" in params or "cat=406,408" in params
assert "401" not in params assert "401" not in params
@@ -46,14 +46,16 @@ def test_parse_result_reads_item_category(haidan_spider):
"code": 0, "code": 0,
"data": { "data": {
"1": {"name": "张学友 - 他在那里 FLAC", "category": 408, "size": "1024"}, "1": {"name": "张学友 - 他在那里 FLAC", "category": 408, "size": "1024"},
"2": {"name": "流浪地球", "category": 401, "size": "1024"}, "2": {"name": "张学友演唱会", "category": 406, "size": "1024"},
"3": {"name": "剧集 S01", "category": 402, "size": "1024"}, "3": {"name": "流浪地球", "category": 401, "size": "1024"},
"4": {"name": "剧集 S01", "category": 402, "size": "1024"},
}, },
} }
torrents = haidan_spider._HaiDanSpider__parse_result(result) torrents = haidan_spider._HaiDanSpider__parse_result(result)
assert [torrent["category"] for torrent in torrents] == [ assert [torrent["category"] for torrent in torrents] == [
MediaType.MUSIC.value,
MediaType.MUSIC.value, MediaType.MUSIC.value,
MediaType.MOVIE.value, MediaType.MOVIE.value,
MediaType.TV.value, MediaType.TV.value,
+3 -3
View File
@@ -26,7 +26,7 @@ def hddolby_spider(monkeypatch):
def test_music_search_uses_music_categories(hddolby_spider): def test_music_search_uses_music_categories(hddolby_spider):
"""音乐搜索应提交杜比音乐唱片分区分类""" """音乐搜索应提交杜比高品质音频和音乐视频分区"""
params = hddolby_spider._HddolbySpider__get_params("张学友", MediaType.MUSIC, 0) params = hddolby_spider._HddolbySpider__get_params("张学友", MediaType.MUSIC, 0)
assert params["categories"] == HddolbySpider._music_category assert params["categories"] == HddolbySpider._music_category
@@ -40,7 +40,7 @@ def test_movie_search_keeps_movie_categories(hddolby_spider):
def test_parse_result_marks_music_torrents(hddolby_spider): def test_parse_result_marks_music_torrents(hddolby_spider):
"""音乐唱片分区种子应标记为音乐媒体类型,演唱会视频仍按原逻辑处理""" """高品质音频和音乐视频分区都应标记为音乐媒体类型"""
results = hddolby_spider._HddolbySpider__parse_result([ results = hddolby_spider._HddolbySpider__parse_result([
{"id": 1, "name": "VA - Chillout 2022 FLAC", "category": 408, "size": 1024}, {"id": 1, "name": "VA - Chillout 2022 FLAC", "category": 408, "size": 1024},
{"id": 2, "name": "Epica Live 1080p Blu-ray", "category": 406, "size": 1024}, {"id": 2, "name": "Epica Live 1080p Blu-ray", "category": 406, "size": 1024},
@@ -50,7 +50,7 @@ def test_parse_result_marks_music_torrents(hddolby_spider):
assert [torrent["category"] for torrent in results] == [ assert [torrent["category"] for torrent in results] == [
MediaType.MUSIC.value, MediaType.MUSIC.value,
MediaType.UNKNOWN.value, MediaType.MUSIC.value,
MediaType.MOVIE.value, MediaType.MOVIE.value,
MediaType.TV.value, MediaType.TV.value,
] ]
+24
View File
@@ -225,6 +225,30 @@ def test_typed_search_path_falls_back_to_all_path():
assert parsed_url.path == "/torrents.php" assert parsed_url.path == "/torrents.php"
def test_video_search_uses_dedicated_path_without_synthesized_category_params():
"""专属影视路径已携带分类时,不应再追加站点不支持的默认 cat 参数。"""
indexer = _build_indexer(
domain="https://iptorrents.com/",
search={
"paths": [
{"path": "t?q={keyword}", "type": "all"},
{"path": "t?72&q={keyword}", "type": "movie"},
{"path": "t?73&q={keyword}", "type": "tv"},
],
},
category={
"movie": [{"id": 72, "cat": "Movies"}],
"tv": [{"id": 73, "cat": "TV"}],
},
)
movie_url = _get_search_url(indexer, "Movie 2026", MediaType.MOVIE)
tv_url = _get_search_url(indexer, "Series S01", MediaType.TV)
assert movie_url == "https://iptorrents.com/t?72&q=Movie%202026"
assert tv_url == "https://iptorrents.com/t?73&q=Series%20S01"
def test_music_browse_uses_dedicated_music_entry(): def test_music_browse_uses_dedicated_music_entry():
""" """
订阅刷新浏览音乐资源时应使用站点的音乐专用入口,而不是默认首页。 订阅刷新浏览音乐资源时应使用站点的音乐专用入口,而不是默认首页。
+5 -3
View File
@@ -42,12 +42,14 @@ def test_movie_search_keeps_movie_categories(mteam_spider):
def test_parse_result_marks_music_torrents(mteam_spider): def test_parse_result_marks_music_torrents(mteam_spider):
"""音乐分区种子应标记为音乐媒体类型,供音乐搜索链路筛选。""" """音乐分区种子应标记为音乐媒体类型,供音乐搜索链路筛选。"""
results = mteam_spider._MTorrentSpider__parse_result([ results = mteam_spider._MTorrentSpider__parse_result([
{"id": "1", "name": "周杰伦 - 七里香 [FLAC]", "category": "406", "size": "1024", "status": {}}, {"id": "1", "name": "周杰伦 - 七里香 [FLAC]", "category": "434", "size": "1024", "status": {}},
{"id": "2", "name": "流浪地球 2160p", "category": "419", "size": "1024", "status": {}}, {"id": "2", "name": "周杰伦演唱会", "category": "406", "size": "1024", "status": {}},
{"id": "3", "name": "其他资源", "category": "999", "size": "1024", "status": {}}, {"id": "3", "name": "流浪地球 2160p", "category": "419", "size": "1024", "status": {}},
{"id": "4", "name": "其他资源", "category": "999", "size": "1024", "status": {}},
]) ])
assert [torrent["category"] for torrent in results] == [ assert [torrent["category"] for torrent in results] == [
MediaType.MUSIC.value,
MediaType.MUSIC.value, MediaType.MUSIC.value,
MediaType.MOVIE.value, MediaType.MOVIE.value,
MediaType.UNKNOWN.value, MediaType.UNKNOWN.value,
+56
View File
@@ -61,3 +61,59 @@ def test_site_level_music_type_fills_missing_torrent_category():
spider._SiteSpider__get_category(None) spider._SiteSpider__get_category(None)
assert spider.torrents_info["category"] == MediaType.MUSIC.value assert spider.torrents_info["category"] == MediaType.MUSIC.value
def test_requested_result_media_type_overrides_unrepresentable_site_category():
"""音乐专属查询使用非主分类筛选时,应按显式契约把结果标记为音乐。"""
spider = SiteSpider(
indexer={
"id": "typed-music",
"name": "Typed Music",
"domain": "https://music.example/",
"search": {
"paths": [
{"path": "torrents?q={keyword}", "type": "all"},
{"path": "music?q={keyword}", "type": "music"},
],
"result_media_type": "requested",
},
"category": {
"movie": [{"id": "1", "name": "电影"}],
"music": [{"id": "20", "name": "音乐规格"}],
},
"torrents": {
"fields": {
"category": {"selector": "a.category"},
}
},
},
mtype=MediaType.MUSIC,
)
search_url = spider._SiteSpider__get_search_url()
spider._SiteSpider__get_category(None)
assert search_url == "https://music.example/music?q="
assert spider.torrents_info["category"] == MediaType.MUSIC.value
def test_requested_result_media_type_requires_an_active_type_filter():
"""未命中专属路径或分类参数时,不得把混合搜索结果强制标记为请求类型。"""
spider = SiteSpider(
indexer={
"id": "mixed",
"name": "Mixed",
"domain": "https://mixed.example/",
"search": {
"paths": [{"path": "torrents?q={keyword}", "type": "all"}],
"result_media_type": "requested",
},
"torrents": {"fields": {}},
},
mtype=MediaType.MOVIE,
)
spider._SiteSpider__get_search_url()
spider._SiteSpider__get_category(None)
assert "category" not in spider.torrents_info
+135
View File
@@ -0,0 +1,135 @@
import asyncio
from app.modules.indexer.spider.torrentleech import TorrentLeech
from app.schemas import MediaType
class _FakeResponse:
"""构造 TorrentLeech 搜索测试使用的最小 JSON 响应。"""
status_code = 200
def json(self) -> dict:
"""返回包含音乐分类种子的固定响应。"""
return {
"torrentList": [{
"fid": 100,
"filename": "album.torrent",
"name": "Artist Album FLAC",
"categoryID": 31,
"addedTimestamp": 1767225600,
"size": 1024,
}]
}
def _build_indexer() -> dict:
"""构造 TorrentLeech 音乐搜索所需的最小站点配置。"""
return {
"id": "torrentleech",
"name": "TorrentLeech",
"domain": "https://www.torrentleech.org/",
"ua": "MoviePilot-Test",
"category": {
"movie": [
{"id": 8, "cat": "Movies"},
{"id": 9, "cat": "Movies"},
],
"tv": [
{"id": 26, "cat": "TV"},
{"id": 32, "cat": "TV"},
],
"music": [
{"id": 31, "cat": "Music"},
{"id": 16, "cat": "Music"},
]
},
}
def test_torrentleech_music_search_filters_categories_and_maps_result(monkeypatch):
"""TorrentLeech 同步音乐搜索应提交 Audio/MV 分类并标记结果类型。"""
captured = {}
def fake_get_res(_request, url: str, **_kwargs):
"""记录同步搜索地址并回放 JSON 响应。"""
captured["url"] = url
return _FakeResponse()
monkeypatch.setattr(
"app.modules.indexer.spider.torrentleech.RequestUtils.get_res",
fake_get_res,
)
error, torrents = TorrentLeech(_build_indexer()).search(
keyword="Artist Album",
mtype=MediaType.MUSIC,
)
assert not error
assert captured["url"] == (
"https://www.torrentleech.org/torrents/browse/list/categories/31,16/"
"exact/1/query/Artist%20Album"
)
assert torrents[0]["category"] == MediaType.MUSIC.value
def test_torrentleech_video_search_uses_requested_category_contract(monkeypatch):
"""TorrentLeech 影视搜索应使用资源配置中的分类并标记请求类型。"""
captured = {}
def fake_get_res(_request, url: str, **_kwargs):
"""记录影视搜索地址并返回与电影分类匹配的固定响应。"""
captured["url"] = url
response = _FakeResponse()
response.json = lambda: {
"torrentList": [{
"fid": 101,
"filename": "movie.torrent",
"name": "Movie 2026",
"categoryID": 8,
"addedTimestamp": 1767225600,
"size": 2048,
}]
}
return response
monkeypatch.setattr(
"app.modules.indexer.spider.torrentleech.RequestUtils.get_res",
fake_get_res,
)
error, torrents = TorrentLeech(_build_indexer()).search(
keyword="Movie 2026",
mtype=MediaType.MOVIE,
)
assert not error
assert "/categories/8,9/" in captured["url"]
assert torrents[0]["category"] == MediaType.MOVIE.value
def test_torrentleech_async_music_search_uses_same_contract(monkeypatch):
"""TorrentLeech 异步音乐搜索应复用同步搜索的分类契约。"""
captured = {}
async def fake_get_res(_request, url: str, **_kwargs):
"""记录异步搜索地址并回放 JSON 响应。"""
captured["url"] = url
return _FakeResponse()
monkeypatch.setattr(
"app.modules.indexer.spider.torrentleech.AsyncRequestUtils.get_res",
fake_get_res,
)
error, torrents = asyncio.run(
TorrentLeech(_build_indexer()).async_search(
keyword="Artist Album",
mtype=MediaType.MUSIC,
)
)
assert not error
assert "/categories/31,16/" in captured["url"]
assert torrents[0]["category"] == MediaType.MUSIC.value
+75
View File
@@ -93,6 +93,7 @@ def test_yemapt_search_uses_open_api_auth_and_maps_fields(monkeypatch):
assert captured == { assert captured == {
"url": "https://www.yemapt.org/openApi/torrent/fetchOpenTorrentList.json", "url": "https://www.yemapt.org/openApi/torrent/fetchOpenTorrentList.json",
"json": { "json": {
"categoryId": 4,
"keyword": "Movie", "keyword": "Movie",
"pageParam": {"current": 3, "pageSize": 40}, "pageParam": {"current": 3, "pageSize": 40},
"sorter": {}, "sorter": {},
@@ -152,6 +153,80 @@ def test_yemapt_search_rejects_business_failure(monkeypatch):
assert torrents == [] assert torrents == []
def test_yemapt_maps_audio_and_music_video_categories_to_music():
"""YemaPT 音乐和 MV/演唱会分类都应映射为统一音乐类型。"""
results = YemaSpider(_build_indexer())._parse_result([
{"id": 101, "showName": "Album FLAC", "categoryId": 8},
{"id": 102, "showName": "Live Concert", "categoryId": 16},
])
assert [item["category"] for item in results] == [
MediaType.MUSIC.value,
MediaType.MUSIC.value,
]
def test_yemapt_music_search_queries_both_music_categories(monkeypatch):
"""YemaPT 音乐搜索应分别查询音乐和 MV/演唱会分类。"""
request_payloads = []
def fake_post_res(_request, url: str, json: dict = None, **_kwargs):
"""记录音乐分类请求并按分类返回一条种子。"""
request_payloads.append(json)
category_id = json["categoryId"]
return _FakeResponse({
"success": True,
"data": [{
"id": category_id,
"showName": f"Music {category_id}",
"categoryId": category_id,
}],
})
monkeypatch.setattr(
"app.modules.indexer.spider.yema.RequestUtils.post_res",
fake_post_res,
)
error, torrents = YemaSpider(_build_indexer()).search(
keyword="Artist",
mtype=MediaType.MUSIC,
)
assert not error
assert [payload["categoryId"] for payload in request_payloads] == [8, 16]
assert [torrent["category"] for torrent in torrents] == [
MediaType.MUSIC.value,
MediaType.MUSIC.value,
]
def test_yemapt_tv_search_queries_all_video_categories(monkeypatch):
"""YemaPT 剧集搜索应覆盖剧集、短剧、综艺、动漫、纪录片和体育。"""
request_payloads = []
def fake_post_res(_request, url: str, json: dict = None, **_kwargs):
"""记录剧集分类请求并返回空的成功响应。"""
request_payloads.append(json)
return _FakeResponse({"success": True, "data": []})
monkeypatch.setattr(
"app.modules.indexer.spider.yema.RequestUtils.post_res",
fake_post_res,
)
error, torrents = YemaSpider(_build_indexer()).search(
keyword="Series",
mtype=MediaType.TV,
)
assert not error
assert torrents == []
assert [payload["categoryId"] for payload in request_payloads] == [
5, 6, 13, 14, 15, 17,
]
def test_yemapt_async_search_uses_open_api(monkeypatch): def test_yemapt_async_search_uses_open_api(monkeypatch):
"""YemaPT 异步搜索应使用与同步搜索相同的开放 API 契约。""" """YemaPT 异步搜索应使用与同步搜索相同的开放 API 契约。"""
captured = {} captured = {}