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

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