fix(site): preserve text category IDs and replay resource configs

This commit is contained in:
jxxghp
2026-10-31 17:25:29 +08:00
parent 4dfcac8d04
commit 10e9ade7de
8 changed files with 632 additions and 13 deletions

View File

@@ -672,7 +672,10 @@ async def site_category(
count: CompatibleCountParam = None,
) -> Any:
"""
获取站点分类
获取站点分类,排除搜索参数并按配置顺序合并去重。
:param site_id: 已保存的站点标识
:return: 站点分类映射,分类标识支持数字和文本
"""
site = await query.get(site_id)
if not site:

View File

@@ -128,10 +128,14 @@ class SiteCookieSet(BaseModel):
class SiteCategory(BaseModel):
"""
站点资源分类。
站点资源分类,标识来自站点索引配置,可以是数字或文本。
"""
id: Optional[int] = Field(default=None, description="记录标识")
id: Optional[Union[int, str]] = Field(
default=None,
union_mode="left_to_right",
description="站点分类标识;数字字符串沿用整数响应,其余文本保持原值",
)
cat: Optional[str] = Field(default=None, description="站点分类标识")
desc: Optional[str] = Field(default=None, description="分类说明")

View File

@@ -53,6 +53,10 @@
并传入 `latest_seeding_only=true`,仅窗口内最新记录携带 `seeding_info`,历史统计字段不变。
省略该选项时保留每条记录的完整明细;这不会删除或修改采集历史。
`GET /api/v1/site/category/{site_id}` 只返回分类项,不包含 `field`、`delimiter`、`param`
等搜索配置。分类 `id` 支持整数和文本,数字字符串沿用整数响应;客户端应保留返回值,
浏览资源时将所选分类用逗号拼接后传入 `site.resource` 的 `query.cat`,不要强制转为整数。
`GET /api/v1/torrent/cache` 支持可选 `page` / `count`,每页最多 200 条,省略两项时保留全量合同。
`title` 为不区分大小写的标题包含条件,`site` 为站点名称精确条件,均在分页前应用。
`brief=true` 将描述截至 200 字符,缓存 hash 仍由完整原文生成。

View File

@@ -327,6 +327,10 @@ Select the operation for the task first, then send only fields declared by that
| `download.tasks.active` | none | optional `page`, `count`, `name` |
| `site.list` | none | optional `page`, `count`, `name`, `status=all\|active\|inactive` |
`site.category` 只返回站点分类,不返回分类搜索参数。分类 `id` 可以是整数或文本,
数字字符串沿用整数响应。将所选标识用逗号拼接后传入 `site.resource` 的 `query.cat`,
保留返回值,不要把文本分类强制转为整数。
Subscription execution logs are browser resource endpoints:
`GET /api/v1/subscribe/{subscription_id}/logs` (SSE, or text with `length=-1`)
and `/logs/download` (ZIP). They require the existing resource Cookie and enforce

485
tests/fixtures/site_categories.json vendored Normal file
View File

@@ -0,0 +1,485 @@
{
"source": {
"repository": "jxxghp/MoviePilot-Build",
"commit": "085e3d3cd2b0a44f83346f2aca97cc658d9adaeb",
"files": {
"hhanclub": "sites/private/hhanclub.yml",
"totheglory": "sites/private/totheglory.yml",
"animez": "sites/private/animez.yml",
"starspace": "sites/private/starspace.yml",
"anibt": "sites/public/anibt.yml"
}
},
"indexers": {
"hhanclub": {
"id": "hhanclub",
"name": "憨憨",
"domain": "https://hhanclub.net/",
"encoding": "UTF-8",
"schema": "NexusHhanclub",
"search": {
"paths": [
{
"path": "torrents.php",
"method": "get",
"type": "all"
},
{
"path": "special.php",
"method": "get",
"type": "music"
}
],
"params": {
"search": "{keyword}",
"search_area": 4
},
"batch": {
"delimiter": " ",
"space_replace": "_"
}
},
"category": {
"param": "cat[]",
"movie": [
{
"id": 401,
"cat": "Movies",
"desc": "Movies/电影"
},
{
"id": 405,
"cat": "Anime",
"desc": "Animations/动漫"
},
{
"id": 404,
"cat": "Documentary",
"desc": "Documentaries/纪录片"
}
],
"tv": [
{
"id": 404,
"cat": "Documentary",
"desc": "Documentaries/纪录片"
},
{
"id": 405,
"cat": "Anime",
"desc": "Animations/动漫"
},
{
"id": 402,
"cat": "TV",
"desc": "TV Series/连续剧"
},
{
"id": 403,
"cat": "TV",
"desc": "TV Shows/综艺"
}
],
"music": [
{
"id": 410,
"cat": "Music",
"desc": "Music/音乐"
}
]
}
},
"totheglory": {
"id": "ttg",
"name": "听听歌",
"domain": "https://totheglory.im/",
"encoding": "UTF-8",
"schema": "NexusPhp",
"search": {
"paths": [
{
"path": "browse.php?c=M"
}
],
"params": {
"search_field": "{keyword}",
"c": "M"
},
"imdbid_format": "imdb{imdbid_num}",
"batch": {
"delimiter": "+",
"space_replace": "+"
}
},
"category": {
"field": "search_field",
"delimiter": " 分类:",
"movie": [
{
"id": "电影DVDRip",
"cat": "Movies/SD"
},
{
"id": "电影720p",
"cat": "Movies/HD"
},
{
"id": "电影1080i/p",
"cat": "Movies/HD"
},
{
"id": "BluRay原盘",
"cat": "Movies/BluRay"
},
{
"id": "影视2160p",
"cat": "Movies/UHD"
},
{
"id": "UHD原盘",
"cat": "Movies/UHD"
},
{
"id": "高清动漫",
"cat": "Anime/HD"
},
{
"id": "动漫原盘",
"cat": "Anime/BluRay"
}
],
"tv": [
{
"id": "纪录片720p",
"cat": "Movies/HD"
},
{
"id": "纪录片1080i/p",
"cat": "Movies/HD"
},
{
"id": "纪录片BluRay原盘",
"cat": "Movies/BluRay"
},
{
"id": "影视2160p",
"cat": "TV/UHD"
},
{
"id": "欧美剧720p",
"cat": "TV/HD"
},
{
"id": "欧美剧1080i/p",
"cat": "TV/HD"
},
{
"id": "高清日剧",
"cat": "TV/HD"
},
{
"id": "大陆港台剧1080i/p",
"cat": "TV/HD"
},
{
"id": "大陆港台剧720p",
"cat": "TV/HD"
},
{
"id": "高清韩剧",
"cat": "TV/HD"
},
{
"id": "欧美剧包",
"cat": "TV/HD"
},
{
"id": "日剧包",
"cat": "TV/HD"
},
{
"id": "韩剧包",
"cat": "TV/HD"
},
{
"id": "华语剧包",
"cat": "TV/HD"
},
{
"id": "高清动漫",
"cat": "Anime/HD"
},
{
"id": "韩国综艺",
"cat": "TV/HD"
},
{
"id": "日本综艺",
"cat": "TV/HD"
},
{
"id": "高清综艺",
"cat": "TV/HD"
},
{
"id": "动漫原盘",
"cat": "Anime/BluRay"
}
],
"music": [
{
"id": "无损音乐FLAC&APE",
"cat": "Music"
}
]
}
},
"animez": {
"id": "animez",
"name": "AnimeZ",
"domain": "https://animez.to/",
"encoding": "UTF-8",
"schema": "NexusPhp",
"search": {
"paths": [
{
"path": "torrents.php",
"method": "get"
}
],
"params": {
"search": "{keyword}"
},
"result_media_type": "requested"
},
"category": {
"movie": [
{
"id": "MOVIE",
"value": "MOVIE",
"param": "format[]",
"cat": "Anime",
"desc": "Anime/Movie"
},
{
"id": "SPECIAL",
"value": "SPECIAL",
"param": "format[]",
"cat": "Anime",
"desc": "Anime/Special"
},
{
"id": "OVA",
"value": "OVA",
"param": "format[]",
"cat": "Anime",
"desc": "Anime/OVA"
},
{
"id": "ONA",
"value": "ONA",
"param": "format[]",
"cat": "Anime",
"desc": "Anime/ONA"
}
],
"tv": [
{
"id": "TV",
"value": "TV",
"param": "format[]",
"cat": "Anime",
"desc": "Anime/TV"
},
{
"id": "TV_SHORT",
"value": "TV_SHORT",
"param": "format[]",
"cat": "Anime",
"desc": "Anime/TV Short"
},
{
"id": "SPECIAL",
"value": "SPECIAL",
"param": "format[]",
"cat": "Anime",
"desc": "Anime/Special"
},
{
"id": "OVA",
"value": "OVA",
"param": "format[]",
"cat": "Anime",
"desc": "Anime/OVA"
},
{
"id": "ONA",
"value": "ONA",
"param": "format[]",
"cat": "Anime",
"desc": "Anime/ONA"
}
],
"music": [
{
"id": "MUSIC",
"value": "MUSIC",
"param": "format[]",
"cat": "Music",
"desc": "Anime/Music"
}
]
}
},
"starspace": {
"id": "starspace",
"name": "星空",
"domain": "https://star-space.net/",
"encoding": "UTF-8",
"schema": "NexusPhp",
"search": {
"paths": [
{
"path": "p_torrent/video_list_t.php",
"method": "get",
"type": "all"
},
{
"path": "p_music/music_list_t.php",
"method": "get",
"type": "music"
}
],
"params": {
"search": "{keyword}",
"area": "name"
},
"result_media_type": "requested",
"batch": {
"delimiter": " ",
"space_replace": "_"
}
},
"category": {
"movie": [
{
"id": 401,
"cat": "Movies",
"desc": "Movies/电影"
},
{
"id": 405,
"cat": "Anime",
"desc": "Animations/动漫"
},
{
"id": 404,
"cat": "Documentary",
"desc": "Documentaries/纪录片"
}
],
"tv": [
{
"id": 404,
"cat": "Documentary",
"desc": "Documentaries/纪录片"
},
{
"id": 405,
"cat": "Anime",
"desc": "Animations/动漫"
},
{
"id": 402,
"cat": "TV",
"desc": "TV Series/连续剧"
},
{
"id": 403,
"cat": "TV",
"desc": "TV Shows/综艺"
}
],
"music": [
{
"id": "music",
"cat": "Music",
"desc": "音乐区"
}
]
}
},
"anibt": {
"id": "anibt",
"name": "AniBT",
"domain": "https://anibt.net/",
"encoding": "UTF-8",
"search": {
"paths": [
{
"path": "rss/magnets.xml",
"method": "get",
"type": "all"
},
{
"path": "rss/magnets.xml",
"method": "get",
"type": "movie"
},
{
"path": "rss/magnets.xml",
"method": "get",
"type": "tv"
},
{
"path": "rss/other/music.xml",
"method": "get",
"type": "music"
}
],
"params": {
"q": "{keyword}"
},
"result_media_type": "requested"
},
"category": {
"movie": [
{
"id": "anime",
"cat": "Anime",
"desc": "Anime"
},
{
"id": "raw",
"cat": "Anime",
"desc": "Raw"
}
],
"tv": [
{
"id": "anime",
"cat": "Anime",
"desc": "Anime"
},
{
"id": "raw",
"cat": "Anime",
"desc": "Raw"
}
],
"music": [
{
"id": "music",
"cat": "Music",
"desc": "Music"
},
{
"id": "stage",
"cat": "Music",
"desc": "Stage"
}
]
}
}
}
}

View File

@@ -128,23 +128,27 @@ def test_site_category_only_projects_category_mappings(category, monkeypatch):
def test_site_category_skips_search_parameter_settings(monkeypatch):
"""
分类配置中的 field、delimiter、param 等搜索参数不能被当成分类项逐字符返回。
分类搜索参数及非法列表项不进入响应,音乐分类与跨分组去重保持兼容。
"""
category = {
"field": "cat[]",
"delimiter": "&",
"param": "cat",
"param": "cat[]",
"movie": [{"id": 401, "cat": "Movies", "desc": "电影"}, "invalid"],
"tv": [{"id": 402, "cat": "TV", "desc": "剧集"}],
"tv": [{"id": 401, "cat": "Movies", "desc": "电影"}, {"id": 402, "cat": "TV", "desc": "剧集"}],
"music": [{"id": 410, "cat": "Music", "desc": "音乐"}],
}
query = SimpleNamespace(get=AsyncMock(return_value=SimpleNamespace(domain="https://example.test")))
helper = SimpleNamespace(async_get_indexer=AsyncMock(return_value={"category": category}))
monkeypatch.setattr(site, "SitesHelper", Mock(return_value=helper))
result = asyncio.run(site.site_category(1, query=query, _=None))
assert result == [{"id": 401, "cat": "Movies", "desc": "电影"}, {"id": 402, "cat": "TV", "desc": "剧集"}]
assert result == [
{"id": 401, "cat": "Movies", "desc": "电影"},
{"id": 402, "cat": "TV", "desc": "剧集"},
{"id": 410, "cat": "Music", "desc": "音乐"},
]
assert TypeAdapter(list[SiteCategory]).validate_python(result) == [
SiteCategory(id=401, cat="Movies", desc="电影"),
SiteCategory(id=402, cat="TV", desc="剧集"),
SiteCategory(id=410, cat="Music", desc="音乐"),
]

114
tests/test_site_category.py Normal file
View File

@@ -0,0 +1,114 @@
"""
回放构建仓的站点分类配置,验证接口序列化和搜索参数兼容性。
"""
import copy
import json
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
from urllib.parse import parse_qs, urlparse
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api.endpoints import site
from app.modules.indexer.spider import SiteSpider
from app.schemas.site import SiteCategory
from app.schemas.types import MediaType
@pytest.fixture
def category_indexers():
"""
读取构建仓的公开 YAML 快照,来源提交和路径记录在载具中。
"""
fixture_path = Path(__file__).parent / "fixtures" / "site_categories.json"
return json.loads(fixture_path.read_text(encoding="utf-8"))["indexers"]
@pytest.mark.parametrize(
("site_id", "expected_ids"),
[
("hhanclub", [401, 405, 404, 402, 403, 410]),
("animez", ["MOVIE", "SPECIAL", "OVA", "ONA", "TV", "TV_SHORT", "MUSIC"]),
("starspace", [401, 405, 404, 402, 403, "music"]),
("anibt", ["anime", "raw", "music", "stage"]),
(
"totheglory",
[
"电影DVDRip", "电影720p", "电影1080i/p", "BluRay原盘", "影视2160p", "UHD原盘",
"高清动漫", "动漫原盘", "纪录片720p", "纪录片1080i/p", "纪录片BluRay原盘", "影视2160p",
"欧美剧720p", "欧美剧1080i/p", "高清日剧", "大陆港台剧1080i/p", "大陆港台剧720p",
"高清韩剧", "欧美剧包", "日剧包", "韩剧包", "华语剧包", "韩国综艺", "日本综艺",
"高清综艺", "无损音乐FLAC&APE",
],
),
],
)
def test_site_categories_serialize_real_resource_configs(category_indexers, monkeypatch, site_id, expected_ids):
"""
真实分类经过响应封装后保留数字与文本标识、音乐分组及去重顺序。
"""
indexer = category_indexers[site_id]
snapshot = copy.deepcopy(indexer)
query = SimpleNamespace(get=AsyncMock(return_value=SimpleNamespace(domain=indexer["domain"])))
helper = SimpleNamespace(async_get_indexer=AsyncMock(return_value=indexer))
monkeypatch.setattr(site, "SitesHelper", Mock(return_value=helper))
app = FastAPI()
app.include_router(site.router, prefix="/api/v1/site")
app.dependency_overrides[site.get_site_query_service] = lambda: query
app.dependency_overrides[site.verify_token] = lambda: None
with TestClient(app) as client:
response = client.get("/api/v1/site/category/1")
assert response.status_code == 200
body = response.json()
assert body["success"] is True
assert [item["id"] for item in body["data"]] == expected_ids
assert indexer == snapshot
@pytest.mark.parametrize(
("category_id", "expected"),
[(401, 401), ("401", 401), ("00401", 401), ("music", "music"), ("电影720p", "电影720p"), (None, None)],
)
def test_category_schema_preserves_text_and_legacy_numeric_ids(category_id, expected):
"""
数字字符串沿用旧接口的整数输出,真实文本标识保持原值。
"""
category = SiteCategory(id=category_id)
assert category.id == expected
assert type(category.id) is type(expected)
@pytest.mark.parametrize(
("site_id", "media_type", "category_id", "parameter", "expected_value", "path"),
[
("hhanclub", MediaType.MOVIE, "401", "cat[]", "401", "/torrents.php"),
("hhanclub", MediaType.MUSIC, "410", "cat[]", "410", "/special.php"),
("animez", MediaType.MUSIC, "MUSIC", "format[]", "MUSIC", "/torrents.php"),
("totheglory", MediaType.MOVIE, "电影DVDRip", "search_field", "回放测试 分类:电影DVDRip", "/browse.php"),
(
"totheglory", MediaType.MUSIC, "无损音乐FLAC&APE", "search_field",
"回放测试 分类:无损音乐FLAC&APE", "/browse.php",
),
],
)
def test_resource_categories_keep_real_search_parameters(
category_indexers, site_id, media_type, category_id, parameter, expected_value, path,
):
"""
接口返回的数字及文本分类仍能生成站点要求的精确搜索参数。
"""
spider = SiteSpider(
indexer=category_indexers[site_id], keyword="回放测试", mtype=media_type, cat=category_id,
)
parsed = urlparse(spider._SiteSpider__get_search_url())
assert parsed.path == path
assert parse_qs(parsed.query)[parameter] == [expected_value]

View File

@@ -12,9 +12,10 @@ from app.api.endpoints import site as site_endpoint
@pytest.mark.parametrize("page", [0, 1, 2])
def test_site_resource_preserves_zero_based_pages_and_categories(monkeypatch, page):
@pytest.mark.parametrize("cat", ["401,402", "电影DVDRip,MUSIC,music"])
def test_site_resource_preserves_zero_based_pages_and_categories(monkeypatch, page, cat):
"""
第一页与后续页必须保持独立偏移,多选分类原样交给浏览链路。
第一页与后续页保持独立偏移,数字及文本多选分类原样交给浏览链路。
"""
query = SimpleNamespace(get=AsyncMock(return_value=SimpleNamespace(domain="https://site.example/")))
torrent = SimpleNamespace(to_dict=lambda: {"title": "命中资源"})
@@ -22,11 +23,11 @@ def test_site_resource_preserves_zero_based_pages_and_categories(monkeypatch, pa
monkeypatch.setattr(site_endpoint, "TorrentsChain", lambda: SimpleNamespace(async_browse=browse))
result = asyncio.run(site_endpoint.site_resource(
site_id=1, keyword="关键词", cat="401,402", page=page, query=query,
site_id=1, keyword="关键词", cat=cat, page=page, query=query,
))
assert result == [{"title": "命中资源"}]
query.get.assert_awaited_once_with(1)
browse.assert_awaited_once_with(
domain="https://site.example/", keyword="关键词", cat="401,402", page=page, mtype=None,
domain="https://site.example/", keyword="关键词", cat=cat, page=page, mtype=None,
)