diff --git a/app/api/endpoints/site.py b/app/api/endpoints/site.py index e8a7982df..7586cddb2 100644 --- a/app/api/endpoints/site.py +++ b/app/api/endpoints/site.py @@ -36,6 +36,42 @@ from app.utils.string import StringUtils router = APIRouter() +def _indexer_supports_media_type(indexer: dict, media_type: MediaType) -> bool: + """ + 判断站点索引器是否支持指定媒体类型。 + + :param indexer: 站点索引器配置 + :param media_type: 待搜索的媒体类型 + :return: 是否应在该媒体类型的站点选择列表中显示 + """ + declared_media_type = indexer.get("media_type") + if isinstance(declared_media_type, MediaType): + site_media_type = declared_media_type + elif isinstance(declared_media_type, str): + site_media_type = MediaType.from_agent(declared_media_type) + else: + site_media_type = None + if site_media_type: + return site_media_type == media_type + + categories = indexer.get("category") or {} + if not isinstance(categories, dict): + return media_type != MediaType.MUSIC + + category_key = media_type.to_agent() + if media_type == MediaType.MUSIC: + return bool(categories.get(category_key)) + + declared_category_keys = { + item.to_agent() + for item in (MediaType.MOVIE, MediaType.TV, MediaType.MUSIC) + if categories.get(item.to_agent()) + } + if declared_category_keys: + return category_key in declared_category_keys + return True + + @router.get("/", summary="所有站点", response_model=List[schemas.Site]) async def read_sites( db: AsyncSession = Depends(get_async_db), @@ -47,6 +83,52 @@ async def read_sites( return await Site.async_list_order_by_pri(db) +@router.get( + "/media/{media_type}", + summary="按媒体类型获取可搜索站点", + response_model=List[schemas.Site], +) +async def read_sites_by_media_type( + media_type: str, + db: AsyncSession = Depends(get_async_db), + _: User = Depends(get_current_active_manage_user_async), +) -> List[Site]: + """ + 获取支持指定媒体类型的已配置启用站点。 + + :param media_type: Agent 媒体类型名称或中文媒体类型 + :param db: 异步数据库会话 + :return: 按优先级排序的可搜索站点 + """ + target_media_type = MediaType.from_agent(media_type) + if not target_media_type: + try: + target_media_type = MediaType(media_type) + except ValueError as error: + raise HTTPException(status_code=400, detail="不支持的媒体类型") from error + if target_media_type not in (MediaType.MOVIE, MediaType.TV, MediaType.MUSIC): + raise HTTPException(status_code=400, detail="不支持的媒体类型") + + supported_ids = set() + supported_domains = set() + for indexer in await SitesHelper().async_get_indexers() or []: + if not _indexer_supports_media_type(indexer, target_media_type): + continue + if indexer.get("id") is not None: + supported_ids.add(str(indexer.get("id"))) + domain = StringUtils.get_url_domain(indexer.get("domain")) + if domain: + supported_domains.add(domain) + + sites = await Site.async_list_order_by_pri(db) + return [ + site + for site in sites + if site.is_active + and (str(site.id) in supported_ids or site.domain in supported_domains) + ] + + @router.post("/", summary="新增站点", response_model=schemas.Response) async def add_site( *, diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 13991c490..d5b48aef8 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -147,6 +147,12 @@ FastAPI 的 HTTP 异常在 v1、v2 均统一使用 `message`,不再返回顶 | POST | `/api/v1/transfer/manual/history` | 查询文件、批量文件或目录命中的成功整理历史摘要,用于进入手动整理界面时显示重新整理状态 | | POST | `/api/v1/transfer/manual` | 手动整理;请求体可用 `media_source` + `media_id` 指定本次识别与刮削数据源,同时兼容 `tmdbid`、`doubanid`、`bangumiid`、`anilistid`;命中失败历史时自动清理旧目标和记录后重试,`reorganize=true` 时清理命中的成功历史和非移动模式旧目标后重新整理 | +#### 站点 + +| 方法 | 路径 | 说明 | +| :--- | :--- | :--- | +| GET | `/api/v1/site/media/{media_type}` | 按媒体类型查询已配置且启用的可搜索站点;`media_type` 支持 `movie`、`tv`、`music` 或对应中文类型,音乐仅返回明确声明音乐能力的站点,影视不返回纯音乐站点 | + #### 搜索 / 种子 / 字幕 | 方法 | 路径 | 说明 | diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index c85d22242..ee5c16347 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -1,6 +1,6 @@ --- name: moviepilot-api -version: 9 +version: 10 description: >- Use this skill when you need to call MoviePilot REST API endpoints directly with the bundled Python client. Covers MoviePilot HTTP endpoints across media @@ -252,11 +252,12 @@ Streaming search sends `{"type":"heartbeat"}` every 15 seconds without business | GET | `/api/v1/subscribe/shares` | List shared subscriptions. Params: `name`, `page`, `count`, `genre_id`, `min_rating`, `max_rating`, `sort_type` | | GET | `/api/v1/subscribe/share/statistics` | Share statistics | -### Site (25 endpoints) +### Site (26 endpoints) | Method | Path | Description | |--------|------|-------------| | GET | `/api/v1/site/` | List all sites | +| GET | `/api/v1/site/media/{media_type}` | List configured active sites compatible with `movie`, `tv`, or `music` searches | | POST | `/api/v1/site/` | Add site. Body: Site JSON | | PUT | `/api/v1/site/` | Update site. Body: Site JSON | | GET | `/api/v1/site/{site_id}` | Site detail by ID | diff --git a/tests/test_api_authorization.py b/tests/test_api_authorization.py index c010e2d5b..c9b5dd666 100644 --- a/tests/test_api_authorization.py +++ b/tests/test_api_authorization.py @@ -102,6 +102,7 @@ def test_manage_page_endpoints_accept_manage_permission(): ] async_endpoints = [ site_endpoint.read_sites, + site_endpoint.read_sites_by_media_type, site_endpoint.add_site, site_endpoint.update_site, site_endpoint.update_sites_priority, diff --git a/tests/test_site_media_filter.py b/tests/test_site_media_filter.py new file mode 100644 index 000000000..3a90ae5aa --- /dev/null +++ b/tests/test_site_media_filter.py @@ -0,0 +1,75 @@ +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from fastapi import HTTPException + +from app.api.endpoints import site as site_endpoint +from app.schemas.types import MediaType + + +@pytest.mark.parametrize( + ("indexer", "media_type", "expected"), + [ + ({"media_type": "music"}, MediaType.MUSIC, True), + ({"media_type": "music"}, MediaType.MOVIE, False), + ({"category": {"music": [{"id": "3"}]}}, MediaType.MUSIC, True), + ({"category": {"music": [{"id": "3"}]}}, MediaType.TV, False), + ({"category": {"movie": [{"id": "1"}]}}, MediaType.MOVIE, True), + ({"category": {}}, MediaType.MOVIE, True), + ({"category": {}}, MediaType.MUSIC, False), + ], +) +def test_indexer_supports_requested_media_type(indexer, media_type, expected): + """站点媒体声明和分类配置应生成正确的媒体类型兼容结果。""" + assert site_endpoint._indexer_supports_media_type(indexer, media_type) is expected + + +@pytest.mark.parametrize( + ("media_type", "expected_ids"), + [ + ("music", [2, 3]), + ("movie", [1, 3, 4]), + ("tv", [4]), + ], +) +def test_read_sites_by_media_type_filters_configured_active_sites(monkeypatch, media_type, expected_ids): + """按媒体类型查询时应保留兼容启用站点,并维持数据库优先级顺序。""" + sites = [ + SimpleNamespace(id=1, domain="movie.example", is_active=True), + SimpleNamespace(id=2, domain="music.example", is_active=True), + SimpleNamespace(id=3, domain="mixed.example", is_active=True), + SimpleNamespace(id=4, domain="generic.example", is_active=True), + SimpleNamespace(id=5, domain="inactive.example", is_active=False), + ] + indexers = [ + {"id": 1, "category": {"movie": [{"id": "1"}]}}, + {"id": 2, "media_type": "music"}, + {"id": 3, "category": {"movie": [{"id": "1"}], "music": [{"id": "3"}]}}, + {"id": 4, "category": {}}, + {"id": 5, "media_type": "music"}, + ] + list_sites = AsyncMock(return_value=sites) + get_indexers = AsyncMock(return_value=indexers) + monkeypatch.setattr(site_endpoint.Site, "async_list_order_by_pri", list_sites) + monkeypatch.setattr( + site_endpoint, + "SitesHelper", + lambda: SimpleNamespace(async_get_indexers=get_indexers), + ) + + result = asyncio.run(site_endpoint.read_sites_by_media_type(media_type, db=AsyncMock())) + + assert [site.id for site in result] == expected_ids + list_sites.assert_awaited_once() + get_indexers.assert_awaited_once() + + +def test_read_sites_by_media_type_rejects_unknown_type(): + """未知媒体类型应返回明确的客户端参数错误。""" + with pytest.raises(HTTPException) as error: + asyncio.run(site_endpoint.read_sites_by_media_type("podcast", db=AsyncMock())) + + assert error.value.status_code == 400 + assert error.value.detail == "不支持的媒体类型"