/
/
/
1"""Tests for Zvuk Music parsers."""
2
3from __future__ import annotations
4
5import contextlib
6from unittest.mock import Mock
7
8import pytest
9from music_assistant_models.enums import AlbumType, ImageType
10
11from music_assistant.providers.zvuk_music.constants import SYNTHESIS_PLAYLIST_IDS
12from music_assistant.providers.zvuk_music.parsers import (
13 parse_album,
14 parse_artist,
15 parse_playlist,
16 parse_track,
17)
18
19
20def _create_mock_image(template: str = "https://zvuk.com/image/{width}x{height}") -> Mock:
21 """
22 Create a mock Zvuk Image object.
23
24 :param template: URL template with {width} and {height} placeholders.
25 :return: Mock Image object.
26 """
27 image = Mock()
28 image.get_url = Mock(
29 side_effect=lambda w, h: template.format(width=w, height=h) if template else None
30 )
31 return image
32
33
34def _create_mock_artist(
35 artist_id: int = 123,
36 title: str | None = "Test Artist",
37 image: Mock | None = None,
38 description: str | None = None,
39 second_image: Mock | None = None,
40) -> Mock:
41 """
42 Create a mock Zvuk artist object.
43
44 :param artist_id: Artist ID.
45 :param title: Artist name.
46 :param image: Optional mock image object.
47 :param description: Optional artist biography (only on full Artist).
48 :param second_image: Optional fanart/background image (only on full Artist).
49 :return: Mock artist object.
50 """
51 artist = Mock()
52 artist.id = artist_id
53 artist.title = title
54 artist.image = image
55
56 # description and second_image are only on full Artist, not SimpleArtist
57 if description is not None:
58 artist.description = description
59 else:
60 with contextlib.suppress(AttributeError):
61 del artist.description
62
63 if second_image is not None:
64 artist.second_image = second_image
65 else:
66 with contextlib.suppress(AttributeError):
67 del artist.second_image
68
69 return artist
70
71
72def _create_mock_release(
73 release_id: int = 456,
74 title: str | None = "Test Album",
75 artists: list[Mock] | None = None,
76 release_type: str | None = None,
77 date: str | None = None,
78 explicit: bool = False,
79 genres: list[Mock] | None = None,
80 label: Mock | None = None,
81 image: Mock | None = None,
82) -> Mock:
83 """
84 Create a mock Zvuk release object.
85
86 :param release_id: Release ID.
87 :param title: Album title.
88 :param artists: List of mock artist objects.
89 :param release_type: Release type (album, single, ep, compilation).
90 :param date: Release date in ISO format.
91 :param explicit: Whether the release is explicit.
92 :param genres: List of mock genre objects.
93 :param label: Optional mock label object (only on full Release).
94 :param image: Optional mock image object.
95 :return: Mock release object.
96 """
97 release = Mock()
98 release.id = release_id
99 release.title = title
100 release.artists = artists or []
101 release.explicit = explicit
102 release.image = image
103 release.date = date
104
105 # Type handling
106 if release_type:
107 type_mock = Mock()
108 type_mock.value = release_type
109 release.type = type_mock
110 else:
111 release.type = None
112
113 # get_year method
114 if date:
115 release.get_year = Mock(return_value=int(date[:4]))
116 else:
117 release.get_year = Mock(return_value=None)
118
119 # Genres (only on full Release)
120 if genres is not None:
121 release.genres = genres
122 else:
123 # SimpleRelease doesn't have genres
124 with contextlib.suppress(AttributeError):
125 del release.genres
126
127 # Label (only on full Release)
128 if label is not None:
129 release.label = label
130 else:
131 with contextlib.suppress(AttributeError):
132 del release.label
133
134 return release
135
136
137def _create_mock_track(
138 track_id: int = 789,
139 title: str | None = "Test Track",
140 artists: list[Mock] | None = None,
141 release: Mock | None = None,
142 duration: int = 180,
143 position: int | None = None,
144 explicit: bool = False,
145 genres: list[Mock] | None = None,
146 credits_str: str | None = None,
147) -> Mock:
148 """
149 Create a mock Zvuk track object.
150
151 :param track_id: Track ID.
152 :param title: Track title.
153 :param artists: List of mock artist objects.
154 :param release: Mock release object.
155 :param duration: Track duration in seconds.
156 :param position: Track position in album.
157 :param explicit: Whether the track is explicit.
158 :param genres: List of mock genre objects (only on full Track).
159 :param credits_str: Credits string (only on full Track).
160 :return: Mock track object.
161 """
162 track = Mock()
163 track.id = track_id
164 track.title = title
165 track.artists = artists or []
166 track.release = release
167 track.duration = duration
168 track.explicit = explicit
169
170 # Position is only on full Track, not SimpleTrack
171 if position is not None:
172 track.position = position
173 else:
174 with contextlib.suppress(AttributeError):
175 del track.position
176
177 # Genres are only on full Track, not SimpleTrack
178 if genres is not None:
179 track.genres = genres
180 else:
181 with contextlib.suppress(AttributeError):
182 del track.genres
183
184 # Credits are only on full Track, not SimpleTrack
185 if credits_str is not None:
186 track.credits = credits_str
187 else:
188 with contextlib.suppress(AttributeError):
189 del track.credits
190
191 return track
192
193
194def _create_mock_playlist(
195 playlist_id: int = 999,
196 title: str | None = "Test Playlist",
197 description: str | None = None,
198 user_id: int | None = None,
199 image: Mock | None = None,
200) -> Mock:
201 """
202 Create a mock Zvuk playlist object.
203
204 :param playlist_id: Playlist ID.
205 :param title: Playlist title.
206 :param description: Playlist description.
207 :param user_id: User ID (owner).
208 :param image: Optional mock image object.
209 :return: Mock playlist object.
210 """
211 playlist = Mock()
212 playlist.id = playlist_id
213 playlist.title = title
214 playlist.description = description
215 playlist.image = image
216
217 # user_id is only on full Playlist, not SimplePlaylist
218 if user_id is not None:
219 playlist.user_id = user_id
220 else:
221 with contextlib.suppress(AttributeError):
222 del playlist.user_id
223
224 return playlist
225
226
227@pytest.fixture
228def mock_provider() -> Mock:
229 """Create a mock ZvukMusicProvider."""
230 provider = Mock()
231 provider.instance_id = "zvuk_music_test"
232 provider.domain = "zvuk_music"
233 provider.client = Mock()
234 provider.client.user_id = 12345
235
236 def mock_get_item_mapping(
237 media_type: str, # noqa: ARG001
238 key: str,
239 name: str,
240 ) -> Mock:
241 mapping = Mock()
242 mapping.item_id = key
243 mapping.name = name
244 return mapping
245
246 provider.get_item_mapping = Mock(side_effect=mock_get_item_mapping)
247 return provider
248
249
250class TestParseArtist:
251 """Tests for parse_artist function."""
252
253 def test_parse_artist_basic(self, mock_provider: Mock) -> None:
254 """Test parsing a basic artist without image."""
255 artist_obj = _create_mock_artist(artist_id=123, title="Test Artist")
256
257 result = parse_artist(mock_provider, artist_obj)
258
259 assert result.item_id == "123"
260 assert result.name == "Test Artist"
261 assert result.provider == "zvuk_music_test"
262 assert len(result.provider_mappings) == 1
263
264 mapping = next(iter(result.provider_mappings))
265 assert mapping.item_id == "123"
266 assert mapping.provider_domain == "zvuk_music"
267 assert mapping.provider_instance == "zvuk_music_test"
268 assert mapping.url == "https://zvuk.com/artist/123"
269
270 def test_parse_artist_with_image(self, mock_provider: Mock) -> None:
271 """Test parsing an artist with image."""
272 image = _create_mock_image("https://zvuk.com/img/{width}x{height}.jpg")
273 artist_obj = _create_mock_artist(artist_id=456, title="Artist With Image", image=image)
274
275 result = parse_artist(mock_provider, artist_obj)
276
277 assert result.item_id == "456"
278 assert result.name == "Artist With Image"
279 assert result.metadata.images is not None
280 assert len(result.metadata.images) == 1
281 assert result.metadata.images[0].type == ImageType.THUMB
282 assert result.metadata.images[0].path == "https://zvuk.com/img/600x600.jpg?size=600x600"
283 assert result.metadata.images[0].remotely_accessible is True
284
285 def test_parse_artist_unknown_name(self, mock_provider: Mock) -> None:
286 """Test parsing an artist with missing title defaults to Unknown Artist."""
287 artist_obj = _create_mock_artist(artist_id=789, title=None)
288
289 result = parse_artist(mock_provider, artist_obj)
290
291 assert result.name == "Unknown Artist"
292
293 def test_parse_artist_with_description(self, mock_provider: Mock) -> None:
294 """Test parsing a full artist with biography/description."""
295 artist_obj = _create_mock_artist(
296 artist_id=123, title="Artist Bio", description="A great musician."
297 )
298
299 result = parse_artist(mock_provider, artist_obj)
300
301 assert result.metadata.description == "A great musician."
302
303 def test_parse_artist_without_description(self, mock_provider: Mock) -> None:
304 """Test parsing a SimpleArtist without description (attribute absent)."""
305 artist_obj = _create_mock_artist(artist_id=123, title="Simple Artist")
306
307 result = parse_artist(mock_provider, artist_obj)
308
309 assert result.metadata.description is None
310
311 def test_parse_artist_with_fanart(self, mock_provider: Mock) -> None:
312 """Test parsing an artist with second_image mapped as FANART."""
313 second_image = _create_mock_image("https://zvuk.com/fanart/{width}x{height}.jpg")
314 artist_obj = _create_mock_artist(
315 artist_id=123, title="Artist With Fanart", second_image=second_image
316 )
317
318 result = parse_artist(mock_provider, artist_obj)
319
320 assert result.metadata.images is not None
321 fanart_images = [i for i in result.metadata.images if i.type == ImageType.FANART]
322 assert len(fanart_images) == 1
323 assert fanart_images[0].path == "https://zvuk.com/fanart/600x600.jpg?size=600x600"
324
325 def test_parse_artist_with_thumb_and_fanart(self, mock_provider: Mock) -> None:
326 """Test parsing an artist with both thumb and fanart images."""
327 image = _create_mock_image("https://zvuk.com/img/{width}x{height}.jpg")
328 second_image = _create_mock_image("https://zvuk.com/fanart/{width}x{height}.jpg")
329 artist_obj = _create_mock_artist(
330 artist_id=123,
331 title="Artist Both Images",
332 image=image,
333 second_image=second_image,
334 )
335
336 result = parse_artist(mock_provider, artist_obj)
337
338 assert result.metadata.images is not None
339 assert len(result.metadata.images) == 2
340 types = {i.type for i in result.metadata.images}
341 assert ImageType.THUMB in types
342 assert ImageType.FANART in types
343
344 def test_parse_artist_second_image_subtype_remapped(self, mock_provider: Mock) -> None:
345 """SecondImage subtype in second_image URL is remapped to cover_background."""
346 second_image = _create_mock_image(
347 "https://cdn-image.zvuk.com/pic?hash=abc&id=1&size=300x300&type=artist&subtype=secondImage"
348 )
349 artist_obj = _create_mock_artist(artist_id=1, title="Artist", second_image=second_image)
350
351 result = parse_artist(mock_provider, artist_obj)
352
353 assert result.metadata.images is not None
354 fanart_images = [i for i in result.metadata.images if i.type == ImageType.FANART]
355 assert len(fanart_images) == 1
356 assert "subtype=cover_background" in fanart_images[0].path
357 assert "subtype=secondImage" not in fanart_images[0].path
358
359
360class TestParseAlbum:
361 """Tests for parse_album function."""
362
363 def test_parse_album_basic(self, mock_provider: Mock) -> None:
364 """Test parsing a basic album."""
365 release_obj = _create_mock_release(release_id=456, title="Test Album")
366
367 result = parse_album(mock_provider, release_obj)
368
369 assert result.item_id == "456"
370 assert result.name == "Test Album"
371 assert result.provider == "zvuk_music_test"
372 assert result.album_type == AlbumType.ALBUM
373
374 mapping = next(iter(result.provider_mappings))
375 assert mapping.url == "https://zvuk.com/release/456"
376
377 def test_parse_album_type_single(self, mock_provider: Mock) -> None:
378 """Test parsing an album with type single."""
379 release_obj = _create_mock_release(
380 release_id=1, title="Single Track", release_type="single"
381 )
382
383 result = parse_album(mock_provider, release_obj)
384
385 assert result.album_type == AlbumType.SINGLE
386
387 def test_parse_album_type_ep(self, mock_provider: Mock) -> None:
388 """Test parsing an album with type EP."""
389 release_obj = _create_mock_release(release_id=2, title="EP Release", release_type="ep")
390
391 result = parse_album(mock_provider, release_obj)
392
393 assert result.album_type == AlbumType.EP
394
395 def test_parse_album_type_compilation(self, mock_provider: Mock) -> None:
396 """Test parsing an album with type compilation."""
397 release_obj = _create_mock_release(
398 release_id=3, title="Greatest Hits", release_type="compilation"
399 )
400
401 result = parse_album(mock_provider, release_obj)
402
403 assert result.album_type == AlbumType.COMPILATION
404
405 def test_parse_album_with_date(self, mock_provider: Mock) -> None:
406 """Test parsing an album with release date."""
407 release_obj = _create_mock_release(release_id=456, title="Album 2023", date="2023-06-15")
408
409 result = parse_album(mock_provider, release_obj)
410
411 assert result.year == 2023
412 assert result.metadata.release_date is not None
413 assert result.metadata.release_date.year == 2023
414 assert result.metadata.release_date.month == 6
415 assert result.metadata.release_date.day == 15
416
417 def test_parse_album_explicit(self, mock_provider: Mock) -> None:
418 """Test parsing an explicit album."""
419 release_obj = _create_mock_release(release_id=456, title="Explicit Album", explicit=True)
420
421 result = parse_album(mock_provider, release_obj)
422
423 assert result.metadata.explicit is True
424
425 def test_parse_album_with_artists(self, mock_provider: Mock) -> None:
426 """Test parsing an album with artists."""
427 artists = [
428 _create_mock_artist(artist_id=1, title="Artist One"),
429 _create_mock_artist(artist_id=2, title="Artist Two"),
430 ]
431 release_obj = _create_mock_release(
432 release_id=456, title="Collaboration Album", artists=artists
433 )
434
435 result = parse_album(mock_provider, release_obj)
436
437 assert len(result.artists) == 2
438 assert result.artists[0].name == "Artist One"
439 assert result.artists[1].name == "Artist Two"
440
441 def test_parse_album_with_genres(self, mock_provider: Mock) -> None:
442 """Test parsing an album with genres (full Release only)."""
443 genre1 = Mock()
444 genre1.name = "Rock"
445 genre2 = Mock()
446 genre2.name = "Alternative"
447 release_obj = _create_mock_release(
448 release_id=456, title="Rock Album", genres=[genre1, genre2]
449 )
450
451 result = parse_album(mock_provider, release_obj)
452
453 assert result.metadata.genres == {"Rock", "Alternative"}
454
455 def test_parse_album_with_image(self, mock_provider: Mock) -> None:
456 """Test parsing an album with cover image."""
457 image = _create_mock_image("https://zvuk.com/cover/{width}x{height}.jpg")
458 release_obj = _create_mock_release(release_id=456, title="Album With Cover", image=image)
459
460 result = parse_album(mock_provider, release_obj)
461
462 assert result.metadata.images is not None
463 assert len(result.metadata.images) == 1
464 assert result.metadata.images[0].path == "https://zvuk.com/cover/600x600.jpg?size=600x600"
465
466 def test_parse_album_with_version_in_title(self, mock_provider: Mock) -> None:
467 """Test parsing an album with version in title."""
468 release_obj = _create_mock_release(release_id=456, title="Album Name (Deluxe Edition)")
469
470 result = parse_album(mock_provider, release_obj)
471
472 assert result.name == "Album Name"
473 assert result.version == "Deluxe Edition"
474
475 def test_parse_album_with_label(self, mock_provider: Mock) -> None:
476 """Test parsing a full Release with record label."""
477 label = Mock()
478 label.title = "XL Recordings"
479 release_obj = _create_mock_release(release_id=456, title="Labeled Album", label=label)
480
481 result = parse_album(mock_provider, release_obj)
482
483 assert result.metadata.label == "XL Recordings"
484
485 def test_parse_album_without_label(self, mock_provider: Mock) -> None:
486 """Test parsing a SimpleRelease without label attribute."""
487 release_obj = _create_mock_release(release_id=456, title="No Label Album")
488
489 result = parse_album(mock_provider, release_obj)
490
491 assert result.metadata.label is None
492
493
494class TestParseTrack:
495 """Tests for parse_track function."""
496
497 def test_parse_track_basic(self, mock_provider: Mock) -> None:
498 """Test parsing a basic track."""
499 track_obj = _create_mock_track(track_id=789, title="Test Track", duration=180)
500
501 result = parse_track(mock_provider, track_obj)
502
503 assert result.item_id == "789"
504 assert result.name == "Test Track"
505 assert result.duration == 180
506 assert result.provider == "zvuk_music_test"
507
508 mapping = next(iter(result.provider_mappings))
509 assert mapping.url == "https://zvuk.com/track/789"
510
511 def test_parse_track_with_artists(self, mock_provider: Mock) -> None:
512 """Test parsing a track with artists."""
513 artists = [
514 _create_mock_artist(artist_id=1, title="Singer"),
515 _create_mock_artist(artist_id=2, title="Featuring Artist"),
516 ]
517 track_obj = _create_mock_track(track_id=789, title="Track", artists=artists)
518
519 result = parse_track(mock_provider, track_obj)
520
521 assert len(result.artists) == 2
522 assert result.artists[0].name == "Singer"
523 assert result.artists[1].name == "Featuring Artist"
524
525 def test_parse_track_with_release(self, mock_provider: Mock) -> None:
526 """Test parsing a track with album (release) information."""
527 release = Mock()
528 release.id = 456
529 release.title = "Test Album"
530 release.image = _create_mock_image("https://zvuk.com/cover/{width}x{height}.jpg")
531
532 track_obj = _create_mock_track(track_id=789, title="Track", release=release)
533
534 result = parse_track(mock_provider, track_obj)
535
536 assert result.album is not None
537 assert result.album.item_id == "456"
538 assert result.album.name == "Test Album"
539 mock_provider.get_item_mapping.assert_called_with(
540 media_type="album",
541 key="456",
542 name="Test Album",
543 )
544
545 def test_parse_track_with_release_image(self, mock_provider: Mock) -> None:
546 """Test parsing a track gets image from release."""
547 release = Mock()
548 release.id = 456
549 release.title = "Test Album"
550 release.image = _create_mock_image("https://zvuk.com/cover/{width}x{height}.jpg")
551
552 track_obj = _create_mock_track(track_id=789, title="Track", release=release)
553
554 result = parse_track(mock_provider, track_obj)
555
556 assert result.metadata.images is not None
557 assert len(result.metadata.images) == 1
558 assert result.metadata.images[0].path == "https://zvuk.com/cover/600x600.jpg?size=600x600"
559
560 def test_parse_track_explicit(self, mock_provider: Mock) -> None:
561 """Test parsing an explicit track."""
562 track_obj = _create_mock_track(track_id=789, title="Explicit Track", explicit=True)
563
564 result = parse_track(mock_provider, track_obj)
565
566 assert result.metadata.explicit is True
567
568 def test_parse_track_with_position(self, mock_provider: Mock) -> None:
569 """Test parsing a track with position (full Track only)."""
570 track_obj = _create_mock_track(track_id=789, title="Track 5", position=5)
571
572 result = parse_track(mock_provider, track_obj)
573
574 assert result.track_number == 5
575
576 def test_parse_track_with_version_in_title(self, mock_provider: Mock) -> None:
577 """Test parsing a track with version in title."""
578 track_obj = _create_mock_track(track_id=789, title="Song Name (Acoustic Version)")
579
580 result = parse_track(mock_provider, track_obj)
581
582 assert result.name == "Song Name"
583 assert result.version == "Acoustic Version"
584
585 def test_parse_track_unknown_name(self, mock_provider: Mock) -> None:
586 """Test parsing a track with missing title defaults to Unknown Track."""
587 track_obj = _create_mock_track(track_id=789, title=None)
588
589 result = parse_track(mock_provider, track_obj)
590
591 assert result.name == "Unknown Track"
592
593 def test_parse_track_with_genres(self, mock_provider: Mock) -> None:
594 """Test parsing a full Track with genres."""
595 g1 = Mock()
596 g1.name = "Rock"
597 g2 = Mock()
598 g2.name = "Alternative"
599 track_obj = _create_mock_track(track_id=789, title="Genres Track", genres=[g1, g2])
600
601 result = parse_track(mock_provider, track_obj)
602
603 assert result.metadata.genres == {"Rock", "Alternative"}
604
605 def test_parse_track_without_genres(self, mock_provider: Mock) -> None:
606 """Test parsing a SimpleTrack without genres attribute."""
607 track_obj = _create_mock_track(track_id=789, title="No Genres Track")
608
609 result = parse_track(mock_provider, track_obj)
610
611 assert result.metadata.genres is None
612
613 def test_parse_track_with_credits(self, mock_provider: Mock) -> None:
614 """Test parsing a full Track with credits."""
615 track_obj = _create_mock_track(
616 track_id=789, title="Credited Track", credits_str="John Lennon; Paul McCartney"
617 )
618
619 result = parse_track(mock_provider, track_obj)
620
621 assert result.metadata.performers == {"John Lennon; Paul McCartney"}
622
623 def test_parse_track_without_credits(self, mock_provider: Mock) -> None:
624 """Test parsing a SimpleTrack without credits attribute."""
625 track_obj = _create_mock_track(track_id=789, title="No Credits Track")
626
627 result = parse_track(mock_provider, track_obj)
628
629 assert result.metadata.performers is None
630
631
632class TestParsePlaylist:
633 """Tests for parse_playlist function."""
634
635 def test_parse_playlist_basic(self, mock_provider: Mock) -> None:
636 """Test parsing a basic playlist."""
637 playlist_obj = _create_mock_playlist(playlist_id=999, title="Test Playlist")
638
639 result = parse_playlist(mock_provider, playlist_obj)
640
641 assert result.item_id == "999"
642 assert result.name == "Test Playlist"
643 assert result.provider == "zvuk_music_test"
644 assert result.owner == "Zvuk Music"
645 assert result.is_editable is False
646
647 mapping = next(iter(result.provider_mappings))
648 assert mapping.url == "https://zvuk.com/playlist/999"
649 assert mapping.is_unique is False
650
651 def test_parse_playlist_editable(self, mock_provider: Mock) -> None:
652 """Test parsing a user-owned playlist (is_editable=True)."""
653 playlist_obj = _create_mock_playlist(
654 playlist_id=999,
655 title="My Playlist",
656 user_id=12345, # Same as mock_provider.client.user_id
657 )
658
659 result = parse_playlist(mock_provider, playlist_obj)
660
661 assert result.is_editable is True
662 assert result.owner == "Me"
663
664 mapping = next(iter(result.provider_mappings))
665 assert mapping.is_unique is True
666
667 def test_parse_playlist_not_editable(self, mock_provider: Mock) -> None:
668 """Test parsing another user's playlist (is_editable=False)."""
669 playlist_obj = _create_mock_playlist(
670 playlist_id=999,
671 title="Their Playlist",
672 user_id=99999, # Different from mock_provider.client.user_id
673 )
674
675 result = parse_playlist(mock_provider, playlist_obj)
676
677 assert result.is_editable is False
678 assert result.owner == "Zvuk Music"
679
680 def test_parse_playlist_with_description(self, mock_provider: Mock) -> None:
681 """Test parsing a playlist with description."""
682 playlist_obj = _create_mock_playlist(
683 playlist_id=999,
684 title="Playlist",
685 description="A great collection of songs",
686 )
687
688 result = parse_playlist(mock_provider, playlist_obj)
689
690 assert result.metadata.description == "A great collection of songs"
691
692 def test_parse_playlist_with_image(self, mock_provider: Mock) -> None:
693 """Test parsing a playlist with cover image."""
694 image = _create_mock_image("https://zvuk.com/playlist/{width}x{height}.jpg")
695 playlist_obj = _create_mock_playlist(
696 playlist_id=999,
697 title="Playlist With Cover",
698 image=image,
699 )
700
701 result = parse_playlist(mock_provider, playlist_obj)
702
703 assert result.metadata.images is not None
704 assert len(result.metadata.images) == 1
705 assert (
706 result.metadata.images[0].path == "https://zvuk.com/playlist/600x600.jpg?size=600x600"
707 )
708
709 def test_parse_playlist_unknown_name(self, mock_provider: Mock) -> None:
710 """Test parsing a playlist with missing title defaults to Unknown Playlist."""
711 playlist_obj = _create_mock_playlist(playlist_id=999, title=None)
712
713 result = parse_playlist(mock_provider, playlist_obj)
714
715 assert result.name == "Unknown Playlist"
716
717 def test_parse_playlist_image_src_none(self, mock_provider: Mock) -> None:
718 """
719 Test parsing a playlist whose Image object has src=None does not raise.
720
721 Regression test for AttributeError when image.get_url() is called with src=None.
722 Zvuk API returns Image objects with src=None for user-created playlists without covers.
723 """
724 image = Mock()
725 image.src = None
726 image.get_url = Mock(
727 side_effect=AttributeError("'NoneType' object has no attribute 'startswith'")
728 )
729 playlist_obj = _create_mock_playlist(
730 playlist_id=999, title="Playlist No Cover", image=image
731 )
732
733 result = parse_playlist(mock_provider, playlist_obj)
734
735 assert result.name == "Playlist No Cover"
736 assert result.metadata.images is None or len(result.metadata.images) == 0
737
738 def test_parse_playlist_simple_playlist_no_user_id(self, mock_provider: Mock) -> None:
739 """
740 Test that SimplePlaylist (no user_id) is parsed as not editable.
741
742 Synthesis playlists (IDs 3,4,6,11,12,13,14,15) are returned as SimplePlaylist
743 objects by get_short_playlist() â they have no user_id attribute.
744 """
745 playlist_obj = _create_mock_playlist(
746 playlist_id=3, # synthesis playlist ID
747 title="Свежие ÑелизÑ",
748 description="ТолÑко в ваÑем вкÑÑе",
749 user_id=None, # SimplePlaylist: no user_id
750 )
751
752 result = parse_playlist(mock_provider, playlist_obj)
753
754 assert result.name == "Свежие ÑелизÑ"
755 assert result.metadata.description == "ТолÑко в ваÑем вкÑÑе"
756 assert result.is_editable is False
757
758 def test_parse_playlist_simple_playlist_with_image(self, mock_provider: Mock) -> None:
759 """Test that SimplePlaylist with image URL is parsed correctly."""
760 image = Mock()
761 image.src = "https://obs-image-service.example.com/abc123"
762 image.get_url = Mock(return_value="https://zvuk.com/synthesis/600x600.jpg")
763
764 playlist_obj = _create_mock_playlist(
765 playlist_id=6,
766 title="Ðогда Ñ
оÑеÑÑÑ Ð¼ÑзÑки",
767 description="У ÑиÑÐ¸Ð½Ñ Ð½ÐµÑ ÑанÑов", # noqa: RUF001
768 user_id=None,
769 image=image,
770 )
771
772 result = parse_playlist(mock_provider, playlist_obj)
773
774 assert result.name == "Ðогда Ñ
оÑеÑÑÑ Ð¼ÑзÑки"
775 assert result.is_editable is False
776 # Image should be mapped
777 assert result.metadata.images is not None
778 assert len(result.metadata.images) == 1
779
780 def test_parse_playlist_synthesis_ids_treated_as_not_editable(
781 self, mock_provider: Mock
782 ) -> None:
783 """Verify all synthesis playlist IDs (3,4,6,11,12,13,14,15) parse as not editable."""
784 for pid in SYNTHESIS_PLAYLIST_IDS:
785 playlist_obj = _create_mock_playlist(
786 playlist_id=pid,
787 title=f"Playlist {pid}",
788 user_id=None, # SimplePlaylist has no user_id
789 )
790 result = parse_playlist(mock_provider, playlist_obj)
791 assert result.is_editable is False, f"Playlist {pid} should not be editable"
792