Files
MoviePilot/app/modules/theaudiodb/__init__.py

733 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from typing import Any, Optional, Tuple, Union
from app.runtime.cache import cached
from app.runtime.config import settings
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))