/
/
/
1"""Tests for playlist parsing and generation helpers."""
2
3from typing import Any, cast
4from unittest.mock import AsyncMock, MagicMock
5
6import pytest
7from music_assistant_models.enums import ContentType, ExternalID, ImageType, MediaType
8from music_assistant_models.media_items import (
9 AudioFormat,
10 ItemMapping,
11 MediaItemImage,
12 MediaItemMetadata,
13 ProviderMapping,
14 Radio,
15 SoundEffect,
16 Track,
17 UniqueList,
18)
19
20from music_assistant.controllers.music.media.playlists import PlaylistController
21from music_assistant.helpers.playlists import (
22 ImageInfo,
23 PlaylistItem,
24 ProviderMappingInfo,
25 construct_media_item_from_playlist_item,
26 generate_m3u,
27 media_item_to_playlist_item,
28 parse_extinf_title,
29 parse_m3u,
30 parse_m3u_playlist_image,
31 parse_m3u_playlist_name,
32)
33
34# --------------------------------------------------------------------------- #
35# Existing tests (EXTINF parsing) #
36# --------------------------------------------------------------------------- #
37
38
39def test_m3u_extinf_duration_not_truncated() -> None:
40 """Test that EXTINF duration is parsed as full string, not truncated to first char."""
41 m3u_data = "#EXTM3U\n#EXTINF:120,Test Song\nhttp://example.com/song.mp3\n"
42 result = parse_m3u(m3u_data)
43 assert len(result) == 1
44 assert result[0].length == "120"
45 assert result[0].title == "Test Song"
46
47
48def test_m3u_extinf_negative_duration() -> None:
49 """Test that EXTINF with -1 duration is treated as None (unknown length)."""
50 m3u_data = "#EXTM3U\n#EXTINF:-1,Live Stream\nhttp://example.com/stream\n"
51 result = parse_m3u(m3u_data)
52 assert len(result) == 1
53 assert result[0].length is None
54 assert result[0].title == "Live Stream"
55
56
57def test_m3u_extinf_single_digit_duration() -> None:
58 """Test that single-digit durations still work correctly."""
59 m3u_data = "#EXTM3U\n#EXTINF:5,Short Clip\nhttp://example.com/clip.mp3\n"
60 result = parse_m3u(m3u_data)
61 assert len(result) == 1
62 assert result[0].length == "5"
63
64
65# --------------------------------------------------------------------------- #
66# parse_extinf_title #
67# --------------------------------------------------------------------------- #
68
69
70def test_parse_extinf_title_with_artist() -> None:
71 """Test parsing 'Artist - Title' format."""
72 artist, title = parse_extinf_title("Radiohead - Everything In Its Right Place")
73 assert artist == "Radiohead"
74 assert title == "Everything In Its Right Place"
75
76
77def test_parse_extinf_title_without_artist() -> None:
78 """Test parsing title without artist separator."""
79 artist, title = parse_extinf_title("Just A Title")
80 assert artist is None
81 assert title == "Just A Title"
82
83
84def test_parse_extinf_title_none() -> None:
85 """Test parsing None title."""
86 artist, title = parse_extinf_title(None)
87 assert artist is None
88 assert title is None
89
90
91def test_parse_extinf_title_multiple_separators() -> None:
92 """Test that only the first ' - ' is used as separator."""
93 artist, title = parse_extinf_title("Artist - Title - Remix")
94 assert artist == "Artist"
95 assert title == "Title - Remix"
96
97
98# --------------------------------------------------------------------------- #
99# EXTMA metadata parsing #
100# --------------------------------------------------------------------------- #
101
102
103def test_m3u_extma_parsing() -> None:
104 """Test that #EXTMA metadata is parsed into the metadata dict."""
105 m3u_data = (
106 "#EXTM3U\n"
107 "#EXTMA:media_type=track||isrc=USRC17607839||album=OK Computer\n"
108 "#EXTINF:240,Radiohead - Everything In Its Right Place\n"
109 "spotify://track/abc123\n"
110 )
111 result = parse_m3u(m3u_data)
112 assert len(result) == 1
113 assert result[0].metadata is not None
114 assert result[0].metadata["media_type"] == "track"
115 assert result[0].metadata["isrc"] == "USRC17607839"
116 assert result[0].metadata["album"] == "OK Computer"
117
118
119def test_m3u_extma_without_metadata() -> None:
120 """Test that entries without EXTMA have None metadata."""
121 m3u_data = "#EXTM3U\n#EXTINF:120,Test\nhttp://example.com/song.mp3\n"
122 result = parse_m3u(m3u_data)
123 assert result[0].metadata is None
124
125
126# --------------------------------------------------------------------------- #
127# EXTPROV provider mapping parsing #
128# --------------------------------------------------------------------------- #
129
130
131def test_m3u_extprov_parsing() -> None:
132 """Test that #EXTPROV lines are parsed into provider mappings."""
133 m3u_data = (
134 "#EXTM3U\n"
135 "#EXTPROV:spotify||abc123||spotify_1||flac||96000||24||320\n"
136 "#EXTPROV:tidal||xyz789||tidal_1||flac||192000||24||0\n"
137 "#EXTINF:240,Radiohead - Everything In Its Right Place\n"
138 "spotify://track/abc123\n"
139 )
140 result = parse_m3u(m3u_data)
141 assert len(result) == 1
142 assert len(result[0].providers) == 2
143 prov1 = result[0].providers[0]
144 assert prov1.domain == "spotify"
145 assert prov1.item_id == "abc123"
146 assert prov1.instance_id == "spotify_1"
147 assert prov1.content_type == "flac"
148 assert prov1.sample_rate == 96000
149 assert prov1.bit_depth == 24
150 assert prov1.bit_rate == 320
151 prov2 = result[0].providers[1]
152 assert prov2.domain == "tidal"
153 assert prov2.item_id == "xyz789"
154 assert prov2.instance_id == "tidal_1"
155 assert prov2.sample_rate == 192000
156
157
158def test_m3u_extprov_minimal() -> None:
159 """Test EXTPROV with only the 2 required fields (domain and item_id)."""
160 m3u_data = "#EXTM3U\n#EXTPROV:spotify||abc123\n#EXTINF:120,Test\nspotify://track/abc123\n"
161 result = parse_m3u(m3u_data)
162 assert len(result[0].providers) == 1
163 assert result[0].providers[0].domain == "spotify"
164 assert result[0].providers[0].item_id == "abc123"
165 assert result[0].providers[0].instance_id == ""
166 assert result[0].providers[0].sample_rate == 0
167
168
169def test_m3u_extprov_invalid_skipped() -> None:
170 """Test that malformed EXTPROV lines are skipped."""
171 m3u_data = "#EXTM3U\n#EXTPROV:onlyonefield\n#EXTINF:120,Test\nhttp://example.com/song.mp3\n"
172 result = parse_m3u(m3u_data)
173 assert len(result[0].providers) == 0
174
175
176# --------------------------------------------------------------------------- #
177# EXTIMG image parsing #
178# --------------------------------------------------------------------------- #
179
180
181def test_m3u_extimg_parsing() -> None:
182 """Test that #EXTIMG lines are parsed into image info."""
183 m3u_data = (
184 "#EXTM3U\n"
185 "#EXTIMG:thumb||https://img.example.com/abc.jpg||spotify||true\n"
186 "#EXTINF:120,Test\n"
187 "spotify://track/abc123\n"
188 )
189 result = parse_m3u(m3u_data)
190 assert len(result[0].images) == 1
191 img = result[0].images[0]
192 assert img.type == "thumb"
193 assert img.path == "https://img.example.com/abc.jpg"
194 assert img.provider == "spotify"
195 assert img.remotely_accessible is True
196
197
198def test_m3u_extimg_not_remotely_accessible() -> None:
199 """Test EXTIMG with remotely_accessible=false."""
200 m3u_data = "#EXTM3U\n#EXTIMG:thumb||/local/path.jpg||builtin||false\n#EXTINF:120,Test\ntest\n"
201 result = parse_m3u(m3u_data)
202 assert result[0].images[0].remotely_accessible is False
203
204
205def test_m3u_extimg_multiple() -> None:
206 """Test multiple EXTIMG lines per entry."""
207 m3u_data = (
208 "#EXTM3U\n"
209 "#EXTIMG:thumb||https://thumb.jpg||spotify||true\n"
210 "#EXTIMG:fanart||https://fanart.jpg||spotify||true\n"
211 "#EXTINF:120,Test\n"
212 "spotify://track/abc123\n"
213 )
214 result = parse_m3u(m3u_data)
215 assert len(result[0].images) == 2
216
217
218# --------------------------------------------------------------------------- #
219# #PLAYLIST directive #
220# --------------------------------------------------------------------------- #
221
222
223def test_parse_m3u_playlist_name() -> None:
224 """Test extracting playlist name from #PLAYLIST directive."""
225 m3u_data = "#EXTM3U\n#PLAYLIST:My Playlist\n#EXTINF:120,Test\ntest.mp3\n"
226 assert parse_m3u_playlist_name(m3u_data) == "My Playlist"
227
228
229def test_parse_m3u_playlist_name_missing() -> None:
230 """Test that None is returned when no #PLAYLIST directive exists."""
231 m3u_data = "#EXTM3U\n#EXTINF:120,Test\ntest.mp3\n"
232 assert parse_m3u_playlist_name(m3u_data) is None
233
234
235def test_parse_m3u_playlist_image() -> None:
236 """Test extracting a playlist-level cover image from #EXTIMG."""
237 m3u_data = (
238 "#EXTM3U\n"
239 "#EXTIMG:https://img.example.com/cover.jpg\n"
240 "#PLAYLIST:My Playlist\n"
241 "#EXTINF:120,Test\n"
242 "test.mp3\n"
243 )
244 assert parse_m3u_playlist_image(m3u_data) == "https://img.example.com/cover.jpg"
245
246
247def test_parse_m3u_playlist_image_missing() -> None:
248 """Test that None is returned when no playlist-level image exists."""
249 m3u_data = "#EXTM3U\n#PLAYLIST:My Playlist\n#EXTINF:120,Test\ntest.mp3\n"
250 assert parse_m3u_playlist_image(m3u_data) is None
251
252
253# --------------------------------------------------------------------------- #
254# generate_m3u #
255# --------------------------------------------------------------------------- #
256
257
258def test_generate_m3u_basic() -> None:
259 """Test basic M3U generation with EXTINF."""
260 items = [
261 PlaylistItem(path="spotify://track/abc123", title="Artist - Song", length="240"),
262 ]
263 result = generate_m3u("My Playlist", items)
264 assert "#EXTM3U\n" in result
265 assert "#PLAYLIST:My Playlist\n" in result
266 assert "#EXTINF:240,Artist - Song\n" in result
267 assert "spotify://track/abc123\n" in result
268
269
270def test_generate_m3u_with_metadata() -> None:
271 """Test M3U generation with EXTMA metadata."""
272 items = [
273 PlaylistItem(
274 path="spotify://track/abc123",
275 title="Artist - Song",
276 length="240",
277 metadata={"media_type": "track", "isrc": "USRC123"},
278 ),
279 ]
280 result = generate_m3u("Test", items)
281 assert "#EXTMA:media_type=track||isrc=USRC123\n" in result
282
283
284def test_generate_m3u_with_providers() -> None:
285 """Test M3U generation with EXTPROV lines."""
286 items = [
287 PlaylistItem(
288 path="spotify://track/abc123",
289 title="Test",
290 length="120",
291 providers=[
292 ProviderMappingInfo(
293 domain="spotify",
294 item_id="abc123",
295 instance_id="spotify_1",
296 content_type="flac",
297 sample_rate=96000,
298 bit_depth=24,
299 bit_rate=320,
300 ),
301 ],
302 ),
303 ]
304 result = generate_m3u("Test", items)
305 assert "#EXTPROV:spotify||abc123||spotify_1||flac||96000||24||320\n" in result
306
307
308def test_generate_m3u_with_images() -> None:
309 """Test M3U generation with EXTIMG lines."""
310 items = [
311 PlaylistItem(
312 path="spotify://track/abc123",
313 title="Test",
314 length="120",
315 images=[
316 ImageInfo(
317 type="thumb",
318 path="https://img.jpg",
319 provider="spotify",
320 remotely_accessible=True,
321 )
322 ],
323 ),
324 ]
325 result = generate_m3u("Test", items)
326 assert "#EXTIMG:thumb||https://img.jpg||spotify||true\n" in result
327
328
329def test_generate_m3u_with_playlist_image() -> None:
330 """Test M3U generation with a playlist-level cover image."""
331 items = [PlaylistItem(path="spotify://track/abc123", title="Test", length="120")]
332 result = generate_m3u("Test", items, "https://img.example.com/cover.jpg")
333 assert result.startswith("#EXTM3U\n#EXTIMG:https://img.example.com/cover.jpg\n#PLAYLIST:Test\n")
334
335
336def test_generate_m3u_empty() -> None:
337 """Test generating an empty M3U playlist."""
338 result = generate_m3u("Empty Playlist", [])
339 assert result == "#EXTM3U\n#PLAYLIST:Empty Playlist\n"
340
341
342def test_generate_m3u_no_extinf_without_title() -> None:
343 """Test that entries without title/length skip the EXTINF line."""
344 items = [PlaylistItem(path="spotify://track/abc123")]
345 result = generate_m3u("Test", items)
346 assert "#EXTINF" not in result
347 assert "spotify://track/abc123\n" in result
348
349
350# --------------------------------------------------------------------------- #
351# Round-trip: generate -> parse #
352# --------------------------------------------------------------------------- #
353
354
355def test_round_trip_full() -> None:
356 """Test that generate_m3u output can be parsed back with all metadata preserved."""
357 original = PlaylistItem(
358 path="spotify://track/abc123",
359 title="Radiohead - Everything In Its Right Place",
360 length="240",
361 metadata={"media_type": "track", "isrc": "USRC17607839", "album": "OK Computer"},
362 providers=[
363 ProviderMappingInfo(
364 domain="spotify",
365 item_id="abc123",
366 instance_id="spotify_1",
367 content_type="flac",
368 sample_rate=96000,
369 bit_depth=24,
370 bit_rate=320,
371 ),
372 ProviderMappingInfo(
373 domain="tidal",
374 item_id="xyz789",
375 instance_id="tidal_1",
376 content_type="flac",
377 sample_rate=192000,
378 bit_depth=24,
379 bit_rate=0,
380 ),
381 ],
382 images=[
383 ImageInfo(
384 type="thumb",
385 path="https://img.example.com/thumb.jpg",
386 provider="spotify",
387 remotely_accessible=True,
388 ),
389 ],
390 )
391
392 m3u_data = generate_m3u("Test Playlist", [original])
393 parsed = parse_m3u(m3u_data)
394
395 assert len(parsed) == 1
396 item = parsed[0]
397 assert item.path == original.path
398 assert item.title == original.title
399 assert item.length == original.length
400
401 # metadata round-trip
402 assert item.metadata == original.metadata
403
404 # provider round-trip
405 assert len(item.providers) == 2
406 assert item.providers[0].domain == "spotify"
407 assert item.providers[0].item_id == "abc123"
408 assert item.providers[0].instance_id == "spotify_1"
409 assert item.providers[0].content_type == "flac"
410 assert item.providers[0].sample_rate == 96000
411 assert item.providers[0].bit_depth == 24
412 assert item.providers[0].bit_rate == 320
413 assert item.providers[1].domain == "tidal"
414 assert item.providers[1].sample_rate == 192000
415
416 # image round-trip
417 assert len(item.images) == 1
418 assert item.images[0].type == "thumb"
419 assert item.images[0].path == "https://img.example.com/thumb.jpg"
420 assert item.images[0].provider == "spotify"
421 assert item.images[0].remotely_accessible is True
422
423 # playlist name round-trip
424 assert parse_m3u_playlist_name(m3u_data) == "Test Playlist"
425
426
427def test_round_trip_multiple_entries() -> None:
428 """Test round-trip with multiple entries preserves order and all data."""
429 items = [
430 PlaylistItem(
431 path="spotify://track/track1",
432 title="Artist A - Song 1",
433 length="200",
434 metadata={"media_type": "track"},
435 ),
436 PlaylistItem(
437 path="tidal://track/track2",
438 title="Artist B - Song 2",
439 length="300",
440 metadata={"media_type": "track"},
441 ),
442 PlaylistItem(
443 path="builtin://radio/http://stream.example.com",
444 title="Radio Station",
445 length="-1", # will be parsed as None
446 metadata={"media_type": "radio"},
447 ),
448 ]
449 m3u_data = generate_m3u("Multi", items)
450 parsed = parse_m3u(m3u_data)
451
452 assert len(parsed) == 3
453 assert parsed[0].path == "spotify://track/track1"
454 assert parsed[1].path == "tidal://track/track2"
455 assert parsed[2].path == "builtin://radio/http://stream.example.com"
456 # -1 duration is normalized to None on parse
457 assert parsed[2].length is None
458
459
460def test_round_trip_unknown_length_keeps_title() -> None:
461 """Test that an entry with unknown length survives repeated rewrites."""
462 original = PlaylistItem(
463 path="builtin://radio/http://stream.example.com",
464 title="My Custom Station Name",
465 metadata={"media_type": "radio", "name": "My Custom Station Name"},
466 images=[
467 ImageInfo(
468 type="thumb",
469 path="https://img.example.com/station.jpg",
470 provider="builtin",
471 remotely_accessible=True,
472 ),
473 ],
474 )
475 # playlist files are rewritten on every edit, so parse -> generate must be lossless
476 parsed = parse_m3u(generate_m3u("Radio", [original]))
477 reparsed = parse_m3u(generate_m3u("Radio", parsed))
478
479 assert len(reparsed) == 1
480 assert reparsed[0].title == "My Custom Station Name"
481 assert reparsed[0].length is None
482 assert reparsed[0].metadata == original.metadata
483 assert reparsed[0].images[0].path == "https://img.example.com/station.jpg"
484
485
486def test_round_trip_bare_uris() -> None:
487 """Test round-trip with bare URIs (no metadata) - migrated playlists."""
488 items = [
489 PlaylistItem(path="spotify://track/abc123"),
490 PlaylistItem(path="tidal://track/xyz789"),
491 ]
492 m3u_data = generate_m3u("Migrated", items)
493 parsed = parse_m3u(m3u_data)
494 assert len(parsed) == 2
495 assert parsed[0].path == "spotify://track/abc123"
496 assert parsed[0].metadata is None
497 assert parsed[0].providers == []
498 assert parsed[1].path == "tidal://track/xyz789"
499
500
501def test_construct_media_item_from_playlist_item_sound_effect() -> None:
502 """Stored playlist metadata reconstructs a SoundEffect with mappings and artwork."""
503
504 class DummyProvider:
505 def __init__(self, domain: str, instance_id: str) -> None:
506 self.domain = domain
507 self.instance_id = instance_id
508
509 mass = MagicMock()
510 builtin_provider = DummyProvider("builtin", "builtin_1")
511 mass.get_provider.side_effect = lambda ref: {
512 "builtin": builtin_provider,
513 "builtin_1": builtin_provider,
514 }.get(ref)
515 item = PlaylistItem(
516 path="builtin://sound_effect/http://example.com/chime.mp3",
517 title="Chime",
518 length="12",
519 metadata={
520 "media_type": MediaType.SOUND_EFFECT.value,
521 "name": "Chime",
522 "mbid": "soundeffect-mbid",
523 },
524 providers=[
525 ProviderMappingInfo(
526 domain="builtin",
527 item_id="http://example.com/chime.mp3",
528 instance_id="builtin_1",
529 content_type="mp3",
530 sample_rate=44100,
531 bit_depth=16,
532 bit_rate=192,
533 )
534 ],
535 images=[
536 ImageInfo(
537 type="thumb",
538 path="https://example.com/chime.jpg",
539 provider="builtin",
540 remotely_accessible=True,
541 )
542 ],
543 )
544
545 result = construct_media_item_from_playlist_item(item, mass)
546
547 assert isinstance(result, SoundEffect)
548 assert result.name == "Chime"
549 assert result.duration == 12
550 assert result.get_external_id(ExternalID.MB_RECORDING) == "soundeffect-mbid"
551 assert result.provider_mappings
552 mapping = next(iter(result.provider_mappings))
553 assert mapping.provider_domain == "builtin"
554 assert mapping.provider_instance == "builtin_1"
555 assert mapping.item_id == "http://example.com/chime.mp3"
556 assert result.metadata.images
557 assert result.metadata.images[0].path == "https://example.com/chime.jpg"
558
559
560def test_media_item_to_playlist_item_sound_effect_round_trip() -> None:
561 """SoundEffect playlist items round-trip through M3U metadata without losing data."""
562
563 class DummyProvider:
564 def __init__(self, domain: str, instance_id: str) -> None:
565 self.domain = domain
566 self.instance_id = instance_id
567
568 mass = MagicMock()
569 builtin_provider = DummyProvider("builtin", "builtin_1")
570 mass.get_provider.side_effect = lambda ref: {
571 "builtin": builtin_provider,
572 "builtin_1": builtin_provider,
573 }.get(ref)
574 image = MediaItemImage(
575 type=ImageType.THUMB,
576 path="https://example.com/chime.jpg",
577 provider="builtin",
578 remotely_accessible=True,
579 )
580 sound_effect = SoundEffect(
581 item_id="http://example.com/chime.mp3",
582 provider="builtin",
583 name="Chime",
584 provider_mappings={
585 ProviderMapping(
586 item_id="http://example.com/chime.mp3",
587 provider_domain="builtin",
588 provider_instance="builtin_1",
589 audio_format=AudioFormat(
590 content_type=ContentType.MP3,
591 sample_rate=44100,
592 bit_depth=16,
593 bit_rate=192,
594 ),
595 )
596 },
597 external_ids={(ExternalID.MB_RECORDING, "soundeffect-mbid")},
598 metadata=MediaItemMetadata(images=UniqueList([image])),
599 )
600 sound_effect.duration = 12
601
602 playlist_item = media_item_to_playlist_item(sound_effect)
603 parsed = parse_m3u(generate_m3u("FX", [playlist_item]))
604 reconstructed = construct_media_item_from_playlist_item(parsed[0], mass)
605
606 assert playlist_item.path == "builtin://sound_effect/http://example.com/chime.mp3"
607 assert playlist_item.metadata == {
608 "media_type": MediaType.SOUND_EFFECT.value,
609 "name": "Chime",
610 "mbid": "soundeffect-mbid",
611 }
612 assert playlist_item.length == "12"
613 assert playlist_item.images[0].path == "https://example.com/chime.jpg"
614 assert isinstance(reconstructed, SoundEffect)
615 assert reconstructed.duration == 12
616 assert reconstructed.provider_mappings
617 mapping = next(iter(reconstructed.provider_mappings))
618 assert mapping.provider_instance == "builtin_1"
619 assert mapping.item_id == "http://example.com/chime.mp3"
620 assert reconstructed.metadata.images
621 assert reconstructed.metadata.images[0].path == "https://example.com/chime.jpg"
622
623
624# --------------------------------------------------------------------------- #
625# media_item_to_playlist_item tests #
626# --------------------------------------------------------------------------- #
627
628
629def test_media_item_to_playlist_item_track() -> None:
630 """Test conversion of a Track with full metadata to PlaylistItem."""
631 artist = ItemMapping(
632 item_id="art1", provider="spotify", name="Radiohead", media_type=MediaType.ARTIST
633 )
634 album = ItemMapping(
635 item_id="alb1", provider="spotify", name="Kid A", media_type=MediaType.ALBUM
636 )
637 img = MediaItemImage(
638 type=ImageType.THUMB,
639 path="https://example.com/img.jpg",
640 provider="spotify",
641 remotely_accessible=True,
642 )
643 track = Track(
644 item_id="abc123",
645 provider="spotify",
646 name="Everything In Its Right Place",
647 duration=240,
648 version="Deluxe",
649 provider_mappings={
650 ProviderMapping(
651 item_id="abc123",
652 provider_domain="spotify",
653 provider_instance="spotify_1",
654 audio_format=AudioFormat(
655 content_type=ContentType.FLAC,
656 sample_rate=44100,
657 bit_depth=16,
658 bit_rate=320,
659 ),
660 ),
661 },
662 artists=UniqueList([artist]),
663 album=album,
664 external_ids={(ExternalID.ISRC, "USRC17607839")},
665 metadata=MediaItemMetadata(images=UniqueList([img])),
666 )
667
668 result = media_item_to_playlist_item(track)
669
670 assert result.path == "spotify://track/abc123"
671 assert result.title == "Radiohead - Everything In Its Right Place"
672 assert result.length == "240"
673 assert result.metadata is not None
674 assert result.metadata["media_type"] == "track"
675 assert result.metadata["name"] == "Everything In Its Right Place"
676 assert result.metadata["isrc"] == "USRC17607839"
677 assert result.metadata["version"] == "Deluxe"
678 assert len(result.providers) == 1
679 assert result.providers[0].domain == "spotify"
680 assert result.providers[0].item_id == "abc123"
681 assert result.providers[0].content_type == "flac"
682 assert result.providers[0].sample_rate == 44100
683 assert result.providers[0].bit_rate == 320
684 assert len(result.artists) == 1
685 assert result.artists[0].name == "Radiohead"
686 assert result.album is not None
687 assert result.album.name == "Kid A"
688 assert len(result.images) == 1
689 assert result.images[0].type == "thumb"
690 assert result.images[0].remotely_accessible is True
691
692
693def test_media_item_to_playlist_item_radio() -> None:
694 """Test conversion of a Radio to PlaylistItem."""
695 radio = Radio(
696 item_id="radio1",
697 provider="builtin",
698 name="Test FM",
699 provider_mappings={
700 ProviderMapping(
701 item_id="radio1",
702 provider_domain="builtin",
703 provider_instance="builtin",
704 audio_format=AudioFormat(content_type=ContentType.OGG),
705 ),
706 },
707 )
708
709 result = media_item_to_playlist_item(radio)
710
711 assert result.path == "builtin://radio/radio1"
712 assert result.title == "Test FM"
713 assert result.metadata is not None
714 assert result.metadata["media_type"] == "radio"
715 assert result.podcast is None
716 assert result.album is None
717 assert len(result.artists) == 0
718 # a radio has no duration, which must still yield an #EXTINF line carrying the name
719 assert result.length is None
720 assert "#EXTINF:-1,Test FM" in generate_m3u("Radio Stations", [result])
721
722
723def test_media_item_to_playlist_item_no_version() -> None:
724 """Test that version is omitted from metadata when empty."""
725 track = Track(
726 item_id="t1",
727 provider="tidal",
728 name="Simple Track",
729 duration=180,
730 provider_mappings={
731 ProviderMapping(
732 item_id="t1",
733 provider_domain="tidal",
734 provider_instance="tidal_1",
735 audio_format=AudioFormat(content_type=ContentType.FLAC),
736 ),
737 },
738 )
739
740 result = media_item_to_playlist_item(track)
741
742 assert result.metadata is not None
743 assert "version" not in result.metadata
744
745
746def test_media_item_to_playlist_item_multiple_providers() -> None:
747 """Test that multiple provider mappings are collected, one per domain, highest quality first."""
748 track = Track(
749 item_id="t1",
750 provider="spotify",
751 name="Multi Provider Track",
752 duration=200,
753 provider_mappings={
754 ProviderMapping(
755 item_id="t1",
756 provider_domain="spotify",
757 provider_instance="spotify_1",
758 audio_format=AudioFormat(
759 content_type=ContentType.OGG, sample_rate=44100, bit_depth=0, bit_rate=320
760 ),
761 ),
762 ProviderMapping(
763 item_id="t2",
764 provider_domain="tidal",
765 provider_instance="tidal_1",
766 audio_format=AudioFormat(
767 content_type=ContentType.FLAC, sample_rate=96000, bit_depth=24, bit_rate=0
768 ),
769 ),
770 },
771 )
772
773 result = media_item_to_playlist_item(track)
774
775 assert len(result.providers) == 2
776 domains = {p.domain for p in result.providers}
777 assert domains == {"spotify", "tidal"}
778 # primary URI uses the highest quality provider
779 assert result.path == "tidal://track/t2"
780
781
782def test_radio_entries_round_trip() -> None:
783 """Test that radio entries survive generation and parsing unchanged."""
784 items = [
785 PlaylistItem(
786 path="http://stream.example.com/radio1",
787 title="Jazz FM",
788 length="-1",
789 metadata={"media_type": "radio"},
790 ),
791 PlaylistItem(
792 path="http://stream.example.com/radio2",
793 title="Classic Rock Radio",
794 length="-1",
795 metadata={"media_type": "radio"},
796 ),
797 ]
798 m3u_data = generate_m3u("Radio Stations", items)
799 parsed = parse_m3u(m3u_data)
800
801 assert len(parsed) == 2
802 assert parsed[0].path == "http://stream.example.com/radio1"
803 assert parsed[0].title == "Jazz FM"
804 assert parsed[0].metadata is not None
805 assert parsed[0].metadata["media_type"] == "radio"
806 assert parsed[1].path == "http://stream.example.com/radio2"
807 assert parsed[1].title == "Classic Rock Radio"
808 assert parse_m3u_playlist_name(m3u_data) == "Radio Stations"
809
810
811def test_zero_duration_track_round_trips_unknown_length() -> None:
812 """Test that a zero-duration track gets an #EXTINF:-1 line that parses back to None."""
813 track = Track(
814 item_id="t1",
815 provider="builtin",
816 name="Unknown Length",
817 duration=0,
818 provider_mappings={
819 ProviderMapping(
820 item_id="http://example.com/live.mp3",
821 provider_domain="builtin",
822 provider_instance="builtin",
823 ),
824 },
825 )
826
827 m3u_data = generate_m3u("Playlist", [media_item_to_playlist_item(track)])
828
829 assert "#EXTINF:-1,Unknown Length" in m3u_data
830 parsed = parse_m3u(m3u_data)
831 assert len(parsed) == 1
832 assert parsed[0].length is None
833 assert parsed[0].title == "Unknown Length"
834
835
836# --------------------------------------------------------------------------- #
837# construct_media_item_from_playlist_item â entries without #EXTPROV #
838# --------------------------------------------------------------------------- #
839
840
841def _mass_with_builtin() -> MagicMock:
842 """Return a mock mass whose only resolvable provider is builtin."""
843
844 class DummyProvider:
845 domain = "builtin"
846 instance_id = "builtin"
847
848 mass = MagicMock()
849 mass.get_provider.side_effect = lambda ref: DummyProvider() if ref == "builtin" else None
850 return mass
851
852
853def test_construct_plain_url_gets_builtin_mapping() -> None:
854 """A bare stream URL with no #EXTPROV must still produce a usable provider mapping."""
855 item = PlaylistItem(path="http://stream.example.com/radio1")
856
857 media_item = construct_media_item_from_playlist_item(
858 item, cast("Any", _mass_with_builtin()), MediaType.RADIO
859 )
860
861 assert isinstance(media_item, Radio)
862 # an item with no mappings never reaches library_add and stays unplayable
863 assert len(media_item.provider_mappings) == 1
864 mapping = next(iter(media_item.provider_mappings))
865 assert mapping.provider_domain == "builtin"
866 assert mapping.available is True
867 # builtin's item_id *is* the stream url, so the whole url carries through
868 assert mapping.item_id == "http://stream.example.com/radio1"
869 assert media_item.item_id == "http://stream.example.com/radio1"
870
871
872@pytest.mark.parametrize(
873 "path",
874 [
875 "some/relative/file.mp3",
876 "/media/library/file.mp3",
877 # an MA-style provider URI is not a stream URL; builtin would ffprobe it and fail
878 "radiobrowser://radio/123",
879 "file://host/share/file.mp3",
880 ],
881)
882def test_construct_non_stream_url_gets_no_fallback_mapping(path: str) -> None:
883 """Only a plain stream URL falls back to builtin; anything else stays unmapped."""
884 media_item = construct_media_item_from_playlist_item(
885 PlaylistItem(path=path), cast("Any", _mass_with_builtin()), MediaType.RADIO
886 )
887
888 assert media_item is not None
889 # claiming builtin availability here would mask an entry that cannot be played
890 assert media_item.provider_mappings == set()
891
892
893def test_construct_defaults_to_requested_media_type() -> None:
894 """An entry without #EXTMA media_type honours the caller's default instead of Track."""
895 item = PlaylistItem(path="http://stream.example.com/radio1")
896 mass = cast("Any", _mass_with_builtin())
897
898 assert isinstance(
899 construct_media_item_from_playlist_item(item, mass, MediaType.RADIO),
900 Radio,
901 )
902 # the default stays Track for every existing caller
903 assert isinstance(construct_media_item_from_playlist_item(item, mass), Track)
904
905
906def test_construct_keeps_the_provider_instance() -> None:
907 """The entry's own instance is used, not whichever instance of the domain loaded first."""
908
909 class DummyProvider:
910 def __init__(self, domain: str, instance_id: str) -> None:
911 self.domain = domain
912 self.instance_id = instance_id
913
914 first = DummyProvider("radiobrowser", "radiobrowser--AAA")
915 second = DummyProvider("radiobrowser", "radiobrowser--BBB")
916 providers = {
917 # a bare domain lookup resolves to the first configured instance
918 "radiobrowser": first,
919 "radiobrowser--AAA": first,
920 "radiobrowser--BBB": second,
921 }
922 mass = MagicMock()
923 mass.get_provider.side_effect = lambda ref: providers.get(ref)
924 item = PlaylistItem(
925 path="radiobrowser://radio/station-9",
926 metadata={"media_type": MediaType.RADIO.value, "name": "Station Nine"},
927 providers=[ProviderMappingInfo("radiobrowser", "station-9", "radiobrowser--BBB")],
928 )
929
930 media_item = construct_media_item_from_playlist_item(item, cast("Any", mass), MediaType.RADIO)
931
932 assert media_item is not None
933 assert media_item.provider == "radiobrowser--BBB"
934
935
936# --------------------------------------------------------------------------- #
937# PlaylistController.tracks() â provider-driven pagination #
938# --------------------------------------------------------------------------- #
939
940
941def _make_controller() -> PlaylistController:
942 """Return a PlaylistController with a minimal mock mass."""
943 controller = PlaylistController.__new__(PlaylistController)
944 controller.mass = MagicMock()
945 return controller
946
947
948@pytest.mark.asyncio
949async def test_tracks_relays_all_provider_pages() -> None:
950 """tracks() relays every non-empty provider page until exhausted (provider decides size)."""
951 controller = _make_controller()
952 page0 = [MagicMock(spec=Track) for _ in range(20)]
953 page1 = [MagicMock(spec=Track) for _ in range(15)]
954 get_tracks = AsyncMock(side_effect=[page0, page1, []])
955 cast("Any", controller)._get_provider_playlist_tracks = get_tracks
956
957 result = [t async for t in controller.tracks("abc", "some_provider")]
958
959 # No controller-side cap: a multi-page (e.g. dynamic) list is relayed in full.
960 assert len(result) == 35
961 assert get_tracks.await_count == 3
962
963
964@pytest.mark.asyncio
965async def test_tracks_stops_on_empty_page() -> None:
966 """tracks() stops fetching once the provider yields an empty page."""
967 controller = _make_controller()
968 page0 = [MagicMock(spec=Track) for _ in range(7)]
969 get_tracks = AsyncMock(side_effect=[page0, []])
970 cast("Any", controller)._get_provider_playlist_tracks = get_tracks
971
972 result = [t async for t in controller.tracks("abc", "some_provider")]
973
974 assert len(result) == 7
975 assert get_tracks.await_count == 2
976