/
/
/
1"""Converters for Bandcamp API models to Music Assistant models."""
2
3from contextlib import suppress
4from datetime import UTC, datetime
5from typing import TYPE_CHECKING, TypedDict
6
7from music_assistant_models.enums import ContentType, ImageType, MediaType
8from music_assistant_models.media_items import Album as MAAlbum
9from music_assistant_models.media_items import Artist as MAArtist
10from music_assistant_models.media_items import (
11 AudioFormat,
12 ItemMapping,
13 MediaItemImage,
14 ProviderMapping,
15 UniqueList,
16)
17from music_assistant_models.media_items import Track as MATrack
18
19if TYPE_CHECKING:
20 from bandcamp_async_api.models import BCAlbum as APIAlbum
21 from bandcamp_async_api.models import BCArtist as APIArtist
22 from bandcamp_async_api.models import BCTrack as APITrack
23 from bandcamp_async_api.models import (
24 FeedTrack,
25 SearchResultAlbum,
26 SearchResultArtist,
27 SearchResultTrack,
28 )
29
30
31class DiscographyItem(TypedDict, total=False):
32 """Raw discography item dict from the band_details API."""
33
34 item_id: int
35 item_type: str
36 band_id: int
37 title: str
38 artist_name: str | None
39 band_name: str | None
40 art_id: int | None
41 release_date: str | None
42 is_purchasable: bool
43
44
45class BandcampConverters:
46 """Converters for Bandcamp API models to Music Assistant models."""
47
48 def __init__(self, domain: str, instance_id: str):
49 """Initialize converters with provider information."""
50 self.domain = domain
51 self.instance_id = instance_id
52
53 @staticmethod
54 def streaming_url_from_api(
55 streaming_info: dict[str, str],
56 ) -> tuple[str | None, int | None, ContentType]:
57 """
58 Parse streaming URL info.
59
60 :param streaming_info: Dict of format keys to URLs from the Bandcamp API.
61 """
62 # Extract streaming URL with priority: mp3-v0 > mp3-320 > mp3-128
63 bitrate = None
64 streaming_url = None
65 content_type = ContentType.MP3
66 if "mp3-v0" in streaming_info:
67 streaming_url = streaming_info["mp3-v0"]
68 elif "mp3-320" in streaming_info:
69 streaming_url = streaming_info["mp3-320"]
70 bitrate = 320
71 elif "mp3-128" in streaming_info:
72 streaming_url = streaming_info["mp3-128"]
73 bitrate = 128
74 elif streaming_info:
75 streaming_url = next(iter(streaming_info.values()))
76 content_type = ContentType.UNKNOWN
77 return streaming_url, bitrate, content_type
78
79 def track_from_search(self, item: SearchResultTrack) -> MATrack:
80 """Create a Track from new API SearchResultTrack."""
81 track_id = f"{item.artist_id}-{item.album_id or 0}-{item.id}"
82 return MATrack(
83 item_id=track_id,
84 provider=self.instance_id,
85 name=item.name,
86 artists=UniqueList(
87 [
88 ItemMapping(
89 media_type=MediaType.ARTIST,
90 item_id=str(item.artist_id),
91 provider=self.instance_id,
92 name=item.artist_name,
93 )
94 ]
95 ),
96 album=(
97 ItemMapping(
98 media_type=MediaType.ALBUM,
99 item_id=f"{item.artist_id}-{item.album_id or 0}",
100 provider=self.instance_id,
101 name=item.album_name,
102 )
103 if item.album_id
104 else None
105 ),
106 provider_mappings={
107 ProviderMapping(
108 item_id=track_id,
109 provider_domain=self.domain,
110 provider_instance=self.instance_id,
111 url=item.url,
112 )
113 },
114 )
115
116 def album_from_search(self, item: SearchResultAlbum) -> MAAlbum:
117 """Create an Album from new API SearchResultAlbum."""
118 album_id = f"{item.artist_id}-{item.id}"
119 output = MAAlbum(
120 item_id=album_id,
121 provider=self.instance_id,
122 name=item.name,
123 uri=item.url,
124 artists=UniqueList(
125 [
126 ItemMapping(
127 media_type=MediaType.ARTIST,
128 item_id=str(item.artist_id),
129 provider=self.instance_id,
130 name=item.artist_name,
131 uri=item.artist_url,
132 )
133 ]
134 ),
135 provider_mappings={
136 ProviderMapping(
137 item_id=album_id,
138 provider_domain=self.domain,
139 provider_instance=self.instance_id,
140 url=item.url,
141 )
142 },
143 )
144 output.metadata.add_image(
145 MediaItemImage(
146 type=ImageType.THUMB,
147 path=item.image_url,
148 provider=self.instance_id,
149 remotely_accessible=True,
150 )
151 )
152 return output
153
154 def artist_from_search(self, item: SearchResultArtist) -> MAArtist:
155 """Create an Artist from new API SearchResultArtist."""
156 output = MAArtist(
157 item_id=str(item.id),
158 provider=self.instance_id,
159 name=item.name,
160 uri=item.url,
161 provider_mappings={
162 ProviderMapping(
163 item_id=str(item.id),
164 provider_domain=self.domain,
165 provider_instance=self.instance_id,
166 url=item.url,
167 )
168 },
169 )
170 output.metadata.genres = item.tags
171 if item.url:
172 output.metadata.description = item.url
173 output.metadata.add_image(
174 MediaItemImage(
175 type=ImageType.THUMB,
176 path=item.image_url,
177 provider=self.instance_id,
178 remotely_accessible=True,
179 )
180 )
181 return output
182
183 def track_from_api(
184 self,
185 track: APITrack,
186 album_id: str | int | None = None,
187 album_name: str = "",
188 album_image_url: str = "",
189 ) -> MATrack:
190 """Convert a Track object from the API to MA Track format."""
191 album_id = album_id or 0
192 _, bitrate, content_type = self.streaming_url_from_api(track.streaming_url or {})
193 output = MATrack(
194 item_id=f"{track.artist.id}-{album_id}-{track.id}",
195 provider=self.instance_id,
196 name=track.title,
197 artists=UniqueList(
198 [
199 ItemMapping(
200 media_type=MediaType.ARTIST,
201 item_id=str(track.artist.id),
202 provider=self.instance_id,
203 name=track.artist.name,
204 )
205 ]
206 ),
207 disc_number=0,
208 duration=track.duration,
209 provider_mappings={
210 ProviderMapping(
211 item_id=f"{track.artist.id}-{album_id}-{track.id}",
212 provider_domain=self.domain,
213 provider_instance=self.instance_id,
214 url=track.url,
215 audio_format=AudioFormat(
216 content_type=content_type,
217 bit_rate=bitrate,
218 ),
219 )
220 },
221 )
222 if track.track_number is not None:
223 output.track_number = track.track_number
224
225 if album_id:
226 output.album = ItemMapping(
227 media_type=MediaType.ALBUM,
228 item_id=f"{track.artist.id}-{album_id}",
229 provider=self.instance_id,
230 name=album_name,
231 )
232 elif hasattr(track, "album") and track.album:
233 # If the track has an album attribute, use that information
234 output.album = ItemMapping(
235 media_type=MediaType.ALBUM,
236 item_id=f"{track.artist.id}-{track.album.id}",
237 provider=self.instance_id,
238 name=track.album.title,
239 )
240 output.metadata.lyrics = track.lyrics
241 if album_image_url:
242 output.metadata.add_image(
243 MediaItemImage(
244 type=ImageType.THUMB,
245 path=album_image_url,
246 provider=self.instance_id,
247 remotely_accessible=True,
248 )
249 )
250 return output
251
252 def track_from_feed(self, track: FeedTrack) -> MATrack:
253 """Convert a feed track_list entry to MA Track format."""
254 album_id = track.album_id or 0
255 item_id = f"{track.band_id}-{album_id}-{track.track_id}"
256 _, bitrate, content_type = self.streaming_url_from_api(track.streaming_url or {})
257 output = MATrack(
258 item_id=item_id,
259 provider=self.instance_id,
260 name=track.title,
261 duration=int(track.duration) if track.duration else 0,
262 artists=UniqueList(
263 [
264 ItemMapping(
265 media_type=MediaType.ARTIST,
266 item_id=str(track.band_id),
267 provider=self.instance_id,
268 name=track.band_name,
269 )
270 ]
271 ),
272 provider_mappings={
273 ProviderMapping(
274 item_id=item_id,
275 provider_domain=self.domain,
276 provider_instance=self.instance_id,
277 url=track.track_url,
278 audio_format=AudioFormat(content_type=content_type, bit_rate=bitrate),
279 )
280 },
281 )
282 if track.track_num is not None:
283 output.track_number = track.track_num
284 if album_id:
285 output.album = ItemMapping(
286 media_type=MediaType.ALBUM,
287 item_id=f"{track.band_id}-{album_id}",
288 provider=self.instance_id,
289 name=track.album_title or "",
290 )
291 if track.art_id:
292 output.metadata.add_image(
293 MediaItemImage(
294 type=ImageType.THUMB,
295 path=f"https://f4.bcbits.com/img/a{track.art_id}_0.jpg",
296 provider=self.instance_id,
297 remotely_accessible=True,
298 )
299 )
300 return output
301
302 def artist_from_api(self, artist: APIArtist) -> MAArtist:
303 """Convert an API Artist object to MA Artist format."""
304 output = MAArtist(
305 item_id=str(artist.id),
306 uri=artist.url,
307 provider=self.instance_id,
308 name=artist.name,
309 provider_mappings={
310 ProviderMapping(
311 item_id=str(artist.id),
312 provider_domain=self.domain,
313 provider_instance=self.instance_id,
314 url=artist.url,
315 )
316 },
317 )
318 output.metadata.description = f"{artist.url}\n{artist.bio or ''}".strip()
319 output.metadata.add_image(
320 MediaItemImage(
321 type=ImageType.THUMB,
322 path=artist.image_url,
323 provider=self.instance_id,
324 remotely_accessible=True,
325 )
326 )
327 return output
328
329 def album_from_discography_item(self, item: DiscographyItem) -> MAAlbum:
330 """
331 Convert a raw discography dict to MA Album format.
332
333 Discography items come from the band_details API and contain summary
334 data (title, art_id, release_date string) without full album details.
335 Fields not available from the discography endpoint (url, description)
336 are omitted and populated later when get_album fetches full details.
337 """
338 band_id = item.get("band_id", 0)
339 item_id = item.get("item_id", 0)
340 album_id = f"{band_id}-{item_id}"
341 artist_name = item.get("artist_name") or item.get("band_name") or ""
342
343 # Build art URL from art_id (matches _build_art_url in parsers.py)
344 art_id = item.get("art_id")
345 art_url = f"https://f4.bcbits.com/img/a{art_id}_0.jpg" if art_id else ""
346
347 # Parse year from release_date string like "21 Feb 2020 00:00:00 GMT"
348 year = None
349 release_date = item.get("release_date")
350 if release_date:
351 with suppress(ValueError, TypeError, IndexError):
352 # Format: "21 Feb 2020 00:00:00 GMT" â extract year directly
353 year = int(release_date.split()[2])
354
355 output = MAAlbum(
356 item_id=album_id,
357 provider=self.instance_id,
358 name=item.get("title") or "",
359 artists=UniqueList(
360 [
361 ItemMapping(
362 media_type=MediaType.ARTIST,
363 item_id=str(band_id),
364 provider=self.instance_id,
365 name=artist_name,
366 )
367 ]
368 ),
369 provider_mappings={
370 ProviderMapping(
371 item_id=album_id,
372 provider_domain=self.domain,
373 provider_instance=self.instance_id,
374 )
375 },
376 year=year,
377 )
378 if art_url:
379 output.metadata.add_image(
380 MediaItemImage(
381 type=ImageType.THUMB,
382 path=art_url,
383 provider=self.instance_id,
384 remotely_accessible=True,
385 )
386 )
387 return output
388
389 def album_from_api(self, album: APIAlbum) -> MAAlbum:
390 """Convert an API Album object to MA Album format."""
391 album_id = f"{album.artist.id}-{album.id}"
392 output = MAAlbum(
393 item_id=album_id,
394 provider=self.instance_id,
395 name=album.title,
396 artists=UniqueList(
397 [
398 ItemMapping(
399 media_type=MediaType.ARTIST,
400 item_id=str(album.artist.id),
401 provider=self.instance_id,
402 name=album.artist.name,
403 image=MediaItemImage(
404 path=album.art_url,
405 type=ImageType.THUMB,
406 provider=self.instance_id,
407 remotely_accessible=True,
408 ),
409 )
410 ]
411 ),
412 provider_mappings={
413 ProviderMapping(
414 item_id=album_id,
415 provider_domain=self.domain,
416 provider_instance=self.instance_id,
417 url=album.url,
418 )
419 },
420 year=datetime.fromtimestamp(album.release_date, tz=UTC).year
421 if album.release_date
422 else None,
423 )
424 output.metadata.add_image(
425 MediaItemImage(
426 type=ImageType.THUMB,
427 path=album.art_url,
428 provider=self.instance_id,
429 remotely_accessible=True,
430 )
431 )
432 output.metadata.description = f"{album.url}\n{album.about or ''}".strip()
433 return output
434