from typing import Any, Optional, Tuple, Union from app.runtime.cache import cached from app.runtime.settings import RuntimeSettingsCompat settings = RuntimeSettingsCompat() from app.domain.context import ( MusicAlbumInfo, MusicArtistInfo, MusicInfo, ) from app.domain.meta.metabase import MetaBase from app.domain.meta.metamusic import MetaMusic from app.runtime.log import logger from app.modules import _ModuleBase from app.schemas.types import ( MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MediaRecognizeType, MediaSource, MediaSourceSelection, MediaType, ModuleType, ) from app.adapters.network.http import AsyncRequestUtils, RequestUtils from app.domain.media import is_media_source_selected class TheAudioDbModule(_ModuleBase): """通过 TheAudioDB V1 API 提供音乐搜索、详情和手动识别能力。""" _source = MediaSource.TheAudioDB _base_url = "https://www.theaudiodb.com/api/v1/json" _detail_url = "https://www.theaudiodb.com" def init_module(self) -> None: """初始化无状态的 TheAudioDB 模块。""" def init_setting(self) -> Optional[Tuple[str, Union[str, bool]]]: """TheAudioDB 使用环境配置中的 API Key,无独立启用开关。""" return None def stop(self) -> None: """停止模块;当前实现没有需要释放的持久资源。""" def test(self) -> Tuple[bool, str]: """测试 TheAudioDB 艺术家搜索接口连通性。""" result = self._request_json("search.php", {"s": "coldplay"}) return (True, "") if result is not None else (False, "TheAudioDB 网络连接失败") @staticmethod def get_name() -> str: """返回模块展示名称。""" return "TheAudioDB" @staticmethod def get_music_source() -> MediaSource: """返回音乐识别使用的数据源标识。""" return TheAudioDbModule._source @staticmethod def get_type() -> ModuleType: """返回模块所属的媒体识别类型。""" return ModuleType.MediaRecognize @staticmethod def get_subtype() -> MediaRecognizeType: """返回 TheAudioDB 模块子类型。""" return MediaRecognizeType.TheAudioDB @staticmethod def get_priority() -> int: """返回音乐识别优先级,位于默认 MusicBrainz 之后。""" return 1 def search_music( self, meta: MetaMusic, limit: int = 20, media_source: Optional[MediaSourceSelection] = None, ) -> Optional[list[MusicInfo]]: """按请求来源搜索 TheAudioDB 单曲、专辑和艺术家。""" if not is_media_source_selected(media_source, self._source): return None normalized_limit = max(1, min(limit, 100)) tracks = self._search_tracks(meta) albums = self._search_albums(meta) artists = self._search_artists(meta) return self._interleave_results( tracks, albums, artists, limit=normalized_limit, ) def recognize_media( self, meta: MetaBase = None, mtype: MediaType = None, media_source: Optional[MediaSource] = None, media_id: Optional[str] = None, **kwargs, ) -> Optional[MusicInfo]: """仅响应显式 TheAudioDB 音乐请求,并返回带原生 ID 的标准音乐信息。""" music_type = kwargs.get("music_type") if media_source != self._source: return None if not isinstance(meta, MetaMusic): if mtype == MediaType.MUSIC and media_id: detail_kwargs = ( {"music_type": music_type} if music_type is not None else {} ) return self.recognize_music( media_source, str(media_id), **detail_kwargs ) return None resolved_media_id = media_id or meta.media_id if resolved_media_id: detail_kwargs = ( {"music_type": music_type} if music_type is not None else {} ) return self.recognize_music( media_source, str(resolved_media_id), **detail_kwargs ) if music_type != MUSIC_ENTITY_ALBUM: matched = self._select_track(meta, self._search_tracks(meta)) if matched: return matched if music_type == MUSIC_ENTITY_RECORDING: return None album = self._select_album(meta, self._search_albums(meta)) return album.to_music_info() if album else None async def async_recognize_media( self, meta: MetaBase = None, mtype: MediaType = None, media_source: Optional[MediaSource] = None, media_id: Optional[str] = None, **kwargs, ) -> Optional[MusicInfo]: """异步识别 TheAudioDB 音乐详情或按元数据匹配单曲。""" music_type = kwargs.get("music_type") if media_source != self._source: return None if not isinstance(meta, MetaMusic): if mtype == MediaType.MUSIC and media_id: return await self.async_recognize_music( media_source, str(media_id), music_type=music_type, ) return None resolved_media_id = media_id or meta.media_id if resolved_media_id: return await self.async_recognize_music( media_source, str(resolved_media_id), music_type=music_type, ) if music_type != MUSIC_ENTITY_ALBUM: matched = self._select_track( meta, await self._async_search_tracks(meta), ) if matched: return matched if music_type == MUSIC_ENTITY_RECORDING: return None album = self._select_album( meta, await self._async_search_albums(meta), ) return album.to_music_info() if album else None def recognize_music( self, media_source: MediaSource, media_id: str, music_type: Optional[str] = None, ) -> Optional[MusicInfo]: """按 TheAudioDB 原生 ID 和实体类型获取详情;空类型保留旧版探测顺序。""" if media_source != self._source or not media_id: return None if music_type != MUSIC_ENTITY_ALBUM: payload = self._request_json("track.php", {"h": media_id}) track = self._first_entity(payload, "track", "tracks") if track: return self._track_to_info(track) if music_type == MUSIC_ENTITY_RECORDING: return None album = self.music_album(media_source, media_id) return album.to_music_info() if album else None async def async_recognize_music( self, media_source: MediaSource, media_id: str, music_type: Optional[str] = None, ) -> Optional[MusicInfo]: """异步按 TheAudioDB 原生 ID 和实体类型获取详情。""" if media_source != self._source or not media_id: return None if music_type != MUSIC_ENTITY_ALBUM: payload = await self._async_request_json("track.php", {"h": media_id}) track = self._first_entity(payload, "track", "tracks") if track: return self._track_to_info(track) if music_type == MUSIC_ENTITY_RECORDING: return None album = await self._async_music_album(media_source, media_id) return album.to_music_info() if album else None async def _async_music_album( self, media_source: MediaSource, media_id: str, ) -> Optional[MusicAlbumInfo]: """异步按 TheAudioDB 专辑 ID 获取标准化专辑详情和曲目。""" if media_source != self._source or not media_id: return None payload = await self._async_request_json("album.php", {"m": media_id}) item = self._first_entity(payload, "album", "albums") if not item: return None album = self._album_to_info(item) tracks_payload = await self._async_request_json("track.php", {"m": media_id}) album.tracks = [ info for track in self._entities(tracks_payload, "track", "tracks") if (info := self._track_to_info(track, album=album)) ] return album def music_album( self, media_source: MediaSource, media_id: str, ) -> Optional[MusicAlbumInfo]: """按 TheAudioDB 专辑 ID 获取标准化专辑详情和曲目。""" if media_source != self._source or not media_id: return None payload = self._request_json("album.php", {"m": media_id}) item = self._first_entity(payload, "album", "albums") if not item: return None album = self._album_to_info(item) tracks_payload = self._request_json("track.php", {"m": media_id}) album.tracks = [ info for track in self._entities(tracks_payload, "track", "tracks") if (info := self._track_to_info(track, album=album)) ] return album def music_artist( self, media_source: MediaSource, media_id: str, ) -> Optional[MusicArtistInfo]: """按 TheAudioDB 艺术家 ID 获取标准化艺术家详情。""" if media_source != self._source or not media_id: return None payload = self._request_json("artist.php", {"i": media_id}) item = self._first_entity(payload, "artists", "artist") return self._artist_to_info(item) if item else None def music_artist_albums( self, media_source: MediaSource, media_id: str, page: int = 1, count: int = 30, album_type: Optional[str] = None, ) -> list[MusicInfo]: """按 TheAudioDB 艺术家 ID 分页返回专辑列表。""" if media_source != self._source or not media_id: return [] payload = self._request_json("album.php", {"i": media_id}) albums = [self._album_to_info(item) for item in self._entities(payload, "album", "albums")] if album_type: normalized_type = album_type.casefold() albums = [ album for album in albums if (album.album_type or "").casefold() == normalized_type ] start = max(page - 1, 0) * max(1, count) return [album.to_music_info() for album in albums[start:start + max(1, count)]] def music_album_related( self, media_source: MediaSource, media_id: str, count: int = 24, ) -> Optional[list[MusicInfo]]: """按专辑主艺术家返回 TheAudioDB 同艺人专辑,供详情页关联浏览。""" if media_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() def _search_tracks(self, meta: MetaMusic) -> list[MusicInfo]: """使用曲名和艺术家搜索 TheAudioDB 单曲。""" title = meta.title artist = meta.artists[0] if meta.artists else meta.album_artist if not title or not artist: return [] params = {"t": title, "s": artist} payload = self._request_json("searchtrack.php", params) return [ info for item in self._entities(payload, "track", "tracks") if (info := self._track_to_info(item)) ] async def _async_search_tracks(self, meta: MetaMusic) -> list[MusicInfo]: """异步使用曲名和艺术家搜索 TheAudioDB 单曲。""" title = meta.title artist = meta.artists[0] if meta.artists else meta.album_artist if not title or not artist: return [] params = {"t": title, "s": artist} payload = await self._async_request_json("searchtrack.php", params) return [ info for item in self._entities(payload, "track", "tracks") if (info := self._track_to_info(item)) ] def _search_albums(self, meta: MetaMusic) -> list[MusicAlbumInfo]: """使用专辑名和艺术家搜索 TheAudioDB 专辑。""" album_name = meta.album or meta.title artist = meta.artists[0] if meta.artists else meta.album_artist if not album_name or not artist: return [] params = {"a": album_name, "s": artist} payload = self._request_json("searchalbum.php", params) return [self._album_to_info(item) for item in self._entities(payload, "album", "albums")] async def _async_search_albums( self, meta: MetaMusic, ) -> list[MusicAlbumInfo]: """异步使用专辑名和艺术家搜索 TheAudioDB 专辑。""" album_name = meta.album or meta.title artist = meta.artists[0] if meta.artists else meta.album_artist if not album_name or not artist: return [] params = {"a": album_name, "s": artist} payload = await self._async_request_json("searchalbum.php", params) return [ self._album_to_info(item) for item in self._entities(payload, "album", "albums") ] def _search_artists(self, meta: MetaMusic) -> list[MusicArtistInfo]: """使用艺术家线索搜索 TheAudioDB 艺术家。""" name = meta.artists[0] if meta.artists else meta.title if not name: return [] payload = self._request_json("search.php", {"s": name}) return [ self._artist_to_info(item) for item in self._entities(payload, "artists", "artist") ] @classmethod def _select_track( cls, meta: MetaMusic, candidates: list[MusicInfo], ) -> Optional[MusicInfo]: """按曲名和可用艺术家线索选择可信单曲候选。""" for candidate in candidates: if not cls._same_text(meta.title, candidate.title): continue if meta.artists and not any( cls._same_text(expected, actual) for expected in meta.artists for actual in candidate.artists ): continue return candidate return None @classmethod def _select_album( cls, meta: MetaMusic, candidates: list[MusicAlbumInfo], ) -> Optional[MusicAlbumInfo]: """按专辑名和可用艺术家线索选择可信专辑候选。""" expected_title = meta.album or meta.title for candidate in candidates: if not cls._same_text(expected_title, candidate.title): continue if meta.artists and not any( cls._same_text(expected, actual) for expected in meta.artists for actual in candidate.artists ): continue return candidate return None @classmethod def _track_to_info( cls, item: dict[str, Any], album: Optional[MusicAlbumInfo] = None, ) -> Optional[MusicInfo]: """将 TheAudioDB 单曲响应转换为标准音乐信息。""" media_id = cls._text(item.get("idTrack") or item.get("id")) title = cls._text(item.get("strTrack") or item.get("name")) if not media_id or not title: return None artist = cls._text(item.get("strArtist")) artist_id = cls._text(item.get("idArtist")) duration_ms = cls._optional_int(item.get("intDuration")) genres = cls._unique_texts([item.get("strGenre"), item.get("strStyle")]) return MusicInfo( media_source=cls._source, media_id=media_id, title=title, artists=[artist] if artist else list(album.artists if album else []), artist_ids=[artist_id] if artist_id else list(album.artist_ids if album else []), album=cls._text(item.get("strAlbum")) or (album.title if album else None), album_artist=artist or (album.artist if album else None), album_id=cls._text(item.get("idAlbum")) or (album.media_id if album else None), year=album.year if album else None, release_date=album.release_date if album else None, disc_number=cls._optional_int(item.get("intCD")), track_number=cls._optional_int(item.get("intTrackNumber")), duration=duration_ms // 1000 if duration_ms else None, isrc=cls._text(item.get("strISRC")), cover_url=cls._first_text( item, "strTrackThumb", "strTrack3DCase", ) or (album.cover_url if album else None), lyrics=cls._text(item.get("strTrackLyrics")), category=" / ".join(genres), genres=genres, names=cls._unique_texts([title, item.get("strTrackAlternate")]), detail_link=f"{cls._detail_url}/track/{media_id}", raw_data={ "musicbrainz_id": cls._text(item.get("strMusicBrainzID")), "musicbrainz_album_id": cls._text(item.get("strMusicBrainzAlbumID")), }, ) @classmethod def _album_to_info(cls, item: dict[str, Any]) -> MusicAlbumInfo: """将 TheAudioDB 专辑响应转换为标准专辑信息。""" media_id = cls._text(item.get("idAlbum") or item.get("id")) title = cls._text(item.get("strAlbum") or item.get("name")) artist = cls._text(item.get("strArtist")) artist_id = cls._text(item.get("idArtist")) genres = cls._unique_texts([item.get("strGenre"), item.get("strStyle")]) release_date = cls._text(item.get("strReleaseDate")) if not release_date: release_date = cls._text(item.get("intYearReleased")) return MusicAlbumInfo( media_source=cls._source, media_id=media_id, title=title, artists=[artist] if artist else [], artist_ids=[artist_id] if artist_id else [], album_type=cls._text(item.get("strReleaseFormat")), release_date=release_date, cover_url=cls._first_text( item, "strAlbumThumbHQ", "strAlbumThumb", "strAlbum3DCase", "strAlbumCDart", ), genres=genres, tags=cls._unique_texts([item.get("strMood"), item.get("strStyle")]), rating=cls._optional_float(item.get("intScore")), rating_votes=cls._optional_int(item.get("intScoreVotes")), detail_link=f"{cls._detail_url}/album/{media_id}" if media_id else None, raw_data={"description": cls._localized_text(item, "strDescription")}, ) @classmethod def _artist_to_info(cls, item: dict[str, Any]) -> MusicArtistInfo: """将 TheAudioDB 艺术家响应转换为标准艺术家信息。""" media_id = cls._text(item.get("idArtist") or item.get("id")) name = cls._text(item.get("strArtist") or item.get("name")) links = {} website = cls._text(item.get("strWebsite")) if website: links["official homepage"] = website return MusicArtistInfo( media_source=cls._source, media_id=media_id, name=name, disambiguation=cls._text(item.get("strArtistAlternate")), artist_type=cls._text(item.get("strStyle")), gender=cls._text(item.get("strGender")), country=cls._text(item.get("strCountry")), begin_date=cls._text(item.get("intFormedYear") or item.get("intBornYear")), end_date=cls._text(item.get("intDiedYear") or item.get("strDisbanded")), ended=bool(item.get("intDiedYear") or item.get("strDisbanded")), genres=cls._unique_texts([item.get("strGenre"), item.get("strStyle")]), aliases=cls._split_text(item.get("strArtistAlternate")), image_url=cls._first_text( item, "strArtistThumb", "strArtistFanart", "strArtistWideThumb", "strArtistCutout", ), detail_link=f"{cls._detail_url}/artist/{media_id}" if media_id else None, external_links=links, raw_data={ "musicbrainz_id": cls._text(item.get("strMusicBrainzID")), "biography": cls._localized_text(item, "strBiography"), }, ) @staticmethod def _interleave_results( tracks: list[MusicInfo], albums: list[MusicAlbumInfo], artists: list[MusicArtistInfo], limit: int, ) -> list[MusicInfo]: """交错合并三类搜索结果,避免单一实体占满候选列表。""" groups = [tracks, [item.to_music_info() for item in albums], [item.to_music_info() for item in artists]] results: list[MusicInfo] = [] for index in range(max((len(group) for group in groups), default=0)): for group in groups: if index < len(group): results.append(group[index]) if len(results) >= limit: return results return results @classmethod @cached(maxsize=settings.CONF.theaudiodb, ttl=settings.CONF.meta, skip_none=True) def _request_json( cls, endpoint: str, params: Optional[dict[str, Any]] = None, ) -> Optional[dict[str, Any]]: """请求 TheAudioDB V1 JSON 接口并统一处理错误响应。""" api_key = str(settings.THEAUDIODB_API_KEY or "").strip() if not api_key: logger.warning("TheAudioDB API Key 未配置,跳过请求") return None response = RequestUtils( ua=settings.USER_AGENT, proxies=settings.PROXY, timeout=30, ).get_res( url=f"{cls._base_url}/{api_key}/{endpoint}", params=params or {}, ) if response is None: return None try: if response.status_code != 200: return None diagnostic = cls._response_diagnostic(response, endpoint) if getattr(response, "content", None) in (b"", ""): logger.warning(f"TheAudioDB 返回空响应:{diagnostic}") return None try: payload = response.json() except (TypeError, ValueError) as err: logger.warning( f"TheAudioDB 响应解析失败:{diagnostic},错误:{str(err)}" ) return None return payload if isinstance(payload, dict) else None finally: response.close() @classmethod @cached( maxsize=settings.CONF.theaudiodb, ttl=settings.CONF.meta, skip_none=True, shared_key="_request_json", ) async def _async_request_json( cls, endpoint: str, params: Optional[dict[str, Any]] = None, ) -> Optional[dict[str, Any]]: """异步请求 TheAudioDB V1 JSON 接口并统一处理错误响应。""" api_key = str(settings.THEAUDIODB_API_KEY or "").strip() if not api_key: logger.warning("TheAudioDB API Key 未配置,跳过请求") return None response = await AsyncRequestUtils( ua=settings.USER_AGENT, proxies=settings.PROXY, timeout=30, ).get_res( url=f"{cls._base_url}/{api_key}/{endpoint}", params=params or {}, ) if response is None: return None try: if response.status_code != 200: return None diagnostic = cls._response_diagnostic(response, endpoint) if getattr(response, "content", None) in (b"", ""): logger.warning(f"TheAudioDB 返回空响应:{diagnostic}") return None try: payload = response.json() except (TypeError, ValueError) as err: logger.warning( f"TheAudioDB 响应解析失败:{diagnostic},错误:{str(err)}" ) return None return payload if isinstance(payload, dict) else None finally: await response.aclose() @staticmethod def _response_diagnostic(response: Any, endpoint: str) -> str: """生成不包含 API Key 的 TheAudioDB 响应诊断摘要。""" headers = getattr(response, "headers", {}) or {} content_type = headers.get("Content-Type", "") if hasattr(headers, "get") else "" body = str(getattr(response, "text", "") or "").replace("\n", " ")[:200] return ( f"endpoint={endpoint}, HTTP={getattr(response, 'status_code', '')}, " f"Content-Type={content_type}, body={body!r}" ) @staticmethod def _entities( payload: Optional[dict[str, Any]], *keys: str, ) -> list[dict[str, Any]]: """从兼容 V1/V2 命名的响应字段中提取实体列表。""" if not payload: return [] for key in keys: value = payload.get(key) if isinstance(value, list): return [item for item in value if isinstance(item, dict)] if isinstance(value, dict): return [value] return [] @classmethod def _first_entity( cls, payload: Optional[dict[str, Any]], *keys: str, ) -> Optional[dict[str, Any]]: """返回响应中的首个实体。""" entities = cls._entities(payload, *keys) return entities[0] if entities else None @staticmethod def _text(value: Any) -> Optional[str]: """把外部响应值转换为去空白文本。""" text = str(value).strip() if value is not None else "" return text or None @classmethod def _first_text(cls, item: dict[str, Any], *keys: str) -> Optional[str]: """按优先级返回外部响应中的首个非空文本。""" return next((text for key in keys if (text := cls._text(item.get(key)))), None) @classmethod def _localized_text(cls, item: dict[str, Any], prefix: str) -> Optional[str]: """优先返回中文说明,不存在时回退到英文说明。""" return cls._first_text(item, f"{prefix}CN", f"{prefix}EN") @staticmethod def _optional_int(value: Any) -> Optional[int]: """将外部响应值安全转换为整数。""" try: return int(value) if value not in (None, "") else None except (TypeError, ValueError): return None @staticmethod def _optional_float(value: Any) -> float: """将外部评分安全转换为浮点数。""" try: return float(value) if value not in (None, "") else 0.0 except (TypeError, ValueError): return 0.0 @classmethod def _split_text(cls, value: Any) -> list[str]: """把分号或斜线分隔的外部文本转换为去重列表。""" text = cls._text(value) if not text: return [] return cls._unique_texts(text.replace("/", ";").split(";")) @classmethod def _unique_texts(cls, values: list[Any]) -> list[str]: """过滤空值并按大小写无关方式去重。""" results = [] seen = set() for value in values: text = cls._text(value) identity = text.casefold() if text else "" if not text or identity in seen: continue seen.add(identity) results.append(text) return results @staticmethod def _same_text(left: Optional[str], right: Optional[str]) -> bool: """使用音乐元数据紧凑文本规则比较标题和艺术家。""" return bool(left and right and MetaMusic.compact_text(left) == MetaMusic.compact_text(right))