From 10e9ade7de0f533b182ecf933147a78c4f3d5fc2 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 31 Oct 2026 17:25:29 +0800 Subject: [PATCH] fix(site): preserve text category IDs and replay resource configs --- app/api/endpoints/site.py | 5 +- app/schemas/site.py | 8 +- docs/mcp-api.md | 4 + skills/moviepilot-api/SKILL.md | 4 + tests/fixtures/site_categories.json | 485 ++++++++++++++++++++++++++++ tests/test_api_optional_inputs.py | 16 +- tests/test_site_category.py | 114 +++++++ tests/test_site_resource.py | 9 +- 8 files changed, 632 insertions(+), 13 deletions(-) create mode 100644 tests/fixtures/site_categories.json create mode 100644 tests/test_site_category.py diff --git a/app/api/endpoints/site.py b/app/api/endpoints/site.py index 0cb4bc361..4f983d6c3 100644 --- a/app/api/endpoints/site.py +++ b/app/api/endpoints/site.py @@ -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: diff --git a/app/schemas/site.py b/app/schemas/site.py index 373e0d242..905616c71 100644 --- a/app/schemas/site.py +++ b/app/schemas/site.py @@ -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="分类说明") diff --git a/docs/mcp-api.md b/docs/mcp-api.md index fed444874..96f27c236 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -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 仍由完整原文生成。 diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index 72d788fbe..17fdfec44 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -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 diff --git a/tests/fixtures/site_categories.json b/tests/fixtures/site_categories.json new file mode 100644 index 000000000..9439ed2d1 --- /dev/null +++ b/tests/fixtures/site_categories.json @@ -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" + } + ] + } + } + } +} diff --git a/tests/test_api_optional_inputs.py b/tests/test_api_optional_inputs.py index bbec5e84e..3862ad155 100644 --- a/tests/test_api_optional_inputs.py +++ b/tests/test_api_optional_inputs.py @@ -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="音乐"), ] diff --git a/tests/test_site_category.py b/tests/test_site_category.py new file mode 100644 index 000000000..0e9b66db3 --- /dev/null +++ b/tests/test_site_category.py @@ -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] diff --git a/tests/test_site_resource.py b/tests/test_site_resource.py index b1137c785..179d24446 100644 --- a/tests/test_site_resource.py +++ b/tests/test_site_resource.py @@ -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, )