mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-21 16:23:34 +08:00
refactor(media): unify source identity and music browsing
This commit is contained in:
@@ -1116,7 +1116,8 @@ meta_cases = [{
|
||||
"pix": "1080p",
|
||||
"video_codec": "",
|
||||
"audio_codec": "",
|
||||
"tmdbid": 18165,
|
||||
"media_source": "themoviedb",
|
||||
"media_id": "18165",
|
||||
"fps": None
|
||||
}
|
||||
}, {
|
||||
@@ -1133,7 +1134,8 @@ meta_cases = [{
|
||||
"pix": "1080p",
|
||||
"video_codec": "",
|
||||
"audio_codec": "",
|
||||
"tmdbid": 27205,
|
||||
"media_source": "themoviedb",
|
||||
"media_id": "27205",
|
||||
"fps": None
|
||||
}
|
||||
}, {
|
||||
@@ -1150,7 +1152,8 @@ meta_cases = [{
|
||||
"pix": "",
|
||||
"video_codec": "",
|
||||
"audio_codec": "",
|
||||
"tmdbid": 1396
|
||||
"media_source": "themoviedb",
|
||||
"media_id": "1396"
|
||||
}
|
||||
}, {
|
||||
"path": "/movies/Breaking Bad (2008) [tmdb=1396]/S2/",
|
||||
@@ -1166,7 +1169,8 @@ meta_cases = [{
|
||||
"pix": "",
|
||||
"video_codec": "",
|
||||
"audio_codec": "",
|
||||
"tmdbid": 1396
|
||||
"media_source": "themoviedb",
|
||||
"media_id": "1396"
|
||||
}
|
||||
}, {
|
||||
"path": "/movies/Breaking Bad (2008) [tmdb=1396]/Season 1/Breaking.Bad.S01E01.1080p.mkv",
|
||||
@@ -1182,7 +1186,8 @@ meta_cases = [{
|
||||
"pix": "1080p",
|
||||
"video_codec": "",
|
||||
"audio_codec": "",
|
||||
"tmdbid": 1396,
|
||||
"media_source": "themoviedb",
|
||||
"media_id": "1396",
|
||||
"fps": None
|
||||
}
|
||||
}, {
|
||||
@@ -1199,7 +1204,8 @@ meta_cases = [{
|
||||
"pix": "1080p",
|
||||
"video_codec": "",
|
||||
"audio_codec": "",
|
||||
"tmdbid": 1399,
|
||||
"media_source": "themoviedb",
|
||||
"media_id": "1399",
|
||||
"fps": None
|
||||
}
|
||||
}, {
|
||||
@@ -1216,7 +1222,8 @@ meta_cases = [{
|
||||
"pix": "1080p",
|
||||
"video_codec": "",
|
||||
"audio_codec": "",
|
||||
"tmdbid": 19995,
|
||||
"media_source": "themoviedb",
|
||||
"media_id": "19995",
|
||||
"fps": None
|
||||
}
|
||||
}, {
|
||||
|
||||
@@ -11,8 +11,14 @@ from app.agent.tools.impl._torrent_search_utils import simplify_search_result
|
||||
from app.agent.tools.impl.add_download_tasks import AddDownloadTasksTool
|
||||
from app.agent.tools.impl.add_subscribe import AddSubscribeTool
|
||||
from app.agent.tools.impl.get_recommendations import GetRecommendationsTool
|
||||
from app.agent.tools.impl.query_library_exists import QueryLibraryExistsTool
|
||||
from app.agent.tools.impl.query_media_detail import QueryMediaDetailTool
|
||||
from app.agent.tools.impl.query_library_exists import (
|
||||
QueryLibraryExistsInput,
|
||||
QueryLibraryExistsTool,
|
||||
)
|
||||
from app.agent.tools.impl.query_media_detail import (
|
||||
QueryMediaDetailInput,
|
||||
QueryMediaDetailTool,
|
||||
)
|
||||
from app.agent.tools.impl.query_subscribe_shares import QuerySubscribeSharesTool
|
||||
from app.agent.tools.impl.query_subscribe_history import QuerySubscribeHistoryTool
|
||||
from app.agent.tools.impl.recognize_media import RecognizeMediaTool
|
||||
@@ -29,13 +35,13 @@ from app.core.context import (
|
||||
TorrentInfo,
|
||||
)
|
||||
from app.core.meta import MetaMusic
|
||||
from app.schemas.types import MediaType
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
|
||||
|
||||
def _recording() -> MusicInfo:
|
||||
"""构造 Agent 工具测试使用的单曲信息。"""
|
||||
return MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source=MediaSource.MusicBrainz,
|
||||
media_id="recording-1",
|
||||
title="晴天",
|
||||
artists=["周杰伦"],
|
||||
@@ -75,7 +81,7 @@ def test_search_torrents_forwards_album_namespace_before_recognition():
|
||||
def _album() -> MusicInfo:
|
||||
"""构造 Agent 工具测试使用的整张专辑信息。"""
|
||||
return MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source=MediaSource.MusicBrainz,
|
||||
media_id="release-group-1",
|
||||
music_type=MUSIC_ENTITY_ALBUM,
|
||||
title="叶惠美",
|
||||
@@ -198,7 +204,7 @@ def test_add_subscribe_rejects_artist_as_browse_only_entity():
|
||||
def test_query_album_detail_exposes_complete_track_contract():
|
||||
"""专辑详情应返回预期曲目总数和曲目身份,供整包搜索与校验使用。"""
|
||||
album = MusicAlbumInfo(
|
||||
source="musicbrainz",
|
||||
media_source=MediaSource.MusicBrainz,
|
||||
media_id="release-group-1",
|
||||
title="叶惠美",
|
||||
artists=["周杰伦"],
|
||||
@@ -278,7 +284,7 @@ def test_scrape_album_uses_unified_entity_recognition(tmp_path):
|
||||
def test_query_artist_detail_marks_entity_as_non_subscribable():
|
||||
"""艺术家详情应明确标记为不可订阅,避免 Agent 混入获取流程。"""
|
||||
artist = MusicArtistInfo(
|
||||
source="musicbrainz",
|
||||
media_source=MediaSource.MusicBrainz,
|
||||
media_id="artist-1",
|
||||
name="周杰伦",
|
||||
artist_type="Person",
|
||||
@@ -397,10 +403,6 @@ def test_query_subscribe_history_uses_database_media_values_and_music_fields(mon
|
||||
year="2003",
|
||||
type=MediaType.MUSIC.value,
|
||||
season=None,
|
||||
tmdbid=None,
|
||||
doubanid=None,
|
||||
bangumiid=None,
|
||||
anilistid=None,
|
||||
media_source="musicbrainz",
|
||||
media_id="release-group-1",
|
||||
music_type="album",
|
||||
@@ -532,6 +534,14 @@ def test_query_library_exists_treats_album_as_atomic_complete_entity():
|
||||
}
|
||||
|
||||
|
||||
def test_agent_identity_schemas_only_expose_media_source_and_media_id():
|
||||
"""Agent 精确媒体工具不得继续暴露任一数据源专用 ID 输入字段。"""
|
||||
legacy_fields = {"tmdb_id", "douban_id", "bangumi_id", "anilist_id"}
|
||||
for schema in (QueryMediaDetailInput, QueryLibraryExistsInput):
|
||||
assert legacy_fields.isdisjoint(schema.model_fields)
|
||||
assert schema.model_fields["media_source"].annotation is MediaSource
|
||||
|
||||
|
||||
def test_listenbrainz_album_chart_preserves_entity_and_bounded_page_size():
|
||||
"""音乐榜单应把专辑实体与有界分页参数传递给缓存后的 MusicChain。"""
|
||||
async_chart = AsyncMock(return_value=[_album()])
|
||||
|
||||
@@ -283,7 +283,7 @@ def test_write_audio_metadata_maps_music_info_to_easy_tags(monkeypatch):
|
||||
success = AudioMetadataHelper.write(
|
||||
Path("/music/08 - Get Lucky.flac"),
|
||||
MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id=RECORDING_ID,
|
||||
title="Get Lucky",
|
||||
artists=["Daft Punk", "Pharrell Williams"],
|
||||
|
||||
207
tests/test_media_identity_cleanup_migration.py
Normal file
207
tests/test_media_identity_cleanup_migration.py
Normal file
@@ -0,0 +1,207 @@
|
||||
import importlib
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
|
||||
|
||||
LEGACY_COLUMNS = {
|
||||
"tmdbid", "imdbid", "tvdbid", "doubanid", "bangumiid",
|
||||
"anilistid", "mediaid",
|
||||
}
|
||||
|
||||
|
||||
def _legacy_identity_columns(include_mediaid: bool = False) -> list[sa.Column]:
|
||||
"""构造升级前来源专用媒体身份字段。"""
|
||||
columns = [
|
||||
sa.Column("tmdbid", sa.Integer()),
|
||||
sa.Column("imdbid", sa.String()),
|
||||
sa.Column("tvdbid", sa.Integer()),
|
||||
sa.Column("doubanid", sa.String()),
|
||||
sa.Column("bangumiid", sa.Integer()),
|
||||
sa.Column("anilistid", sa.Integer()),
|
||||
]
|
||||
if include_mediaid:
|
||||
columns.append(sa.Column("mediaid", sa.String()))
|
||||
return columns
|
||||
|
||||
|
||||
def _create_tables(connection) -> dict[str, sa.Table]:
|
||||
"""创建 3.0.1 升级前六张通用媒体表的最小结构。"""
|
||||
metadata = sa.MetaData()
|
||||
tables = {
|
||||
"subscribe": sa.Table(
|
||||
"subscribe", metadata,
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("media_source", sa.String()),
|
||||
sa.Column("media_id", sa.String()),
|
||||
*_legacy_identity_columns(include_mediaid=True),
|
||||
),
|
||||
"subscribehistory": sa.Table(
|
||||
"subscribehistory", metadata,
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("media_source", sa.String()),
|
||||
sa.Column("media_id", sa.String()),
|
||||
*_legacy_identity_columns(include_mediaid=True),
|
||||
),
|
||||
"downloadhistory": sa.Table(
|
||||
"downloadhistory", metadata,
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("media_source", sa.String()),
|
||||
sa.Column("media_id", sa.String()),
|
||||
*_legacy_identity_columns(),
|
||||
),
|
||||
"transferhistory": sa.Table(
|
||||
"transferhistory", metadata,
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("media_source", sa.String()),
|
||||
sa.Column("media_id", sa.String()),
|
||||
*_legacy_identity_columns(),
|
||||
),
|
||||
"downloadfailure": sa.Table(
|
||||
"downloadfailure", metadata,
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("type", sa.String()),
|
||||
sa.Column("site", sa.Integer()),
|
||||
sa.Column("media_source", sa.String()),
|
||||
sa.Column("media_id", sa.String()),
|
||||
sa.Column("tmdbid", sa.Integer()),
|
||||
sa.Column("doubanid", sa.String()),
|
||||
sa.Column("bangumiid", sa.Integer()),
|
||||
sa.Column("anilistid", sa.Integer()),
|
||||
),
|
||||
"mediaserveritem": sa.Table(
|
||||
"mediaserveritem", metadata,
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("item_type", sa.String()),
|
||||
sa.Column("tmdbid", sa.Integer()),
|
||||
sa.Column("imdbid", sa.String()),
|
||||
sa.Column("tvdbid", sa.Integer()),
|
||||
),
|
||||
}
|
||||
metadata.create_all(connection)
|
||||
return tables
|
||||
|
||||
|
||||
def _operations(connection) -> Operations:
|
||||
"""创建绑定当前连接的 Alembic Operations。"""
|
||||
return Operations(MigrationContext.configure(connection))
|
||||
|
||||
|
||||
def test_cleanup_migration_keeps_one_complete_identity_and_drops_legacy_columns(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""升级应修复半对身份、保留完整身份并删除六表全部专用列。"""
|
||||
migration = importlib.import_module(
|
||||
"database.versions.8a4c7e1d2f90_3_0_1"
|
||||
)
|
||||
engine = sa.create_engine("sqlite://")
|
||||
|
||||
with engine.begin() as connection:
|
||||
tables = _create_tables(connection)
|
||||
connection.execute(tables["subscribe"].insert(), [
|
||||
{
|
||||
"id": 1,
|
||||
"media_source": "tmdb",
|
||||
"media_id": "550",
|
||||
"tmdbid": 999,
|
||||
"mediaid": None,
|
||||
"doubanid": None,
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"media_source": None,
|
||||
"media_id": "stale-partial",
|
||||
"mediaid": "anilist:154587",
|
||||
"tmdbid": None,
|
||||
"doubanid": None,
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"media_source": None,
|
||||
"media_id": None,
|
||||
"mediaid": None,
|
||||
"tmdbid": 1396,
|
||||
"doubanid": "999999",
|
||||
},
|
||||
])
|
||||
connection.execute(tables["subscribehistory"].insert(), {
|
||||
"id": 1, "mediaid": "bangumi:400602",
|
||||
})
|
||||
connection.execute(tables["downloadhistory"].insert(), {
|
||||
"id": 1, "doubanid": "35209731",
|
||||
})
|
||||
connection.execute(tables["transferhistory"].insert(), {
|
||||
"id": 1, "anilistid": 154587,
|
||||
})
|
||||
connection.execute(tables["downloadfailure"].insert(), {
|
||||
"id": 1, "type": "电视剧", "site": 1, "bangumiid": 400602,
|
||||
})
|
||||
connection.execute(tables["mediaserveritem"].insert(), {
|
||||
"id": 1, "item_type": "Movie", "tmdbid": 550, "imdbid": "tt0137523",
|
||||
})
|
||||
|
||||
monkeypatch.setattr(migration, "op", _operations(connection))
|
||||
migration.upgrade()
|
||||
migration.upgrade()
|
||||
|
||||
migrated = {
|
||||
name: sa.Table(name, sa.MetaData(), autoload_with=connection)
|
||||
for name in tables
|
||||
}
|
||||
subscribe_rows = connection.execute(
|
||||
sa.select(migrated["subscribe"]).order_by(migrated["subscribe"].c.id)
|
||||
).mappings().all()
|
||||
identities = {
|
||||
name: connection.execute(sa.select(table)).mappings().first()
|
||||
for name, table in migrated.items()
|
||||
if name != "subscribe"
|
||||
}
|
||||
|
||||
for table in migrated.values():
|
||||
assert {"media_source", "media_id"}.issubset(table.c.keys())
|
||||
assert LEGACY_COLUMNS.isdisjoint(table.c.keys())
|
||||
|
||||
assert (subscribe_rows[0]["media_source"], subscribe_rows[0]["media_id"]) == (
|
||||
"themoviedb", "550",
|
||||
)
|
||||
assert (subscribe_rows[1]["media_source"], subscribe_rows[1]["media_id"]) == (
|
||||
"anilist", "154587",
|
||||
)
|
||||
assert (subscribe_rows[2]["media_source"], subscribe_rows[2]["media_id"]) == (
|
||||
"themoviedb", "1396",
|
||||
)
|
||||
assert identities["subscribehistory"]["media_source"] == "bangumi"
|
||||
assert identities["downloadhistory"]["media_source"] == "douban"
|
||||
assert identities["transferhistory"]["media_source"] == "anilist"
|
||||
assert identities["downloadfailure"]["media_source"] == "bangumi"
|
||||
assert identities["mediaserveritem"]["media_source"] == "themoviedb"
|
||||
|
||||
|
||||
def test_cleanup_migration_downgrade_restores_legacy_schema(monkeypatch) -> None:
|
||||
"""降级应恢复旧列并移除仅由本次迁移引入的媒体库身份列。"""
|
||||
migration = importlib.import_module(
|
||||
"database.versions.8a4c7e1d2f90_3_0_1"
|
||||
)
|
||||
engine = sa.create_engine("sqlite://")
|
||||
|
||||
with engine.begin() as connection:
|
||||
_create_tables(connection)
|
||||
monkeypatch.setattr(migration, "op", _operations(connection))
|
||||
migration.upgrade()
|
||||
migration.downgrade()
|
||||
|
||||
inspector = sa.inspect(connection)
|
||||
media_server_columns = {
|
||||
column["name"]
|
||||
for column in inspector.get_columns("mediaserveritem")
|
||||
}
|
||||
subscribe_columns = {
|
||||
column["name"]
|
||||
for column in inspector.get_columns("subscribe")
|
||||
}
|
||||
|
||||
assert {"tmdbid", "imdbid", "tvdbid"}.issubset(media_server_columns)
|
||||
assert {"media_source", "media_id"}.isdisjoint(media_server_columns)
|
||||
assert LEGACY_COLUMNS.issubset(subscribe_columns)
|
||||
assert {"media_source", "media_id"}.issubset(subscribe_columns)
|
||||
@@ -67,7 +67,7 @@ def test_recognize_musicbrainz_source_parses_music_meta() -> None:
|
||||
chain.async_recognize_by_meta = AsyncMock(return_value=None)
|
||||
|
||||
with patch("app.api.endpoints.media.MediaChain", return_value=chain):
|
||||
asyncio.run(recognize(title="周杰伦 - 晴天", source="musicbrainz", _=Mock()))
|
||||
asyncio.run(recognize(title="周杰伦 - 晴天", media_source="musicbrainz", _=Mock()))
|
||||
|
||||
metainfo = chain.async_recognize_by_meta.await_args.args[0]
|
||||
assert isinstance(metainfo, MetaMusic)
|
||||
|
||||
@@ -452,7 +452,7 @@ def test_async_recognize_by_meta_can_skip_obtain_images():
|
||||
def _music_info() -> MusicInfo:
|
||||
"""构造带远端身份的标准音乐信息。"""
|
||||
return MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="recording-1",
|
||||
title="晴天",
|
||||
artists=["周杰伦"],
|
||||
@@ -609,7 +609,7 @@ def test_chain_recognize_media_queries_music_share_when_local_failed():
|
||||
assert result is music
|
||||
recognize_best.assert_called_once_with(meta=meta, cache=False)
|
||||
recognize_source.assert_called_once_with(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
meta=meta,
|
||||
mediaid="recording-1",
|
||||
cache=False,
|
||||
@@ -724,7 +724,7 @@ def test_chain_async_recognize_media_queries_music_share_after_local_fallback():
|
||||
keyword_meta=meta,
|
||||
)
|
||||
recognize_source.assert_awaited_once_with(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
meta=meta,
|
||||
mediaid="recording-1",
|
||||
cache=False,
|
||||
|
||||
@@ -94,7 +94,7 @@ def test_recognize_file_routes_audio_to_music_chain() -> None:
|
||||
return_value=Context(
|
||||
meta_info=MetaMusic(title="晴天", artists=["周杰伦"]),
|
||||
media_info=MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="977e6978-139d-425c-bb98-6b0c62d1e45e",
|
||||
title="晴天",
|
||||
artists=["周杰伦"],
|
||||
@@ -118,7 +118,7 @@ def test_scrape_music_uses_musicbrainz_uuid_and_music_scraper() -> None:
|
||||
"""手动音乐刮削应接受 MusicBrainz UUID 并经统一识别入口后写入音乐标签。"""
|
||||
fileitem = FileItem(storage="local", path="/music/晴天.flac", type="file")
|
||||
info = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="977e6978-139d-425c-bb98-6b0c62d1e45e",
|
||||
title="晴天",
|
||||
)
|
||||
@@ -139,7 +139,7 @@ def test_scrape_music_uses_musicbrainz_uuid_and_music_scraper() -> None:
|
||||
|
||||
assert result.success is True
|
||||
media_chain.recognize_media.assert_called_once_with(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
mediaid="977e6978-139d-425c-bb98-6b0c62d1e45e",
|
||||
mtype=MediaType.MUSIC,
|
||||
music_type="recording",
|
||||
@@ -148,7 +148,7 @@ def test_scrape_music_uses_musicbrainz_uuid_and_music_scraper() -> None:
|
||||
fileitem=fileitem,
|
||||
mediainfo=info,
|
||||
overwrite=True,
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
)
|
||||
|
||||
|
||||
@@ -179,7 +179,7 @@ def test_scrape_music_album_forwards_album_namespace() -> None:
|
||||
"""手动专辑刮削必须把 Release Group ID 标记为 album。"""
|
||||
fileitem = FileItem(storage="local", path="/music/叶惠美", type="dir")
|
||||
info = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="977e6978-139d-425c-bb98-6b0c62d1e45e",
|
||||
music_type="album",
|
||||
title="叶惠美",
|
||||
@@ -207,7 +207,7 @@ def test_scrape_music_accepts_douban_recording_composite_id() -> None:
|
||||
"""豆瓣音乐曲目 ID 使用“专辑ID:曲序”时应通过入口校验。"""
|
||||
fileitem = FileItem(storage="local", path="/music/晴天.flac", type="file")
|
||||
info = MusicInfo(
|
||||
source="doubanmusic",
|
||||
media_source="doubanmusic",
|
||||
media_id="1401853:3",
|
||||
music_type="recording",
|
||||
title="晴天",
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.api.endpoints import media as media_endpoints
|
||||
from app.api.endpoints.media import search
|
||||
from app.chain import ChainBase
|
||||
from app.core.security import verify_token
|
||||
from app.modules.douban import DoubanModule
|
||||
from app.modules.themoviedb import TheMovieDbModule
|
||||
from app.schemas.types import MediaType
|
||||
@@ -30,13 +34,13 @@ def test_media_search_endpoint_forwards_source(
|
||||
search(
|
||||
title="测试",
|
||||
type=search_type,
|
||||
source=source,
|
||||
media_source=source,
|
||||
_=Mock(),
|
||||
)
|
||||
)
|
||||
|
||||
assert result == []
|
||||
search_method.assert_awaited_once_with(name="测试", source=source)
|
||||
search_method.assert_awaited_once_with(name="测试", media_source=source)
|
||||
|
||||
|
||||
def test_media_search_endpoint_forwards_multi_source() -> None:
|
||||
@@ -49,14 +53,47 @@ def test_media_search_endpoint_forwards_multi_source() -> None:
|
||||
search(
|
||||
title="测试",
|
||||
type="media",
|
||||
source="themoviedb,douban",
|
||||
media_source="themoviedb,douban",
|
||||
_=Mock(),
|
||||
)
|
||||
)
|
||||
|
||||
assert result == []
|
||||
chain.async_search.assert_awaited_once_with(
|
||||
title="测试", source="themoviedb,douban"
|
||||
title="测试", media_source="themoviedb,douban"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_media_search_route_accepts_comma_separated_music_sources() -> None:
|
||||
"""真实 FastAPI 路由应接受逗号分隔的数据源,而不能在参数校验阶段返回 422。"""
|
||||
chain = Mock()
|
||||
chain.async_search = AsyncMock(return_value=[])
|
||||
app = FastAPI()
|
||||
app.include_router(media_endpoints.router, prefix="/api/v1/media")
|
||||
app.dependency_overrides[verify_token] = lambda: Mock()
|
||||
|
||||
with patch("app.api.endpoints.media.MusicChain", return_value=chain):
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app),
|
||||
base_url="http://testserver",
|
||||
) as client:
|
||||
response = await client.get(
|
||||
"/api/v1/media/search",
|
||||
params={
|
||||
"title": "周杰伦",
|
||||
"type": "music",
|
||||
"count": 30,
|
||||
"media_source": "musicbrainz,theaudiodb,doubanmusic",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
chain.async_search.assert_awaited_once_with(
|
||||
query="周杰伦",
|
||||
limit=30,
|
||||
media_source="musicbrainz,theaudiodb,doubanmusic",
|
||||
)
|
||||
|
||||
|
||||
@@ -78,7 +115,7 @@ def test_chain_forwards_source_to_modules(
|
||||
getattr(ChainBase, method_name)(
|
||||
chain,
|
||||
name="测试",
|
||||
source="themoviedb",
|
||||
media_source="themoviedb",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -86,7 +123,7 @@ def test_chain_forwards_source_to_modules(
|
||||
chain.async_run_module.assert_awaited_once_with(
|
||||
module_method_name,
|
||||
name="测试",
|
||||
source="themoviedb",
|
||||
media_source="themoviedb",
|
||||
)
|
||||
|
||||
|
||||
@@ -98,10 +135,10 @@ def test_tmdb_person_search_respects_explicit_source(monkeypatch) -> None:
|
||||
module.tmdb.async_search_persons = AsyncMock(return_value=[])
|
||||
|
||||
skipped = asyncio.run(
|
||||
module.async_search_persons(name="测试", source="douban")
|
||||
module.async_search_persons(name="测试", media_source="douban")
|
||||
)
|
||||
result = asyncio.run(
|
||||
module.async_search_persons(name="测试", source="themoviedb")
|
||||
module.async_search_persons(name="测试", media_source="themoviedb")
|
||||
)
|
||||
|
||||
assert skipped is None
|
||||
@@ -119,10 +156,10 @@ def test_douban_person_search_respects_explicit_source(monkeypatch) -> None:
|
||||
module.doubanapi.async_person_search = AsyncMock(return_value={})
|
||||
|
||||
skipped = asyncio.run(
|
||||
module.async_search_persons(name="测试", source="themoviedb")
|
||||
module.async_search_persons(name="测试", media_source="themoviedb")
|
||||
)
|
||||
result = asyncio.run(
|
||||
module.async_search_persons(name="测试", source="douban")
|
||||
module.async_search_persons(name="测试", media_source="douban")
|
||||
)
|
||||
|
||||
assert skipped is None
|
||||
@@ -137,7 +174,7 @@ def test_tmdb_collection_search_rejects_unsupported_source() -> None:
|
||||
module.tmdb.async_search_collections = AsyncMock(return_value=[])
|
||||
|
||||
result = asyncio.run(
|
||||
module.async_search_collections(name="测试", source="douban")
|
||||
module.async_search_collections(name="测试", media_source="douban")
|
||||
)
|
||||
|
||||
assert result is None
|
||||
@@ -152,7 +189,7 @@ def test_tmdb_collection_search_supports_multi_source_request() -> None:
|
||||
|
||||
result = asyncio.run(
|
||||
module.async_search_collections(
|
||||
name="测试", source="themoviedb,douban"
|
||||
name="测试", media_source="themoviedb,douban"
|
||||
)
|
||||
)
|
||||
|
||||
@@ -171,8 +208,8 @@ def test_tmdb_media_search_supports_multi_source_request(monkeypatch) -> None:
|
||||
meta.type = MediaType.UNKNOWN
|
||||
meta.year = None
|
||||
|
||||
skipped = module.search_medias(meta=meta, source="douban")
|
||||
result = module.search_medias(meta=meta, source="themoviedb,douban")
|
||||
skipped = module.search_medias(meta=meta, media_source="douban")
|
||||
result = module.search_medias(meta=meta, media_source="themoviedb,douban")
|
||||
|
||||
assert skipped is None
|
||||
assert result == []
|
||||
@@ -189,7 +226,7 @@ def test_douban_media_search_supports_multi_source_request(monkeypatch) -> None:
|
||||
meta.name = "测试"
|
||||
|
||||
result = asyncio.run(
|
||||
module.async_search_medias(meta=meta, source="themoviedb,douban")
|
||||
module.async_search_medias(meta=meta, media_source="themoviedb,douban")
|
||||
)
|
||||
|
||||
assert result == []
|
||||
@@ -206,7 +243,7 @@ def test_multi_source_request_keeps_missing_module_skipped(monkeypatch) -> None:
|
||||
meta.name = "测试"
|
||||
|
||||
skipped = asyncio.run(
|
||||
module.async_search_medias(meta=meta, source="themoviedb")
|
||||
module.async_search_medias(meta=meta, media_source="themoviedb")
|
||||
)
|
||||
|
||||
assert skipped is None
|
||||
|
||||
@@ -6,53 +6,51 @@ from app.chain.subscribe import SubscribeChain
|
||||
from app.chain.transfer import TransferChain
|
||||
|
||||
|
||||
def _assert_parameter_prefix(method, expected: list[str]) -> None:
|
||||
"""断言新增媒体源参数未改变已有位置参数顺序。"""
|
||||
parameters = list(inspect.signature(method).parameters)
|
||||
assert parameters[:len(expected)] == expected
|
||||
LEGACY_MEDIA_ID_PARAMETERS = {
|
||||
"tmdbid",
|
||||
"doubanid",
|
||||
"bangumiid",
|
||||
"anilistid",
|
||||
"imdbid",
|
||||
"tvdbid",
|
||||
"mediaid",
|
||||
}
|
||||
|
||||
|
||||
def test_search_chain_media_source_parameters_preserve_old_order() -> None:
|
||||
"""搜索链新增媒体源参数必须追加在原有位置参数之后。"""
|
||||
resource_parameters = [
|
||||
"self", "tmdbid", "doubanid", "mtype", "area", "season", "sites", "cache_local"
|
||||
]
|
||||
subtitle_parameters = [
|
||||
"self", "tmdbid", "doubanid", "mtype", "season", "episode", "sites", "cache_local"
|
||||
]
|
||||
|
||||
_assert_parameter_prefix(SearchChain.search_by_id, resource_parameters)
|
||||
_assert_parameter_prefix(SearchChain.async_search_by_id, resource_parameters)
|
||||
_assert_parameter_prefix(SearchChain.async_search_by_id_stream, resource_parameters)
|
||||
_assert_parameter_prefix(SearchChain.async_search_subtitles_by_id, subtitle_parameters)
|
||||
_assert_parameter_prefix(SearchChain.async_search_subtitles_by_id_stream, subtitle_parameters)
|
||||
def _assert_unified_media_identity(method, *, required: bool = True) -> None:
|
||||
"""断言通用媒体方法仅暴露成对的统一身份参数。"""
|
||||
parameters = inspect.signature(method).parameters
|
||||
assert "media_source" in parameters
|
||||
assert "media_id" in parameters
|
||||
assert not LEGACY_MEDIA_ID_PARAMETERS.intersection(parameters)
|
||||
if required:
|
||||
assert parameters["media_source"].default is inspect.Parameter.empty
|
||||
assert parameters["media_id"].default is inspect.Parameter.empty
|
||||
|
||||
|
||||
def test_download_chain_media_source_parameters_preserve_old_order() -> None:
|
||||
"""字幕下载链新增媒体源参数必须追加在原有位置参数之后。"""
|
||||
_assert_parameter_prefix(DownloadChain.download_subtitle, [
|
||||
"self", "subtitle", "media_source", "media_id", "tmdbid", "doubanid",
|
||||
"save_path", "username",
|
||||
])
|
||||
def test_search_chain_uses_unified_media_identity() -> None:
|
||||
"""精确资源与字幕搜索必须只接收统一媒体身份。"""
|
||||
for method in (
|
||||
SearchChain.search_by_id,
|
||||
SearchChain.async_search_by_id,
|
||||
SearchChain.async_search_by_id_stream,
|
||||
SearchChain.async_search_subtitles_by_id,
|
||||
SearchChain.async_search_subtitles_by_id_stream,
|
||||
):
|
||||
_assert_unified_media_identity(method)
|
||||
|
||||
|
||||
def test_transfer_chain_media_source_parameters_preserve_old_order() -> None:
|
||||
"""整理链新增媒体源参数必须追加在原有位置参数之后。"""
|
||||
_assert_parameter_prefix(TransferChain.manual_transfer, [
|
||||
"self", "fileitem", "target_storage", "target_path", "tmdbid", "doubanid",
|
||||
"media_source", "media_id", "mtype", "season", "episode_group", "transfer_type",
|
||||
"epformat", "min_filesize", "scrape", "library_type_folder",
|
||||
"library_category_folder", "force", "background", "downloader", "download_hash",
|
||||
"preview", "sync_extra_files", "cleanup_dest_fileitem",
|
||||
])
|
||||
def test_download_chain_uses_unified_media_identity() -> None:
|
||||
"""字幕下载必须只接收统一媒体身份。"""
|
||||
_assert_unified_media_identity(DownloadChain.download_subtitle)
|
||||
|
||||
|
||||
def test_subscribe_chain_media_source_parameters_preserve_old_order() -> None:
|
||||
"""订阅链新增媒体源参数必须追加在原有位置参数之后。"""
|
||||
parameters = [
|
||||
"self", "title", "year", "mtype", "tmdbid", "doubanid", "bangumiid", "mediaid",
|
||||
"episode_group", "season", "channel", "source", "userid", "username", "message", "exist_ok",
|
||||
]
|
||||
def test_transfer_chain_uses_unified_media_identity() -> None:
|
||||
"""手动整理可选身份也必须使用成对的统一字段。"""
|
||||
_assert_unified_media_identity(TransferChain.manual_transfer, required=False)
|
||||
|
||||
_assert_parameter_prefix(SubscribeChain.add, parameters)
|
||||
_assert_parameter_prefix(SubscribeChain.async_add, parameters)
|
||||
|
||||
def test_subscribe_chain_uses_unified_media_identity() -> None:
|
||||
"""订阅入口可选身份也必须使用成对的统一字段。"""
|
||||
_assert_unified_media_identity(SubscribeChain.add, required=False)
|
||||
_assert_unified_media_identity(SubscribeChain.async_add, required=False)
|
||||
|
||||
@@ -9,7 +9,7 @@ from app.core.metainfo import MetaInfo, MetaInfoPath, find_metainfo
|
||||
from app.core.meta import MetaBase, MetaMusic
|
||||
from app.core.meta.metaanime import MetaAnime
|
||||
from app.helper.torrent import TorrentHelper
|
||||
from app.schemas.types import MediaType
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
from tests.cases.meta import meta_cases
|
||||
|
||||
|
||||
@@ -39,8 +39,9 @@ def test_metainfo():
|
||||
"fps": meta_info.fps or None,
|
||||
}
|
||||
|
||||
if info.get("target").get("tmdbid"):
|
||||
target["tmdbid"] = meta_info.tmdbid
|
||||
if info.get("target").get("media_source"):
|
||||
target["media_source"] = str(meta_info.media_source)
|
||||
target["media_id"] = meta_info.media_id
|
||||
|
||||
expected = info.get("target")
|
||||
if "fps" not in expected:
|
||||
@@ -67,11 +68,11 @@ def test_emby_format_ids():
|
||||
("/movies/Avatar (2009) {tmdb-19995}/Avatar.2009.1080p.mkv", 19995),
|
||||
]
|
||||
|
||||
for path_str, expected_tmdbid in test_paths:
|
||||
for path_str, expected_media_id in test_paths:
|
||||
meta = MetaInfoPath(Path(path_str))
|
||||
assert meta.tmdbid == expected_tmdbid, (
|
||||
f"路径 {path_str} 期望的tmdbid为 {expected_tmdbid},实际识别为 {meta.tmdbid}"
|
||||
)
|
||||
assert meta.media_source == MediaSource.TMDB
|
||||
assert meta.media_id == str(expected_media_id)
|
||||
assert not hasattr(meta, "tmdbid")
|
||||
|
||||
|
||||
def test_metainfopath_with_custom_words():
|
||||
@@ -129,11 +130,7 @@ def test_torrent_title_match_ignores_question_mark_variants():
|
||||
season_years={},
|
||||
)
|
||||
torrent_meta = SimpleNamespace(
|
||||
tmdbid=None,
|
||||
doubanid=None,
|
||||
bangumiid=None,
|
||||
anilistid=None,
|
||||
cn_name=None,
|
||||
cn_name=None,
|
||||
en_name="Otaku ni Yasashii Gal wa Inai",
|
||||
type=MediaType.TV,
|
||||
year=None,
|
||||
@@ -143,8 +140,7 @@ def test_torrent_title_match_ignores_question_mark_variants():
|
||||
site_name="MiKan",
|
||||
title="[今晚月色真美][Otaku ni Yasashii Gal wa Inai!?][12][1080P]",
|
||||
category=MediaType.TV.value,
|
||||
imdbid=None,
|
||||
description=None,
|
||||
description=None,
|
||||
)
|
||||
|
||||
assert TorrentHelper.match_torrent(
|
||||
@@ -390,7 +386,8 @@ def test_custom_words_episode_offset_supports_multiplication_expression():
|
||||
)
|
||||
|
||||
assert meta.name == "哈哈哈哈哈"
|
||||
assert meta.tmdbid == 112732
|
||||
assert meta.media_source == MediaSource.TMDB
|
||||
assert meta.media_id == "112732"
|
||||
assert meta.begin_season == 6
|
||||
assert meta.episode == "E05"
|
||||
assert meta.apply_words == custom_words
|
||||
@@ -427,7 +424,8 @@ def test_custom_words_support_episode_group_parameter():
|
||||
f"Bakemonogatari => 物语系列 {{[tmdbid=46195;type=tv;g={group_id};s=1]}}"
|
||||
]
|
||||
meta = MetaInfo(title="Bakemonogatari 01", custom_words=custom_words)
|
||||
assert meta.tmdbid == 46195
|
||||
assert meta.media_source == MediaSource.TMDB
|
||||
assert meta.media_id == "46195"
|
||||
assert meta.type.value == "电视剧"
|
||||
assert meta.begin_season == 1
|
||||
assert meta.episode_group == group_id
|
||||
@@ -443,7 +441,8 @@ def test_custom_words_support_special_season_zero_parameter():
|
||||
with patch("app.core.metainfo.rust_accel.parse_metainfo", return_value=None):
|
||||
meta = MetaInfo(title="Test Show 01", custom_words=custom_words)
|
||||
|
||||
assert meta.tmdbid == 12345
|
||||
assert meta.media_source == MediaSource.TMDB
|
||||
assert meta.media_id == "12345"
|
||||
assert meta.type.value == "电视剧"
|
||||
assert meta.begin_season == 0
|
||||
|
||||
@@ -559,10 +558,24 @@ def test_streaming_platform_word_kept_in_movie_title():
|
||||
def test_emby_tmdbid_overrides_braced_metainfo_tmdbid():
|
||||
"""测试 Emby [tmdbid] 标签保持历史优先级。"""
|
||||
title, metainfo = find_metainfo("Movie {[tmdbid=111;type=movies]} [tmdbid=222]")
|
||||
assert metainfo["tmdbid"] == "222"
|
||||
assert metainfo["media_source"] == MediaSource.TMDB
|
||||
assert metainfo["media_id"] == "222"
|
||||
assert "tmdbid" not in metainfo
|
||||
assert "[tmdbid=222]" not in title
|
||||
|
||||
|
||||
def test_generic_media_identity_tag_is_the_only_output_contract():
|
||||
"""通用标签应产生枚举来源和字符串ID,且不暴露来源专用字段。"""
|
||||
title, metainfo = find_metainfo(
|
||||
"Movie {[media_source=themoviedb;media_id=550;type=movies]}"
|
||||
)
|
||||
|
||||
assert title.strip() == "Movie"
|
||||
assert metainfo["media_source"] == MediaSource.TMDB
|
||||
assert metainfo["media_id"] == "550"
|
||||
assert {"tmdbid", "doubanid", "bangumiid", "anilistid"}.isdisjoint(metainfo)
|
||||
|
||||
|
||||
def test_metainfopath_auxiliary_chinese_stem_uses_parent_title():
|
||||
"""测试辅助文件名合并父目录标题与年份。"""
|
||||
path = Path(
|
||||
|
||||
@@ -130,13 +130,13 @@ def test_recognize_album_directory_maps_files(tmp_path, music_chain, monkeypatch
|
||||
files.append(file)
|
||||
|
||||
album = MusicAlbumInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="rg-1",
|
||||
title="七里香",
|
||||
artists=["周杰伦"],
|
||||
tracks=[
|
||||
MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id=f"rec-{index + 1}",
|
||||
title=name,
|
||||
artists=["周杰伦"],
|
||||
@@ -182,13 +182,13 @@ def test_async_recognize_album_directory_calls_async_module(
|
||||
files.append(file)
|
||||
|
||||
album = MusicAlbumInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="rg-1",
|
||||
title="七里香",
|
||||
artists=["周杰伦"],
|
||||
tracks=[
|
||||
MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id=f"rec-{index + 1}",
|
||||
title=name,
|
||||
artists=["周杰伦"],
|
||||
@@ -274,7 +274,7 @@ def test_recognize_music_by_path_falls_back_to_album_match(tmp_path, monkeypatch
|
||||
file.write_bytes(b"RIFF")
|
||||
|
||||
matched_info = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="rec-1",
|
||||
title="我的地盘",
|
||||
artists=["周杰伦"],
|
||||
@@ -306,7 +306,7 @@ def test_async_music_album_fallback_calls_async_directory_match(tmp_path, monkey
|
||||
file = album_dir / "01.我的地盘.wav"
|
||||
file.write_bytes(b"RIFF")
|
||||
matched_info = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="rec-1",
|
||||
title="我的地盘",
|
||||
)
|
||||
|
||||
@@ -144,7 +144,7 @@ def test_normalize_candidates_deduplicates_source_identity():
|
||||
"""同一来源和媒体 ID 的音乐候选应只保留一次。"""
|
||||
results = MusicChain.normalize_candidates(
|
||||
[
|
||||
MusicInfo(source="musicbrainz", media_id="recording-1", title="A"),
|
||||
MusicInfo(media_source="musicbrainz", media_id="recording-1", title="A"),
|
||||
{
|
||||
"type": "音乐",
|
||||
"source": "musicbrainz",
|
||||
@@ -162,8 +162,8 @@ def test_normalize_candidates_keeps_different_entities_with_same_source_id():
|
||||
"""同一来源 ID 在不同音乐实体命名空间下不能互相去重。"""
|
||||
results = MusicChain.normalize_candidates(
|
||||
[
|
||||
MusicInfo(source="musicbrainz", media_id="shared-id", music_type="recording", title="Song"),
|
||||
MusicInfo(source="musicbrainz", media_id="shared-id", music_type="album", title="Album"),
|
||||
MusicInfo(media_source="musicbrainz", media_id="shared-id", music_type="recording", title="Song"),
|
||||
MusicInfo(media_source="musicbrainz", media_id="shared-id", music_type="album", title="Album"),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -185,7 +185,7 @@ def test_normalize_candidates_deduplicates_metadata_without_id():
|
||||
def test_to_meta_preserves_selected_identity():
|
||||
"""候选转换后应保留下载和整理所需的标准身份。"""
|
||||
info = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="recording-1",
|
||||
title="晴天",
|
||||
artists=["周杰伦"],
|
||||
@@ -212,8 +212,8 @@ def test_chart_converts_page_to_listenbrainz_offset(monkeypatch):
|
||||
"""记录榜单模块调用并返回重复候选。"""
|
||||
requested.update(method=method, **kwargs)
|
||||
return [
|
||||
MusicInfo(source="musicbrainz", media_id="recording-1", title="晴天"),
|
||||
MusicInfo(source="musicbrainz", media_id="recording-1", title="晴天"),
|
||||
MusicInfo(media_source="musicbrainz", media_id="recording-1", title="晴天"),
|
||||
MusicInfo(media_source="musicbrainz", media_id="recording-1", title="晴天"),
|
||||
]
|
||||
|
||||
monkeypatch.setattr(chain, "run_module", fake_run_module)
|
||||
@@ -237,17 +237,17 @@ def test_async_chart_applies_music_explore_filters(monkeypatch):
|
||||
"""返回包含不同热度和封面状态的榜单候选。"""
|
||||
assert method == "music_chart"
|
||||
return [
|
||||
MusicInfo(media_id="1", source="musicbrainz", title="A", listen_count=300),
|
||||
MusicInfo(media_id="1", media_source="musicbrainz", title="A", listen_count=300),
|
||||
MusicInfo(
|
||||
media_id="2",
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
title="B",
|
||||
listen_count=120,
|
||||
cover_url="https://coverartarchive.org/release/2/front-500",
|
||||
),
|
||||
MusicInfo(
|
||||
media_id="3",
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
title="C",
|
||||
listen_count=240,
|
||||
cover_url="https://coverartarchive.org/release/3/front-500",
|
||||
@@ -275,9 +275,9 @@ def test_musicbrainz_module_select_candidate_prefers_matching_audio_tags():
|
||||
"""文件识别应优先选择标题、艺术家和专辑均匹配的 MusicBrainz 候选。"""
|
||||
meta = MetaMusic(title="晴天", artists=["周杰伦"], album="叶惠美")
|
||||
candidates = [
|
||||
MusicInfo(source="musicbrainz", media_id="1", title="晴天", artists=["其他歌手"]),
|
||||
MusicInfo(media_source="musicbrainz", media_id="1", title="晴天", artists=["其他歌手"]),
|
||||
MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="2",
|
||||
title="晴天",
|
||||
artists=["周杰伦"],
|
||||
@@ -285,7 +285,7 @@ def test_musicbrainz_module_select_candidate_prefers_matching_audio_tags():
|
||||
),
|
||||
]
|
||||
|
||||
selected = MusicBrainzModule._select_candidate(meta, candidates, source="musicbrainz")
|
||||
selected = MusicBrainzModule._select_candidate(meta, candidates, media_source="musicbrainz")
|
||||
|
||||
assert selected is candidates[1]
|
||||
|
||||
@@ -302,7 +302,7 @@ def test_async_recognize_by_path_reads_local_audio_tags(tmp_path, monkeypatch):
|
||||
duration=221,
|
||||
)
|
||||
info = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="recording-1",
|
||||
title="眼泪成诗",
|
||||
artists=["孙燕姿"],
|
||||
@@ -349,7 +349,7 @@ def test_recognize_best_only_queries_musicbrainz(monkeypatch):
|
||||
year=2003,
|
||||
)
|
||||
expected = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="mb-1",
|
||||
title="晴天",
|
||||
artists=["周杰伦"],
|
||||
@@ -363,7 +363,7 @@ def test_recognize_best_only_queries_musicbrainz(monkeypatch):
|
||||
|
||||
assert result is expected
|
||||
recognize_source.assert_called_once_with(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
meta=meta,
|
||||
cache=True,
|
||||
music_type="recording",
|
||||
@@ -375,7 +375,7 @@ def test_recognize_from_source_selects_only_declared_music_module(monkeypatch):
|
||||
chain = MusicChain()
|
||||
meta = MetaMusic(title="晴天", artists=["周杰伦"])
|
||||
expected = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="recording-1",
|
||||
title="晴天",
|
||||
artists=["周杰伦"],
|
||||
@@ -391,7 +391,7 @@ def test_recognize_from_source_selects_only_declared_music_module(monkeypatch):
|
||||
)
|
||||
|
||||
result = chain.recognize_from_source(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
meta=meta,
|
||||
cache=True,
|
||||
)
|
||||
@@ -401,7 +401,7 @@ def test_recognize_from_source_selects_only_declared_music_module(monkeypatch):
|
||||
music_module.recognize_media.assert_called_once_with(
|
||||
meta=meta,
|
||||
mtype=MediaType.MUSIC,
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
mediaid=None,
|
||||
cache=True,
|
||||
)
|
||||
@@ -426,7 +426,7 @@ def test_async_recognize_best_only_queries_musicbrainz(monkeypatch):
|
||||
chain = MusicChain()
|
||||
meta = MetaMusic(title="晴天", artists=["周杰伦"])
|
||||
expected = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="recording-1",
|
||||
title="晴天",
|
||||
artists=["周杰伦"],
|
||||
@@ -438,7 +438,7 @@ def test_async_recognize_best_only_queries_musicbrainz(monkeypatch):
|
||||
|
||||
assert result is expected
|
||||
recognize_source.assert_awaited_once_with(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
meta=meta,
|
||||
cache=True,
|
||||
music_type="recording",
|
||||
@@ -449,7 +449,7 @@ def test_async_recognize_from_source_calls_module_async_method(monkeypatch):
|
||||
"""单源异步识别必须直接等待模块异步入口。"""
|
||||
chain = MusicChain()
|
||||
expected = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="recording-1",
|
||||
title="晴天",
|
||||
)
|
||||
@@ -505,7 +505,7 @@ def test_async_chart_forwards_album_entity(monkeypatch):
|
||||
return [
|
||||
MusicInfo(
|
||||
media_id="release-group-1",
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
music_type="album",
|
||||
title="ARIRANG",
|
||||
listen_count=10,
|
||||
@@ -533,10 +533,10 @@ def test_async_fresh_releases_keeps_official_order(monkeypatch):
|
||||
"""记录新发行请求参数并返回带封面与不带封面的候选。"""
|
||||
requested.update(method=method, **kwargs)
|
||||
return [
|
||||
MusicInfo(media_id="b", source="musicbrainz", music_type="album", title="B"),
|
||||
MusicInfo(media_id="b", media_source="musicbrainz", music_type="album", title="B"),
|
||||
MusicInfo(
|
||||
media_id="a",
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
music_type="album",
|
||||
title="A",
|
||||
cover_url="https://coverartarchive.org/release/a/front-500",
|
||||
@@ -565,16 +565,16 @@ def test_async_artist_related_deduplicates_artists(monkeypatch):
|
||||
"""返回重复的关联艺术家候选。"""
|
||||
assert method == "music_artist_related"
|
||||
return [
|
||||
MusicArtistInfo(source="musicbrainz", media_id="artist-1", name="Brian May"),
|
||||
MusicArtistInfo(source="musicbrainz", media_id="artist-1", name="Brian May"),
|
||||
MusicArtistInfo(source="musicbrainz", media_id="artist-2", name="John Deacon"),
|
||||
MusicArtistInfo(media_source="musicbrainz", media_id="artist-1", name="Brian May"),
|
||||
MusicArtistInfo(media_source="musicbrainz", media_id="artist-1", name="Brian May"),
|
||||
MusicArtistInfo(media_source="musicbrainz", media_id="artist-2", name="John Deacon"),
|
||||
]
|
||||
|
||||
monkeypatch.setattr(chain, "async_run_module", fake_async_run_module)
|
||||
|
||||
import asyncio
|
||||
|
||||
results = asyncio.run(chain.async_artist_related(source="musicbrainz", media_id="artist-0"))
|
||||
results = asyncio.run(chain.async_artist_related(media_source="musicbrainz", media_id="artist-0"))
|
||||
|
||||
assert [item.media_id for item in results] == ["artist-1", "artist-2"]
|
||||
|
||||
@@ -587,7 +587,7 @@ def test_async_album_restores_dataclass_from_plugin_dict(monkeypatch):
|
||||
"""模拟插件模块以字典形式返回专辑详情。"""
|
||||
assert method == "music_album"
|
||||
return MusicAlbumInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="release-group-1",
|
||||
title="A Night at the Opera",
|
||||
artists=["Queen"],
|
||||
@@ -598,7 +598,7 @@ def test_async_album_restores_dataclass_from_plugin_dict(monkeypatch):
|
||||
|
||||
import asyncio
|
||||
|
||||
album = asyncio.run(chain.async_album(source="musicbrainz", media_id="release-group-1"))
|
||||
album = asyncio.run(chain.async_album(media_source="musicbrainz", media_id="release-group-1"))
|
||||
|
||||
assert album is not None
|
||||
assert album.year == 1975
|
||||
|
||||
@@ -44,7 +44,7 @@ def test_music_meta_round_trip_preserves_list_isolation():
|
||||
def test_music_info_serializes_shared_media_display_fields():
|
||||
"""MusicInfo 应输出现有媒体卡片可复用的展示字段。"""
|
||||
info = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="release-1",
|
||||
title="七里香",
|
||||
artists=["周杰伦"],
|
||||
@@ -68,7 +68,7 @@ def test_core_context_serializes_music_models_without_video_fields():
|
||||
context = Context(
|
||||
meta_info=MetaMusic(title="七里香", artists=["周杰伦"]),
|
||||
media_info=MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="release-1",
|
||||
title="七里香",
|
||||
artists=["周杰伦"],
|
||||
|
||||
@@ -14,7 +14,7 @@ from app.schemas.types import MediaType
|
||||
def _music_info() -> MusicInfo:
|
||||
"""构造下载测试使用的标准音乐信息。"""
|
||||
return MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="recording-1",
|
||||
title="晴天",
|
||||
artists=["周杰伦"],
|
||||
@@ -28,7 +28,7 @@ def _music_info() -> MusicInfo:
|
||||
def _album_info(total_tracks: int | None = 3) -> MusicInfo:
|
||||
"""构造整张专辑下载校验使用的目标信息。"""
|
||||
return MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="release-group-1",
|
||||
music_type=MUSIC_ENTITY_ALBUM,
|
||||
title="叶惠美",
|
||||
|
||||
@@ -43,15 +43,15 @@ def test_music_routes_are_registered():
|
||||
assert any(
|
||||
path == "/media/search" and "GET" in methods for path, methods in routes
|
||||
)
|
||||
for recommend_path in (
|
||||
"/recommend/music_theaudiodb_albums",
|
||||
"/recommend/music_theaudiodb_tracks",
|
||||
"/recommend/music_douban",
|
||||
):
|
||||
for recommend_path in ("/recommend/music_douban",):
|
||||
assert any(
|
||||
path == recommend_path and "GET" in methods
|
||||
for path, methods in routes
|
||||
)
|
||||
assert not any(
|
||||
path.startswith("/recommend/music_theaudiodb")
|
||||
for path, _methods in routes
|
||||
)
|
||||
|
||||
|
||||
def test_media_search_routes_music_queries_with_query_kwarg():
|
||||
@@ -62,7 +62,7 @@ def test_media_search_routes_music_queries_with_query_kwarg():
|
||||
chain.async_search = AsyncMock(
|
||||
return_value=[
|
||||
MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="recording-1",
|
||||
music_type="recording",
|
||||
title="晴天",
|
||||
@@ -108,7 +108,7 @@ def test_media_search_forwards_explicit_music_source():
|
||||
title="Coldplay",
|
||||
type="music",
|
||||
count=20,
|
||||
source="theaudiodb",
|
||||
media_source="theaudiodb",
|
||||
_=Mock(),
|
||||
)
|
||||
)
|
||||
@@ -117,7 +117,7 @@ def test_media_search_forwards_explicit_music_source():
|
||||
chain.async_search.assert_awaited_once_with(
|
||||
query="Coldplay",
|
||||
limit=20,
|
||||
source="theaudiodb",
|
||||
media_source="theaudiodb",
|
||||
)
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ def test_recognize_music_returns_detail():
|
||||
chain = Mock()
|
||||
chain.async_recognize_media = AsyncMock(
|
||||
return_value=MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="recording-1",
|
||||
title="晴天",
|
||||
)
|
||||
@@ -138,7 +138,7 @@ def test_recognize_music_returns_detail():
|
||||
result = asyncio.run(
|
||||
recognize_music(
|
||||
request=MusicRecognizeRequest(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="recording-1",
|
||||
music_type="recording",
|
||||
),
|
||||
@@ -148,8 +148,8 @@ def test_recognize_music_returns_detail():
|
||||
|
||||
assert result.media_id == "recording-1"
|
||||
chain.async_recognize_media.assert_awaited_once_with(
|
||||
source="musicbrainz",
|
||||
mediaid="recording-1",
|
||||
media_source="musicbrainz",
|
||||
media_id="recording-1",
|
||||
mtype=MediaType.MUSIC,
|
||||
music_type="recording",
|
||||
)
|
||||
@@ -168,7 +168,7 @@ def test_recognize_music_returns_404_for_unknown_item():
|
||||
):
|
||||
asyncio.run(
|
||||
recognize_music(
|
||||
request=MusicRecognizeRequest(source="musicbrainz", media_id="missing"),
|
||||
request=MusicRecognizeRequest(media_source="musicbrainz", media_id="missing"),
|
||||
_=Mock(),
|
||||
)
|
||||
)
|
||||
@@ -182,7 +182,7 @@ def test_explore_music_forwards_filters_and_serializes_chart():
|
||||
chain.async_chart = AsyncMock(
|
||||
return_value=[
|
||||
MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="recording-1",
|
||||
title="晴天",
|
||||
artists=["周杰伦"],
|
||||
@@ -222,7 +222,7 @@ def test_explore_music_supports_official_fresh_release_mode():
|
||||
chain.async_fresh_releases = AsyncMock(
|
||||
return_value=[
|
||||
MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="release-group-1",
|
||||
music_type="album",
|
||||
title="ARIRANG",
|
||||
@@ -258,16 +258,16 @@ def test_explore_music_supports_official_fresh_release_mode():
|
||||
)
|
||||
|
||||
|
||||
def test_explore_music_forwards_selected_metadata_source():
|
||||
"""TheAudioDB 与豆瓣探索应走可扩展发现链而不是 ListenBrainz。"""
|
||||
def test_explore_music_forwards_douban_music_source():
|
||||
"""豆瓣音乐探索应走可扩展发现链而不是 ListenBrainz。"""
|
||||
chain = Mock()
|
||||
chain.async_discover = AsyncMock(
|
||||
return_value=[
|
||||
MusicInfo(
|
||||
source="theaudiodb",
|
||||
media_source="doubanmusic",
|
||||
media_id="album-1",
|
||||
music_type="album",
|
||||
title="Parachutes",
|
||||
title="范特西",
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -275,22 +275,26 @@ def test_explore_music_forwards_selected_metadata_source():
|
||||
with patch("app.api.endpoints.music.MusicChain", return_value=chain):
|
||||
result = asyncio.run(
|
||||
explore_music(
|
||||
source="theaudiodb",
|
||||
media_source="doubanmusic",
|
||||
entity="album",
|
||||
country="gb",
|
||||
mode="tag",
|
||||
tags="流行,华语",
|
||||
douban_sort="S",
|
||||
page=2,
|
||||
count=20,
|
||||
_=Mock(),
|
||||
)
|
||||
)
|
||||
|
||||
assert result[0].source == "theaudiodb"
|
||||
assert result[0].media_source == "doubanmusic"
|
||||
chain.async_discover.assert_awaited_once_with(
|
||||
source="theaudiodb",
|
||||
media_source="doubanmusic",
|
||||
page=2,
|
||||
count=20,
|
||||
entity="album",
|
||||
country="gb",
|
||||
mode="tag",
|
||||
tags="流行,华语",
|
||||
sort="S",
|
||||
)
|
||||
|
||||
|
||||
@@ -299,9 +303,9 @@ def test_explore_music_filters_missing_covers_for_external_sources():
|
||||
chain = Mock()
|
||||
chain.async_discover = AsyncMock(
|
||||
return_value=[
|
||||
MusicInfo(source="doubanmusic", media_id="album-1", title="No Cover"),
|
||||
MusicInfo(media_source="doubanmusic", media_id="album-1", title="No Cover"),
|
||||
MusicInfo(
|
||||
source="doubanmusic",
|
||||
media_source="doubanmusic",
|
||||
media_id="album-2",
|
||||
title="With Cover",
|
||||
cover_url="https://img.example/album-2.jpg",
|
||||
@@ -312,7 +316,7 @@ def test_explore_music_filters_missing_covers_for_external_sources():
|
||||
with patch("app.api.endpoints.music.MusicChain", return_value=chain):
|
||||
result = asyncio.run(
|
||||
explore_music(
|
||||
source="doubanmusic",
|
||||
media_source="doubanmusic",
|
||||
with_cover=True,
|
||||
_=Mock(),
|
||||
)
|
||||
@@ -326,14 +330,14 @@ def test_music_album_returns_tracks_and_releases():
|
||||
chain = Mock()
|
||||
chain.async_album = AsyncMock(
|
||||
return_value=MusicAlbumInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="release-group-1",
|
||||
title="A Night at the Opera",
|
||||
artists=["Queen"],
|
||||
artist_ids=["artist-1"],
|
||||
album_type="Album",
|
||||
release_date="1975-11-21",
|
||||
tracks=[MusicInfo(source="musicbrainz", media_id="recording-1", title="Love of My Life")],
|
||||
tracks=[MusicInfo(media_source="musicbrainz", media_id="recording-1", title="Love of My Life")],
|
||||
releases=[MusicRelease(media_id="release-1", title="A Night at the Opera", date="1975")],
|
||||
)
|
||||
)
|
||||
@@ -346,7 +350,7 @@ def test_music_album_returns_tracks_and_releases():
|
||||
assert result.total_tracks == 1
|
||||
assert result.tracks[0].media_id == "recording-1"
|
||||
assert result.releases[0].media_id == "release-1"
|
||||
chain.async_album.assert_awaited_once_with(source="musicbrainz", media_id="release-group-1")
|
||||
chain.async_album.assert_awaited_once_with(media_source="musicbrainz", media_id="release-group-1")
|
||||
|
||||
|
||||
def test_music_album_returns_404_for_unknown_album():
|
||||
@@ -369,7 +373,7 @@ def test_music_album_related_returns_source_results():
|
||||
chain.async_album_related = AsyncMock(
|
||||
return_value=[
|
||||
MusicInfo(
|
||||
source="doubanmusic",
|
||||
media_source="doubanmusic",
|
||||
media_id="album-2",
|
||||
music_type="album",
|
||||
title="依然范特西",
|
||||
@@ -382,14 +386,14 @@ def test_music_album_related_returns_source_results():
|
||||
music_album_related(
|
||||
album_id="album-1",
|
||||
count=12,
|
||||
source="doubanmusic",
|
||||
media_source="doubanmusic",
|
||||
_=Mock(),
|
||||
)
|
||||
)
|
||||
|
||||
assert result[0].media_id == "album-2"
|
||||
chain.async_album_related.assert_awaited_once_with(
|
||||
source="doubanmusic",
|
||||
media_source="doubanmusic",
|
||||
media_id="album-1",
|
||||
count=12,
|
||||
)
|
||||
@@ -400,7 +404,7 @@ def test_music_artist_returns_detail():
|
||||
chain = Mock()
|
||||
chain.async_artist = AsyncMock(
|
||||
return_value=MusicArtistInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="artist-1",
|
||||
name="Queen",
|
||||
artist_type="Group",
|
||||
@@ -414,7 +418,7 @@ def test_music_artist_returns_detail():
|
||||
assert result.name == "Queen"
|
||||
assert result.title == "Queen"
|
||||
assert result.music_type == "artist"
|
||||
chain.async_artist.assert_awaited_once_with(source="musicbrainz", media_id="artist-1")
|
||||
chain.async_artist.assert_awaited_once_with(media_source="musicbrainz", media_id="artist-1")
|
||||
|
||||
|
||||
def test_music_artist_albums_forwards_pagination_and_type():
|
||||
@@ -423,7 +427,7 @@ def test_music_artist_albums_forwards_pagination_and_type():
|
||||
chain.async_artist_albums = AsyncMock(
|
||||
return_value=[
|
||||
MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="release-group-1",
|
||||
music_type="album",
|
||||
title="News of the World",
|
||||
@@ -438,7 +442,7 @@ def test_music_artist_albums_forwards_pagination_and_type():
|
||||
|
||||
assert result[0].media_id == "release-group-1"
|
||||
chain.async_artist_albums.assert_awaited_once_with(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="artist-1",
|
||||
page=2,
|
||||
count=10,
|
||||
@@ -452,7 +456,7 @@ def test_music_artist_related_returns_relationship_text():
|
||||
chain.async_artist_related = AsyncMock(
|
||||
return_value=[
|
||||
MusicArtistInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="artist-2",
|
||||
name="Freddie Mercury",
|
||||
relation="member of band",
|
||||
@@ -465,7 +469,7 @@ def test_music_artist_related_returns_relationship_text():
|
||||
|
||||
assert result[0].relation == "member of band"
|
||||
chain.async_artist_related.assert_awaited_once_with(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="artist-1",
|
||||
count=5,
|
||||
)
|
||||
|
||||
@@ -23,7 +23,7 @@ from app.modules.zspace.zspace import ZSpace
|
||||
def _recording() -> MusicInfo:
|
||||
"""构造媒体库匹配使用的单曲目标。"""
|
||||
return MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="recording-1",
|
||||
music_type="recording",
|
||||
title="晴天",
|
||||
@@ -35,7 +35,7 @@ def _recording() -> MusicInfo:
|
||||
def _album() -> MusicInfo:
|
||||
"""构造媒体库完整性匹配使用的专辑目标。"""
|
||||
return MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="release-group-1",
|
||||
music_type="album",
|
||||
title="叶惠美",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""TheAudioDB 与豆瓣音乐识别源的标准化和路由测试。"""
|
||||
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from unittest.mock import AsyncMock, Mock, call
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -37,11 +37,11 @@ def test_theaudiodb_module_maps_track_and_album(monkeypatch):
|
||||
|
||||
results = module.search_music(
|
||||
MetaMusic(title="Yellow", artists=["Coldplay"]),
|
||||
source="theaudiodb",
|
||||
media_source="theaudiodb",
|
||||
)
|
||||
|
||||
assert results and len(results) == 1
|
||||
assert results[0].source == "theaudiodb"
|
||||
assert results[0].media_source == "theaudiodb"
|
||||
assert results[0].media_id == "32793500"
|
||||
assert results[0].album_id == "2109619"
|
||||
assert results[0].duration == 269
|
||||
@@ -54,10 +54,10 @@ def test_theaudiodb_module_ignores_other_sources(monkeypatch):
|
||||
request = Mock()
|
||||
monkeypatch.setattr(module, "_request_json", request)
|
||||
|
||||
searched = module.search_music(MetaMusic(title="Yellow"), source="musicbrainz")
|
||||
searched = module.search_music(MetaMusic(title="Yellow"), media_source="musicbrainz")
|
||||
recognized = module.recognize_media(
|
||||
meta=MetaMusic(title="Yellow"),
|
||||
source="doubanmusic",
|
||||
media_source="doubanmusic",
|
||||
)
|
||||
|
||||
assert searched is None
|
||||
@@ -81,44 +81,6 @@ def test_theaudiodb_detail_respects_requested_entity(monkeypatch):
|
||||
request.assert_called_once_with("album.php", {"m": "2109619"})
|
||||
|
||||
|
||||
def test_theaudiodb_discover_maps_and_sorts_trending_albums(monkeypatch):
|
||||
"""TheAudioDB 探索应按榜位排序并保留趋势来源元数据。"""
|
||||
module = TheAudioDbModule()
|
||||
request = Mock(return_value={
|
||||
"trending": [
|
||||
{
|
||||
"idAlbum": "album-2",
|
||||
"strAlbum": "Second",
|
||||
"strArtist": "Artist",
|
||||
"intChartPlace": "2",
|
||||
"strCountry": "GB",
|
||||
},
|
||||
{
|
||||
"idAlbum": "album-1",
|
||||
"strAlbum": "First",
|
||||
"strArtist": "Artist",
|
||||
"intChartPlace": "1",
|
||||
"strCountry": "GB",
|
||||
},
|
||||
]
|
||||
})
|
||||
monkeypatch.setattr(module, "_request_json", request)
|
||||
|
||||
results = module.music_discover(
|
||||
source="theaudiodb",
|
||||
entity=MUSIC_ENTITY_ALBUM,
|
||||
country="GB",
|
||||
)
|
||||
|
||||
assert results and [item.media_id for item in results] == ["album-1", "album-2"]
|
||||
assert results[0].source == "theaudiodb"
|
||||
assert results[0].raw_data["chart_position"] == 1
|
||||
request.assert_called_once_with(
|
||||
"trending.php",
|
||||
{"country": "gb", "type": "itunes", "format": "albums"},
|
||||
)
|
||||
|
||||
|
||||
def test_theaudiodb_album_related_excludes_current_album(monkeypatch):
|
||||
"""TheAudioDB 关联专辑应按当前专辑艺术家查询并排除自身。"""
|
||||
module = TheAudioDbModule()
|
||||
@@ -195,13 +157,13 @@ def test_douban_music_search_and_album_mapping(monkeypatch):
|
||||
|
||||
results = module.search_music(
|
||||
MetaMusic(title="范特西", artists=["周杰伦"]),
|
||||
source="doubanmusic",
|
||||
media_source="doubanmusic",
|
||||
)
|
||||
album = module.music_album("doubanmusic", "1401853")
|
||||
|
||||
assert results and results[0].source == "doubanmusic"
|
||||
assert results and results[0].media_source == "doubanmusic"
|
||||
assert results[0].music_type == MUSIC_ENTITY_ALBUM
|
||||
assert album and album.source == "doubanmusic"
|
||||
assert album and album.media_source == "doubanmusic"
|
||||
assert album.year == 2001
|
||||
assert album.artists == ["周杰伦"]
|
||||
assert album.album_type == "CD"
|
||||
@@ -212,7 +174,7 @@ def test_douban_music_search_and_album_mapping(monkeypatch):
|
||||
|
||||
|
||||
def test_douban_music_discover_and_related_accept_collection_wrappers(monkeypatch):
|
||||
"""豆瓣音乐合集与相关推荐应兼容 subject 包装并保留专辑身份。"""
|
||||
"""豆瓣新碟榜与相关推荐应兼容 subject 包装并保留专辑身份。"""
|
||||
module = DoubanModule()
|
||||
module.doubanapi = Mock()
|
||||
wrapped_item = {
|
||||
@@ -225,18 +187,18 @@ def test_douban_music_discover_and_related_accept_collection_wrappers(monkeypatc
|
||||
"cover": {"url": "https://img.example/fantasy.jpg"},
|
||||
},
|
||||
}
|
||||
module.doubanapi.music_single.return_value = {
|
||||
module.doubanapi.music_chart.return_value = {
|
||||
"subject_collection_items": [wrapped_item]
|
||||
}
|
||||
module.doubanapi.music_recommendations.return_value = [wrapped_item["subject"]]
|
||||
|
||||
discovered = module.music_discover("doubanmusic", page=2, count=10)
|
||||
discovered = module.music_discover("doubanmusic", page=1, count=10)
|
||||
related = module.music_album_related("doubanmusic", "album-1", count=6)
|
||||
|
||||
assert discovered and discovered[0].media_id == "1401853"
|
||||
assert discovered[0].cover_url == "https://img.example/fantasy.jpg"
|
||||
assert related and related[0].source == "doubanmusic"
|
||||
module.doubanapi.music_single.assert_called_once_with(start=10, count=10)
|
||||
assert related and related[0].media_source == "doubanmusic"
|
||||
module.doubanapi.music_chart.assert_called_once_with()
|
||||
module.doubanapi.music_recommendations.assert_called_once_with(
|
||||
subject_id="album-1",
|
||||
start=0,
|
||||
@@ -244,6 +206,33 @@ def test_douban_music_discover_and_related_accept_collection_wrappers(monkeypatc
|
||||
)
|
||||
|
||||
|
||||
def test_douban_music_tag_discover_intersects_official_tag_results():
|
||||
"""豆瓣音乐组合筛选应按原生条目 ID 求交集,并保持主风格排序。"""
|
||||
module = DoubanModule()
|
||||
module.doubanapi = Mock()
|
||||
module.doubanapi.music_tag.side_effect = [
|
||||
{"items": [
|
||||
{"id": "1", "type": "music", "title": "流行华语一"},
|
||||
{"id": "2", "type": "music", "title": "仅流行"},
|
||||
{"id": "3", "type": "music", "title": "流行华语二"},
|
||||
]},
|
||||
{"items": [
|
||||
{"id": "3", "type": "music", "title": "流行华语二"},
|
||||
{"id": "1", "type": "music", "title": "流行华语一"},
|
||||
]},
|
||||
]
|
||||
|
||||
results = module.music_discover(
|
||||
"doubanmusic", mode="tag", tags="流行,华语", sort="S", count=20
|
||||
)
|
||||
|
||||
assert [item.media_id for item in results] == ["1", "3"]
|
||||
assert module.doubanapi.music_tag.call_args_list == [
|
||||
call(tag="流行", start=0, count=100, sort="S"),
|
||||
call(tag="华语", start=0, count=100, sort="S"),
|
||||
]
|
||||
|
||||
|
||||
def test_douban_music_recognize_expands_album_to_matching_track(monkeypatch):
|
||||
"""自动文件识别有专辑线索时,豆瓣应返回专辑内音轨而不是专辑实体。"""
|
||||
module = DoubanModule()
|
||||
@@ -275,7 +264,7 @@ def test_douban_music_recognize_expands_album_to_matching_track(monkeypatch):
|
||||
album="范特西",
|
||||
track_number=2,
|
||||
),
|
||||
source="doubanmusic",
|
||||
media_source="doubanmusic",
|
||||
)
|
||||
|
||||
assert result and result.music_type == "recording"
|
||||
@@ -325,7 +314,7 @@ async def test_douban_music_async_recognize_maps_real_songs(monkeypatch):
|
||||
artists=["周杰伦"],
|
||||
album="范特西",
|
||||
),
|
||||
source="doubanmusic",
|
||||
media_source="doubanmusic",
|
||||
)
|
||||
|
||||
assert result and result.media_id == "1401853:1"
|
||||
@@ -335,9 +324,9 @@ async def test_douban_music_async_recognize_maps_real_songs(monkeypatch):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_douban_recognize_media_routes_only_douban_music(monkeypatch):
|
||||
"""豆瓣音乐使用独立 source,不能与影视豆瓣入口或其它音乐源串线。"""
|
||||
"""豆瓣音乐使用独立数据源,不能与影视豆瓣入口或其它音乐源串线。"""
|
||||
module = DoubanModule()
|
||||
expected = MusicInfo(source="doubanmusic", media_id="1401853", title="范特西")
|
||||
expected = MusicInfo(media_source="doubanmusic", media_id="1401853", title="范特西")
|
||||
recognize_music = Mock(return_value=expected)
|
||||
recognize_video = Mock()
|
||||
async_recognize_music = AsyncMock(return_value=expected)
|
||||
@@ -347,17 +336,17 @@ async def test_douban_recognize_media_routes_only_douban_music(monkeypatch):
|
||||
|
||||
recognized = module.recognize_media(
|
||||
meta=MetaMusic(title="范特西"),
|
||||
source="doubanmusic",
|
||||
mediaid="1401853",
|
||||
media_source="doubanmusic",
|
||||
media_id="1401853",
|
||||
)
|
||||
ignored = module.recognize_media(
|
||||
meta=MetaMusic(title="范特西"),
|
||||
source="theaudiodb",
|
||||
media_source="theaudiodb",
|
||||
)
|
||||
async_recognized = await module.async_recognize_media(
|
||||
mtype=MediaType.MUSIC,
|
||||
source="doubanmusic",
|
||||
mediaid="1401853",
|
||||
media_source="doubanmusic",
|
||||
media_id="1401853",
|
||||
)
|
||||
|
||||
assert recognized is expected
|
||||
@@ -376,25 +365,25 @@ async def test_music_chain_defaults_to_musicbrainz_and_forwards_explicit_source(
|
||||
monkeypatch.setattr(chain, "async_run_module", async_run_module)
|
||||
|
||||
chain.search("Yellow")
|
||||
await chain.async_search("范特西", source="doubanmusic")
|
||||
await chain.async_search("范特西", media_source="doubanmusic")
|
||||
|
||||
assert run_module.call_args.kwargs["source"] == "musicbrainz"
|
||||
assert async_run_module.await_args.kwargs["source"] == "doubanmusic"
|
||||
assert run_module.call_args.kwargs["media_source"] == "musicbrainz"
|
||||
assert async_run_module.await_args.kwargs["media_source"] == "doubanmusic"
|
||||
|
||||
|
||||
def test_music_scrape_resolves_with_selected_source(tmp_path, monkeypatch):
|
||||
"""无显式 ID 的音乐刮削应使用用户选择的来源识别本地音频。"""
|
||||
path = tmp_path / "Yellow.flac"
|
||||
path.write_bytes(b"audio")
|
||||
expected = MusicInfo(source="theaudiodb", media_id="32793500", title="Yellow")
|
||||
expected = MusicInfo(media_source="theaudiodb", media_id="32793500", title="Yellow")
|
||||
recognize = Mock(return_value=(MetaMusic(title="Yellow"), expected))
|
||||
monkeypatch.setattr(MediaChain, "recognize_music_by_path", recognize)
|
||||
|
||||
result = MediaChain._resolve_music_scrape_info(
|
||||
path,
|
||||
mediainfo=None,
|
||||
source="theaudiodb",
|
||||
media_source="theaudiodb",
|
||||
)
|
||||
|
||||
assert result is expected
|
||||
recognize.assert_called_once_with(path, source="theaudiodb")
|
||||
recognize.assert_called_once_with(path, media_source="theaudiodb")
|
||||
|
||||
@@ -22,7 +22,7 @@ def _fallback_music(title: str = "晴天", **kwargs) -> MusicInfo:
|
||||
def _remote_music() -> MusicInfo:
|
||||
"""构造带远端身份的标准音乐识别结果。"""
|
||||
return MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="recording-1",
|
||||
title="晴天",
|
||||
artists=["周杰伦"],
|
||||
@@ -68,7 +68,7 @@ def test_music_recognize_help_sends_event_and_rematches(monkeypatch):
|
||||
with patch("app.chain.media.eventmanager") as em:
|
||||
em.check.return_value = True
|
||||
em.send_event.return_value = event
|
||||
result = chain.recognize_by_meta(meta, source="musicbrainz")
|
||||
result = chain.recognize_by_meta(meta, media_source="musicbrainz")
|
||||
|
||||
assert result is remote
|
||||
assert em.check.call_args.args[0] == ChainEventType.MusicNameRecognize
|
||||
|
||||
@@ -23,7 +23,7 @@ from app.schemas.types import MediaType
|
||||
def _music_info() -> MusicInfo:
|
||||
"""构造带远端身份的标准音乐信息,用于断言路由返回值。"""
|
||||
return MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="recording-1",
|
||||
title="晴天",
|
||||
artists=["周杰伦"],
|
||||
@@ -40,7 +40,7 @@ def test_media_chain_recognize_by_meta_routes_metamusic_to_module(monkeypatch):
|
||||
monkeypatch.setattr(chain, "recognize_media", Mock(return_value=expected))
|
||||
|
||||
result = chain.recognize_by_meta(
|
||||
meta, source="musicbrainz", mtype=MediaType.MUSIC
|
||||
meta, media_source="musicbrainz", mtype=MediaType.MUSIC
|
||||
)
|
||||
|
||||
# 音乐不再旁路辅助识别选择流程,原生识别带共享元数据与剧集组参数
|
||||
@@ -61,7 +61,7 @@ def test_media_chain_async_recognize_by_meta_routes_metamusic_to_module(monkeypa
|
||||
|
||||
async def runner():
|
||||
return await chain.async_recognize_by_meta(
|
||||
meta, source="musicbrainz", mtype=MediaType.MUSIC
|
||||
meta, media_source="musicbrainz", mtype=MediaType.MUSIC
|
||||
)
|
||||
|
||||
result = asyncio.run(runner())
|
||||
@@ -95,7 +95,7 @@ def test_media_chain_recognize_by_path_routes_musicbrainz_source_to_music_chain(
|
||||
recognize_music = Mock(return_value=(expected_meta, expected_info))
|
||||
monkeypatch.setattr(MediaChain, "recognize_music_by_path", recognize_music)
|
||||
|
||||
context = MediaChain().recognize_by_path("/downloads/晴天", source="musicbrainz")
|
||||
context = MediaChain().recognize_by_path("/downloads/晴天", media_source="musicbrainz")
|
||||
|
||||
recognize_music.assert_called_once()
|
||||
assert recognize_music.call_args.kwargs["source"] == "musicbrainz"
|
||||
@@ -149,7 +149,7 @@ def test_recognize_music_by_path_fingerprint_mbid_skips_later_tiers(monkeypatch)
|
||||
tag_meta = MetaMusic(title="Tagged Title")
|
||||
filename_meta = MetaMusic(title="Filename Title")
|
||||
expected = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id=recording_id,
|
||||
title="Get Lucky",
|
||||
)
|
||||
@@ -187,7 +187,7 @@ def test_recognize_music_by_path_tag_mbid_skips_multi_source_matching(monkeypatc
|
||||
)
|
||||
filename_meta = MetaMusic(title="Filename Title")
|
||||
expected = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id=recording_id,
|
||||
title="Tagged Title",
|
||||
)
|
||||
@@ -219,7 +219,7 @@ def test_recognize_music_by_path_falls_back_from_tags_to_filename(monkeypatch):
|
||||
tag_meta = MetaMusic(title="Tagged Title")
|
||||
filename_meta = MetaMusic(title="Filename Title")
|
||||
expected = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="recording-from-filename",
|
||||
title="Filename Title",
|
||||
)
|
||||
@@ -251,7 +251,7 @@ def test_async_recognize_music_by_path_fingerprint_mbid_skips_later_tiers(monkey
|
||||
recording_id = "38035858-f990-4fbb-b3b2-f2f8b958eeba"
|
||||
merged = MetaMusic(title="Get Lucky")
|
||||
expected = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id=recording_id,
|
||||
title="Get Lucky",
|
||||
)
|
||||
@@ -304,13 +304,13 @@ def test_chain_explicit_music_source_bypasses_generic_module_dispatch(monkeypatc
|
||||
):
|
||||
result = chain.recognize_media(
|
||||
mtype=MediaType.MUSIC,
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
mediaid="recording-1",
|
||||
)
|
||||
|
||||
assert result is expected
|
||||
recognize_source.assert_called_once_with(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
meta=None,
|
||||
mediaid="recording-1",
|
||||
cache=True,
|
||||
@@ -325,7 +325,7 @@ def test_music_chain_rejects_cross_entity_detail_result(monkeypatch):
|
||||
monkeypatch.setattr(chain, "_recognize_from_source", recognize_source)
|
||||
|
||||
result = chain.recognize_from_source(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
mediaid="recording-1",
|
||||
music_type=MUSIC_ENTITY_ALBUM,
|
||||
)
|
||||
@@ -342,7 +342,7 @@ def test_music_chain_rejects_replaced_explicit_identity(monkeypatch):
|
||||
monkeypatch.setattr(chain, "_recognize_from_source", Mock(return_value=replaced))
|
||||
|
||||
result = chain.recognize_from_source(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
mediaid="recording-requested",
|
||||
music_type="recording",
|
||||
)
|
||||
@@ -457,7 +457,7 @@ def test_musicbrainz_module_recognize_media_uses_detail_when_meta_has_identity(m
|
||||
recording_search = Mock(return_value=[])
|
||||
monkeypatch.setattr(module, "_search_recordings", recording_search)
|
||||
|
||||
result = module.recognize_media(meta=meta, source="musicbrainz")
|
||||
result = module.recognize_media(meta=meta, media_source="musicbrainz")
|
||||
|
||||
module.recognize_music.assert_called_once_with("musicbrainz", "recording-1")
|
||||
recording_search.assert_not_called()
|
||||
@@ -497,7 +497,7 @@ def test_musicbrainz_module_recognize_media_by_music_type_and_media_id(monkeypat
|
||||
monkeypatch.setattr(module, "recognize_music", Mock(return_value=expected))
|
||||
|
||||
result = module.recognize_media(
|
||||
mtype=MediaType.MUSIC, source="musicbrainz", mediaid="recording-1"
|
||||
mtype=MediaType.MUSIC, media_source="musicbrainz", mediaid="recording-1"
|
||||
)
|
||||
|
||||
module.recognize_music.assert_called_once_with("musicbrainz", "recording-1")
|
||||
@@ -526,7 +526,7 @@ def test_theaudiodb_module_async_recognize_media(monkeypatch):
|
||||
"""异步 TheAudioDB 识别应直接调用异步检索而不进入同步入口。"""
|
||||
module = TheAudioDbModule()
|
||||
expected = MusicInfo(
|
||||
source="theaudiodb",
|
||||
media_source="theaudiodb",
|
||||
media_id="track-1",
|
||||
title="晴天",
|
||||
)
|
||||
@@ -538,7 +538,7 @@ def test_theaudiodb_module_async_recognize_media(monkeypatch):
|
||||
result = asyncio.run(module.async_recognize_media(
|
||||
meta=MetaMusic(title="晴天"),
|
||||
mtype=MediaType.MUSIC,
|
||||
source="theaudiodb",
|
||||
media_source="theaudiodb",
|
||||
))
|
||||
|
||||
async_search.assert_awaited_once()
|
||||
|
||||
@@ -17,7 +17,7 @@ def _media_chain() -> MediaChain:
|
||||
def _album_info() -> MusicInfo:
|
||||
"""构造专辑批量刮削使用的标准目标。"""
|
||||
return MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="release-group-1",
|
||||
music_type=MUSIC_ENTITY_ALBUM,
|
||||
title="叶惠美",
|
||||
@@ -140,7 +140,7 @@ def test_generic_scrape_dispatches_music_without_entering_video_handlers() -> No
|
||||
chain = _media_chain()
|
||||
chain.scrape_music_metadata = Mock(return_value=(True, "已刮削 1 个音频文件"))
|
||||
music = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="recording-1",
|
||||
title="晴天",
|
||||
artists=["周杰伦"],
|
||||
@@ -176,7 +176,7 @@ def test_default_scraping_config_enables_missing_only_music_lyrics() -> None:
|
||||
def test_album_track_match_uses_disc_track_title_and_duration() -> None:
|
||||
"""整张专辑刮削时应把本地音轨绑定到对应 Recording,不能复用专辑级身份。"""
|
||||
album = MusicAlbumInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="album-1",
|
||||
title="叶惠美",
|
||||
tracks=[
|
||||
@@ -347,7 +347,7 @@ def test_music_scrape_event_preserves_independent_policy_overwrite() -> None:
|
||||
chain.scrape_music_metadata = Mock(return_value=(True, "done"))
|
||||
fileitem = FileItem(storage="local", path="/music/叶惠美", type="dir")
|
||||
mediainfo = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="album-1",
|
||||
music_type=MUSIC_ENTITY_ALBUM,
|
||||
title="叶惠美",
|
||||
@@ -390,8 +390,8 @@ def test_music_scrape_event_uses_only_batch_files_and_per_track_contexts() -> No
|
||||
]
|
||||
chain.storagechain.get_file_item.side_effect = audio_files
|
||||
recordings = [
|
||||
MusicInfo(source="musicbrainz", media_id="recording-1", title="以父之名"),
|
||||
MusicInfo(source="musicbrainz", media_id="recording-3", title="晴天"),
|
||||
MusicInfo(media_source="musicbrainz", media_id="recording-1", title="以父之名"),
|
||||
MusicInfo(media_source="musicbrainz", media_id="recording-3", title="晴天"),
|
||||
]
|
||||
|
||||
chain.scrape_metadata_event(Event(
|
||||
@@ -434,8 +434,8 @@ def test_music_scrape_batch_applies_each_recording_to_its_own_file() -> None:
|
||||
FileItem(storage="local", path="/music/02.flac", type="file", name="02.flac"),
|
||||
]
|
||||
recordings = [
|
||||
MusicInfo(source="musicbrainz", media_id="recording-1", title="Track 1"),
|
||||
MusicInfo(source="musicbrainz", media_id="recording-2", title="Track 2"),
|
||||
MusicInfo(media_source="musicbrainz", media_id="recording-1", title="Track 1"),
|
||||
MusicInfo(media_source="musicbrainz", media_id="recording-2", title="Track 2"),
|
||||
]
|
||||
|
||||
success, message = chain.scrape_music_metadata(
|
||||
|
||||
@@ -11,7 +11,7 @@ def test_music_context_builder_keeps_only_music_category():
|
||||
"""精确音乐搜索只应保留明确标记为音乐分类的站点资源。"""
|
||||
chain = SearchChain()
|
||||
music = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="recording-1",
|
||||
title="Get Lucky",
|
||||
artists=["Daft Punk"],
|
||||
@@ -53,7 +53,7 @@ def test_music_search_continues_after_unrelated_first_keyword_results():
|
||||
"""首组关键词只命中其它专辑时应继续尝试后续关键词,不能提前返回空结果。"""
|
||||
chain = SearchChain()
|
||||
music = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="recording-1",
|
||||
title="Get Lucky",
|
||||
artists=["Daft Punk"],
|
||||
@@ -85,7 +85,7 @@ def test_music_search_continues_after_unrelated_first_keyword_results():
|
||||
def test_music_search_matches_artist_from_resource_description():
|
||||
"""精确音乐搜索应使用副标题中的艺术家,兼容主标题只有曲名的站点。"""
|
||||
music = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="recording-1",
|
||||
title="晴天",
|
||||
artists=["周杰伦"],
|
||||
@@ -103,7 +103,7 @@ def test_search_by_id_routes_music_identity_to_recognize_and_process():
|
||||
"""MusicBrainz 精确身份搜索应经统一识别入口识别后进入现有搜索处理链。"""
|
||||
chain = SearchChain()
|
||||
music = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="recording-1",
|
||||
title="晴天",
|
||||
artists=["周杰伦"],
|
||||
@@ -117,7 +117,7 @@ def test_search_by_id_routes_music_identity_to_recognize_and_process():
|
||||
patch.object(chain, "process", return_value=expected) as process,
|
||||
):
|
||||
result = chain.search_by_id(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
mediaid="recording-1",
|
||||
mtype=MediaType.MUSIC,
|
||||
music_type="recording",
|
||||
@@ -126,7 +126,7 @@ def test_search_by_id_routes_music_identity_to_recognize_and_process():
|
||||
|
||||
assert result == expected
|
||||
media_chain.recognize_media.assert_called_once_with(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
mediaid="recording-1",
|
||||
tmdbid=None,
|
||||
doubanid=None,
|
||||
|
||||
@@ -20,7 +20,7 @@ from app.schemas.types import MediaType
|
||||
def _music_info() -> MusicInfo:
|
||||
"""构造音乐订阅测试使用的标准目标。"""
|
||||
return MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="recording-1",
|
||||
title="晴天",
|
||||
artists=["周杰伦"],
|
||||
@@ -321,7 +321,7 @@ def test_album_best_version_requires_confirmed_full_coverage():
|
||||
current_priority=90,
|
||||
)
|
||||
album = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="release-group-1",
|
||||
music_type=MUSIC_ENTITY_ALBUM,
|
||||
title="叶惠美",
|
||||
@@ -543,7 +543,7 @@ def test_legacy_music_subscription_rejects_artist_recognition_result():
|
||||
"""旧订阅缺少实体类型时不得把艺术家识别结果迁移成可下载订阅。"""
|
||||
subscribe = _subscribe(music_type=None)
|
||||
artist = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="artist-1",
|
||||
music_type=MUSIC_ENTITY_ARTIST,
|
||||
title="周杰伦",
|
||||
@@ -567,7 +567,7 @@ def test_album_subscription_preserves_track_count_snapshot_when_remote_omits_it(
|
||||
media_id="release-group-1",
|
||||
)
|
||||
remote = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="release-group-1",
|
||||
music_type=MUSIC_ENTITY_ALBUM,
|
||||
title="叶惠美",
|
||||
@@ -618,7 +618,7 @@ def test_music_subscribe_target_validation_enforces_entity_semantics():
|
||||
recording = _music_info()
|
||||
recording.total_tracks = 11
|
||||
album = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="release-group-1",
|
||||
music_type=MUSIC_ENTITY_ALBUM,
|
||||
title="叶惠美",
|
||||
@@ -662,7 +662,7 @@ def test_album_target_sync_does_not_clear_stable_track_count():
|
||||
total_tracks=11,
|
||||
)
|
||||
album = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="release-group-1",
|
||||
music_type=MUSIC_ENTITY_ALBUM,
|
||||
title="叶惠美",
|
||||
@@ -686,7 +686,7 @@ def test_prepare_music_subscription_rejects_album_without_track_count():
|
||||
total_tracks=None,
|
||||
)
|
||||
album = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="release-group-unknown",
|
||||
music_type=MUSIC_ENTITY_ALBUM,
|
||||
title="未知专辑",
|
||||
|
||||
@@ -20,7 +20,7 @@ from app.schemas.types import EventType, MediaType
|
||||
def _music_context() -> tuple[MetaMusic, MusicInfo]:
|
||||
"""构造整理测试使用的音乐元数据和媒体信息。"""
|
||||
info = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="recording-1",
|
||||
title="Get Lucky",
|
||||
artists=["Daft Punk", "Pharrell Williams"],
|
||||
@@ -62,7 +62,7 @@ def test_music_retry_restores_history_entity_namespace(tmp_path, monkeypatch):
|
||||
)
|
||||
media_chain = Mock()
|
||||
media_chain.recognize_media.return_value = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="release-group-1",
|
||||
music_type="album",
|
||||
title="叶惠美",
|
||||
@@ -108,7 +108,7 @@ def test_music_rename_prefers_track_meta_over_album_media():
|
||||
total_tracks=11,
|
||||
)
|
||||
album = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="album-1",
|
||||
music_type="album",
|
||||
title="完美的一天",
|
||||
@@ -179,7 +179,7 @@ def test_music_scrape_batch_event_preserves_each_track_context():
|
||||
fileitem=source,
|
||||
meta=MetaMusic(title=title, track_number=number),
|
||||
mediainfo=MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id=f"recording-{number}",
|
||||
title=title,
|
||||
track_number=number,
|
||||
@@ -260,7 +260,7 @@ def test_download_history_music_type_falls_back_to_versioned_note():
|
||||
def test_restore_album_context_keeps_album_identity_and_track_specific_tags(tmp_path, monkeypatch):
|
||||
"""整专整理应保留选中的专辑身份,同时使用每个文件自己的曲名、艺术家和曲序。"""
|
||||
album = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="release-group-1",
|
||||
music_type="album",
|
||||
title="叶惠美",
|
||||
@@ -461,14 +461,14 @@ def test_job_manager_separates_music_entity_namespaces_for_same_provider_id():
|
||||
"""数据源 ID 相同但实体类型不同的单曲和专辑不能共享作业。"""
|
||||
manager = JobManager()
|
||||
recording = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="shared-id",
|
||||
music_type="recording",
|
||||
title="Track",
|
||||
artists=["Artist"],
|
||||
)
|
||||
album = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="shared-id",
|
||||
music_type="album",
|
||||
title="Album",
|
||||
@@ -577,7 +577,7 @@ def test_automatic_audio_transfer_runs_music_recognition(tmp_path, monkeypatch):
|
||||
extension="flac",
|
||||
)
|
||||
recognized = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="recording-1",
|
||||
title="晴天",
|
||||
artists=["周杰伦"],
|
||||
@@ -681,7 +681,7 @@ def test_explicit_music_batch_excludes_video_from_mixed_directory(tmp_path, monk
|
||||
extension="flac",
|
||||
)
|
||||
recognized = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="recording-1",
|
||||
title="Get Lucky",
|
||||
artists=["Daft Punk"],
|
||||
@@ -729,7 +729,7 @@ def test_downloader_process_forwards_music_history_type(tmp_path, monkeypatch):
|
||||
audio_path = tmp_path / "晴天.flac"
|
||||
audio_path.write_bytes(b"fake-flac")
|
||||
recognized = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="recording-1",
|
||||
title="晴天",
|
||||
artists=["周杰伦"],
|
||||
|
||||
@@ -44,14 +44,14 @@ def test_select_candidate_matches_traditional_chinese_title():
|
||||
meta = MetaMusic(title="永遠是朋友", artists=["毛阿敏"])
|
||||
candidates = [
|
||||
MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="recording-1",
|
||||
title="永远是朋友",
|
||||
artists=["毛阿敏"],
|
||||
),
|
||||
]
|
||||
|
||||
selected = MusicBrainzModule._select_candidate(meta, candidates, source="musicbrainz")
|
||||
selected = MusicBrainzModule._select_candidate(meta, candidates, media_source="musicbrainz")
|
||||
|
||||
assert selected is candidates[0]
|
||||
|
||||
@@ -640,14 +640,14 @@ def test_select_candidate_matches_traditional_chinese_recording():
|
||||
"""条目为繁体写法时,简体资源标题仍应命中候选。"""
|
||||
meta = MetaMusic(title="芸开了", artists=["许茹芸"])
|
||||
candidate = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
music_type="recording",
|
||||
media_id="recording-1",
|
||||
title="芸開了",
|
||||
artists=["許茹芸"],
|
||||
)
|
||||
|
||||
matched = MusicBrainzModule._select_candidate(meta, [candidate], source="musicbrainz")
|
||||
matched = MusicBrainzModule._select_candidate(meta, [candidate], media_source="musicbrainz")
|
||||
|
||||
assert matched is not None
|
||||
assert matched.media_id == "recording-1"
|
||||
@@ -697,7 +697,7 @@ def test_select_album_candidate_matches_lead_token_structure():
|
||||
"""条目「主体名 补充说明」结构与资源主体名首段一致时应弱匹配命中。"""
|
||||
meta = MetaMusic(title="许茹芸的爱情电影主题曲", artists=["许茹芸"], year=2003)
|
||||
album = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
music_type="album",
|
||||
media_id="album-1",
|
||||
title="愛情電影主題曲 雲且留住",
|
||||
@@ -715,7 +715,7 @@ def test_select_album_candidate_matches_performance_suffix():
|
||||
"""资源标题带演出后缀(S.H.E十七音乐会)时,条目本体一致应弱匹配命中。"""
|
||||
meta = MetaMusic(title="S.H.E十七音乐会", artists=["S.H.E"], year=2018)
|
||||
album = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
music_type="album",
|
||||
media_id="album-1",
|
||||
title="十七",
|
||||
@@ -733,7 +733,7 @@ def test_select_album_candidate_strips_volume_suffix():
|
||||
"""系列专辑卷号后缀(Vol. 3)是发行分卷标记,本体名一致应弱匹配命中。"""
|
||||
meta = MetaMusic(title="好歌茹芸, Vol. 3", artists=["许茹芸"], year=2011)
|
||||
album = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
music_type="album",
|
||||
media_id="album-1",
|
||||
title="好歌, 茹芸: Valen Hsu Greatest Hits",
|
||||
@@ -751,7 +751,7 @@ def test_select_album_candidate_rejects_wrong_volume():
|
||||
"""资源带卷号时其他分卷候选不能被弱匹配采信。"""
|
||||
meta = MetaMusic(title="Ibiza Lounge Moments, Vol. 1", artists=["Various Artists"], year=2022)
|
||||
wrong_volume = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
music_type="album",
|
||||
media_id="album-wrong",
|
||||
title="Ibiza Lounge Moments, Vol. 3",
|
||||
@@ -770,7 +770,7 @@ def test_select_album_candidate_matches_contained_title():
|
||||
year=2019,
|
||||
)
|
||||
album = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
music_type="album",
|
||||
media_id="album-1",
|
||||
title="Quentin Tarantino's Once Upon a Time in Hollywood: "
|
||||
@@ -820,7 +820,7 @@ def test_select_album_candidate_matches_soundtrack_body():
|
||||
year=1994,
|
||||
)
|
||||
album = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
music_type="album",
|
||||
media_id="album-1",
|
||||
title="Pulp Fiction: Music From the Motion Picture",
|
||||
@@ -838,21 +838,21 @@ def test_select_candidate_rejects_wrong_artist_same_title():
|
||||
"""已知艺术家时,同名异曲的候选不能因标题相等被采信。"""
|
||||
meta = MetaMusic(title="因为有你", artists=["毛阿敏"])
|
||||
wrong_artist = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
music_type="recording",
|
||||
media_id="recording-wrong",
|
||||
title="因为有你",
|
||||
artists=["张蔷"],
|
||||
)
|
||||
|
||||
assert MusicBrainzModule._select_candidate(meta, [wrong_artist], source="musicbrainz") is None
|
||||
assert MusicBrainzModule._select_candidate(meta, [wrong_artist], media_source="musicbrainz") is None
|
||||
|
||||
|
||||
def test_select_candidate_rejects_artist_only_match():
|
||||
"""CJK 逐字 OR 检索召回宽,标题未命中的候选不能仅凭艺术家署名被采信。"""
|
||||
meta = MetaMusic(title="茹此精彩十三首", artists=["许茹芸"])
|
||||
same_artist_other_song = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
music_type="recording",
|
||||
media_id="recording-wrong",
|
||||
title="半首歌",
|
||||
@@ -860,14 +860,14 @@ def test_select_candidate_rejects_artist_only_match():
|
||||
)
|
||||
|
||||
assert MusicBrainzModule._select_candidate(
|
||||
meta, [same_artist_other_song], source="musicbrainz") is None
|
||||
meta, [same_artist_other_song], media_source="musicbrainz") is None
|
||||
|
||||
|
||||
def test_select_album_candidate_requires_title_and_artist():
|
||||
"""专辑候选需标题(含去括号弱匹配)与艺术家同时命中才采信。"""
|
||||
meta = MetaMusic(title="我爱夜 (新歌+精选)", artists=["许茹芸"], year=2003)
|
||||
album_hit = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
music_type="album",
|
||||
media_id="album-1",
|
||||
title="我爱夜",
|
||||
@@ -881,7 +881,7 @@ def test_select_album_candidate_requires_title_and_artist():
|
||||
assert matched.media_id == "album-1"
|
||||
|
||||
album_wrong_artist = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
music_type="album",
|
||||
media_id="album-2",
|
||||
title="我爱夜",
|
||||
@@ -895,7 +895,7 @@ def test_select_album_candidate_matches_colon_subtitle():
|
||||
"""条目「主标题:副标题」结构应与资源主标题弱匹配命中。"""
|
||||
meta = MetaMusic(title="天国的情人", artists=["邓丽君"])
|
||||
album = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
music_type="album",
|
||||
media_id="album-colon",
|
||||
title="天國的情人:鄧麗君逝世十周年紀念聲影存集",
|
||||
@@ -912,7 +912,7 @@ def test_select_album_candidate_matches_head_title():
|
||||
"""条目「曲名-歌手《巡演名》」连字符前置命名应与资源曲名弱匹配命中。"""
|
||||
meta = MetaMusic(title="为你盛开", artists=["许巍"])
|
||||
album = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
music_type="album",
|
||||
media_id="album-head",
|
||||
title="为你盛开-许巍《无尽光芒》巡回演唱会现场纪念",
|
||||
|
||||
@@ -127,7 +127,7 @@ def test_navidrome_album_exists_requires_complete_track_count(monkeypatch):
|
||||
service.search_music.return_value = []
|
||||
monkeypatch.setattr(module, "get_instances", lambda: {"music": service})
|
||||
album = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="release-group-1",
|
||||
music_type="album",
|
||||
title="叶惠美",
|
||||
|
||||
@@ -7,7 +7,7 @@ import pytest
|
||||
from app.chain.recommend import RecommendChain
|
||||
from app.core.cache import TTLCache
|
||||
from app.core.context import MusicInfo
|
||||
from app.schemas.types import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING
|
||||
from app.schemas.types import MUSIC_ENTITY_ALBUM
|
||||
|
||||
SYNC_EMPTY_CACHE_CASES = [
|
||||
("tmdb_movies", "app.chain.recommend.TmdbChain", "tmdb_discover"),
|
||||
@@ -101,7 +101,7 @@ def test_music_weekly_uses_music_chart():
|
||||
chain = RecommendChain()
|
||||
with patch("app.chain.recommend.MusicChain") as music_chain:
|
||||
music_chain.return_value.chart.return_value = [
|
||||
MusicInfo(source="musicbrainz", media_id="recording-1", title="晴天")
|
||||
MusicInfo(media_source="musicbrainz", media_id="recording-1", title="晴天")
|
||||
]
|
||||
|
||||
result = chain.music_weekly(page=2, count=10)
|
||||
@@ -120,7 +120,7 @@ def test_async_music_weekly_uses_music_chart():
|
||||
with patch("app.chain.recommend.MusicChain") as music_chain:
|
||||
music_chain.return_value.async_chart = AsyncMock(
|
||||
return_value=[
|
||||
MusicInfo(source="musicbrainz", media_id="recording-1", title="晴天")
|
||||
MusicInfo(media_source="musicbrainz", media_id="recording-1", title="晴天")
|
||||
]
|
||||
)
|
||||
|
||||
@@ -134,73 +134,46 @@ def test_async_music_weekly_uses_music_chart():
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method_name", "source", "entity", "country"),
|
||||
[
|
||||
("music_theaudiodb_albums", "theaudiodb", MUSIC_ENTITY_ALBUM, "gb"),
|
||||
("music_theaudiodb_tracks", "theaudiodb", MUSIC_ENTITY_RECORDING, "gb"),
|
||||
("music_douban", "doubanmusic", MUSIC_ENTITY_ALBUM, "us"),
|
||||
],
|
||||
)
|
||||
def test_music_source_recommendations_use_discover(
|
||||
method_name: str,
|
||||
source: str,
|
||||
entity: str,
|
||||
country: str,
|
||||
):
|
||||
"""新增音乐推荐入口应保留来源与实体,并输出统一媒体字典。"""
|
||||
def test_music_douban_recommendations_use_discover():
|
||||
"""豆瓣音乐推荐入口应保留来源与实体,并输出统一媒体字典。"""
|
||||
chain = RecommendChain()
|
||||
with patch("app.chain.recommend.MusicChain") as music_chain:
|
||||
music_chain.return_value.discover.return_value = [
|
||||
MusicInfo(source=source, media_id="music-1", music_type=entity, title="Music")
|
||||
MusicInfo(
|
||||
media_source="doubanmusic",
|
||||
media_id="music-1",
|
||||
music_type=MUSIC_ENTITY_ALBUM,
|
||||
title="Music",
|
||||
)
|
||||
]
|
||||
|
||||
kwargs = {"page": 2, "count": 10}
|
||||
if source == "theaudiodb":
|
||||
kwargs["country"] = country
|
||||
result = getattr(chain, method_name)(**kwargs)
|
||||
result = chain.music_douban(page=2, count=10)
|
||||
|
||||
assert result[0]["source"] == source
|
||||
expected_kwargs = {
|
||||
"source": source,
|
||||
"page": 2,
|
||||
"count": 10,
|
||||
"entity": entity,
|
||||
}
|
||||
if source == "theaudiodb":
|
||||
expected_kwargs["country"] = country
|
||||
music_chain.return_value.discover.assert_called_once_with(**expected_kwargs)
|
||||
assert result[0]["media_source"] == "doubanmusic"
|
||||
music_chain.return_value.discover.assert_called_once_with(
|
||||
media_source="doubanmusic",
|
||||
page=2,
|
||||
count=10,
|
||||
entity=MUSIC_ENTITY_ALBUM,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method_name", "source", "entity"),
|
||||
[
|
||||
("async_music_theaudiodb_albums", "theaudiodb", MUSIC_ENTITY_ALBUM),
|
||||
("async_music_theaudiodb_tracks", "theaudiodb", MUSIC_ENTITY_RECORDING),
|
||||
("async_music_douban", "doubanmusic", MUSIC_ENTITY_ALBUM),
|
||||
],
|
||||
)
|
||||
def test_async_music_source_recommendations_use_discover(
|
||||
method_name: str,
|
||||
source: str,
|
||||
entity: str,
|
||||
):
|
||||
"""异步音乐推荐入口应调用统一发现链并保留来源。"""
|
||||
def test_async_music_douban_recommendations_use_discover():
|
||||
"""异步豆瓣音乐推荐入口应调用统一发现链并保留来源。"""
|
||||
chain = RecommendChain()
|
||||
with patch("app.chain.recommend.MusicChain") as music_chain:
|
||||
music_chain.return_value.async_discover = AsyncMock(
|
||||
return_value=[MusicInfo(source=source, media_id="music-1", title="Music")]
|
||||
return_value=[
|
||||
MusicInfo(media_source="doubanmusic", media_id="music-1", title="Music")
|
||||
]
|
||||
)
|
||||
|
||||
result = asyncio.run(getattr(chain, method_name)(page=1, count=30))
|
||||
result = asyncio.run(chain.async_music_douban(page=1, count=30))
|
||||
|
||||
assert result[0]["source"] == source
|
||||
expected_kwargs = {
|
||||
"source": source,
|
||||
"page": 1,
|
||||
"count": 30,
|
||||
"entity": entity,
|
||||
}
|
||||
if source == "theaudiodb":
|
||||
expected_kwargs["country"] = "us"
|
||||
music_chain.return_value.async_discover.assert_awaited_once_with(**expected_kwargs)
|
||||
assert result[0]["media_source"] == "doubanmusic"
|
||||
music_chain.return_value.async_discover.assert_awaited_once_with(
|
||||
media_source="doubanmusic",
|
||||
page=1,
|
||||
count=30,
|
||||
entity=MUSIC_ENTITY_ALBUM,
|
||||
)
|
||||
|
||||
@@ -8,81 +8,59 @@ from app.api.endpoints import search as search_endpoint
|
||||
from app.chain import subscribe as subscribe_module
|
||||
from app.chain.subscribe import SubscribeChain
|
||||
from app.core.context import MediaInfo
|
||||
from app.schemas.types import MediaType
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
from app.utils.media import normalize_media_source
|
||||
|
||||
|
||||
def test_resolve_anilist_search_params_preserves_source_identity() -> None:
|
||||
"""AniList 媒体键应直接解析为统一搜索身份。"""
|
||||
def test_media_source_normalization_rejects_unknown_source() -> None:
|
||||
"""固定枚举之外的来源不能进入统一身份链路。"""
|
||||
assert normalize_media_source("plugin_source") is None
|
||||
assert normalize_media_source("tmdb") == MediaSource.TMDB
|
||||
|
||||
|
||||
def test_resolve_anilist_search_params_preserves_identity() -> None:
|
||||
"""精确搜索参数应保留枚举来源和原生 ID。"""
|
||||
params, message = asyncio.run(
|
||||
search_endpoint._resolve_media_search_params("anilist:154587")
|
||||
search_endpoint._resolve_media_search_params(
|
||||
MediaSource.AniList,
|
||||
"154587",
|
||||
media_type=MediaType.TV,
|
||||
)
|
||||
)
|
||||
|
||||
assert message == ""
|
||||
assert params == {"source": "anilist", "mediaid": "154587"}
|
||||
assert params == {
|
||||
"media_source": MediaSource.AniList,
|
||||
"media_id": "154587",
|
||||
}
|
||||
|
||||
|
||||
def test_resource_search_forwards_custom_plugin_source(monkeypatch) -> None:
|
||||
"""资源搜索 API 应把自定义插件来源原样传给搜索链。"""
|
||||
@pytest.mark.parametrize(
|
||||
("media_source", "media_id"),
|
||||
[
|
||||
(MediaSource.MusicBrainz, "release-group-1"),
|
||||
(MediaSource.TheAudioDB, "2109619"),
|
||||
(MediaSource.DoubanMusic, "1401853"),
|
||||
],
|
||||
)
|
||||
def test_resource_search_forwards_music_identity(
|
||||
monkeypatch,
|
||||
media_source: MediaSource,
|
||||
media_id: str,
|
||||
) -> None:
|
||||
"""音乐资源搜索应传递统一身份及音乐实体类型。"""
|
||||
captured = {}
|
||||
|
||||
class FakeTorrent:
|
||||
"""提供资源搜索响应需要的最小种子对象。"""
|
||||
|
||||
@staticmethod
|
||||
def to_dict() -> dict:
|
||||
"""返回可序列化的测试种子。"""
|
||||
return {"title": "Plugin result"}
|
||||
|
||||
class FakeSearchChain:
|
||||
"""记录资源搜索链收到的统一身份。"""
|
||||
|
||||
async def async_search_by_id(self, **kwargs):
|
||||
"""保存搜索参数并返回单条测试结果。"""
|
||||
captured.update(kwargs)
|
||||
return [FakeTorrent()]
|
||||
|
||||
monkeypatch.setattr(search_endpoint, "SearchChain", FakeSearchChain)
|
||||
|
||||
response = asyncio.run(
|
||||
search_endpoint.search_by_id(
|
||||
mediaid="plugin_source:custom-1",
|
||||
mtype="tv",
|
||||
_=None,
|
||||
)
|
||||
)
|
||||
|
||||
assert response.success
|
||||
assert captured["source"] == "plugin_source"
|
||||
assert captured["mediaid"] == "custom-1"
|
||||
assert captured["mtype"] == MediaType.TV
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("source", "media_id"),
|
||||
[
|
||||
("musicbrainz", "release-group-1"),
|
||||
("theaudiodb", "2109619"),
|
||||
("doubanmusic", "1401853"),
|
||||
],
|
||||
)
|
||||
def test_resource_search_forwards_music_entity_namespace(
|
||||
monkeypatch,
|
||||
source: str,
|
||||
media_id: str,
|
||||
) -> None:
|
||||
"""音乐资源搜索 API 应在识别前传递单曲或专辑实体类型。"""
|
||||
captured = {}
|
||||
|
||||
class FakeTorrent:
|
||||
"""提供音乐资源搜索响应需要的最小种子对象。"""
|
||||
|
||||
@staticmethod
|
||||
def to_dict() -> dict:
|
||||
"""返回可序列化的测试种子。"""
|
||||
return {"title": "Album result"}
|
||||
|
||||
class FakeSearchChain:
|
||||
"""记录音乐资源搜索链收到的实体命名空间。"""
|
||||
"""记录精确资源搜索收到的参数。"""
|
||||
|
||||
async def async_search_by_id(self, **kwargs):
|
||||
"""保存搜索参数并返回单条测试结果。"""
|
||||
@@ -93,7 +71,8 @@ def test_resource_search_forwards_music_entity_namespace(
|
||||
|
||||
response = asyncio.run(
|
||||
search_endpoint.search_by_id(
|
||||
mediaid=f"{source}:{media_id}",
|
||||
media_id=media_id,
|
||||
media_source=media_source,
|
||||
mtype="music",
|
||||
music_type="album",
|
||||
_=None,
|
||||
@@ -101,21 +80,21 @@ def test_resource_search_forwards_music_entity_namespace(
|
||||
)
|
||||
|
||||
assert response.success
|
||||
assert captured["source"] == source
|
||||
assert captured["mediaid"] == media_id
|
||||
assert captured["media_source"] == media_source
|
||||
assert captured["media_id"] == media_id
|
||||
assert captured["mtype"] == MediaType.MUSIC
|
||||
assert captured["music_type"] == "album"
|
||||
|
||||
|
||||
def test_subtitle_search_forwards_anilist_identity(monkeypatch) -> None:
|
||||
"""字幕搜索 API 应把 AniList 身份传给字幕搜索链。"""
|
||||
"""字幕精确搜索应把 AniList 统一身份传给搜索链。"""
|
||||
captured = {}
|
||||
|
||||
class FakeSearchChain:
|
||||
"""记录字幕搜索链收到的统一身份。"""
|
||||
"""记录字幕搜索收到的参数。"""
|
||||
|
||||
async def async_search_subtitles_by_id(self, **kwargs):
|
||||
"""保存字幕搜索参数并返回空结果。"""
|
||||
"""保存搜索参数并返回空结果。"""
|
||||
captured.update(kwargs)
|
||||
return []
|
||||
|
||||
@@ -123,285 +102,128 @@ def test_subtitle_search_forwards_anilist_identity(monkeypatch) -> None:
|
||||
|
||||
source, message = asyncio.run(
|
||||
search_endpoint._build_subtitle_search_source(
|
||||
mediaid="anilist:154587",
|
||||
media_source=MediaSource.AniList,
|
||||
media_id="154587",
|
||||
mtype="tv",
|
||||
)
|
||||
)
|
||||
assert message == ""
|
||||
assert asyncio.run(source) == []
|
||||
assert captured["source"] == "anilist"
|
||||
assert captured["mediaid"] == "154587"
|
||||
assert captured["media_source"] == MediaSource.AniList
|
||||
assert captured["media_id"] == "154587"
|
||||
|
||||
|
||||
def test_media_detail_forwards_custom_plugin_source(monkeypatch) -> None:
|
||||
"""媒体详情 API 应允许插件自定义来源处理原生 ID。"""
|
||||
captured = {}
|
||||
def test_media_detail_forwards_unified_identity(monkeypatch) -> None:
|
||||
"""媒体详情应只向识别链传递来源和原生 ID。"""
|
||||
media = MediaInfo(
|
||||
source="plugin_source",
|
||||
type=MediaType.MOVIE,
|
||||
title="Plugin movie",
|
||||
)
|
||||
|
||||
class FakeMediaChain:
|
||||
"""记录详情识别链收到的统一身份。"""
|
||||
|
||||
async def async_recognize_media(self, **kwargs):
|
||||
"""保存识别参数并返回插件媒体信息。"""
|
||||
captured.update(kwargs)
|
||||
return media
|
||||
|
||||
async def async_obtain_images(self, _media):
|
||||
"""跳过测试中的真实图片获取。"""
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(media_endpoint, "MediaChain", FakeMediaChain)
|
||||
|
||||
result = asyncio.run(
|
||||
media_endpoint.detail(
|
||||
mediaid="plugin_source:custom-1",
|
||||
type_name=MediaType.MOVIE.value,
|
||||
_=None,
|
||||
)
|
||||
)
|
||||
|
||||
assert result["title"] == "Plugin movie"
|
||||
assert captured["source"] == "plugin_source"
|
||||
assert captured["mediaid"] == "custom-1"
|
||||
|
||||
|
||||
def test_media_detail_falls_back_to_title_for_legacy_discover_source(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""仅提供探索列表的旧插件应在原生 ID 识别失败后按标题年份兜底。"""
|
||||
media = MediaInfo(
|
||||
source="themoviedb",
|
||||
media_source=MediaSource.AniList,
|
||||
media_id="154587",
|
||||
type=MediaType.TV,
|
||||
title="旧版剧集",
|
||||
tmdb_id=12345,
|
||||
title="Frieren",
|
||||
)
|
||||
media_chain = Mock()
|
||||
media_chain.async_recognize_media = AsyncMock(return_value=None)
|
||||
media_chain.async_recognize_by_meta = AsyncMock(return_value=media)
|
||||
media_chain.async_recognize_media = AsyncMock(return_value=media)
|
||||
media_chain.async_obtain_images = AsyncMock(return_value=None)
|
||||
monkeypatch.setattr(media_endpoint, "MediaChain", Mock(return_value=media_chain))
|
||||
monkeypatch.setattr(
|
||||
media_endpoint.eventmanager,
|
||||
"async_send_event",
|
||||
AsyncMock(return_value=None),
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
media_endpoint.detail(
|
||||
mediaid="tvdb:81189",
|
||||
media_id="154587",
|
||||
media_source=MediaSource.AniList,
|
||||
type_name=MediaType.TV.value,
|
||||
title="旧版剧集",
|
||||
year="2026",
|
||||
_=None,
|
||||
)
|
||||
)
|
||||
|
||||
assert result["tmdb_id"] == 12345
|
||||
assert result["media_source"] == MediaSource.AniList.value
|
||||
assert result["media_id"] == "154587"
|
||||
media_chain.async_recognize_media.assert_awaited_once_with(
|
||||
source="tvdb",
|
||||
mediaid="81189",
|
||||
media_source=MediaSource.AniList,
|
||||
media_id="154587",
|
||||
mtype=MediaType.TV,
|
||||
)
|
||||
fallback_meta = media_chain.async_recognize_by_meta.await_args.args[0]
|
||||
assert fallback_meta.name == "旧版剧集"
|
||||
assert fallback_meta.year == "2026"
|
||||
assert fallback_meta.type == MediaType.TV
|
||||
|
||||
|
||||
def test_media_detail_uses_convert_event_for_legacy_discover_source(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""旧探索插件提供 ID 转换事件时应优先转换到系统识别源。"""
|
||||
media = MediaInfo(
|
||||
source="themoviedb",
|
||||
type=MediaType.MOVIE,
|
||||
title="转换电影",
|
||||
tmdb_id=54321,
|
||||
)
|
||||
event_data = media_endpoint.MediaRecognizeConvertEventData(
|
||||
mediaid="legacy:42",
|
||||
convert_type="themoviedb",
|
||||
)
|
||||
event_data.media_dict["id"] = 54321
|
||||
media_chain = Mock()
|
||||
media_chain.async_recognize_media = AsyncMock(side_effect=[None, media])
|
||||
media_chain.async_recognize_by_meta = AsyncMock(
|
||||
side_effect=AssertionError("转换成功后不应再按标题识别")
|
||||
)
|
||||
media_chain.async_obtain_images = AsyncMock(return_value=None)
|
||||
monkeypatch.setattr(media_endpoint, "MediaChain", Mock(return_value=media_chain))
|
||||
monkeypatch.setattr(
|
||||
media_endpoint.eventmanager,
|
||||
"async_send_event",
|
||||
AsyncMock(return_value=Mock(event_data=event_data)),
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
media_endpoint.detail(
|
||||
mediaid="legacy:42",
|
||||
type_name=MediaType.MOVIE.value,
|
||||
title="转换电影",
|
||||
year="2026",
|
||||
_=None,
|
||||
)
|
||||
)
|
||||
|
||||
assert result["tmdb_id"] == 54321
|
||||
assert media_chain.async_recognize_media.await_count == 2
|
||||
assert media_chain.async_recognize_media.await_args_list[0].kwargs == {
|
||||
"source": "legacy",
|
||||
"mediaid": "42",
|
||||
"mtype": MediaType.MOVIE,
|
||||
}
|
||||
assert media_chain.async_recognize_media.await_args_list[1].kwargs == {
|
||||
"source": "themoviedb",
|
||||
"mediaid": "54321",
|
||||
"mtype": MediaType.MOVIE,
|
||||
}
|
||||
media_chain.async_recognize_by_meta.assert_not_awaited()
|
||||
|
||||
|
||||
def test_media_detail_does_not_fallback_for_builtin_source(monkeypatch) -> None:
|
||||
"""内置来源的明确 ID 查询失败时不应被标题识别替换身份。"""
|
||||
def test_media_detail_does_not_fallback_for_explicit_identity(monkeypatch) -> None:
|
||||
"""明确身份识别失败时不能按标题切换到其他来源。"""
|
||||
media_chain = Mock()
|
||||
media_chain.async_recognize_media = AsyncMock(return_value=None)
|
||||
media_chain.async_recognize_by_meta = AsyncMock(
|
||||
side_effect=AssertionError("不应按标题切换识别源")
|
||||
)
|
||||
convert_event = AsyncMock(return_value=None)
|
||||
monkeypatch.setattr(media_endpoint, "MediaChain", Mock(return_value=media_chain))
|
||||
monkeypatch.setattr(
|
||||
media_endpoint.eventmanager,
|
||||
"async_send_event",
|
||||
convert_event,
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
media_endpoint.detail(
|
||||
mediaid="tmdb:999999",
|
||||
media_id="999999",
|
||||
media_source=MediaSource.TMDB,
|
||||
type_name=MediaType.MOVIE.value,
|
||||
title="错误兜底电影",
|
||||
year="2026",
|
||||
_=None,
|
||||
)
|
||||
)
|
||||
|
||||
assert isinstance(result, media_endpoint.schemas.MediaInfo)
|
||||
media_chain.async_recognize_by_meta.assert_not_awaited()
|
||||
convert_event.assert_not_awaited()
|
||||
|
||||
|
||||
def test_media_seasons_builds_anilist_season_response(monkeypatch) -> None:
|
||||
"""AniList 详情应能通过统一季信息接口返回剧集季。"""
|
||||
captured = {}
|
||||
media = MediaInfo(
|
||||
source="anilist",
|
||||
type=MediaType.TV,
|
||||
title="Frieren",
|
||||
anilist_id=154587,
|
||||
poster_path="https://images.example.com/frieren.jpg",
|
||||
season_info=[{
|
||||
"season_number": 1,
|
||||
"name": "Season 1",
|
||||
"episode_count": 28,
|
||||
}],
|
||||
)
|
||||
|
||||
class FakeMediaChain:
|
||||
"""记录季信息识别链收到的 AniList 身份。"""
|
||||
|
||||
async def async_recognize_media(self, **kwargs):
|
||||
"""保存识别参数并返回 AniList 媒体信息。"""
|
||||
captured.update(kwargs)
|
||||
return media
|
||||
|
||||
monkeypatch.setattr(media_endpoint, "MediaChain", FakeMediaChain)
|
||||
|
||||
result = asyncio.run(
|
||||
media_endpoint.seasons(mediaid="anilist:154587", _=None)
|
||||
)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].season_number == 1
|
||||
assert result[0].episode_count == 28
|
||||
assert result[0].poster_path == media.poster_path
|
||||
assert captured["source"] == "anilist"
|
||||
assert captured["mediaid"] == "154587"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mediaid", "media_kwargs", "episode_count"),
|
||||
("media_source", "media_id", "media_kwargs", "episode_count"),
|
||||
[
|
||||
(
|
||||
"douban:db-7301",
|
||||
{
|
||||
"douban_info": {
|
||||
"episodes_count": 12,
|
||||
"id": "db-7301",
|
||||
"subtype": "tv",
|
||||
"title": "豆瓣剧集",
|
||||
}
|
||||
},
|
||||
MediaSource.Douban,
|
||||
"7301",
|
||||
{"douban_info": {"episodes_count": 12, "id": "7301", "subtype": "tv"}},
|
||||
12,
|
||||
),
|
||||
(
|
||||
"bangumi:7302",
|
||||
{
|
||||
"bangumi_info": {
|
||||
"id": 7302,
|
||||
"name_cn": "Bangumi 剧集",
|
||||
"platform": "TV",
|
||||
"total_episodes": 13,
|
||||
}
|
||||
},
|
||||
MediaSource.Bangumi,
|
||||
"7302",
|
||||
{"bangumi_info": {"id": 7302, "platform": "TV", "total_episodes": 13}},
|
||||
13,
|
||||
),
|
||||
(
|
||||
"anilist:7303",
|
||||
{
|
||||
"anilist_info": {
|
||||
"episodes": 14,
|
||||
"format": "TV",
|
||||
"id": 7303,
|
||||
"title": {"native": "AniList 剧集"},
|
||||
}
|
||||
},
|
||||
MediaSource.AniList,
|
||||
"7303",
|
||||
{"anilist_info": {"episodes": 14, "format": "TV", "id": 7303}},
|
||||
14,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_media_seasons_uses_source_episode_count_and_defaults_to_first_season(
|
||||
monkeypatch, mediaid: str, media_kwargs: dict, episode_count: int,
|
||||
def test_media_seasons_uses_source_episode_count(
|
||||
monkeypatch,
|
||||
media_source: MediaSource,
|
||||
media_id: str,
|
||||
media_kwargs: dict,
|
||||
episode_count: int,
|
||||
) -> None:
|
||||
"""非 TMDB 来源应使用自身总集数构造第 1 季,不依赖 TMDB。"""
|
||||
"""非 TMDB 来源应使用自身集数构造季信息。"""
|
||||
media = MediaInfo(**media_kwargs)
|
||||
media_chain = Mock()
|
||||
media_chain.async_recognize_media = AsyncMock(return_value=media)
|
||||
monkeypatch.setattr(media_endpoint, "MediaChain", Mock(return_value=media_chain))
|
||||
|
||||
result = asyncio.run(
|
||||
media_endpoint.seasons(mediaid=mediaid, season=None, _=None)
|
||||
media_endpoint.seasons(
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
season=None,
|
||||
_=None,
|
||||
)
|
||||
)
|
||||
|
||||
assert media.season is None
|
||||
assert media.seasons[1] == list(range(1, episode_count + 1))
|
||||
assert len(result) == 1
|
||||
assert result[0].season_number == 1
|
||||
assert result[0].episode_count == episode_count
|
||||
media_chain.async_recognize_media.assert_awaited_once_with(
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
mtype=MediaType.TV,
|
||||
cache=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mediaid",
|
||||
["douban:db-7401", "bangumi:7402", "anilist:7403"],
|
||||
)
|
||||
def test_media_seasons_does_not_fallback_to_default_source_for_explicit_identity(
|
||||
monkeypatch, mediaid: str,
|
||||
) -> None:
|
||||
"""明确来源查询失败时应直接返回空列表,不能按标题切换到默认源。"""
|
||||
def test_media_seasons_does_not_fallback_for_explicit_identity(monkeypatch) -> None:
|
||||
"""明确身份查询失败时季信息接口应直接返回空列表。"""
|
||||
media_chain = Mock()
|
||||
media_chain.async_recognize_media = AsyncMock(return_value=None)
|
||||
media_chain.async_recognize_by_meta = AsyncMock(
|
||||
@@ -411,7 +233,8 @@ def test_media_seasons_does_not_fallback_to_default_source_for_explicit_identity
|
||||
|
||||
result = asyncio.run(
|
||||
media_endpoint.seasons(
|
||||
mediaid=mediaid,
|
||||
media_source=MediaSource.AniList,
|
||||
media_id="7403",
|
||||
title="来源查询失败剧集",
|
||||
year="2026",
|
||||
_=None,
|
||||
@@ -419,12 +242,11 @@ def test_media_seasons_does_not_fallback_to_default_source_for_explicit_identity
|
||||
)
|
||||
|
||||
assert result == []
|
||||
media_chain.async_recognize_media.assert_awaited_once()
|
||||
media_chain.async_recognize_by_meta.assert_not_awaited()
|
||||
|
||||
|
||||
def test_subscribe_add_keeps_inferred_anilist_source_during_title_fallback() -> None:
|
||||
"""同步新增订阅按兼容 ID 推导来源后,标题兜底仍应限定 AniList。"""
|
||||
def test_subscribe_add_does_not_fallback_for_explicit_identity() -> None:
|
||||
"""同步新增订阅的显式身份识别失败后不能按标题换源。"""
|
||||
media_chain = Mock()
|
||||
media_chain.recognize_media.return_value = None
|
||||
media_chain.recognize_by_meta.return_value = None
|
||||
@@ -435,20 +257,18 @@ def test_subscribe_add_keeps_inferred_anilist_source_during_title_fallback() ->
|
||||
title="AniList 同步订阅",
|
||||
year="2026",
|
||||
mtype=MediaType.TV,
|
||||
anilistid=154587,
|
||||
tmdbid=209867,
|
||||
media_source="anilist",
|
||||
media_source=MediaSource.AniList,
|
||||
media_id="154587",
|
||||
)
|
||||
|
||||
assert sid is None
|
||||
assert message == "未识别到媒体信息"
|
||||
assert media_chain.recognize_media.call_args.kwargs["source"] == "anilist"
|
||||
assert media_chain.recognize_media.call_args.kwargs["mediaid"] == "154587"
|
||||
assert media_chain.recognize_by_meta.call_args.kwargs["source"] == "anilist"
|
||||
media_chain.recognize_media.assert_called_once()
|
||||
media_chain.recognize_by_meta.assert_not_called()
|
||||
|
||||
|
||||
def test_subscribe_async_add_keeps_inferred_anilist_source_during_title_fallback() -> None:
|
||||
"""异步新增订阅按兼容 ID 推导来源后,标题兜底仍应限定 AniList。"""
|
||||
def test_subscribe_async_add_does_not_fallback_for_explicit_identity() -> None:
|
||||
"""异步新增订阅的显式身份识别失败后不能按标题换源。"""
|
||||
media_chain = Mock()
|
||||
media_chain.async_recognize_media = AsyncMock(return_value=None)
|
||||
media_chain.async_recognize_by_meta = AsyncMock(return_value=None)
|
||||
@@ -460,14 +280,12 @@ def test_subscribe_async_add_keeps_inferred_anilist_source_during_title_fallback
|
||||
title="AniList 异步订阅",
|
||||
year="2026",
|
||||
mtype=MediaType.TV,
|
||||
anilistid=154587,
|
||||
tmdbid=209867,
|
||||
media_source="anilist",
|
||||
media_source=MediaSource.AniList,
|
||||
media_id="154587",
|
||||
)
|
||||
)
|
||||
|
||||
assert sid is None
|
||||
assert message == "未识别到媒体信息"
|
||||
assert media_chain.async_recognize_media.await_args.kwargs["source"] == "anilist"
|
||||
assert media_chain.async_recognize_media.await_args.kwargs["mediaid"] == "154587"
|
||||
assert media_chain.async_recognize_by_meta.await_args.kwargs["source"] == "anilist"
|
||||
media_chain.async_recognize_media.assert_awaited_once()
|
||||
media_chain.async_recognize_by_meta.assert_not_awaited()
|
||||
|
||||
@@ -426,16 +426,9 @@ class SubscribeChainTest(TestCase):
|
||||
"state": "R",
|
||||
"note": [],
|
||||
"manual_total_episode": 0,
|
||||
"tmdbid": 1,
|
||||
"doubanid": None,
|
||||
"year": "2026",
|
||||
"imdbid": None,
|
||||
"tvdbid": None,
|
||||
"bangumiid": None,
|
||||
"anilistid": None,
|
||||
"media_source": "themoviedb",
|
||||
"media_id": "1",
|
||||
"mediaid": "tmdb:1",
|
||||
"episode_group": None,
|
||||
"poster": None,
|
||||
"backdrop": None,
|
||||
@@ -454,7 +447,7 @@ class SubscribeChainTest(TestCase):
|
||||
torrent_info=SimpleNamespace(pri_order=priority),
|
||||
selected_episodes=selected_episodes,
|
||||
meta_info=SimpleNamespace(season_list=[1], episode_list=meta_episodes or selected_episodes or []),
|
||||
media_info=SimpleNamespace(type=MediaType.TV, tmdb_id=1, douban_id=None),
|
||||
media_info=SimpleNamespace(type=MediaType.TV, media_source="themoviedb", media_id="1"),
|
||||
confirmed_full_coverage=False,
|
||||
)
|
||||
|
||||
@@ -513,7 +506,6 @@ class SubscribeChainTest(TestCase):
|
||||
subscribe = self._build_subscribe(
|
||||
best_version=0,
|
||||
custom_words=None,
|
||||
doubanid=None,
|
||||
episode_group=None,
|
||||
sites=[],
|
||||
tmdbid=1,
|
||||
@@ -569,7 +561,6 @@ class SubscribeChainTest(TestCase):
|
||||
subscribe = self._build_subscribe(
|
||||
best_version=0,
|
||||
custom_words=None,
|
||||
doubanid=None,
|
||||
episode_group=None,
|
||||
filter_groups=[],
|
||||
keyword=None,
|
||||
@@ -578,21 +569,20 @@ class SubscribeChainTest(TestCase):
|
||||
search_imdbid=False,
|
||||
season=0,
|
||||
sites=[],
|
||||
tmdbid=1,
|
||||
username="",
|
||||
downloader=None,
|
||||
)
|
||||
mediainfo = SimpleNamespace(
|
||||
clear=lambda: None,
|
||||
douban_id=None,
|
||||
media_source="themoviedb",
|
||||
media_id="1",
|
||||
title_year="Test Show (2026)",
|
||||
tmdb_id=1,
|
||||
type=MediaType.TV,
|
||||
)
|
||||
torrent_media = SimpleNamespace(
|
||||
clear=lambda: None,
|
||||
douban_id=None,
|
||||
tmdb_id=1,
|
||||
media_source="themoviedb",
|
||||
media_id="1",
|
||||
type=MediaType.TV,
|
||||
)
|
||||
context = SimpleNamespace(
|
||||
@@ -2025,10 +2015,8 @@ class SubscribeNoteTrackingTest(TestCase):
|
||||
meta_info=SimpleNamespace(season_list=[1], episode_list=list(episodes)),
|
||||
media_info=SimpleNamespace(
|
||||
type=MediaType.TV,
|
||||
tmdb_id=1,
|
||||
douban_id=None,
|
||||
bangumi_id=None,
|
||||
anilist_id=None,
|
||||
media_source="themoviedb",
|
||||
media_id="1",
|
||||
),
|
||||
torrent_info=SimpleNamespace(pri_order=99, title="fake-torrent"),
|
||||
selected_episodes=list(episodes),
|
||||
@@ -2064,8 +2052,12 @@ class SubscribeNoteTrackingTest(TestCase):
|
||||
chain.finish_subscribe_or_not(
|
||||
subscribe=subscribe,
|
||||
meta=SimpleNamespace(type=MediaType.TV),
|
||||
mediainfo=SimpleNamespace(title_year="Test Show (2026)", type=MediaType.TV,
|
||||
tmdb_id=1, douban_id=None),
|
||||
mediainfo=SimpleNamespace(
|
||||
title_year="Test Show (2026)",
|
||||
type=MediaType.TV,
|
||||
media_source="themoviedb",
|
||||
media_id="1",
|
||||
),
|
||||
downloads=downloads,
|
||||
lefts=None,
|
||||
)
|
||||
@@ -2168,8 +2160,8 @@ class SubscribeProgressEntrypointTest(TestCase):
|
||||
"current_priority": None,
|
||||
"episode_priority": {},
|
||||
"last_update": None,
|
||||
"tmdbid": 10001,
|
||||
"doubanid": None,
|
||||
"media_source": "themoviedb",
|
||||
"media_id": "10001",
|
||||
"year": "2026",
|
||||
"manual_total_episode": 0,
|
||||
}
|
||||
@@ -2189,14 +2181,14 @@ class SubscribeProgressEntrypointTest(TestCase):
|
||||
def test_compute_lack_episode_normal_tv_no_exists_boundaries(self):
|
||||
subscribe = self._build_subscribe(best_version=0, note=[1])
|
||||
missing_all = {
|
||||
10001: {
|
||||
"tmdb:10001": {
|
||||
1: self.module.schemas.NotExistMediaInfo(
|
||||
season=1, episodes=[], total_episode=5, start_episode=1
|
||||
)
|
||||
}
|
||||
}
|
||||
missing_some = {
|
||||
10001: {
|
||||
"tmdb:10001": {
|
||||
1: self.module.schemas.NotExistMediaInfo(
|
||||
season=1, episodes=[2, 4], total_episode=5, start_episode=1
|
||||
)
|
||||
@@ -2495,13 +2487,13 @@ class SubscribeProgressEntrypointTest(TestCase):
|
||||
subscribe = self._build_subscribe(best_version=0, lack_episode=5)
|
||||
mediainfo = SimpleNamespace(
|
||||
type=MediaType.TV,
|
||||
tmdb_id=10001,
|
||||
douban_id=None,
|
||||
media_source="themoviedb",
|
||||
media_id="10001",
|
||||
title_year="测试剧 (2026)",
|
||||
seasons={1: [1, 2, 3, 4, 5]},
|
||||
)
|
||||
no_exists = {
|
||||
10001: {
|
||||
"tmdb:10001": {
|
||||
1: self.module.schemas.NotExistMediaInfo(
|
||||
season=1, episodes=[2, 4], total_episode=5, start_episode=1
|
||||
)
|
||||
@@ -2606,11 +2598,8 @@ class SubscribeProgressConsolidationTest(TestCase):
|
||||
title="总集增长剧",
|
||||
title_year="总集增长剧 (2026)",
|
||||
year="2026",
|
||||
tmdb_id=31000,
|
||||
douban_id=None,
|
||||
bangumi_id=None,
|
||||
anilist_id=None,
|
||||
source="themoviedb",
|
||||
media_source="themoviedb",
|
||||
media_id="31000",
|
||||
vote_average=9.5,
|
||||
overview="overview",
|
||||
imdb_id="tt1234567",
|
||||
@@ -2652,8 +2641,8 @@ class SubscribeProgressConsolidationTest(TestCase):
|
||||
10,
|
||||
season=1,
|
||||
mediainfo=self._mediainfo(total_episode=10),
|
||||
tmdbid=31030,
|
||||
doubanid=None,
|
||||
media_source="themoviedb",
|
||||
media_id="31030",
|
||||
subscribe_id=31,
|
||||
scene="precheck",
|
||||
)
|
||||
@@ -2671,8 +2660,8 @@ class SubscribeProgressConsolidationTest(TestCase):
|
||||
10,
|
||||
season=1,
|
||||
mediainfo=self._mediainfo(total_episode=10),
|
||||
tmdbid=31030,
|
||||
doubanid=None,
|
||||
media_source="themoviedb",
|
||||
media_id="31030",
|
||||
subscribe_id=31,
|
||||
scene="precheck",
|
||||
))
|
||||
@@ -2696,8 +2685,8 @@ class SubscribeProgressConsolidationTest(TestCase):
|
||||
current_priority=80,
|
||||
episode_priority=None,
|
||||
note=[],
|
||||
tmdbid=31031,
|
||||
doubanid=None,
|
||||
media_source="themoviedb",
|
||||
media_id="31031",
|
||||
manual_total_episode=0,
|
||||
)
|
||||
mediainfo = self._mediainfo(total_episode=5)
|
||||
@@ -2738,8 +2727,8 @@ class SubscribeProgressConsolidationTest(TestCase):
|
||||
current_priority=None,
|
||||
episode_priority={str(episode): 80 for episode in range(1, 101)},
|
||||
note=[],
|
||||
tmdbid=31034,
|
||||
doubanid=None,
|
||||
media_source="themoviedb",
|
||||
media_id="31034",
|
||||
manual_total_episode=0,
|
||||
)
|
||||
updates = []
|
||||
@@ -2790,8 +2779,8 @@ class SubscribeProgressConsolidationTest(TestCase):
|
||||
current_priority=100,
|
||||
episode_priority={str(episode): 100 for episode in range(1, 101)},
|
||||
note=[],
|
||||
tmdbid=31040,
|
||||
doubanid=None,
|
||||
media_source="themoviedb",
|
||||
media_id="31040",
|
||||
manual_total_episode=0,
|
||||
)
|
||||
updates = []
|
||||
@@ -2838,8 +2827,8 @@ class SubscribeProgressConsolidationTest(TestCase):
|
||||
current_priority=82,
|
||||
episode_priority={"1": 90, "2": 80, "3": 82},
|
||||
note=[1, 2, 3],
|
||||
tmdbid=31041,
|
||||
doubanid=None,
|
||||
media_source="themoviedb",
|
||||
media_id="31041",
|
||||
manual_total_episode=0,
|
||||
)
|
||||
|
||||
@@ -2868,8 +2857,8 @@ class SubscribeProgressConsolidationTest(TestCase):
|
||||
current_priority=82,
|
||||
episode_priority={},
|
||||
note=[],
|
||||
tmdbid=31045,
|
||||
doubanid=None,
|
||||
media_source="themoviedb",
|
||||
media_id="31045",
|
||||
manual_total_episode=0,
|
||||
)
|
||||
|
||||
@@ -2896,8 +2885,8 @@ class SubscribeProgressConsolidationTest(TestCase):
|
||||
current_priority=82,
|
||||
episode_priority={"1": 90, "2": 80, "3": 82, "4": 70, "5": 60},
|
||||
note=[1, 2, 3, 4, 5],
|
||||
tmdbid=31043,
|
||||
doubanid=None,
|
||||
media_source="themoviedb",
|
||||
media_id="31043",
|
||||
manual_total_episode=0,
|
||||
)
|
||||
|
||||
@@ -2926,8 +2915,8 @@ class SubscribeProgressConsolidationTest(TestCase):
|
||||
current_priority=100,
|
||||
episode_priority={str(episode): 100 for episode in range(11, 101)},
|
||||
note=[],
|
||||
tmdbid=31042,
|
||||
doubanid=None,
|
||||
media_source="themoviedb",
|
||||
media_id="31042",
|
||||
manual_total_episode=0,
|
||||
)
|
||||
updates = []
|
||||
@@ -2975,8 +2964,8 @@ class SubscribeProgressConsolidationTest(TestCase):
|
||||
current_priority=100,
|
||||
episode_priority={str(episode): 100 for episode in range(11, 101)},
|
||||
note=[],
|
||||
tmdbid=31044,
|
||||
doubanid=None,
|
||||
media_source="themoviedb",
|
||||
media_id="31044",
|
||||
manual_total_episode=0,
|
||||
)
|
||||
updates = []
|
||||
@@ -3024,8 +3013,8 @@ class SubscribeProgressConsolidationTest(TestCase):
|
||||
current_priority=None,
|
||||
episode_priority={},
|
||||
note=[],
|
||||
tmdbid=31035,
|
||||
doubanid=None,
|
||||
media_source="themoviedb",
|
||||
media_id="31035",
|
||||
manual_total_episode=0,
|
||||
)
|
||||
updates = []
|
||||
@@ -3065,8 +3054,8 @@ class SubscribeProgressConsolidationTest(TestCase):
|
||||
current_priority=None,
|
||||
episode_priority={},
|
||||
note=[],
|
||||
tmdbid=31037,
|
||||
doubanid=None,
|
||||
media_source="themoviedb",
|
||||
media_id="31037",
|
||||
manual_total_episode=1,
|
||||
)
|
||||
|
||||
@@ -3106,8 +3095,8 @@ class SubscribeProgressConsolidationTest(TestCase):
|
||||
current_priority=None,
|
||||
episode_priority={},
|
||||
note=[],
|
||||
tmdbid=31038,
|
||||
doubanid=None,
|
||||
media_source="themoviedb",
|
||||
media_id="31038",
|
||||
manual_total_episode=0,
|
||||
)
|
||||
|
||||
@@ -3149,8 +3138,8 @@ class SubscribeProgressConsolidationTest(TestCase):
|
||||
note=[],
|
||||
year="2026",
|
||||
episode_group=None,
|
||||
tmdbid=31033,
|
||||
doubanid=None,
|
||||
media_source="themoviedb",
|
||||
media_id="31033",
|
||||
manual_total_episode=0,
|
||||
)
|
||||
updates = []
|
||||
@@ -3194,8 +3183,8 @@ class SubscribeProgressConsolidationTest(TestCase):
|
||||
note=[],
|
||||
year="2026",
|
||||
episode_group=None,
|
||||
tmdbid=31039,
|
||||
doubanid=None,
|
||||
media_source="themoviedb",
|
||||
media_id="31039",
|
||||
manual_total_episode=0,
|
||||
)
|
||||
updates = []
|
||||
@@ -3239,8 +3228,8 @@ class SubscribeProgressConsolidationTest(TestCase):
|
||||
note=[],
|
||||
year="2026",
|
||||
episode_group=None,
|
||||
tmdbid=31043,
|
||||
doubanid=None,
|
||||
media_source="themoviedb",
|
||||
media_id="31043",
|
||||
manual_total_episode=0,
|
||||
)
|
||||
updates = []
|
||||
@@ -3257,7 +3246,7 @@ class SubscribeProgressConsolidationTest(TestCase):
|
||||
chain.resolve_subscribe_missing = lambda **kwargs: (
|
||||
False,
|
||||
{
|
||||
31043: {
|
||||
"tmdb:31043": {
|
||||
1: SimpleNamespace(
|
||||
season=1,
|
||||
episodes=list(range(91, 101)),
|
||||
@@ -3298,8 +3287,8 @@ class SubscribeProgressConsolidationTest(TestCase):
|
||||
note=[],
|
||||
year="2026",
|
||||
episode_group=None,
|
||||
tmdbid=31045,
|
||||
doubanid=None,
|
||||
media_source="themoviedb",
|
||||
media_id="31045",
|
||||
manual_total_episode=0,
|
||||
)
|
||||
updates = []
|
||||
@@ -3351,7 +3340,8 @@ class SubscribeProgressConsolidationTest(TestCase):
|
||||
title="总集创建剧",
|
||||
year="2026",
|
||||
mtype=MediaType.TV,
|
||||
tmdbid=31041,
|
||||
media_source="themoviedb",
|
||||
media_id="31041",
|
||||
season=1,
|
||||
message=False,
|
||||
)
|
||||
@@ -3439,8 +3429,8 @@ class SubscribeDownloadFactsTest(TestCase):
|
||||
"best_version_full": 0,
|
||||
"current_priority": None,
|
||||
"episode_priority": {},
|
||||
"tmdbid": 30003,
|
||||
"doubanid": None,
|
||||
"media_source": "themoviedb",
|
||||
"media_id": "30003",
|
||||
"manual_total_episode": 0,
|
||||
}
|
||||
values.update(overrides)
|
||||
@@ -3454,10 +3444,8 @@ class SubscribeDownloadFactsTest(TestCase):
|
||||
meta_info=SimpleNamespace(episode_list=episodes or [], season_list=[1]),
|
||||
media_info=SimpleNamespace(
|
||||
type=MediaType.TV,
|
||||
tmdb_id=30003,
|
||||
douban_id=None,
|
||||
bangumi_id=None,
|
||||
anilist_id=None,
|
||||
media_source="themoviedb",
|
||||
media_id="30003",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -3611,7 +3599,8 @@ class SubscribeDownloadFactsTest(TestCase):
|
||||
current_priority=60,
|
||||
episode_priority={},
|
||||
note=[],
|
||||
tmdbid=30003,
|
||||
media_source="themoviedb",
|
||||
media_id="30003",
|
||||
total_episode=1,
|
||||
lack_episode=1,
|
||||
)
|
||||
@@ -3650,7 +3639,8 @@ class SubscribeDownloadFactsTest(TestCase):
|
||||
current_priority=60,
|
||||
episode_priority={},
|
||||
note=[],
|
||||
tmdbid=30003,
|
||||
media_source="themoviedb",
|
||||
media_id="30003",
|
||||
total_episode=1,
|
||||
lack_episode=1,
|
||||
)
|
||||
@@ -3686,7 +3676,8 @@ class SubscribeDownloadFactsTest(TestCase):
|
||||
current_priority=None,
|
||||
episode_priority={},
|
||||
note=[],
|
||||
tmdbid=30003,
|
||||
media_source="themoviedb",
|
||||
media_id="30003",
|
||||
total_episode=1,
|
||||
lack_episode=1,
|
||||
)
|
||||
@@ -3722,7 +3713,8 @@ class SubscribeDownloadFactsTest(TestCase):
|
||||
current_priority=None,
|
||||
episode_priority={},
|
||||
note=[],
|
||||
tmdbid=30003,
|
||||
media_source="themoviedb",
|
||||
media_id="30003",
|
||||
total_episode=1,
|
||||
lack_episode=1,
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app.api.endpoints.subscribe import create_subscribe
|
||||
from app.schemas.subscribe import Subscribe
|
||||
from app.schemas.types import EventType, MediaType
|
||||
from app.schemas.types import EventType, MediaSource, MediaType
|
||||
|
||||
|
||||
class SubscribeEndpointTest(TestCase):
|
||||
@@ -331,25 +331,27 @@ class SubscribeEndpointTest(TestCase):
|
||||
self.assertEqual(response.message, "订阅不存在")
|
||||
sub_share.assert_not_awaited()
|
||||
|
||||
def test_subscribe_mediaid_returns_owner_when_other_candidate_matches_first(self):
|
||||
def test_subscribe_media_identity_returns_owner_when_other_candidate_matches_first(self):
|
||||
"""
|
||||
按媒体查询订阅时,他人订阅不能挡住当前用户自己的订阅。
|
||||
"""
|
||||
from app.api.endpoints.subscribe import subscribe_mediaid
|
||||
from app.api.endpoints.subscribe import subscribe_media_identity
|
||||
|
||||
other = _EndpointSubscribe(id=13, username="bob", tmdbid=123, season=1)
|
||||
own = _EndpointSubscribe(id=14, username="alice", tmdbid=123, season=1)
|
||||
other = _EndpointSubscribe(
|
||||
id=13, username="bob", media_source="themoviedb", media_id="123", season=1
|
||||
)
|
||||
own = _EndpointSubscribe(
|
||||
id=14, username="alice", media_source="themoviedb", media_id="123", season=1
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.api.endpoints.subscribe.Subscribe.async_exists",
|
||||
new=AsyncMock(return_value=other),
|
||||
), patch(
|
||||
"app.api.endpoints.subscribe.Subscribe.async_get_by_tmdbid",
|
||||
"app.api.endpoints.subscribe.Subscribe.async_list_by_media_identity",
|
||||
new=AsyncMock(return_value=[other, own]),
|
||||
):
|
||||
result = asyncio.run(
|
||||
subscribe_mediaid(
|
||||
mediaid="tmdb:123",
|
||||
subscribe_media_identity(
|
||||
media_id="123",
|
||||
media_source=MediaSource.TMDB,
|
||||
season=1,
|
||||
db=object(),
|
||||
current_user=_EndpointUser(name="alice", is_superuser=False),
|
||||
@@ -358,9 +360,9 @@ class SubscribeEndpointTest(TestCase):
|
||||
|
||||
self.assertEqual(result.id, 14)
|
||||
|
||||
def test_subscribe_mediaid_distinguishes_recording_and_album_entities(self):
|
||||
def test_subscribe_media_identity_distinguishes_recording_and_album_entities(self):
|
||||
"""同一来源身份下查询专辑时不能返回单曲订阅。"""
|
||||
from app.api.endpoints.subscribe import subscribe_mediaid
|
||||
from app.api.endpoints.subscribe import subscribe_media_identity
|
||||
|
||||
recording = _EndpointSubscribe(
|
||||
id=21,
|
||||
@@ -384,8 +386,9 @@ class SubscribeEndpointTest(TestCase):
|
||||
new=AsyncMock(return_value=[recording, album]),
|
||||
) as list_by_identity:
|
||||
result = asyncio.run(
|
||||
subscribe_mediaid(
|
||||
mediaid="musicbrainz:shared-id",
|
||||
subscribe_media_identity(
|
||||
media_id="shared-id",
|
||||
media_source=MediaSource.MusicBrainz,
|
||||
music_type="album",
|
||||
db=object(),
|
||||
current_user=_EndpointUser(name="alice", is_superuser=False),
|
||||
@@ -395,29 +398,21 @@ class SubscribeEndpointTest(TestCase):
|
||||
self.assertEqual(result.id, 22)
|
||||
self.assertEqual(list_by_identity.await_args.kwargs["music_type"], "album")
|
||||
|
||||
def test_subscribe_mediaid_uses_music_title_parser_for_fallback(self):
|
||||
"""音乐身份未命中时应先查原题,再按艺术家与曲名语义查询,不能套影视解析。"""
|
||||
from app.api.endpoints.subscribe import subscribe_mediaid
|
||||
|
||||
recording = _EndpointSubscribe(
|
||||
id=23,
|
||||
username="alice",
|
||||
type=MediaType.MUSIC.value,
|
||||
music_type="recording",
|
||||
name="晴天",
|
||||
)
|
||||
title_lookup = AsyncMock(side_effect=[[], [recording]])
|
||||
def test_subscribe_media_identity_does_not_fallback_to_title(self):
|
||||
"""统一身份未命中时不得按标题串联其他来源的订阅。"""
|
||||
from app.api.endpoints.subscribe import subscribe_media_identity
|
||||
|
||||
with patch(
|
||||
"app.api.endpoints.subscribe.list_subscribes_by_media_key",
|
||||
"app.api.endpoints.subscribe.Subscribe.async_list_by_media_identity",
|
||||
new=AsyncMock(return_value=[]),
|
||||
), patch(
|
||||
"app.api.endpoints.subscribe.Subscribe.async_list_by_title",
|
||||
new=title_lookup,
|
||||
):
|
||||
new=AsyncMock(),
|
||||
) as title_lookup:
|
||||
result = asyncio.run(
|
||||
subscribe_mediaid(
|
||||
mediaid="musicbrainz:legacy-recording",
|
||||
subscribe_media_identity(
|
||||
media_id="legacy-recording",
|
||||
media_source=MediaSource.MusicBrainz,
|
||||
title="周杰伦 - 晴天",
|
||||
music_type="recording",
|
||||
db=object(),
|
||||
@@ -425,42 +420,37 @@ class SubscribeEndpointTest(TestCase):
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(result.id, 23)
|
||||
self.assertEqual(
|
||||
[call.kwargs for call in title_lookup.await_args_list],
|
||||
[
|
||||
{"title": "周杰伦 - 晴天", "season": None},
|
||||
{"title": "晴天", "season": None},
|
||||
],
|
||||
)
|
||||
self.assertIsNone(result.id)
|
||||
title_lookup.assert_not_awaited()
|
||||
|
||||
def test_delete_subscribe_by_mediaid_deletes_owner_when_other_douban_match_first(self):
|
||||
def test_delete_subscribe_by_media_identity_deletes_owner_candidate(self):
|
||||
"""
|
||||
按媒体删除订阅时,应在候选集合中删除当前用户自己的订阅。
|
||||
"""
|
||||
from app.api.endpoints.subscribe import delete_subscribe_by_mediaid
|
||||
from app.api.endpoints.subscribe import delete_subscribe_by_media_identity
|
||||
|
||||
other = _EndpointSubscribe(id=15, username="bob", doubanid="douban-1")
|
||||
own = _EndpointSubscribe(id=16, username="alice", doubanid="douban-1")
|
||||
other = _EndpointSubscribe(
|
||||
id=15, username="bob", media_source="douban", media_id="douban-1"
|
||||
)
|
||||
own = _EndpointSubscribe(
|
||||
id=16, username="alice", media_source="douban", media_id="douban-1"
|
||||
)
|
||||
db = _EndpointAsyncDb()
|
||||
|
||||
with patch(
|
||||
"app.api.endpoints.subscribe.Subscribe.async_get_by_doubanid",
|
||||
new=AsyncMock(return_value=other),
|
||||
), patch(
|
||||
"app.api.endpoints.subscribe.Subscribe.async_list_by_doubanid",
|
||||
"app.api.endpoints.subscribe.Subscribe.async_list_by_media_identity",
|
||||
new=AsyncMock(return_value=[other, own]),
|
||||
create=True,
|
||||
), patch(
|
||||
"app.api.endpoints.subscribe.build_subscribe_event_payload",
|
||||
return_value={"id": 16, "doubanid": "douban-1"},
|
||||
return_value={"id": 16, "media_source": "douban", "media_id": "douban-1"},
|
||||
), patch(
|
||||
"app.api.endpoints.subscribe.eventmanager.async_send_event",
|
||||
new=AsyncMock(),
|
||||
) as send_event:
|
||||
response = asyncio.run(
|
||||
delete_subscribe_by_mediaid(
|
||||
mediaid="douban:douban-1",
|
||||
delete_subscribe_by_media_identity(
|
||||
media_id="douban-1",
|
||||
media_source=MediaSource.Douban,
|
||||
db=db,
|
||||
current_user=_EndpointUser(name="alice", is_superuser=False),
|
||||
)
|
||||
@@ -470,18 +460,19 @@ class SubscribeEndpointTest(TestCase):
|
||||
self.assertEqual(db.deleted, [own])
|
||||
send_event.assert_awaited_once()
|
||||
|
||||
def test_delete_subscribe_by_mediaid_forwards_music_entity(self):
|
||||
def test_delete_subscribe_by_media_identity_forwards_music_entity(self):
|
||||
"""取消专辑订阅时必须把实体类型传给统一身份查询。"""
|
||||
from app.api.endpoints.subscribe import delete_subscribe_by_mediaid
|
||||
from app.api.endpoints.subscribe import delete_subscribe_by_media_identity
|
||||
|
||||
db = _EndpointAsyncDb()
|
||||
with patch(
|
||||
"app.api.endpoints.subscribe.list_subscribes_by_media_key",
|
||||
"app.api.endpoints.subscribe.list_subscribes_by_media_identity",
|
||||
new=AsyncMock(return_value=[]),
|
||||
) as list_by_key:
|
||||
response = asyncio.run(
|
||||
delete_subscribe_by_mediaid(
|
||||
mediaid="musicbrainz:release-group-1",
|
||||
delete_subscribe_by_media_identity(
|
||||
media_id="release-group-1",
|
||||
media_source=MediaSource.MusicBrainz,
|
||||
music_type="album",
|
||||
db=db,
|
||||
current_user=_EndpointUser(name="alice", is_superuser=False),
|
||||
@@ -491,7 +482,8 @@ class SubscribeEndpointTest(TestCase):
|
||||
self.assertTrue(response.success)
|
||||
list_by_key.assert_awaited_once_with(
|
||||
db,
|
||||
"musicbrainz:release-group-1",
|
||||
MediaSource.MusicBrainz,
|
||||
"release-group-1",
|
||||
None,
|
||||
"album",
|
||||
)
|
||||
@@ -826,7 +818,7 @@ class SubscribeEndpointTest(TestCase):
|
||||
):
|
||||
self.assertNotIn(field, payload)
|
||||
|
||||
def test_create_subscribe_preserves_special_season_zero_with_doubanid(self):
|
||||
def test_create_subscribe_preserves_special_season_zero_with_douban_identity(self):
|
||||
"""
|
||||
新增订阅带豆瓣 ID 且显式指定 S0 时,标题规整不应覆盖调用方传入的季号。
|
||||
"""
|
||||
@@ -834,7 +826,8 @@ class SubscribeEndpointTest(TestCase):
|
||||
name="测试剧集",
|
||||
year="2026",
|
||||
type=MediaType.TV.value,
|
||||
doubanid="12345",
|
||||
media_source=MediaSource.Douban,
|
||||
media_id="12345",
|
||||
season=0,
|
||||
total_episode=5,
|
||||
lack_episode=5,
|
||||
@@ -1169,12 +1162,8 @@ class _EndpointMediaInfo:
|
||||
title = "测试剧集"
|
||||
year = "2026"
|
||||
type = MediaType.TV
|
||||
tmdb_id = 123
|
||||
imdb_id = "tt123"
|
||||
tvdb_id = 456
|
||||
douban_id = "douban-1"
|
||||
bangumi_id = 789
|
||||
anilist_id = 154587
|
||||
media_source = MediaSource.TMDB
|
||||
media_id = "123"
|
||||
episode_group = None
|
||||
vote_average = 8.0
|
||||
overview = "测试简介"
|
||||
@@ -1222,9 +1211,8 @@ def test_subscribe_accepts_empty_strings_for_numeric_fields():
|
||||
subscribe = Subscribe(
|
||||
name="Random Access Memories",
|
||||
type=MediaType.MUSIC.value,
|
||||
tmdbid="",
|
||||
bangumiid="",
|
||||
anilistid="",
|
||||
media_source="",
|
||||
media_id="",
|
||||
season="",
|
||||
total_episode="",
|
||||
start_episode="",
|
||||
@@ -1238,7 +1226,8 @@ def test_subscribe_accepts_empty_strings_for_numeric_fields():
|
||||
filter_groups="",
|
||||
)
|
||||
|
||||
assert subscribe.tmdbid is None
|
||||
assert subscribe.media_source is None
|
||||
assert subscribe.media_id is None
|
||||
assert subscribe.season is None
|
||||
assert subscribe.best_version is None
|
||||
assert subscribe.episode_priority is None
|
||||
@@ -1258,7 +1247,8 @@ def test_subscribe_preserves_explicit_zero_and_numeric_string_values():
|
||||
name="测试剧集",
|
||||
type=MediaType.TV.value,
|
||||
season="2",
|
||||
tmdbid="123",
|
||||
media_source=MediaSource.TMDB,
|
||||
media_id="123",
|
||||
total_episode=0,
|
||||
start_episode=0,
|
||||
search_imdbid=0,
|
||||
@@ -1266,7 +1256,8 @@ def test_subscribe_preserves_explicit_zero_and_numeric_string_values():
|
||||
)
|
||||
|
||||
assert subscribe.season == 2
|
||||
assert subscribe.tmdbid == 123
|
||||
assert subscribe.media_source == MediaSource.TMDB
|
||||
assert subscribe.media_id == "123"
|
||||
assert subscribe.total_episode == 0
|
||||
assert subscribe.start_episode == 0
|
||||
assert subscribe.search_imdbid == 0
|
||||
@@ -1280,7 +1271,8 @@ def test_create_subscribe_accepts_music_payload_with_empty_strings():
|
||||
type=MediaType.MUSIC.value,
|
||||
music_type="album",
|
||||
total_tracks=13,
|
||||
tmdbid="",
|
||||
media_source="",
|
||||
media_id="",
|
||||
season="",
|
||||
total_episode="",
|
||||
episode_priority="",
|
||||
@@ -1301,7 +1293,8 @@ def test_create_subscribe_accepts_music_payload_with_empty_strings():
|
||||
assert response.success is True
|
||||
payload = async_add.await_args.kwargs
|
||||
# 空字符串回退默认值后应正确传入持久化链路
|
||||
assert payload["tmdbid"] is None
|
||||
assert payload["media_source"] is None
|
||||
assert payload["media_id"] is None
|
||||
assert payload["total_episode"] == 0
|
||||
assert payload["sites"] == []
|
||||
assert payload["type"] == MediaType.MUSIC.value
|
||||
|
||||
@@ -9,7 +9,7 @@ from app.db.models.subscribe import Subscribe
|
||||
from app.db.models.subscribehistory import SubscribeHistory
|
||||
from app.db.subscribe_oper import SubscribeOper
|
||||
from app.core.context import MusicInfo
|
||||
from app.schemas.types import MediaType
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
|
||||
|
||||
def _media(episode_group):
|
||||
@@ -18,16 +18,8 @@ def _media(episode_group):
|
||||
title="测试剧",
|
||||
year="2026",
|
||||
type=MediaType.TV,
|
||||
source="themoviedb",
|
||||
media_source="themoviedb",
|
||||
media_source=MediaSource.TMDB,
|
||||
media_id="987654321",
|
||||
mediaid="tmdb:987654321",
|
||||
tmdb_id=987654321,
|
||||
imdb_id=None,
|
||||
tvdb_id=None,
|
||||
douban_id=None,
|
||||
bangumi_id=None,
|
||||
anilist_id=None,
|
||||
episode_group=episode_group,
|
||||
vote_average=8.0,
|
||||
overview="测试简介",
|
||||
@@ -102,7 +94,7 @@ def test_music_subscribe_persists_release_cover_as_poster_and_backdrop():
|
||||
persisted = SimpleNamespace(id=92)
|
||||
created = SimpleNamespace(create=MagicMock())
|
||||
media = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source=MediaSource.MusicBrainz,
|
||||
media_id="977e6978-139d-425c-bb98-6b0c62d1e45e",
|
||||
title="晴天",
|
||||
cover_url="https://coverartarchive.org/release-group/example/front-500",
|
||||
@@ -125,7 +117,7 @@ def test_music_subscribe_persists_numeric_year_as_string():
|
||||
persisted = SimpleNamespace(id=93)
|
||||
created = SimpleNamespace(create=MagicMock())
|
||||
media = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source=MediaSource.MusicBrainz,
|
||||
media_id="2af54891-e954-40e8-8b90-b7e98c740f21",
|
||||
title="心愿",
|
||||
year=2025,
|
||||
@@ -148,7 +140,7 @@ def test_music_album_subscription_persists_entity_and_track_count():
|
||||
persisted = SimpleNamespace(id=94)
|
||||
created = SimpleNamespace(create=MagicMock())
|
||||
media = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source=MediaSource.MusicBrainz,
|
||||
media_id="release-group-1",
|
||||
music_type="album",
|
||||
title="叶惠美",
|
||||
@@ -173,7 +165,7 @@ def test_music_recording_subscription_drops_album_track_count_and_scopes_identit
|
||||
persisted = SimpleNamespace(id=95)
|
||||
created = SimpleNamespace(create=MagicMock())
|
||||
media = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source=MediaSource.MusicBrainz,
|
||||
media_id="recording-1",
|
||||
music_type="recording",
|
||||
title="晴天",
|
||||
@@ -271,44 +263,62 @@ def test_exists_defaults_to_main_season_episode_group():
|
||||
with patch("app.db.subscribe_oper.Subscribe") as subscribe_model:
|
||||
subscribe_model.exists.return_value = SimpleNamespace(id=1)
|
||||
|
||||
assert oper.exists(tmdbid=100, season=1) is True
|
||||
assert oper.exists(
|
||||
media_source=MediaSource.TMDB, media_id="100", season=1
|
||||
) is True
|
||||
assert subscribe_model.exists.call_args.kwargs["episode_group"] is None
|
||||
|
||||
assert oper.exists(tmdbid=100, season=1, episode_group="eg-1") is True
|
||||
assert oper.exists(
|
||||
media_source=MediaSource.TMDB,
|
||||
media_id="100",
|
||||
season=1,
|
||||
episode_group="eg-1",
|
||||
) is True
|
||||
assert subscribe_model.exists.call_args.kwargs["episode_group"] == "eg-1"
|
||||
|
||||
with patch("app.db.subscribe_oper.SubscribeHistory") as history_model:
|
||||
history_model.exists.return_value = SimpleNamespace(id=2)
|
||||
|
||||
assert oper.exist_history(tmdbid=100, season=1) is True
|
||||
assert oper.exist_history(
|
||||
media_source=MediaSource.TMDB, media_id="100", season=1
|
||||
) is True
|
||||
assert history_model.exists.call_args.kwargs["episode_group"] is None
|
||||
|
||||
assert oper.exist_history(tmdbid=100, season=1, episode_group="eg-1") is True
|
||||
assert oper.exist_history(
|
||||
media_source=MediaSource.TMDB,
|
||||
media_id="100",
|
||||
season=1,
|
||||
episode_group="eg-1",
|
||||
) is True
|
||||
assert history_model.exists.call_args.kwargs["episode_group"] == "eg-1"
|
||||
|
||||
|
||||
def test_subscribe_exists_distinguishes_same_season_episode_groups():
|
||||
"""同一媒体同一季的主季、自定义剧集组应分别命中各自订阅。"""
|
||||
oper = SubscribeOper()
|
||||
tmdbid = -(900_000_000 + os.getpid())
|
||||
media_id = str(-(900_000_000 + os.getpid()))
|
||||
created_ids = []
|
||||
rows = [
|
||||
Subscribe(name="主季订阅", type=MediaType.TV.value, state="N",
|
||||
tmdbid=tmdbid, season=1, episode_group=None),
|
||||
media_source=MediaSource.TMDB.value, media_id=media_id,
|
||||
season=1, episode_group=None),
|
||||
Subscribe(name="剧集组订阅", type=MediaType.TV.value, state="N",
|
||||
tmdbid=tmdbid, season=1, episode_group="eg-1"),
|
||||
media_source=MediaSource.TMDB.value, media_id=media_id,
|
||||
season=1, episode_group="eg-1"),
|
||||
]
|
||||
try:
|
||||
for row in rows:
|
||||
row.create(oper._db)
|
||||
|
||||
main_season = Subscribe.exists(
|
||||
oper._db, tmdbid=tmdbid, season=1, episode_group=None,
|
||||
oper._db, media_source=MediaSource.TMDB,
|
||||
media_id=media_id, season=1, episode_group=None,
|
||||
)
|
||||
created_ids.append(main_season.id)
|
||||
main_name = main_season.name
|
||||
episode_group = Subscribe.exists(
|
||||
oper._db, tmdbid=tmdbid, season=1, episode_group="eg-1",
|
||||
oper._db, media_source=MediaSource.TMDB,
|
||||
media_id=media_id, season=1, episode_group="eg-1",
|
||||
)
|
||||
created_ids.append(episode_group.id)
|
||||
episode_group_name = episode_group.name
|
||||
@@ -317,7 +327,12 @@ def test_subscribe_exists_distinguishes_same_season_episode_groups():
|
||||
assert episode_group_name == "剧集组订阅"
|
||||
|
||||
Subscribe.delete(oper._db, rid=created_ids.pop(0))
|
||||
assert Subscribe.exists(oper._db, tmdbid=tmdbid, season=1) is None
|
||||
assert Subscribe.exists(
|
||||
oper._db,
|
||||
media_source=MediaSource.TMDB,
|
||||
media_id=media_id,
|
||||
season=1,
|
||||
) is None
|
||||
finally:
|
||||
for subscribe_id in created_ids:
|
||||
Subscribe.delete(oper._db, rid=subscribe_id)
|
||||
@@ -384,12 +399,8 @@ def test_subscribe_chain_exists_forwards_episode_group():
|
||||
assert SubscribeChain.exists(media, meta) is True
|
||||
|
||||
subscribe_oper_cls.return_value.exists.assert_called_once_with(
|
||||
tmdbid=media.tmdb_id,
|
||||
doubanid=media.douban_id,
|
||||
bangumiid=media.bangumi_id,
|
||||
anilistid=media.anilist_id,
|
||||
media_source="themoviedb",
|
||||
media_id=str(media.tmdb_id),
|
||||
media_source=MediaSource.TMDB,
|
||||
media_id=media.media_id,
|
||||
music_type=None,
|
||||
season=1,
|
||||
episode_group="eg-1",
|
||||
|
||||
@@ -13,7 +13,7 @@ from app.db.message_oper import MessageOper
|
||||
from app.modules.indexer import IndexerModule
|
||||
from app.modules.indexer.parser.sunnypt import SunnyPTSiteUserInfo
|
||||
from app.modules.indexer.spider.sunnypt import SunnyPTSpider
|
||||
from app.schemas import MediaType, NotificationType
|
||||
from app.schemas import MediaSource, MediaType, NotificationType
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
@@ -217,6 +217,23 @@ def test_sunnypt_async_search_uses_api_contract(monkeypatch):
|
||||
assert calls[0][2]["X-API-Key"] == "sunny-secret"
|
||||
|
||||
|
||||
def test_indexer_normalizes_spider_imdb_identity() -> None:
|
||||
"""Indexer 应在边界将 Spider 的 IMDb 字段转换为统一媒体身份。"""
|
||||
torrents = IndexerModule._IndexerModule__parse_result(
|
||||
site={"id": 1, "name": "Sunny"},
|
||||
result_array=[{
|
||||
"title": "Movie.2026.1080p.WEB-DL",
|
||||
"imdbid": "tt1234567",
|
||||
}],
|
||||
seconds=1,
|
||||
)
|
||||
|
||||
assert len(torrents) == 1
|
||||
assert torrents[0].media_source == MediaSource.IMDb
|
||||
assert torrents[0].media_id == "tt1234567"
|
||||
assert "imdbid" not in torrents[0].to_dict()
|
||||
|
||||
|
||||
def test_sunnypt_user_parser_reads_profile_and_messages_without_marking_read(monkeypatch):
|
||||
"""SunnyPT 用户解析器应读取统计和未读消息,但不能在解析阶段标记已读。"""
|
||||
requested_urls = []
|
||||
|
||||
@@ -13,7 +13,7 @@ from app.utils.crypto import HashUtils
|
||||
def _album_context() -> Context:
|
||||
"""构造带完整专辑身份的缓存上下文。"""
|
||||
album = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="release-group-1",
|
||||
music_type=MUSIC_ENTITY_ALBUM,
|
||||
title="叶惠美",
|
||||
|
||||
@@ -2,7 +2,7 @@ from types import SimpleNamespace
|
||||
|
||||
from app.chain.torrents import TorrentsChain
|
||||
from app.core.context import Context, MediaInfo, TorrentInfo
|
||||
from app.schemas.types import MediaType
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
|
||||
|
||||
def _chain() -> TorrentsChain:
|
||||
@@ -12,12 +12,8 @@ def _chain() -> TorrentsChain:
|
||||
|
||||
def _subscribe(**kwargs):
|
||||
defaults = {
|
||||
"tmdbid": 100,
|
||||
"doubanid": None,
|
||||
"bangumiid": None,
|
||||
"anilistid": None,
|
||||
"media_source": None,
|
||||
"media_id": None,
|
||||
"media_source": MediaSource.TMDB,
|
||||
"media_id": "100",
|
||||
"season": 1,
|
||||
"name": "测试剧",
|
||||
"type": MediaType.TV.value,
|
||||
@@ -29,10 +25,10 @@ def _subscribe(**kwargs):
|
||||
def _ctx(
|
||||
title: str = "测试剧 S01E05",
|
||||
*,
|
||||
tmdb_id: int = 100,
|
||||
douban_id: str = None,
|
||||
meta_tmdbid: int = None,
|
||||
meta_doubanid: str = None,
|
||||
media_source: MediaSource = MediaSource.TMDB,
|
||||
media_id: str = "100",
|
||||
meta_media_source: MediaSource = None,
|
||||
meta_media_id: str = None,
|
||||
meta_type=MediaType.TV,
|
||||
media_season: int = None,
|
||||
begin_season: int = 1,
|
||||
@@ -43,12 +39,8 @@ def _ctx(
|
||||
title=title,
|
||||
name="测试剧",
|
||||
type=meta_type,
|
||||
tmdbid=meta_tmdbid,
|
||||
doubanid=meta_doubanid,
|
||||
bangumiid=None,
|
||||
anilistid=None,
|
||||
media_source=None,
|
||||
media_id=None,
|
||||
media_source=meta_media_source,
|
||||
media_id=meta_media_id,
|
||||
begin_season=begin_season,
|
||||
end_season=end_season,
|
||||
begin_episode=5,
|
||||
@@ -56,14 +48,14 @@ def _ctx(
|
||||
),
|
||||
media_info=MediaInfo(
|
||||
type=MediaType.TV,
|
||||
tmdb_id=tmdb_id,
|
||||
douban_id=douban_id,
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
season=media_season,
|
||||
),
|
||||
torrent_info=TorrentInfo(title=title),
|
||||
resource_source="rss",
|
||||
match_source="tmdbid" if tmdb_id else "unknown",
|
||||
candidate_recognized=bool(tmdb_id or douban_id),
|
||||
match_source=str(media_source) if media_source and media_id else "unknown",
|
||||
candidate_recognized=bool(media_source and media_id),
|
||||
media_info_is_target=False,
|
||||
)
|
||||
|
||||
@@ -74,13 +66,13 @@ def test_cache_candidates_return_deep_copies(monkeypatch):
|
||||
|
||||
result = _chain().get_subscribe_cache_candidates(_subscribe(), stype="rss")
|
||||
result[0].meta_info.title = "changed"
|
||||
result[0].media_info.tmdb_id = 999
|
||||
result[0].media_info.media_id = "999"
|
||||
|
||||
assert result[0] is not source
|
||||
assert result[0].meta_info is not source.meta_info
|
||||
assert result[0].media_info is not source.media_info
|
||||
assert source.meta_info.title == "测试剧 S01E05"
|
||||
assert source.media_info.tmdb_id == 100
|
||||
assert source.media_info.media_id == "100"
|
||||
|
||||
|
||||
def test_cache_candidates_reject_season_conflict(monkeypatch):
|
||||
@@ -97,7 +89,7 @@ def test_cache_candidates_keep_multi_season_candidate_covering_target(monkeypatc
|
||||
result = _chain().get_subscribe_cache_candidates(_subscribe(), stype="rss")
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].match_source == "tmdbid"
|
||||
assert result[0].match_source == "themoviedb"
|
||||
|
||||
|
||||
def test_cache_candidates_keep_multi_season_candidate_when_media_season_is_range_start(monkeypatch):
|
||||
@@ -107,7 +99,7 @@ def test_cache_candidates_keep_multi_season_candidate_when_media_season_is_range
|
||||
result = _chain().get_subscribe_cache_candidates(_subscribe(season=2), stype="rss")
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].match_source == "tmdbid"
|
||||
assert result[0].match_source == "themoviedb"
|
||||
|
||||
|
||||
def test_cache_candidates_ignore_default_meta_season_list_when_no_explicit_meta_season(monkeypatch):
|
||||
@@ -115,12 +107,8 @@ def test_cache_candidates_ignore_default_meta_season_list_when_no_explicit_meta_
|
||||
title = "测试剧 E05"
|
||||
name = "测试剧"
|
||||
type = MediaType.TV
|
||||
tmdbid = None
|
||||
doubanid = None
|
||||
bangumiid = None
|
||||
anilistid = None
|
||||
media_source = None
|
||||
media_id = None
|
||||
media_source = MediaSource.TMDB
|
||||
media_id = "100"
|
||||
begin_season = None
|
||||
end_season = None
|
||||
|
||||
@@ -135,33 +123,37 @@ def test_cache_candidates_ignore_default_meta_season_list_when_no_explicit_meta_
|
||||
result = _chain().get_subscribe_cache_candidates(_subscribe(season=2), stype="rss")
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].match_source == "tmdbid"
|
||||
assert result[0].match_source == "themoviedb"
|
||||
|
||||
|
||||
def test_title_fallback_requires_explicit_flag(monkeypatch):
|
||||
source = _ctx(tmdb_id=None)
|
||||
source = _ctx(media_source=None, media_id=None)
|
||||
monkeypatch.setattr(TorrentsChain, "get_torrents", lambda self, stype=None: {"site": [source]})
|
||||
|
||||
assert _chain().get_subscribe_cache_candidates(_subscribe(), stype="rss") == []
|
||||
|
||||
|
||||
def test_title_fallback_is_diagnostic_only_and_uses_target_media(monkeypatch):
|
||||
source = _ctx(tmdb_id=None)
|
||||
source = _ctx(media_source=None, media_id=None)
|
||||
monkeypatch.setattr(TorrentsChain, "get_torrents", lambda self, stype=None: {"site": [source]})
|
||||
|
||||
result = _chain().get_subscribe_cache_candidates(_subscribe(doubanid="200"), stype="rss", allow_title_match=True)
|
||||
result = _chain().get_subscribe_cache_candidates(
|
||||
_subscribe(media_source=MediaSource.Douban, media_id="200"),
|
||||
stype="rss",
|
||||
allow_title_match=True,
|
||||
)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].match_source == "title"
|
||||
assert result[0].candidate_recognized is False
|
||||
assert result[0].media_info_is_target is True
|
||||
assert result[0].media_info.tmdb_id == 100
|
||||
assert result[0].media_info.douban_id == "200"
|
||||
assert source.media_info.tmdb_id is None
|
||||
assert result[0].media_info.media_source == MediaSource.Douban
|
||||
assert result[0].media_info.media_id == "200"
|
||||
assert source.media_info.media_id is None
|
||||
|
||||
|
||||
def test_title_fallback_rejects_meta_type_conflict(monkeypatch):
|
||||
source = _ctx(tmdb_id=None, meta_type=MediaType.MOVIE)
|
||||
source = _ctx(media_source=None, media_id=None, meta_type=MediaType.MOVIE)
|
||||
source.media_info.type = None
|
||||
monkeypatch.setattr(TorrentsChain, "get_torrents", lambda self, stype=None: {"site": [source]})
|
||||
|
||||
@@ -173,8 +165,8 @@ def test_title_fallback_rejects_meta_type_conflict(monkeypatch):
|
||||
|
||||
|
||||
def test_title_fallback_rejects_explicit_conflicting_identity(monkeypatch):
|
||||
source = _ctx(tmdb_id=999)
|
||||
source.match_source = "tmdbid"
|
||||
source = _ctx(media_id="999")
|
||||
source.match_source = "themoviedb"
|
||||
monkeypatch.setattr(TorrentsChain, "get_torrents", lambda self, stype=None: {"site": [source]})
|
||||
|
||||
assert _chain().get_subscribe_cache_candidates(
|
||||
@@ -185,7 +177,12 @@ def test_title_fallback_rejects_explicit_conflicting_identity(monkeypatch):
|
||||
|
||||
|
||||
def test_title_fallback_rejects_meta_explicit_conflicting_identity(monkeypatch):
|
||||
source = _ctx(tmdb_id=None, meta_tmdbid=999)
|
||||
source = _ctx(
|
||||
media_source=None,
|
||||
media_id=None,
|
||||
meta_media_source=MediaSource.TMDB,
|
||||
meta_media_id="999",
|
||||
)
|
||||
monkeypatch.setattr(TorrentsChain, "get_torrents", lambda self, stype=None: {"site": [source]})
|
||||
|
||||
assert _chain().get_subscribe_cache_candidates(
|
||||
|
||||
@@ -210,7 +210,7 @@ def test_transfer_history_preserves_album_entity_context() -> None:
|
||||
oper = object.__new__(TransferHistoryOper)
|
||||
oper.add_force = Mock(return_value=SimpleNamespace(id=1))
|
||||
media = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_source="musicbrainz",
|
||||
media_id="release-group-1",
|
||||
music_type=MUSIC_ENTITY_ALBUM,
|
||||
title="叶惠美",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.modules.zspace.zspace import ZSpace
|
||||
from app.schemas.types import MediaSource
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
@@ -125,7 +126,11 @@ def test_get_items_expands_boxset_movies() -> None:
|
||||
items = list(client.get_items(parent="library-id"))
|
||||
|
||||
assert [item.item_id for item in items] == ["movie-1", "movie-2"]
|
||||
assert [item.tmdbid for item in items] == [269149, 1084242]
|
||||
assert [item.media_source for item in items] == [
|
||||
MediaSource.TMDB,
|
||||
MediaSource.TMDB,
|
||||
]
|
||||
assert [item.media_id for item in items] == ["269149", "1084242"]
|
||||
calls = request_utils_cls.return_value.get_res.call_args_list
|
||||
assert calls[0].kwargs["params"]["Recursive"] == "true"
|
||||
assert calls[1].kwargs["params"]["ParentId"] == "collection-1"
|
||||
@@ -171,7 +176,11 @@ def test_get_items_expands_boxset_series() -> None:
|
||||
items = list(client.get_items(parent="library-id"))
|
||||
|
||||
assert [item.item_id for item in items] == ["series-1", "series-2"]
|
||||
assert [item.tmdbid for item in items] == [1396, 1396]
|
||||
assert [item.media_source for item in items] == [
|
||||
MediaSource.TMDB,
|
||||
MediaSource.TMDB,
|
||||
]
|
||||
assert [item.media_id for item in items] == ["1396", "1396"]
|
||||
|
||||
|
||||
def test_get_items_loads_detail_when_list_metadata_is_incomplete() -> None:
|
||||
@@ -212,8 +221,8 @@ def test_get_items_loads_detail_when_list_metadata_is_incomplete() -> None:
|
||||
|
||||
assert len(items) == 1
|
||||
assert items[0].item_id == "movie-1"
|
||||
assert items[0].tmdbid == 269149
|
||||
assert items[0].imdbid == "tt2948356"
|
||||
assert items[0].media_source == MediaSource.TMDB
|
||||
assert items[0].media_id == "269149"
|
||||
assert items[0].year == 2016
|
||||
assert items[0].path.endswith("疯狂动物城.mkv")
|
||||
assert request_utils_cls.return_value.get_res.call_args_list[1].args[0] == (
|
||||
|
||||
Reference in New Issue
Block a user