/
/
/
1"""Test Bandcamp converters."""
2
3from unittest.mock import Mock
4
5import pytest
6from bandcamp_async_api.models import BCAlbum, BCArtist, BCTrack, FeedTrack
7from music_assistant_models.enums import ContentType
8
9from music_assistant.providers.bandcamp.converters import BandcampConverters, DiscographyItem
10
11
12@pytest.fixture
13def converters() -> BandcampConverters:
14 """Return a BandcampConverters instance."""
15 return BandcampConverters("bandcamp", "bandcamp_test")
16
17
18def test_track_from_search(converters: BandcampConverters) -> None:
19 """Test converting SearchResultTrack to MA Track."""
20 # Create a mock SearchResultTrack
21 search_result = Mock()
22 search_result.artist_id = 123
23 search_result.album_id = 456
24 search_result.id = 789
25 search_result.name = "Test Track"
26 search_result.artist_name = "Test Artist"
27 search_result.album_name = "Test Album"
28 search_result.url = "https://test.bandcamp.com/track/test-track"
29
30 result = converters.track_from_search(search_result)
31
32 assert result.item_id == "123-456-789"
33 assert result.name == "Test Track"
34 assert result.provider == "bandcamp_test"
35
36
37def test_album_from_search(converters: BandcampConverters) -> None:
38 """Test converting SearchResultAlbum to MA Album."""
39 # Create a mock SearchResultAlbum
40 search_result = Mock()
41 search_result.artist_id = 123
42 search_result.id = 456
43 search_result.name = "Test Album"
44 search_result.artist_name = "Test Artist"
45 search_result.image_url = "https://f4.bcbits.com/img/a1234567890_16.jpg"
46 search_result.url = "https://test.bandcamp.com/album/test-album"
47 search_result.artist_url = "https://test.bandcamp.com"
48
49 result = converters.album_from_search(search_result)
50
51 assert result.item_id == "123-456"
52 assert result.name == "Test Album"
53 assert result.provider == "bandcamp_test"
54 # Without disambiguation context, the converter conservatively
55 # synthesizes a `{band_id}:{slug}` artist ID â the provider's search
56 # method overrides this with a real ID when a `b` result confirms
57 # the page owner's name matches.
58 artist = next(iter(result.artists))
59 assert artist.item_id == "123:test-artist"
60
61
62def test_album_from_search_uses_provided_artist_item_id(
63 converters: BandcampConverters,
64) -> None:
65 """When the provider resolves the artist ID, the converter must honor it."""
66 search_result = Mock()
67 search_result.artist_id = 123
68 search_result.id = 456
69 search_result.name = "Test Album"
70 search_result.artist_name = "Test Artist"
71 search_result.image_url = None
72 search_result.url = "https://test.bandcamp.com/album/test-album"
73 search_result.artist_url = "https://test.bandcamp.com"
74
75 result = converters.album_from_search(search_result, artist_item_id="123")
76
77 artist = next(iter(result.artists))
78 assert artist.item_id == "123"
79
80
81def test_track_from_search_uses_provided_artist_item_id(
82 converters: BandcampConverters,
83) -> None:
84 """Same dedup path for track results."""
85 search_result = Mock()
86 search_result.artist_id = 441379041
87 search_result.album_id = 1938115920
88 search_result.id = 2114682405
89 search_result.name = "Haunted"
90 search_result.artist_name = "Mortaja"
91 search_result.album_name = "Combined Minds"
92 search_result.url = "https://audiophob.bandcamp.com/track/haunted"
93
94 result = converters.track_from_search(search_result, artist_item_id="441379041:mortaja")
95
96 artist = next(iter(result.artists))
97 assert artist.item_id == "441379041:mortaja"
98
99
100def test_artist_from_search(converters: BandcampConverters) -> None:
101 """Test converting SearchResultArtist to MA Artist."""
102 # Create a mock SearchResultArtist
103 search_result = Mock()
104 search_result.id = 123
105 search_result.name = "Test Artist"
106 search_result.url = "https://test.bandcamp.com"
107 search_result.image_url = "https://f4.bcbits.com/img/a1234567890_16.jpg"
108 search_result.tags = ["rock", "indie"]
109
110 result = converters.artist_from_search(search_result)
111
112 assert result.item_id == "123"
113 assert result.name == "Test Artist"
114 assert result.provider == "bandcamp_test"
115
116
117def test_track_from_api(converters: BandcampConverters) -> None:
118 """Test converting API Track to MA Track."""
119 # Create mock API models
120 mock_artist = Mock()
121 mock_artist.id = 123
122 mock_artist.name = "Test Artist"
123 mock_artist.url = "https://test.bandcamp.com"
124
125 mock_track = Mock()
126 mock_track.id = 789
127 mock_track.title = "Test Track"
128 mock_track.artist = mock_artist
129 mock_track.url = "https://test.bandcamp.com/track/test-track"
130 mock_track.duration = 300
131 mock_track.lyrics = "Test lyrics"
132 mock_track.track_number = 1
133 mock_track.streaming_url = {"mp3-320": "https://example.com/track.mp3"}
134
135 result = converters.track_from_api(
136 track=mock_track,
137 album_id=456,
138 album_name="Test Album",
139 album_image_url="https://f4.bcbits.com/img/a1234567890_16.jpg",
140 )
141
142 assert result.item_id == "123-456-789"
143 assert result.name == "Test Track"
144 assert result.provider == "bandcamp_test"
145 # Without tralbum_artist there's no separate performer credit, so the
146 # artist link is the plain band ID and the display name is the band.
147 artist = next(iter(result.artists))
148 assert artist.item_id == "123"
149 assert artist.name == "Test Artist"
150
151
152def test_track_from_api_label_release_uses_synthetic_artist_id(
153 converters: BandcampConverters,
154) -> None:
155 """tralbum_artist != band's name â synthetic artist ID + performer display."""
156 mock_artist = Mock()
157 mock_artist.id = 441379041
158 mock_artist.name = "audiophob" # page owner â the label
159 mock_artist.url = "https://audiophob.bandcamp.com"
160
161 mock_track = Mock()
162 mock_track.id = 2114682405
163 mock_track.title = "Haunted"
164 mock_track.artist = mock_artist
165 mock_track.url = "https://audiophob.bandcamp.com/track/haunted"
166 mock_track.duration = 344
167 mock_track.lyrics = None
168 mock_track.track_number = 4
169 mock_track.streaming_url = {"mp3-128": "https://example.com/track.mp3"}
170
171 result = converters.track_from_api(
172 track=mock_track,
173 album_id=1938115920,
174 album_name="Combined Minds",
175 album_image_url="https://f4.bcbits.com/img/a2825942492_16.jpg",
176 tralbum_artist="Mortaja",
177 )
178
179 artist = next(iter(result.artists))
180 assert artist.item_id == "441379041:mortaja"
181 assert artist.name == "Mortaja"
182
183
184def test_artist_from_api(converters: BandcampConverters) -> None:
185 """Test converting API Artist to MA Artist."""
186 # Create mock API artist
187 mock_artist = Mock()
188 mock_artist.id = 123
189 mock_artist.name = "Test Artist"
190 mock_artist.url = "https://test.bandcamp.com"
191 mock_artist.image_url = "https://f4.bcbits.com/img/a1234567890_16.jpg"
192 mock_artist.bio = "Test bio"
193
194 result = converters.artist_from_api(mock_artist)
195
196 assert result.item_id == "123"
197 assert result.name == "Test Artist"
198 assert result.provider == "bandcamp_test"
199
200
201def test_album_from_api(converters: BandcampConverters) -> None:
202 """Album by the band itself: real artist ID, no synthetic credit."""
203 mock_artist = Mock()
204 mock_artist.id = 123
205 mock_artist.name = "Test Artist"
206 mock_artist.url = "https://test.bandcamp.com"
207
208 mock_album = Mock()
209 mock_album.id = 456
210 mock_album.title = "Test Album"
211 mock_album.artist = mock_artist
212 mock_album.url = "https://test.bandcamp.com/album/test-album"
213 mock_album.art_url = "https://f4.bcbits.com/img/a1234567890_16.jpg"
214 mock_album.release_date = 1609459200
215 mock_album.about = "Test album description"
216 mock_album.tralbum_artist = None # no separate performer credit
217
218 result = converters.album_from_api(mock_album)
219
220 assert result.item_id == "123-456"
221 assert result.name == "Test Album"
222 assert result.provider == "bandcamp_test"
223 artist = next(iter(result.artists))
224 assert artist.item_id == "123"
225 assert artist.name == "Test Artist"
226
227
228def test_album_from_api_label_release_uses_synthetic_artist_id(
229 converters: BandcampConverters,
230) -> None:
231 """
232 Label release: page owner != performer â synthetic ``{band_id}:{slug}``.
233
234 `artist.name` is the page owner (the label); `tralbum_artist` is the
235 performer credit. The displayed artist on the album is the performer.
236 """
237 mock_artist = Mock()
238 mock_artist.id = 441379041
239 mock_artist.name = "audiophob" # page owner â the label
240 mock_artist.url = "https://audiophob.bandcamp.com"
241
242 mock_album = Mock()
243 mock_album.id = 1938115920
244 mock_album.title = "Combined Minds"
245 mock_album.artist = mock_artist
246 mock_album.url = "https://audiophob.bandcamp.com/album/combined-minds"
247 mock_album.art_url = "https://f4.bcbits.com/img/a2825942492_16.jpg"
248 mock_album.release_date = 1539907200
249 mock_album.about = ""
250 mock_album.tralbum_artist = "Mortaja" # the performer
251
252 result = converters.album_from_api(mock_album)
253
254 artist = next(iter(result.artists))
255 assert artist.item_id == "441379041:mortaja"
256 assert artist.name == "Mortaja"
257
258
259def test_track_from_api_without_album_info(converters: BandcampConverters) -> None:
260 """Test converting API Track without album info."""
261 # Create mock API models
262 mock_artist = BCArtist(id=123, name="Test Artist", url="https://test.bandcamp.com")
263 mock_track = BCTrack(
264 id=789,
265 title="Test Track",
266 artist=mock_artist,
267 url="https://test.bandcamp.com/track/test-track",
268 duration=300,
269 lyrics="Test lyrics",
270 track_number=1,
271 streaming_url={"mp3-320": "https://example.com/track.mp3"},
272 )
273
274 result = converters.track_from_api(track=mock_track)
275
276 assert result.item_id == "123-0-789"
277 assert result.album is None
278 assert result.metadata.lyrics == "Test lyrics"
279
280
281def test_track_from_api_with_album(converters: BandcampConverters) -> None:
282 """Test converting API Track with album information."""
283 # Create mock API models
284 mock_artist = BCArtist(id=123, name="Test Artist", url="https://test.bandcamp.com")
285 mock_album = BCAlbum(
286 id=456,
287 title="Test Album",
288 artist=mock_artist,
289 url="https://test.bandcamp.com/album/test-album",
290 art_url="https://f4.bcbits.com/img/a1234567890_16.jpg",
291 release_date=1609459200,
292 about="Test album description",
293 )
294 mock_track = BCTrack(
295 id=789,
296 title="Test Track",
297 artist=mock_artist,
298 album=mock_album,
299 url="https://test.bandcamp.com/track/test-track",
300 duration=300,
301 lyrics="Test lyrics",
302 track_number=1,
303 streaming_url={"mp3-320": "https://example.com/track.mp3"},
304 )
305
306 result = converters.track_from_api(track=mock_track)
307
308 assert result.item_id == "123-0-789"
309 assert result.album is not None
310 assert result.album.item_id == "123-456"
311 assert result.album.name == "Test Album"
312
313
314def _make_mock_track(streaming_url: dict[str, str]) -> Mock:
315 """Create a mock API track with the given streaming URL."""
316 mock_artist = Mock()
317 mock_artist.id = 123
318 mock_artist.name = "Test Artist"
319 mock_artist.url = "https://test.bandcamp.com"
320
321 mock_track = Mock()
322 mock_track.id = 789
323 mock_track.title = "Test Track"
324 mock_track.artist = mock_artist
325 mock_track.url = "https://test.bandcamp.com/track/test-track"
326 mock_track.duration = 300
327 mock_track.lyrics = None
328 mock_track.track_number = 1
329 mock_track.streaming_url = streaming_url
330 return mock_track
331
332
333def test_track_from_api_audio_format_mp3_320(converters: BandcampConverters) -> None:
334 """Test that mp3-320 streaming URL sets audio format correctly."""
335 mock_track = _make_mock_track({"mp3-320": "https://example.com/track.mp3"})
336 result = converters.track_from_api(track=mock_track, album_id=456)
337 mapping = next(iter(result.provider_mappings))
338 assert mapping.audio_format.content_type == ContentType.MP3
339 assert mapping.audio_format.bit_rate == 320
340
341
342def test_track_from_api_audio_format_mp3_v0(converters: BandcampConverters) -> None:
343 """Test that mp3-v0 streaming URL sets content type with no bitrate (VBR)."""
344 mock_track = _make_mock_track({"mp3-v0": "https://example.com/track.mp3"})
345 result = converters.track_from_api(track=mock_track, album_id=456)
346 mapping = next(iter(result.provider_mappings))
347 assert mapping.audio_format.content_type == ContentType.MP3
348 assert mapping.audio_format.bit_rate is None
349
350
351def test_track_from_api_audio_format_mp3_128(converters: BandcampConverters) -> None:
352 """Test that mp3-128 streaming URL sets audio format correctly."""
353 mock_track = _make_mock_track({"mp3-128": "https://example.com/track.mp3"})
354 result = converters.track_from_api(track=mock_track, album_id=456)
355 mapping = next(iter(result.provider_mappings))
356 assert mapping.audio_format.content_type == ContentType.MP3
357 assert mapping.audio_format.bit_rate == 128
358
359
360def test_track_from_api_audio_format_none_streaming_url(converters: BandcampConverters) -> None:
361 """Test that None streaming_url does not crash."""
362 mock_track = _make_mock_track({"mp3-128": "https://example.com/track.mp3"})
363 mock_track.streaming_url = None
364 result = converters.track_from_api(track=mock_track, album_id=456)
365 mapping = next(iter(result.provider_mappings))
366 assert mapping.audio_format.content_type == ContentType.MP3
367 assert mapping.audio_format.bit_rate is None
368
369
370def test_track_from_feed(converters: BandcampConverters) -> None:
371 """Test converting a FeedTrack to MA Track."""
372 track = FeedTrack(
373 track_id=789,
374 title="Feed Track",
375 band_id=123,
376 band_name="Test Artist",
377 album_id=456,
378 album_title="Test Album",
379 track_num=3,
380 duration=212.5,
381 streaming_url={"mp3-128": "https://example.com/feed.mp3"},
382 art_id=987,
383 track_url="https://test.bandcamp.com/track/feed-track",
384 )
385
386 result = converters.track_from_feed(track)
387
388 assert result.item_id == "123-456-789"
389 assert result.name == "Feed Track"
390 assert result.duration == 212
391 assert result.track_number == 3
392 assert result.album is not None
393 assert result.album.item_id == "123-456"
394 assert result.provider == "bandcamp_test"
395
396
397def test_track_from_feed_standalone(converters: BandcampConverters) -> None:
398 """Test a feed track without an album maps to album_id 0 and omits the album."""
399 track = FeedTrack(track_id=789, title="Single", band_id=123, band_name="Test Artist")
400
401 result = converters.track_from_feed(track)
402
403 assert result.item_id == "123-0-789"
404 assert result.album is None
405
406
407def test_streaming_url_priority_v0_over_320(converters: BandcampConverters) -> None:
408 """Test that mp3-v0 is preferred over mp3-320."""
409 url, bitrate, content_type = converters.streaming_url_from_api(
410 {
411 "mp3-320": "https://example.com/320.mp3",
412 "mp3-v0": "https://example.com/v0.mp3",
413 }
414 )
415 assert url == "https://example.com/v0.mp3"
416 assert bitrate is None
417 assert content_type == ContentType.MP3
418
419
420def test_streaming_url_priority_320_over_128(converters: BandcampConverters) -> None:
421 """Test that mp3-320 is preferred over mp3-128."""
422 url, bitrate, content_type = converters.streaming_url_from_api(
423 {
424 "mp3-128": "https://example.com/128.mp3",
425 "mp3-320": "https://example.com/320.mp3",
426 }
427 )
428 assert url == "https://example.com/320.mp3"
429 assert bitrate == 320
430 assert content_type == ContentType.MP3
431
432
433def test_streaming_url_priority_v0_over_320_over_128(converters: BandcampConverters) -> None:
434 """Test full priority chain when all three formats are present."""
435 url, bitrate, content_type = converters.streaming_url_from_api(
436 {
437 "mp3-128": "https://example.com/128.mp3",
438 "mp3-320": "https://example.com/320.mp3",
439 "mp3-v0": "https://example.com/v0.mp3",
440 }
441 )
442 assert url == "https://example.com/v0.mp3"
443 assert bitrate is None
444 assert content_type == ContentType.MP3
445
446
447def test_streaming_url_fallback_unknown_key(converters: BandcampConverters) -> None:
448 """Test that an unknown streaming key falls back with UNKNOWN content type."""
449 url, bitrate, content_type = converters.streaming_url_from_api(
450 {"ogg-vorbis": "https://example.com/track.ogg"}
451 )
452 assert url == "https://example.com/track.ogg"
453 assert bitrate is None
454 assert content_type == ContentType.UNKNOWN
455
456
457def test_streaming_url_empty_dict(converters: BandcampConverters) -> None:
458 """Test that empty dict returns None for URL and bitrate."""
459 url, bitrate, content_type = converters.streaming_url_from_api({})
460 assert url is None
461 assert bitrate is None
462 assert content_type == ContentType.MP3
463
464
465def test_album_from_discography_item(converters: BandcampConverters) -> None:
466 """Test converting a full discography item to MA Album."""
467 item: DiscographyItem = {
468 "item_id": 394626934,
469 "item_type": "album",
470 "artist_name": "Amanda Palmer & Friends",
471 "band_name": "Amanda Palmer",
472 "title": "Forty-Five Degrees",
473 "art_id": 3547137148,
474 "release_date": "21 Feb 2020 00:00:00 GMT",
475 "band_id": 3463798201,
476 }
477 result = converters.album_from_discography_item(item)
478
479 assert result.item_id == "3463798201-394626934"
480 assert result.name == "Forty-Five Degrees"
481 assert result.year == 2020
482 assert result.provider == "bandcamp_test"
483 artists = list(result.artists)
484 assert len(artists) == 1
485 assert artists[0].name == "Amanda Palmer & Friends"
486 # The collaboration credit differs from Amanda Palmer's own band
487 # name â surface it as a synthetic performer scoped to her band so
488 # users can navigate to *just* the collab releases.
489 assert artists[0].item_id == "3463798201:amanda-palmer-and-friends"
490 assert result.metadata.images
491 assert any("a3547137148_0.jpg" in img.path for img in result.metadata.images)
492
493
494def test_album_from_discography_item_missing_art_id(converters: BandcampConverters) -> None:
495 """Test discography item with no art_id produces album without image."""
496 item: DiscographyItem = {
497 "item_id": 100,
498 "item_type": "album",
499 "band_name": "Test",
500 "title": "No Art",
501 "band_id": 200,
502 "release_date": "01 Jan 2021 00:00:00 GMT",
503 }
504 result = converters.album_from_discography_item(item)
505 assert result.item_id == "200-100"
506 assert result.year == 2021
507 assert not result.metadata.images
508
509
510def test_album_from_discography_item_missing_release_date(converters: BandcampConverters) -> None:
511 """Test discography item with no release_date sets year to None."""
512 item: DiscographyItem = {
513 "item_id": 100,
514 "item_type": "album",
515 "band_name": "Test",
516 "title": "No Date",
517 "band_id": 200,
518 "art_id": 999,
519 }
520 result = converters.album_from_discography_item(item)
521 assert result.year is None
522
523
524def test_album_from_discography_item_artist_name_fallback(
525 converters: BandcampConverters,
526) -> None:
527 """Test that band_name is used when artist_name is None."""
528 item: DiscographyItem = {
529 "item_id": 100,
530 "item_type": "album",
531 "artist_name": None,
532 "band_name": "Fallback Artist",
533 "title": "Test",
534 "band_id": 200,
535 "art_id": 999,
536 "release_date": "15 Mar 2022 00:00:00 GMT",
537 }
538 result = converters.album_from_discography_item(item)
539 artists = list(result.artists)
540 assert artists[0].name == "Fallback Artist"
541 # Performer == band â real artist ID, not synthetic.
542 assert artists[0].item_id == "200"
543
544
545def test_album_from_discography_item_label_release_uses_synthetic_artist(
546 converters: BandcampConverters,
547) -> None:
548 """
549 Discography of a label: per-item ``artist_name`` differs from ``band_name``.
550
551 The artist link should point at a synthetic performer scoped to the
552 label's band_id.
553 """
554 item: DiscographyItem = {
555 "item_id": 1938115920,
556 "item_type": "album",
557 "artist_name": "Mortaja",
558 "band_name": "audiophob",
559 "title": "Combined Minds",
560 "band_id": 441379041,
561 "art_id": 2825942492,
562 "release_date": "18 Oct 2018 00:00:00 GMT",
563 }
564 result = converters.album_from_discography_item(item)
565 artists = list(result.artists)
566 assert artists[0].name == "Mortaja"
567 assert artists[0].item_id == "441379041:mortaja"
568
569
570def test_synthetic_artist_basics(converters: BandcampConverters) -> None:
571 """The synthetic_artist factory builds an Artist scoped to (band, performer)."""
572 artist = converters.synthetic_artist(
573 band_id=441379041,
574 performer_name="Mortaja",
575 url="https://audiophob.bandcamp.com",
576 image_url="https://f4.bcbits.com/img/a2825942492_16.jpg",
577 )
578 assert artist.item_id == "441379041:mortaja"
579 assert artist.name == "Mortaja"
580 assert artist.provider == "bandcamp_test"
581 assert artist.uri == "bandcamp_test://artist/441379041:mortaja"
582 # The provider mapping pins the same composite ID and retains the
583 # hosting page URL for navigation.
584 mapping = next(iter(artist.provider_mappings))
585 assert mapping.item_id == "441379041:mortaja"
586 assert mapping.url == "https://audiophob.bandcamp.com"
587