from typing import Annotated, Optional from fastapi import Depends, HTTPException, Query from app.schemas.music import MusicAlbumInfo as _SchemaMusicAlbumInfo from app.schemas.music import MusicArtistInfo as _SchemaMusicArtistInfo from app.schemas.music import MusicRecognitionCacheData as _SchemaMusicRecognitionCacheData from app.schemas.music import MusicRecognizeRequest as _SchemaMusicRecognizeRequest from app.schemas.response import Response as _SchemaResponse from app.schemas.token import TokenPayload as _SchemaTokenPayload from app.schemas.transfer import MusicInfo as _SchemaMusicInfo from app.api.response import ResponseAPIRouter from app.chain.media import MediaChain from app.chain.recommend import RecommendChain from app.schemas.types import MediaSource, MediaType from app.domain.context import MusicAlbumInfo, MusicArtistInfo, MusicInfo from app.adapters.web.security.access import verify_token from app.api.dependencies.auth import get_current_active_superuser_async from app.chain.listenbrainz import ( LISTENBRAINZ_CHART_RANGES, LISTENBRAINZ_FRESH_MAX_DAYS, LISTENBRAINZ_FRESH_SORTS, ) from app.chain.musicbrainz import MusicBrainzChain router = ResponseAPIRouter() CountParam = Annotated[int, Query(ge=1, le=100)] PageParam = Annotated[int, Query(ge=1)] MusicSourceParam = Annotated[ MediaSource, Query(), ] MusicExploreSourceParam = Annotated[ MediaSource, Query(), ] MusicModeParam = Annotated[str, Query(pattern="^(chart|fresh)$")] MusicEntityParam = Annotated[str, Query(pattern="^(recording|album)$")] MusicRangeParam = Annotated[str, Query(pattern=f"^({'|'.join(LISTENBRAINZ_CHART_RANGES)})$")] MusicSortParam = Annotated[str, Query(pattern="^listen_count\\.(desc|asc)$")] MusicFreshSortParam = Annotated[str, Query(pattern=f"^({'|'.join(LISTENBRAINZ_FRESH_SORTS)})$")] DoubanMusicSortParam = Annotated[str, Query(pattern="^(U|S|R|O)$")] MusicDaysParam = Annotated[int, Query(ge=1, le=LISTENBRAINZ_FRESH_MAX_DAYS)] # MusicBrainz 浏览接口支持的专辑类型筛选 MusicAlbumTypeParam = Annotated[ Optional[str], Query(pattern="^(album|single|ep|broadcast|other|compilation|soundtrack|live|remix)$"), ] _MUSIC_EXPLORE_SOURCES = frozenset({ MediaSource.MusicBrainz, MediaSource.DoubanMusic, }) def _validate_music_source( media_source: MediaSource, allowed_sources: Optional[frozenset[MediaSource]] = None, ) -> MediaSource: """规范音乐来源;仅来源专属端点额外限制内置来源集合。""" try: normalized_source = MediaSource(media_source) except (TypeError, ValueError) as err: raise HTTPException(status_code=422, detail="无效的媒体来源") from err if allowed_sources is not None and normalized_source not in allowed_sources: raise HTTPException(status_code=422, detail="该媒体来源不支持此音乐接口") return normalized_source def _serialize_music(info: MusicInfo) -> _SchemaMusicInfo: """将内部音乐信息转换为 REST 响应模型。""" return _SchemaMusicInfo(**info.to_dict()) def _serialize_album(info: MusicAlbumInfo) -> _SchemaMusicAlbumInfo: """将内部专辑信息转换为 REST 响应模型。""" return _SchemaMusicAlbumInfo(**info.to_dict()) def _serialize_artist(info: MusicArtistInfo) -> _SchemaMusicArtistInfo: """将内部艺术家信息转换为 REST 响应模型。""" return _SchemaMusicArtistInfo(**info.to_dict()) @router.post( "/recognize", summary="识别音乐元数据详情", response_model=_SchemaMusicInfo, ) async def recognize_music( request: _SchemaMusicRecognizeRequest, _: _SchemaTokenPayload = Depends(verify_token), ) -> _SchemaMusicInfo: """根据音乐元数据来源和媒体 ID 获取标准详情,与影视识别共用统一入口。""" recognize_kwargs = { "media_source": request.media_source, "media_id": request.media_id, "mtype": MediaType.MUSIC, } if request.music_type is not None: recognize_kwargs["music_type"] = request.music_type info = await MediaChain().async_recognize_media( **recognize_kwargs, ) if not info: raise HTTPException(status_code=404, detail="未识别到音乐信息") return _serialize_music(info) @router.get( "/cache", summary="查询音乐识别缓存", response_model=_SchemaResponse[_SchemaMusicRecognitionCacheData], ) async def music_recognition_cache( _: object = Depends(get_current_active_superuser_async), ) -> _SchemaResponse: """查询可管理的 MusicBrainz 识别缓存。""" cache_items = MusicBrainzChain().cache_items() recognized_count = sum(1 for item in cache_items if item["media_id"]) return _SchemaResponse( success=True, data={ "count": len(cache_items), "recognized": recognized_count, "unrecognized": len(cache_items) - recognized_count, "data": cache_items, }, ) @router.delete( "/cache/{cache_key:path}", summary="删除指定音乐识别缓存", response_model=_SchemaResponse[None], ) async def delete_music_recognition_cache( cache_key: str, _: object = Depends(get_current_active_superuser_async), ) -> _SchemaResponse: """按缓存键删除单条 MusicBrainz 识别缓存。""" deleted_item = MusicBrainzChain().delete_cache(cache_key) if not deleted_item: return _SchemaResponse(success=False, message="音乐识别缓存不存在") return _SchemaResponse(success=True, message="音乐识别缓存删除成功") @router.delete( "/cache", summary="清空音乐识别缓存", response_model=_SchemaResponse[None] ) async def clear_music_recognition_cache( _: object = Depends(get_current_active_superuser_async), ) -> _SchemaResponse: """清空全部 MusicBrainz 识别缓存。""" MusicBrainzChain().clear_cache() return _SchemaResponse(success=True, message="音乐识别缓存清理完成") @router.get( "/explore", summary="探索音乐", response_model=list[_SchemaMusicInfo], ) async def explore_music( page: PageParam = 1, count: CountParam = 30, media_source: MusicExploreSourceParam = MediaSource.MusicBrainz, mode: MusicModeParam = "chart", entity: MusicEntityParam = "recording", range_name: MusicRangeParam = "this_month", sort_by: MusicSortParam = "listen_count.desc", sort: MusicFreshSortParam = "release_date", days: MusicDaysParam = 14, past: bool = True, future: bool = True, min_listen_count: Annotated[int, Query(ge=0)] = 0, with_cover: bool = False, tags: str = "", douban_sort: DoubanMusicSortParam = "U", _: _SchemaTokenPayload = Depends(verify_token), ) -> list[_SchemaMusicInfo]: """MusicBrainz 返回榜单或新发行,豆瓣音乐固定按官方标签分类浏览。""" media_source = _validate_music_source(media_source, _MUSIC_EXPLORE_SOURCES) chain = RecommendChain() if media_source != MediaSource.MusicBrainz: results = await chain.async_music_discover( media_source=media_source, page=page, count=count, entity=entity, mode="tag", tags=tags, sort=douban_sort, ) elif mode == "fresh": results = await chain.async_music_fresh_releases( days=days, sort=sort, past=past, future=future, page=page, count=count, with_cover=with_cover, ) else: results = await chain.async_music_chart( range_name=range_name, page=page, count=count, sort_by=sort_by, min_listen_count=min_listen_count, with_cover=with_cover, entity=entity, ) if media_source != MediaSource.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] @router.get( "/album/{album_id}", summary="查询音乐专辑详情", response_model=_SchemaMusicAlbumInfo, ) async def music_album( album_id: str, media_source: MusicSourceParam = MediaSource.MusicBrainz, _: _SchemaTokenPayload = Depends(verify_token), ) -> _SchemaMusicAlbumInfo: """按专辑标准 ID 返回专辑详情、曲目列表和发行版本。""" media_source = _validate_music_source(media_source) info = await MediaChain().async_get_music_album( media_source=media_source, media_id=album_id ) if not info: raise HTTPException(status_code=404, detail="未识别到专辑信息") return _serialize_album(info) @router.get( "/album/{album_id}/related", summary="查询关联音乐专辑", response_model=list[_SchemaMusicInfo], ) async def music_album_related( album_id: str, count: CountParam = 24, media_source: MusicSourceParam = MediaSource.MusicBrainz, _: _SchemaTokenPayload = Depends(verify_token), ) -> list[_SchemaMusicInfo]: """按来源和专辑 ID 返回可继续浏览的关联专辑。""" media_source = _validate_music_source(media_source) results = await MediaChain().async_get_music_album_related( media_source=media_source, media_id=album_id, count=count, ) return [_serialize_music(info) for info in results] @router.get( "/artist/{artist_id}/albums", summary="查询艺术家的专辑列表", response_model=list[_SchemaMusicInfo], ) async def music_artist_albums( artist_id: str, page: PageParam = 1, count: CountParam = 30, album_type: MusicAlbumTypeParam = None, media_source: MusicSourceParam = MediaSource.MusicBrainz, _: _SchemaTokenPayload = Depends(verify_token), ) -> list[_SchemaMusicInfo]: """按艺术家标准 ID 分页返回其专辑、EP 和单曲。""" media_source = _validate_music_source(media_source) results = await MediaChain().async_get_music_artist_albums( media_source=media_source, media_id=artist_id, page=page, count=count, album_type=album_type, ) return [_serialize_music(info) for info in results] @router.get( "/artist/{artist_id}/related", summary="查询关联艺术家", response_model=list[_SchemaMusicArtistInfo], ) async def music_artist_related( artist_id: str, count: CountParam = 24, media_source: MusicSourceParam = MediaSource.MusicBrainz, _: _SchemaTokenPayload = Depends(verify_token), ) -> list[_SchemaMusicArtistInfo]: """按艺术家关系返回可继续浏览的关联艺术家。""" media_source = _validate_music_source(media_source) results = await MediaChain().async_get_music_artist_related( media_source=media_source, media_id=artist_id, count=count, ) return [_serialize_artist(info) for info in results] @router.get( "/artist/{artist_id}", summary="查询音乐艺术家详情", response_model=_SchemaMusicArtistInfo, ) async def music_artist( artist_id: str, media_source: MusicSourceParam = MediaSource.MusicBrainz, _: _SchemaTokenPayload = Depends(verify_token), ) -> _SchemaMusicArtistInfo: """按艺术家标准 ID 返回艺术家详情。""" media_source = _validate_music_source(media_source) info = await MediaChain().async_get_music_artist( media_source=media_source, media_id=artist_id ) if not info: raise HTTPException(status_code=404, detail="未识别到艺术家信息") return _serialize_artist(info)