feat(music): add TheAudioDB and Douban discovery

This commit is contained in:
jxxghp
2026-08-12 10:50:13 +08:00
parent a1dd259143
commit d1028a6902
15 changed files with 769 additions and 20 deletions

View File

@@ -133,6 +133,7 @@ async def clear_music_recognition_cache(
async def explore_music(
page: PageParam = 1,
count: CountParam = 30,
source: MusicSourceParam = "musicbrainz",
mode: MusicModeParam = "chart",
entity: MusicEntityParam = "recording",
range_name: MusicRangeParam = "this_month",
@@ -143,11 +144,20 @@ async def explore_music(
future: bool = True,
min_listen_count: Annotated[int, Query(ge=0)] = 0,
with_cover: bool = False,
country: Annotated[str, Query(pattern="^[A-Za-z]{2}$")] = "us",
_: schemas.TokenPayload = Depends(verify_token),
) -> list[schemas.MusicInfo]:
""" ListenBrainz 官方热门榜单或新发行两种模式返回可订阅的音乐候选。"""
"""音乐来源返回可订阅的榜单或新发行候选。"""
chain = MusicChain()
if mode == "fresh":
if source != "musicbrainz":
results = await chain.async_discover(
source=source,
page=page,
count=count,
entity=entity,
country=country,
)
elif mode == "fresh":
results = await chain.async_fresh_releases(
days=days,
sort=sort,
@@ -167,6 +177,8 @@ async def explore_music(
with_cover=with_cover,
entity=entity,
)
if source != "musicbrainz" and with_cover:
results = [info for info in results if info.cover_url or info.poster_path]
return [_serialize_music(info) for info in results]
@@ -187,6 +199,26 @@ async def music_album(
return _serialize_album(info)
@router.get(
"/album/{album_id}/related",
summary="查询关联音乐专辑",
response_model=list[schemas.MusicInfo],
)
async def music_album_related(
album_id: str,
count: CountParam = 24,
source: MusicSourceParam = "musicbrainz",
_: schemas.TokenPayload = Depends(verify_token),
) -> list[schemas.MusicInfo]:
"""按来源和专辑 ID 返回可继续浏览的关联专辑。"""
results = await MusicChain().async_album_related(
source=source,
media_id=album_id,
count=count,
)
return [_serialize_music(info) for info in results]
@router.get(
"/artist/{artist_id}/albums",
summary="查询艺术家的专辑列表",

View File

@@ -74,6 +74,58 @@ async def music_weekly(
return await RecommendChain().async_music_weekly(page=page, count=count)
@router.get(
"/music_theaudiodb_albums",
summary="TheAudioDB 热门专辑",
response_model=List[schemas.MusicInfo],
)
async def music_theaudiodb_albums(
page: Optional[int] = 1,
count: Optional[int] = 30,
country: str = "us",
_: schemas.TokenPayload = Depends(verify_token),
) -> Any:
"""浏览 TheAudioDB 指定国家或地区的热门专辑。"""
return await RecommendChain().async_music_theaudiodb_albums(
page=page,
count=count,
country=country,
)
@router.get(
"/music_theaudiodb_tracks",
summary="TheAudioDB 热门单曲",
response_model=List[schemas.MusicInfo],
)
async def music_theaudiodb_tracks(
page: Optional[int] = 1,
count: Optional[int] = 30,
country: str = "us",
_: schemas.TokenPayload = Depends(verify_token),
) -> Any:
"""浏览 TheAudioDB 指定国家或地区的热门单曲。"""
return await RecommendChain().async_music_theaudiodb_tracks(
page=page,
count=count,
country=country,
)
@router.get(
"/music_douban",
summary="豆瓣音乐推荐",
response_model=List[schemas.MusicInfo],
)
async def music_douban(
page: Optional[int] = 1,
count: Optional[int] = 30,
_: schemas.TokenPayload = Depends(verify_token),
) -> Any:
"""浏览豆瓣音乐推荐合集。"""
return await RecommendChain().async_music_douban(page=page, count=count)
@router.get(
"/douban_showing", summary="豆瓣正在热映", response_model=List[schemas.MediaInfo]
)

View File

@@ -613,7 +613,7 @@ async def subscribe_history(
current_user: User = Depends(get_current_active_user_async),
) -> Any:
"""
查询电影/电视剧订阅历史
查询电影电视剧或音乐订阅历史
"""
if current_user.is_superuser:
histories = await SubscribeHistory.async_list_by_type(

View File

@@ -314,6 +314,44 @@ class MusicChain(ChainBase):
)
return results[:count]
def discover(
self,
source: str,
page: int = 1,
count: int = 30,
entity: str = MUSIC_ENTITY_ALBUM,
country: str = "us",
) -> list[MusicInfo]:
"""按指定音乐源读取推荐榜单,并统一分页候选结构。"""
candidates = self.run_module(
"music_discover",
source=source,
page=page,
count=count,
entity=entity,
country=country,
)
return self.normalize_candidates(candidates, limit=count)
async def async_discover(
self,
source: str,
page: int = 1,
count: int = 30,
entity: str = MUSIC_ENTITY_ALBUM,
country: str = "us",
) -> list[MusicInfo]:
"""异步按指定音乐源读取推荐榜单,并统一分页候选结构。"""
candidates = await self.async_run_module(
"music_discover",
source=source,
page=page,
count=count,
entity=entity,
country=country,
)
return self.normalize_candidates(candidates, limit=count)
async def async_album(self, source: str, media_id: str) -> Optional[MusicAlbumInfo]:
"""异步按来源和专辑 ID 获取标准化专辑详情及曲目。"""
result = await self.async_run_module(
@@ -340,6 +378,21 @@ class MusicChain(ChainBase):
return MusicAlbumInfo.from_dict(result)
return None
async def async_album_related(
self,
source: str,
media_id: str,
count: int = 24,
) -> list[MusicInfo]:
"""异步读取指定来源的关联专辑,供专辑详情继续浏览。"""
candidates = await self.async_run_module(
"music_album_related",
source=source,
media_id=media_id,
count=count,
)
return self.normalize_candidates(candidates, limit=count)
def lyrics(self, music: MetaMusic | MusicInfo) -> Optional[MusicLyrics]:
"""按单曲元数据调用已启用的歌词模块并返回标准歌词。"""
result = self.run_module("music_lyrics", music=music)

View File

@@ -12,6 +12,7 @@ from app.core.config import settings, global_vars
from app.helper.image import ImageHelper
from app.log import logger
from app.schemas import MediaType
from app.schemas.types import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING
from app.utils.common import log_execution_time
from app.utils.singleton import Singleton
@@ -57,6 +58,9 @@ class RecommendChain(ChainBase, metaclass=Singleton):
self.douban_movie_hot,
self.douban_tv_hot,
self.music_weekly,
self.music_theaudiodb_albums,
self.music_theaudiodb_tracks,
self.music_douban,
]
# 缓存并刷新所有推荐数据
@@ -187,6 +191,58 @@ class RecommendChain(ChainBase, metaclass=Singleton):
)
return [media.to_dict() for media in medias]
@log_execution_time(logger=logger)
@cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True)
def music_theaudiodb_albums(
self,
page: Optional[int] = 1,
count: Optional[int] = 30,
country: str = "us",
) -> List[dict]:
"""返回 TheAudioDB 指定国家或地区的热门专辑。"""
medias = MusicChain().discover(
source="theaudiodb",
page=page or 1,
count=count or 30,
entity=MUSIC_ENTITY_ALBUM,
country=country,
)
return [media.to_dict() for media in medias]
@log_execution_time(logger=logger)
@cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True)
def music_theaudiodb_tracks(
self,
page: Optional[int] = 1,
count: Optional[int] = 30,
country: str = "us",
) -> List[dict]:
"""返回 TheAudioDB 指定国家或地区的热门单曲。"""
medias = MusicChain().discover(
source="theaudiodb",
page=page or 1,
count=count or 30,
entity=MUSIC_ENTITY_RECORDING,
country=country,
)
return [media.to_dict() for media in medias]
@log_execution_time(logger=logger)
@cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True)
def music_douban(
self,
page: Optional[int] = 1,
count: Optional[int] = 30,
) -> List[dict]:
"""返回豆瓣音乐推荐合集。"""
medias = MusicChain().discover(
source="doubanmusic",
page=page or 1,
count=count or 30,
entity=MUSIC_ENTITY_ALBUM,
)
return [media.to_dict() for media in medias]
@log_execution_time(logger=logger)
@cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True)
def tmdb_tvs(self, sort_by: Optional[str] = "popularity.desc",
@@ -416,6 +472,58 @@ class RecommendChain(ChainBase, metaclass=Singleton):
)
return [media.to_dict() for media in medias]
@log_execution_time(logger=logger)
@cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True)
async def async_music_theaudiodb_albums(
self,
page: Optional[int] = 1,
count: Optional[int] = 30,
country: str = "us",
) -> List[dict]:
"""异步返回 TheAudioDB 指定国家或地区的热门专辑。"""
medias = await MusicChain().async_discover(
source="theaudiodb",
page=page or 1,
count=count or 30,
entity=MUSIC_ENTITY_ALBUM,
country=country,
)
return [media.to_dict() for media in medias]
@log_execution_time(logger=logger)
@cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True)
async def async_music_theaudiodb_tracks(
self,
page: Optional[int] = 1,
count: Optional[int] = 30,
country: str = "us",
) -> List[dict]:
"""异步返回 TheAudioDB 指定国家或地区的热门单曲。"""
medias = await MusicChain().async_discover(
source="theaudiodb",
page=page or 1,
count=count or 30,
entity=MUSIC_ENTITY_RECORDING,
country=country,
)
return [media.to_dict() for media in medias]
@log_execution_time(logger=logger)
@cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True)
async def async_music_douban(
self,
page: Optional[int] = 1,
count: Optional[int] = 30,
) -> List[dict]:
"""异步返回豆瓣音乐推荐合集。"""
medias = await MusicChain().async_discover(
source="doubanmusic",
page=page or 1,
count=count or 30,
entity=MUSIC_ENTITY_ALBUM,
)
return [media.to_dict() for media in medias]
@log_execution_time(logger=logger)
@cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True)
async def async_douban_movies(self, sort: Optional[str] = "R", tags: Optional[str] = "",

View File

@@ -136,6 +136,40 @@ class DoubanModule(_ModuleBase):
info = self.doubanapi.music_detail(subject_id=str(media_id))
return self._douban_music_to_album(info) if info else None
def music_discover(
self,
source: str,
page: int = 1,
count: int = 30,
entity: str = MUSIC_ENTITY_ALBUM,
country: str = "us",
) -> Optional[List[MusicInfo]]:
"""分页读取豆瓣音乐推荐合集,并保留豆瓣条目原生身份。"""
if source != self._music_source:
return None
del entity, country
result = self.doubanapi.music_single(
start=max(page - 1, 0) * max(1, count),
count=max(1, count),
)
return self._build_music_search_results(result)
def music_album_related(
self,
source: str,
media_id: str,
count: int = 24,
) -> Optional[List[MusicInfo]]:
"""按豆瓣音乐专辑 ID 返回相关推荐条目。"""
if source != self._music_source or not media_id:
return None
result = self.doubanapi.music_recommendations(
subject_id=str(media_id),
start=0,
count=max(1, count),
)
return self._build_music_search_results(result)
def _recognize_music_media(
self,
meta: Optional[MetaMusic],
@@ -278,17 +312,39 @@ class DoubanModule(_ModuleBase):
return candidates[0]
@classmethod
def _build_music_search_results(cls, result: Optional[dict]) -> List[MusicInfo]:
def _build_music_search_results(
cls,
result: Optional[dict | list],
) -> List[MusicInfo]:
"""把豆瓣音乐搜索响应转换为专辑候选列表。"""
items = (result or {}).get("items") or (result or {}).get("musics") or []
payload = result or {}
if isinstance(payload, list):
items = payload
else:
items = (
payload.get("subject_collection_items")
or payload.get("recommendations")
or payload.get("subjects")
or payload.get("items")
or payload.get("musics")
or []
)
candidates = []
for item in items:
if not isinstance(item, dict):
continue
target_type = str(item.get("target_type") or item.get("type") or "").casefold()
target = item.get("target") if isinstance(item.get("target"), dict) else item
target_type = str(item.get("target_type") or "").casefold()
if isinstance(item.get("target"), dict):
target = item["target"]
elif isinstance(item.get("subject"), dict):
target = item["subject"]
else:
target = item
type_name = str(target.get("type_name") or target.get("subtype") or "")
if target_type and target_type not in {"music", "音乐"}:
target_subject_type = str(target.get("type") or "").casefold()
if target_type and target_type not in {"music", "音乐", "subject"}:
continue
if target_subject_type and target_subject_type not in {"music", "音乐"}:
continue
if type_name and type_name not in {"音乐", "music"}:
continue
@@ -473,6 +529,7 @@ class DoubanModule(_ModuleBase):
cover_img.get("url"),
cover.get("large"),
cover.get("normal"),
cover.get("url"),
info.get("cover_url"),
info.get("image"),
]

View File

@@ -623,6 +623,44 @@ class DoubanApi(metaclass=WeakSingleton):
"""异步获取豆瓣音乐详情。"""
return await self.__async_invoke_search(self._urls["music_detail"] + subject_id)
def music_single(self, start: int = 0, count: int = 20) -> dict:
"""分页获取豆瓣音乐推荐合集。"""
return self.__invoke_recommend(
self._urls["music_single"], start=start, count=count
)
async def async_music_single(self, start: int = 0, count: int = 20) -> dict:
"""异步分页获取豆瓣音乐推荐合集。"""
return await self.__async_invoke_recommend(
self._urls["music_single"], start=start, count=count
)
def music_recommendations(
self,
subject_id: str,
start: int = 0,
count: int = 20,
) -> dict:
"""获取豆瓣音乐条目的相关推荐。"""
return self.__invoke_recommend(
self._urls["music_recommendations"] % subject_id,
start=start,
count=count,
)
async def async_music_recommendations(
self,
subject_id: str,
start: int = 0,
count: int = 20,
) -> dict:
"""异步获取豆瓣音乐条目的相关推荐。"""
return await self.__async_invoke_recommend(
self._urls["music_recommendations"] % subject_id,
start=start,
count=count,
)
def movie_top250(self, start: Optional[int] = 0, count: Optional[int] = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""

View File

@@ -274,6 +274,70 @@ class TheAudioDbModule(_ModuleBase):
start = max(page - 1, 0) * max(1, count)
return [album.to_music_info() for album in albums[start:start + max(1, count)]]
def music_discover(
self,
source: str,
page: int = 1,
count: int = 30,
entity: str = MUSIC_ENTITY_ALBUM,
country: str = "us",
) -> Optional[list[MusicInfo]]:
"""读取 TheAudioDB iTunes 趋势榜并转换为可继续浏览的音乐实体。"""
if source != self._source:
return None
payload = self._request_json(
"trending.php",
{
"country": country.casefold(),
"type": "itunes",
"format": "singles" if entity == MUSIC_ENTITY_RECORDING else "albums",
},
)
items = self._entities(payload, "trending")
items.sort(
key=lambda item: self._optional_int(item.get("intChartPlace")) or 10_000
)
candidates = []
for item in items:
if entity == MUSIC_ENTITY_RECORDING:
info = self._track_to_info(item)
else:
info = self._album_to_info(item).to_music_info()
if not info.media_id or not info.title:
continue
info.category = self._text(item.get("strType")) or "iTunes"
info.raw_data.update(
{
"chart_position": self._optional_int(item.get("intChartPlace")),
"chart_country": self._text(item.get("strCountry")),
}
)
candidates.append(info)
start = max(page - 1, 0) * max(1, count)
return candidates[start:start + max(1, count)]
def music_album_related(
self,
source: str,
media_id: str,
count: int = 24,
) -> Optional[list[MusicInfo]]:
"""按专辑主艺术家返回 TheAudioDB 同艺人专辑,供详情页关联浏览。"""
if source != self._source or not media_id:
return None
payload = self._request_json("album.php", {"m": media_id})
album_item = self._first_entity(payload, "album", "albums")
artist_id = self._text((album_item or {}).get("idArtist"))
if not artist_id:
return []
albums_payload = self._request_json("album.php", {"i": artist_id})
albums = [
self._album_to_info(item).to_music_info()
for item in self._entities(albums_payload, "album", "albums")
if self._text(item.get("idAlbum") or item.get("id")) != str(media_id)
]
return albums[:max(1, count)]
def clear_cache(self) -> None:
"""清除 TheAudioDB 请求缓存。"""
self._request_json.cache_clear()

View File

@@ -196,12 +196,16 @@ AniList 榜单、探索、详情、人物和推荐接口优先通过 `anilist-ch
| :--- | :--- | :--- |
| GET | `/api/v1/media/search` | 当 `type=music``source=musicbrainz` 时按歌曲、专辑或歌手关键词搜索音乐元数据,参数:`title``type``count` |
| POST | `/api/v1/music/recognize` | 按 `source` + `media_id` 识别音乐详情,请求体:`MusicRecognizeRequest` |
| GET | `/api/v1/music/explore` | 浏览 ListenBrainz 热门单曲/专辑或新发行专辑,参数:`mode=chart|fresh``entity=recording|album``range_name``sort_by``sort``days``past``future``min_listen_count``with_cover``page``count` |
| GET | `/api/v1/music/album/{album_id}` | 按 MusicBrainz 专辑 ID 查询专辑详情、完整曲目和发行版本,参数:`source` |
| GET | `/api/v1/music/explore` | 按来源浏览音乐;`source=musicbrainz` 支持热门榜单与新发行,`source=theaudiodb` 支持国家/地区趋势专辑或单曲,`source=doubanmusic` 支持豆瓣音乐推荐。参数:`source``mode=chart|fresh``entity=recording|album``country``range_name``sort_by``sort``days``past``future``min_listen_count``with_cover``page``count` |
| GET | `/api/v1/music/album/{album_id}` | 按来源专辑 ID 查询专辑详情、完整曲目和发行版本,参数:`source` |
| GET | `/api/v1/music/album/{album_id}/related` | 按来源查询关联专辑,参数:`source``count` |
| GET | `/api/v1/music/artist/{artist_id}` | 查询艺术家详情;艺术家为只读浏览实体,参数:`source` |
| GET | `/api/v1/music/artist/{artist_id}/albums` | 分页查询艺术家的专辑、EP 和单曲,参数:`source``page``count``album_type` |
| GET | `/api/v1/music/artist/{artist_id}/related` | 查询关联艺术家,参数:`source``count` |
| GET | `/api/v1/recommend/music_weekly` | 浏览本周热门音乐,参数:`page``count` |
| GET | `/api/v1/recommend/music_theaudiodb_albums` | 浏览 TheAudioDB 热门专辑,参数:`country``page``count` |
| GET | `/api/v1/recommend/music_theaudiodb_tracks` | 浏览 TheAudioDB 热门单曲,参数:`country``page``count` |
| GET | `/api/v1/recommend/music_douban` | 浏览豆瓣音乐推荐,参数:`page``count` |
专辑下载与订阅按“整包”处理:下载层会读取种子文件清单并以专辑 `total_tracks` 校验独立音频文件数量;未确认完整覆盖时不会把专辑订阅销订,也不会把部分曲目报告为完整专辑已入库。音乐刮削遵循 `music` 的标签、封面和歌词策略,歌词通过带有界 TTL/LRU 缓存的 LRCLIB 模块保存为同名 `.lrc``.txt` 旁挂文件。

View File

@@ -189,8 +189,9 @@ music on configured music-capable media servers; it does not manage playlists.
|--------|------|-------------|
| GET | `/api/v1/media/search` | Search tracks, albums, or artists with `type=music` or `source=musicbrainz`. Params: `title`, `type`, `count` |
| POST | `/api/v1/music/recognize` | Resolve music metadata. Body: `source`, `media_id` |
| GET | `/api/v1/music/explore` | Explore ListenBrainz charts or fresh albums. Params: `mode`, `entity`, `range_name`, `sort_by`, `sort`, `days`, `past`, `future`, `min_listen_count`, `with_cover`, `page`, `count` |
| GET | `/api/v1/music/explore` | Explore music by `source`: MusicBrainz charts/fresh releases, TheAudioDB country trends, or Douban music recommendations. Params: `source`, `mode`, `entity`, `country`, `range_name`, `sort_by`, `sort`, `days`, `past`, `future`, `min_listen_count`, `with_cover`, `page`, `count` |
| GET | `/api/v1/music/album/{album_id}` | Album detail with tracks and releases. Params: `source` |
| GET | `/api/v1/music/album/{album_id}/related` | Related albums for the selected source. Params: `source`, `count` |
| GET | `/api/v1/music/artist/{artist_id}` | Browse artist detail. Params: `source` |
| GET | `/api/v1/music/artist/{artist_id}/albums` | Browse artist albums/EPs/singles. Params: `source`, `page`, `count`, `album_type` |
| GET | `/api/v1/music/artist/{artist_id}/related` | Browse related artists. Params: `source`, `count` |
@@ -466,13 +467,16 @@ Streaming search sends `{"type":"heartbeat"}` every 15 seconds without business
| GET | `/api/v1/discover/tmdb_movies` | Discover TMDB movies. Params: `sort_by`, `with_genres`, `with_original_language`, `page` |
| GET | `/api/v1/discover/tmdb_tvs` | Discover TMDB TV. Params: same as movies |
### Recommend (15 endpoints)
### Recommend (18 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/recommend/source` | Recommendation data sources |
| GET | `/api/v1/recommend/bangumi_calendar` | Bangumi daily schedule. Params: `page`, `count` |
| GET | `/api/v1/recommend/music_weekly` | ListenBrainz weekly site-wide music chart. Params: `page`, `count` |
| GET | `/api/v1/recommend/music_theaudiodb_albums` | TheAudioDB trending albums. Params: `country`, `page`, `count` |
| GET | `/api/v1/recommend/music_theaudiodb_tracks` | TheAudioDB trending tracks. Params: `country`, `page`, `count` |
| GET | `/api/v1/recommend/music_douban` | Douban music recommendations. Params: `page`, `count` |
| GET | `/api/v1/recommend/douban_showing` | Douban now showing. Params: `page`, `count` |
| GET | `/api/v1/recommend/douban_movies` | Douban movies. Params: `sort`, `tags`, `page`, `count` |
| GET | `/api/v1/recommend/douban_tvs` | Douban TV. Params: `sort`, `tags`, `page`, `count` |

View File

@@ -5,15 +5,16 @@ import pytest
from fastapi import HTTPException
from app.api.apiv1 import api_router
from app.api.endpoints import media as media_endpoints
from app.api.endpoints.music import (
explore_music,
music_album,
music_album_related,
music_artist,
music_artist_albums,
music_artist_related,
recognize_music,
)
from app.api.endpoints import media as media_endpoints
from app.core.context import MusicAlbumInfo, MusicArtistInfo, MusicInfo, MusicRelease
from app.schemas.music import MusicRecognizeRequest
from app.schemas.types import MediaType
@@ -26,6 +27,10 @@ def test_music_routes_are_registered():
assert any(path == "/music/recognize" and "POST" in methods for path, methods in routes)
assert any(path == "/music/explore" and "GET" in methods for path, methods in routes)
assert any(path == "/music/album/{album_id}" and "GET" in methods for path, methods in routes)
assert any(
path == "/music/album/{album_id}/related" and "GET" in methods
for path, methods in routes
)
assert any(path == "/music/artist/{artist_id}" and "GET" in methods for path, methods in routes)
assert any(
path == "/music/artist/{artist_id}/albums" and "GET" in methods
@@ -38,6 +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",
):
assert any(
path == recommend_path and "GET" in methods
for path, methods in routes
)
def test_media_search_routes_music_queries_with_query_kwarg():
@@ -244,6 +258,69 @@ def test_explore_music_supports_official_fresh_release_mode():
)
def test_explore_music_forwards_selected_metadata_source():
"""TheAudioDB 与豆瓣探索应走可扩展发现链而不是 ListenBrainz。"""
chain = Mock()
chain.async_discover = AsyncMock(
return_value=[
MusicInfo(
source="theaudiodb",
media_id="album-1",
music_type="album",
title="Parachutes",
)
]
)
with patch("app.api.endpoints.music.MusicChain", return_value=chain):
result = asyncio.run(
explore_music(
source="theaudiodb",
entity="album",
country="gb",
page=2,
count=20,
_=Mock(),
)
)
assert result[0].source == "theaudiodb"
chain.async_discover.assert_awaited_once_with(
source="theaudiodb",
page=2,
count=20,
entity="album",
country="gb",
)
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(
source="doubanmusic",
media_id="album-2",
title="With Cover",
cover_url="https://img.example/album-2.jpg",
),
]
)
with patch("app.api.endpoints.music.MusicChain", return_value=chain):
result = asyncio.run(
explore_music(
source="doubanmusic",
with_cover=True,
_=Mock(),
)
)
assert [item.media_id for item in result] == ["album-2"]
def test_music_album_returns_tracks_and_releases():
"""专辑接口应返回专辑详情、曲目和发行版本。"""
chain = Mock()
@@ -286,6 +363,38 @@ def test_music_album_returns_404_for_unknown_album():
assert error.value.status_code == 404
def test_music_album_related_returns_source_results():
"""专辑关联浏览接口应传递来源和数量并序列化结果。"""
chain = Mock()
chain.async_album_related = AsyncMock(
return_value=[
MusicInfo(
source="doubanmusic",
media_id="album-2",
music_type="album",
title="依然范特西",
)
]
)
with patch("app.api.endpoints.music.MusicChain", return_value=chain):
result = asyncio.run(
music_album_related(
album_id="album-1",
count=12,
source="doubanmusic",
_=Mock(),
)
)
assert result[0].media_id == "album-2"
chain.async_album_related.assert_awaited_once_with(
source="doubanmusic",
media_id="album-1",
count=12,
)
def test_music_artist_returns_detail():
"""艺术家接口应返回名称、类型和活跃时间。"""
chain = Mock()

View File

@@ -81,6 +81,64 @@ 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()
request = Mock(side_effect=[
{"album": [{"idAlbum": "album-1", "idArtist": "artist-1"}]},
{
"album": [
{"idAlbum": "album-1", "strAlbum": "Current"},
{"idAlbum": "album-2", "strAlbum": "Related"},
]
},
])
monkeypatch.setattr(module, "_request_json", request)
results = module.music_album_related("theaudiodb", "album-1", count=10)
assert results and [item.media_id for item in results] == ["album-2"]
assert request.call_args_list[1].args == ("album.php", {"i": "artist-1"})
def test_douban_detail_rejects_album_id_as_recording(monkeypatch):
"""豆瓣单曲使用专辑加曲序复合 ID纯专辑 ID 不能作为 Recording。"""
module = DoubanModule()
@@ -153,6 +211,39 @@ def test_douban_music_search_and_album_mapping(monkeypatch):
assert album.tracks[1].cover_url == "https://img.example/track.jpg"
def test_douban_music_discover_and_related_accept_collection_wrappers(monkeypatch):
"""豆瓣音乐合集与相关推荐应兼容 subject 包装并保留专辑身份。"""
module = DoubanModule()
module.doubanapi = Mock()
wrapped_item = {
"type": "subject_collection_item",
"subject": {
"id": "1401853",
"type": "music",
"title": "范特西",
"artists": [{"name": "周杰伦"}],
"cover": {"url": "https://img.example/fantasy.jpg"},
},
}
module.doubanapi.music_single.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)
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)
module.doubanapi.music_recommendations.assert_called_once_with(
subject_id="album-1",
start=0,
count=6,
)
def test_douban_music_recognize_expands_album_to_matching_track(monkeypatch):
"""自动文件识别有专辑线索时,豆瓣应返回专辑内音轨而不是专辑实体。"""
module = DoubanModule()

View File

@@ -1,6 +1,8 @@
from types import SimpleNamespace
from unittest.mock import Mock, patch
import pytest
from app.chain.music import MusicChain
from app.chain.subscribe import SubscribeChain, build_subscribe_meta
from app.core.context import (
@@ -8,10 +10,10 @@ from app.core.context import (
MUSIC_ENTITY_ARTIST,
MUSIC_ENTITY_RECORDING,
Context,
MusicInfo,
TorrentInfo,
)
from app.core.meta import MetaMusic
from app.core.context import MusicInfo
from app.schemas.types import MediaType
@@ -736,6 +738,57 @@ def test_subscribe_add_music_uses_explicit_entity_recognize():
media_chain.recognize_by_meta.assert_not_called()
@pytest.mark.parametrize(
("source", "media_id", "title"),
[
("theaudiodb", "2109619", "Parachutes"),
("doubanmusic", "1401853", "范特西"),
],
)
def test_subscribe_add_music_routes_new_album_sources(
source: str,
media_id: str,
title: str,
):
"""新增音乐源的专辑订阅应保留来源、原生 ID 与实体类型。"""
target = MusicInfo(
source=source,
media_id=media_id,
music_type=MUSIC_ENTITY_ALBUM,
title=title,
album=title,
total_tracks=10,
)
media_chain = Mock()
media_chain.recognize_media = Mock(return_value=target)
subscribe_oper = Mock()
subscribe_oper.add.return_value = (1, "")
with patch("app.chain.subscribe.MediaChain", return_value=media_chain), \
patch("app.chain.subscribe.SubscribeOper", return_value=subscribe_oper), \
patch("app.chain.subscribe.MoviePilotServerHelper"), \
patch("app.chain.subscribe.eventmanager"):
sid, err_msg = SubscribeChain().add(
title=title,
year="2000",
mtype=MediaType.MUSIC,
media_source=source,
media_id=media_id,
music_type=MUSIC_ENTITY_ALBUM,
message=False,
)
assert sid == 1
assert err_msg == ""
media_chain.recognize_media.assert_called_once()
assert media_chain.recognize_media.call_args.kwargs["source"] == source
assert media_chain.recognize_media.call_args.kwargs["mediaid"] == media_id
assert media_chain.recognize_media.call_args.kwargs["music_type"] == MUSIC_ENTITY_ALBUM
assert subscribe_oper.add.call_args.kwargs["media_source"] == source
assert subscribe_oper.add.call_args.kwargs["media_id"] == media_id
media_chain.recognize_by_meta.assert_not_called()
def test_subscribe_add_rejects_music_entity_mismatch_before_database_write():
"""请求专辑却识别为单曲时必须中止,不能创建完成语义错误的订阅。"""
media_chain = Mock()

View File

@@ -5,9 +5,9 @@ from unittest.mock import AsyncMock, patch
import pytest
from app.chain.recommend import RecommendChain
from app.core.context import MusicInfo
from app.core.cache import TTLCache
from app.core.context import MusicInfo
from app.schemas.types import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING
SYNC_EMPTY_CACHE_CASES = [
("tmdb_movies", "app.chain.recommend.TmdbChain", "tmdb_discover"),
@@ -132,3 +132,75 @@ def test_async_music_weekly_uses_music_chart():
page=1,
count=30,
)
@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,
):
"""新增音乐推荐入口应保留来源与实体,并输出统一媒体字典。"""
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")
]
kwargs = {"page": 2, "count": 10}
if source == "theaudiodb":
kwargs["country"] = country
result = getattr(chain, method_name)(**kwargs)
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)
@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,
):
"""异步音乐推荐入口应调用统一发现链并保留来源。"""
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")]
)
result = asyncio.run(getattr(chain, method_name)(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)

View File

@@ -57,7 +57,19 @@ def test_resource_search_forwards_custom_plugin_source(monkeypatch) -> None:
assert captured["mtype"] == MediaType.TV
def test_resource_search_forwards_music_entity_namespace(monkeypatch) -> None:
@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 = {}
@@ -81,7 +93,7 @@ def test_resource_search_forwards_music_entity_namespace(monkeypatch) -> None:
response = asyncio.run(
search_endpoint.search_by_id(
mediaid="musicbrainz:release-group-1",
mediaid=f"{source}:{media_id}",
mtype="music",
music_type="album",
_=None,
@@ -89,8 +101,8 @@ def test_resource_search_forwards_music_entity_namespace(monkeypatch) -> None:
)
assert response.success
assert captured["source"] == "musicbrainz"
assert captured["mediaid"] == "release-group-1"
assert captured["source"] == source
assert captured["mediaid"] == media_id
assert captured["mtype"] == MediaType.MUSIC
assert captured["music_type"] == "album"