feat: 按媒体类型筛选搜索站点

This commit is contained in:
jxxghp
2026-08-09 18:12:29 +08:00
parent 1f8d4adba0
commit 36666313b7
5 changed files with 167 additions and 2 deletions
+82
View File
@@ -36,6 +36,42 @@ from app.utils.string import StringUtils
router = APIRouter() 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]) @router.get("/", summary="所有站点", response_model=List[schemas.Site])
async def read_sites( async def read_sites(
db: AsyncSession = Depends(get_async_db), db: AsyncSession = Depends(get_async_db),
@@ -47,6 +83,52 @@ async def read_sites(
return await Site.async_list_order_by_pri(db) 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) @router.post("/", summary="新增站点", response_model=schemas.Response)
async def add_site( async def add_site(
*, *,
+6
View File
@@ -147,6 +147,12 @@ FastAPI 的 HTTP 异常在 v1、v2 均统一使用 `message`,不再返回顶
| POST | `/api/v1/transfer/manual/history` | 查询文件、批量文件或目录命中的成功整理历史摘要,用于进入手动整理界面时显示重新整理状态 | | POST | `/api/v1/transfer/manual/history` | 查询文件、批量文件或目录命中的成功整理历史摘要,用于进入手动整理界面时显示重新整理状态 |
| POST | `/api/v1/transfer/manual` | 手动整理;请求体可用 `media_source` + `media_id` 指定本次识别与刮削数据源,同时兼容 `tmdbid``doubanid``bangumiid``anilistid`;命中失败历史时自动清理旧目标和记录后重试,`reorganize=true` 时清理命中的成功历史和非移动模式旧目标后重新整理 | | 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` 或对应中文类型,音乐仅返回明确声明音乐能力的站点,影视不返回纯音乐站点 |
#### 搜索 / 种子 / 字幕 #### 搜索 / 种子 / 字幕
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
+3 -2
View File
@@ -1,6 +1,6 @@
--- ---
name: moviepilot-api name: moviepilot-api
version: 9 version: 10
description: >- description: >-
Use this skill when you need to call MoviePilot REST API endpoints directly Use this skill when you need to call MoviePilot REST API endpoints directly
with the bundled Python client. Covers MoviePilot HTTP endpoints across media 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/shares` | List shared subscriptions. Params: `name`, `page`, `count`, `genre_id`, `min_rating`, `max_rating`, `sort_type` |
| GET | `/api/v1/subscribe/share/statistics` | Share statistics | | GET | `/api/v1/subscribe/share/statistics` | Share statistics |
### Site (25 endpoints) ### Site (26 endpoints)
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| |--------|------|-------------|
| GET | `/api/v1/site/` | List all sites | | 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 | | POST | `/api/v1/site/` | Add site. Body: Site JSON |
| PUT | `/api/v1/site/` | Update site. Body: Site JSON | | PUT | `/api/v1/site/` | Update site. Body: Site JSON |
| GET | `/api/v1/site/{site_id}` | Site detail by ID | | GET | `/api/v1/site/{site_id}` | Site detail by ID |
+1
View File
@@ -102,6 +102,7 @@ def test_manage_page_endpoints_accept_manage_permission():
] ]
async_endpoints = [ async_endpoints = [
site_endpoint.read_sites, site_endpoint.read_sites,
site_endpoint.read_sites_by_media_type,
site_endpoint.add_site, site_endpoint.add_site,
site_endpoint.update_site, site_endpoint.update_site,
site_endpoint.update_sites_priority, site_endpoint.update_sites_priority,
+75
View File
@@ -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 == "不支持的媒体类型"