mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-06 16:07:01 +08:00
feat: 按媒体类型筛选搜索站点
This commit is contained in:
@@ -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(
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -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` 或对应中文类型,音乐仅返回明确声明音乐能力的站点,影视不返回纯音乐站点 |
|
||||||
|
|
||||||
#### 搜索 / 种子 / 字幕
|
#### 搜索 / 种子 / 字幕
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 == "不支持的媒体类型"
|
||||||
Reference in New Issue
Block a user