/
/
/
1"""Cover Art Archive Metadata provider for Music Assistant."""
2
3from __future__ import annotations
4
5from typing import TYPE_CHECKING
6
7import aiohttp
8from music_assistant_models.enums import ExternalID, ImageType, ProviderFeature
9from music_assistant_models.errors import ResourceTemporarilyUnavailable
10from music_assistant_models.media_items import Album, MediaItemImage, MediaItemMetadata, UniqueList
11
12from music_assistant.controllers.cache import use_cache
13from music_assistant.models.metadata_provider import MetadataProvider
14
15if TYPE_CHECKING:
16 from music_assistant_models.config_entries import ConfigEntry, ProviderConfig
17 from music_assistant_models.provider import ProviderManifest
18
19 from music_assistant.mass import MusicAssistant
20 from music_assistant.models import ProviderInstanceType
21
22SUPPORTED_FEATURES = {
23 ProviderFeature.ALBUM_METADATA,
24}
25
26CAA_BASE_URL = "https://coverartarchive.org"
27
28
29async def setup(
30 mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
31) -> ProviderInstanceType:
32 """Initialize provider(instance) with given configuration."""
33 return CoverArtArchiveMetadataProvider(mass, manifest, config, SUPPORTED_FEATURES)
34
35
36class CoverArtArchiveMetadataProvider(MetadataProvider):
37 """
38 Cover Art Archive Metadata provider.
39
40 Fetches album artwork from the Cover Art Archive using MusicBrainz release group IDs.
41 """
42
43 async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
44 """Return Config entries to setup this provider."""
45 return ()
46
47 @property
48 def priority(self) -> int:
49 """Priority for this provider (lower = more preferred)."""
50 return 40
51
52 async def get_album_metadata(self, album: Album) -> MediaItemMetadata | None:
53 """
54 Retrieve metadata for an album.
55
56 :param album: Album to retrieve metadata for.
57 """
58 mbid = album.get_external_id(ExternalID.MB_RELEASEGROUP)
59 if not mbid:
60 return None
61
62 image_url = await self._get_cover_art_url(mbid)
63 if not image_url:
64 return None
65
66 self.logger.debug("Found cover art for album %s on Cover Art Archive", album.name)
67 return MediaItemMetadata(
68 images=UniqueList(
69 [
70 MediaItemImage(
71 type=ImageType.THUMB,
72 path=image_url,
73 provider=self.domain,
74 remotely_accessible=True,
75 )
76 ]
77 )
78 )
79
80 @use_cache(86400 * 30)
81 async def _get_cover_art_url(self, release_group_id: str) -> str | None:
82 """
83 Retrieve cover art URL for a release group.
84
85 :param release_group_id: MusicBrainz release group ID.
86 """
87 # Try 1200px first, fall back to 500px
88 try:
89 for size in ("front-1200", "front-500"):
90 url = f"{CAA_BASE_URL}/release-group/{release_group_id}/{size}"
91 async with self.mass.http_session.head(url, allow_redirects=True) as response:
92 if response.status == 200:
93 return str(response.url)
94 if response.status != 404:
95 response.raise_for_status()
96 except (aiohttp.ClientError, TimeoutError) as err:
97 # a non-404 status (5xx, 429, ...) or network failure is transient â surface it as
98 # ResourceTemporarilyUnavailable so callers degrade instead of caching "no cover art"
99 raise ResourceTemporarilyUnavailable("Cover Art Archive request failed") from err
100 # a 404 for both sizes means this release group genuinely has no cover art
101 return None
102