Files
MoviePilot/tests/test_media_scrape_endpoint.py
T

280 lines
11 KiB
Python

from unittest.mock import AsyncMock, Mock, patch
import pytest
from app.api.endpoints import media as media_endpoint
from app.api.endpoints.media import recognize_file, scrape
from app.domain.context import Context, MediaInfo
from app.domain.meta.metabase import MetaBase
from app.domain.meta.metamusic import MetaMusic
from app.domain.context import MusicInfo
from app.schemas import FileItem, MediaType
from app.schemas.types import MediaSource
def test_scrape_uses_explicit_media_source_and_id() -> None:
"""手动刮削应使用请求指定的数据源原生ID,并传给后续刮削流程。"""
fileitem = FileItem(storage="alist", path="/movies/Test Movie (2026).mkv", type="file")
media_info = MediaInfo(title="测试电影", type=MediaType.MOVIE)
chain = Mock()
chain.recognize_media.return_value = media_info
scraping_chain = Mock()
with patch("app.api.endpoints.media.MediaChain", return_value=chain) as mock_chain, \
patch("app.api.endpoints.media.ScrapingChain", return_value=scraping_chain):
# mkv 非音频文件,需显式关闭 Mock 的 is_audio_path 避免误入音乐分支
mock_chain.is_audio_path.return_value = False
result = scrape(
fileitem=fileitem,
storage="alist",
media_source=MediaSource.Douban,
media_id="123456",
type_name=MediaType.MOVIE,
_=Mock(),
)
assert result.success is True
chain.recognize_by_path.assert_not_called()
recognize_kwargs = chain.recognize_media.call_args.kwargs
assert recognize_kwargs["media_source"] == MediaSource.Douban
assert recognize_kwargs["media_id"] == "123456"
assert recognize_kwargs["mtype"] == MediaType.MOVIE
chain.obtain_images.assert_called_once_with(mediainfo=media_info)
assert media_info.scrape_source == MediaSource.Douban
scrape_kwargs = scraping_chain.scrape_metadata.call_args.kwargs
assert scrape_kwargs["fileitem"] is fileitem
assert scrape_kwargs["mediainfo"] is media_info
assert scrape_kwargs["overwrite"] is True
def test_scrape_keeps_automatic_recognition_compatible() -> None:
"""未指定媒体ID时应继续按路径识别,并允许仅限定请求级数据源。"""
fileitem = FileItem(storage="alist", path="/tv/Test Show S01E01.mkv", type="file")
meta_info = MetaBase("Test Show S01E01")
media_info = MediaInfo(title="测试剧集", type=MediaType.TV)
chain = Mock()
chain.recognize_by_path.return_value = Context(meta_info=meta_info, media_info=media_info)
scraping_chain = Mock()
with patch("app.api.endpoints.media.MediaChain", return_value=chain) as mock_chain, \
patch("app.api.endpoints.media.ScrapingChain", return_value=scraping_chain):
# mkv 非音频文件,需显式关闭 Mock 的 is_audio_path 避免误入音乐分支
mock_chain.is_audio_path.return_value = False
result = scrape(
fileitem=fileitem,
storage="alist",
media_source=MediaSource.Bangumi,
_=Mock(),
)
assert result.success is True
chain.recognize_by_path.assert_called_once_with(
fileitem.path,
media_source=MediaSource.Bangumi,
obtain_images=True,
)
chain.recognize_media.assert_not_called()
assert media_info.scrape_source == MediaSource.Bangumi
scraping_chain.scrape_metadata.assert_called_once_with(
fileitem=fileitem,
meta=meta_info,
mediainfo=media_info,
overwrite=True,
)
def test_scrape_rejects_media_id_without_source() -> None:
"""原生媒体ID缺少所属数据源时应直接返回明确错误。"""
result = scrape(
fileitem=FileItem(storage="alist", path="/movies/Test.mkv", type="file"),
storage="alist",
media_id="123456",
_=Mock(),
)
assert result.success is False
assert result.message == "指定媒体ID时必须同时指定媒体数据源"
def test_scrape_rejects_zero_media_id_before_recognition() -> None:
"""刮削入口收到零值身份时不得进入识别或刮削链。"""
fileitem = FileItem(storage="local", path="/tmp/test.mkv", type="file")
media_chain = Mock(side_effect=AssertionError("零值身份不应创建媒体链"))
scraping_chain = Mock(side_effect=AssertionError("零值身份不应创建刮削链"))
with patch("app.api.endpoints.media.MediaChain", media_chain), patch(
"app.api.endpoints.media.ScrapingChain", scraping_chain
):
result = scrape(
fileitem=fileitem,
media_source=MediaSource.TMDB,
media_id="0",
type_name=MediaType.MOVIE,
_=Mock(),
)
assert result.success is False
assert result.message == "媒体ID格式无效"
media_chain.assert_not_called()
scraping_chain.assert_not_called()
@pytest.mark.parametrize("media_source", list(MediaSource))
def test_source_media_id_validator_rejects_zero_for_every_source(
media_source: MediaSource,
) -> None:
"""全部固定媒体来源都应把零值原生 ID 视为无效身份。"""
assert not media_endpoint._is_valid_source_media_id(media_source, "0")
def test_recognize_file_routes_audio_to_music_chain() -> None:
"""文件管理识别音频文件时应经统一路径识别入口返回音乐专属上下文。"""
chain = Mock()
chain.async_recognize_by_path = AsyncMock(
return_value=Context(
meta_info=MetaMusic(title="晴天", artists=["周杰伦"]),
media_info=MusicInfo(
media_source="musicbrainz",
media_id="977e6978-139d-425c-bb98-6b0c62d1e45e",
title="晴天",
artists=["周杰伦"],
),
)
)
import asyncio
with patch("app.api.endpoints.media.MediaChain", return_value=chain):
result = asyncio.run(recognize_file(path="/music/晴天.flac", _=Mock()))
assert result["meta_info"]["type"] == "音乐"
assert result["media_info"]["title"] == "晴天"
chain.async_recognize_by_path.assert_awaited_once_with(
"/music/晴天.flac", media_source=None
)
def test_scrape_music_uses_musicbrainz_uuid_and_music_scraper() -> None:
"""手动音乐刮削应接受 MusicBrainz UUID 并经统一识别入口后写入音乐标签。"""
fileitem = FileItem(storage="local", path="/music/晴天.flac", type="file")
info = MusicInfo(
media_source="musicbrainz",
media_id="977e6978-139d-425c-bb98-6b0c62d1e45e",
title="晴天",
)
media_chain = Mock()
media_chain.recognize_media.return_value = info
scraping_chain = Mock()
scraping_chain.scrape_music_metadata.return_value = (True, "已刮削 1 个音频文件")
with patch("app.api.endpoints.media.MediaChain", return_value=media_chain), \
patch("app.api.endpoints.media.ScrapingChain", return_value=scraping_chain):
result = scrape(
fileitem=fileitem,
storage="local",
media_source="musicbrainz",
media_id="977e6978-139d-425c-bb98-6b0c62d1e45e",
type_name=MediaType.MUSIC,
music_type="recording",
_=Mock(),
)
assert result.success is True
media_chain.recognize_media.assert_called_once_with(
media_source="musicbrainz",
media_id="977e6978-139d-425c-bb98-6b0c62d1e45e",
mtype=MediaType.MUSIC,
music_type="recording",
)
scraping_chain.scrape_music_metadata.assert_called_once_with(
fileitem=fileitem,
mediainfo=info,
overwrite=True,
media_source="musicbrainz",
)
def test_scrape_music_without_source_keeps_automatic_recognition() -> None:
"""未选择音乐源时刮削入口应传递空来源,让底层比较全部识别源。"""
fileitem = FileItem(storage="local", path="/music/晴天.flac", type="file")
media_chain = Mock()
scraping_chain = Mock()
scraping_chain.scrape_music_metadata.return_value = (True, "已刮削 1 个音频文件")
with patch("app.api.endpoints.media.MediaChain", return_value=media_chain), \
patch("app.api.endpoints.media.ScrapingChain", return_value=scraping_chain):
result = scrape(
fileitem=fileitem,
storage="local",
type_name=MediaType.MUSIC,
_=Mock(),
)
assert result.success is True
scraping_chain.scrape_music_metadata.assert_called_once_with(
fileitem=fileitem,
mediainfo=None,
overwrite=True,
media_source=None,
)
def test_scrape_music_album_forwards_album_namespace() -> None:
"""手动专辑刮削必须把 Release Group ID 标记为 album。"""
fileitem = FileItem(storage="local", path="/music/叶惠美", type="dir")
info = MusicInfo(
media_source="musicbrainz",
media_id="977e6978-139d-425c-bb98-6b0c62d1e45e",
music_type="album",
title="叶惠美",
)
media_chain = Mock()
media_chain.recognize_media.return_value = info
scraping_chain = Mock()
scraping_chain.scrape_music_metadata.return_value = (True, "已刮削专辑")
with patch("app.api.endpoints.media.MediaChain", return_value=media_chain), \
patch("app.api.endpoints.media.ScrapingChain", return_value=scraping_chain):
result = scrape(
fileitem=fileitem,
storage="local",
media_source="musicbrainz",
media_id="977e6978-139d-425c-bb98-6b0c62d1e45e",
type_name=MediaType.MUSIC,
music_type="album",
_=Mock(),
)
assert result.success is True
assert media_chain.recognize_media.call_args.kwargs["music_type"] == "album"
def test_scrape_music_accepts_douban_recording_composite_id() -> None:
"""豆瓣音乐曲目 ID 使用“专辑ID:曲序”时应通过入口校验。"""
fileitem = FileItem(storage="local", path="/music/晴天.flac", type="file")
info = MusicInfo(
media_source="doubanmusic",
media_id="1401853:3",
music_type="recording",
title="晴天",
)
media_chain = Mock()
media_chain.recognize_media.return_value = info
scraping_chain = Mock()
scraping_chain.scrape_music_metadata.return_value = (True, "已刮削 1 个音频文件")
with patch("app.api.endpoints.media.MediaChain", return_value=media_chain), \
patch("app.api.endpoints.media.ScrapingChain", return_value=scraping_chain):
result = scrape(
fileitem=fileitem,
storage="local",
media_source="doubanmusic",
media_id="1401853:3",
type_name=MediaType.MUSIC,
music_type="recording",
_=Mock(),
)
assert result.success is True
assert media_chain.recognize_media.call_args.kwargs["media_id"] == "1401853:3"