refactor(media): unify source identity and music browsing

This commit is contained in:
jxxghp
2026-08-12 12:56:40 +08:00
parent 9359dcb63d
commit 8bf2f601a6
129 changed files with 3614 additions and 3983 deletions

View File

@@ -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
}
}, {

View File

@@ -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()])

View File

@@ -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"],

View 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)

View File

@@ -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)

View File

@@ -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,

View File

@@ -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="晴天",

View File

@@ -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

View File

@@ -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)

View File

@@ -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(

View File

@@ -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="我的地盘",
)

View File

@@ -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

View File

@@ -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=["周杰伦"],

View File

@@ -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="叶惠美",

View File

@@ -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,
)

View File

@@ -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="叶惠美",

View File

@@ -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")

View File

@@ -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

View File

@@ -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()

View File

@@ -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(

View File

@@ -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,

View File

@@ -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="未知专辑",

View File

@@ -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=["周杰伦"],

View File

@@ -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="为你盛开-许巍《无尽光芒》巡回演唱会现场纪念",

View File

@@ -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="叶惠美",

View File

@@ -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,
)

View File

@@ -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()

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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",

View File

@@ -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 = []

View File

@@ -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="叶惠美",

View File

@@ -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(

View File

@@ -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="叶惠美",

View File

@@ -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] == (