/
/
/
1"""Tests for builtin sound effect URL handling."""
2
3from __future__ import annotations
4
5from typing import Any, cast
6from unittest.mock import AsyncMock, MagicMock
7
8import pytest
9from music_assistant_models.enums import MediaType
10from music_assistant_models.media_items import Radio, SoundEffect, Track
11
12from music_assistant.helpers.playlists import PlaylistItem, ProviderMappingInfo
13from music_assistant.providers.builtin import BuiltinProvider
14
15
16class DummyMediaInfo:
17 """Minimal media info stub for builtin provider tests."""
18
19 def __init__(
20 self,
21 *,
22 title: str = "Intro",
23 duration: int | None = 12,
24 media_format: str = "mp3",
25 sample_rate: int = 44100,
26 bits_per_sample: int = 16,
27 bit_rate: int = 192,
28 channels: int = 2,
29 artists: list[str] | None = None,
30 has_cover_image: bool = False,
31 extra: dict[str, Any] | None = None,
32 ) -> None:
33 """Initialize the stub with builtin media-info fields."""
34 self.title = title
35 self.duration = duration
36 self.format = media_format
37 self.sample_rate = sample_rate
38 self.bits_per_sample = bits_per_sample
39 self.bit_rate = bit_rate
40 self.channels = channels
41 self.artists = artists or []
42 self.has_cover_image = has_cover_image
43 self._extra = extra or {}
44
45 def get(self, key: str, default: Any = None) -> Any:
46 """Return dict-like tag values used by the provider."""
47 return self._extra.get(key, default)
48
49
50def _make_provider() -> BuiltinProvider:
51 """Return a BuiltinProvider instance with mocked collaborators."""
52 provider = BuiltinProvider.__new__(BuiltinProvider)
53 provider.mass = MagicMock()
54 provider.logger = MagicMock()
55 provider.manifest = MagicMock(domain="builtin")
56 provider.config = MagicMock(instance_id="builtin_1")
57 return provider
58
59
60@pytest.mark.asyncio
61async def test_parse_item_returns_sound_effect_when_requested() -> None:
62 """Builtin direct URLs can be parsed into SoundEffect items."""
63 provider = _make_provider()
64 provider._get_media_info = AsyncMock( # type: ignore[method-assign]
65 return_value=DummyMediaInfo(has_cover_image=True)
66 )
67
68 result = await provider.parse_item(
69 "http://example.com/intro.mp3",
70 requested_media_type=MediaType.SOUND_EFFECT,
71 )
72
73 assert isinstance(result, SoundEffect)
74 assert result.name == "Intro"
75 assert result.duration == 12
76 assert result.provider_mappings
77 mapping = next(iter(result.provider_mappings))
78 assert mapping.provider_domain == "builtin"
79 assert mapping.provider_instance == "builtin_1"
80 assert mapping.item_id == "http://example.com/intro.mp3"
81 assert result.metadata.images
82 assert result.metadata.images[0].path == "http://example.com/intro.mp3"
83
84
85@pytest.mark.asyncio
86async def test_get_stream_details_preserves_requested_sound_effect_type() -> None:
87 """Stream details keep SOUND_EFFECT even when the source looks non-seekable."""
88 provider = _make_provider()
89 provider._get_media_info = AsyncMock( # type: ignore[method-assign]
90 return_value=DummyMediaInfo(duration=None)
91 )
92
93 result = await provider.get_stream_details(
94 "http://example.com/intro.mp3",
95 MediaType.SOUND_EFFECT,
96 )
97
98 assert result.media_type == MediaType.SOUND_EFFECT
99 assert result.can_seek is False
100 assert result.allow_seek is False
101
102
103@pytest.mark.asyncio
104async def test_resolve_playlist_item_skips_library_lookup_for_sound_effects() -> None:
105 """Unavailable sound effects stay provider items instead of going through library lookup."""
106 provider = _make_provider()
107 cast("Any", provider.mass.get_provider).return_value = None
108 provider.mass.music = MagicMock()
109 item = PlaylistItem(
110 path="builtin://sound_effect/http://example.com/intro.mp3",
111 title="Intro",
112 length="12",
113 metadata={
114 "media_type": MediaType.SOUND_EFFECT.value,
115 "name": "Intro",
116 },
117 providers=[
118 ProviderMappingInfo(
119 domain="builtin",
120 item_id="http://example.com/intro.mp3",
121 instance_id="missing_builtin",
122 )
123 ],
124 )
125
126 result = await provider._resolve_playlist_item(item)
127
128 assert isinstance(result, SoundEffect)
129 provider.mass.music.get_controller.assert_not_called()
130
131
132@pytest.mark.asyncio
133async def test_get_stream_details_detects_radio_from_icyname_tag() -> None:
134 """Recognize an icy-name tag (stored as icyname) as a radio stream."""
135 provider = _make_provider()
136 provider._get_media_info = AsyncMock( # type: ignore[method-assign]
137 return_value=DummyMediaInfo(duration=180, extra={"icyname": "Radio Station"})
138 )
139
140 result = await provider.get_stream_details(
141 "http://example.com/stream.mp3",
142 MediaType.TRACK,
143 )
144
145 assert result.media_type == MediaType.RADIO
146
147
148@pytest.mark.asyncio
149async def test_parse_item_returns_sound_effect_for_an_untagged_url() -> None:
150 """A plain URL without music tags is a one-off clip, not a track."""
151 provider = _make_provider()
152 provider._get_media_info = AsyncMock( # type: ignore[method-assign]
153 return_value=DummyMediaInfo(title="notification", duration=3)
154 )
155
156 result = await provider.parse_item(
157 "http://example.com/notification.mp3",
158 requested_media_type=MediaType.UNKNOWN,
159 )
160
161 assert isinstance(result, SoundEffect)
162 assert result.name == "notification"
163 assert result.duration == 3
164
165
166@pytest.mark.asyncio
167async def test_parse_item_returns_track_for_a_tagged_url() -> None:
168 """A plain URL carrying music tags stays a track, with its artists."""
169 provider = _make_provider()
170 provider._get_media_info = AsyncMock( # type: ignore[method-assign]
171 return_value=DummyMediaInfo(
172 title="Song", duration=240, artists=["Artist"], extra={"artist": "Artist"}
173 )
174 )
175
176 result = await provider.parse_item(
177 "http://example.com/song.mp3",
178 requested_media_type=MediaType.UNKNOWN,
179 )
180
181 assert isinstance(result, Track)
182 assert result.name == "Song"
183 assert [artist.name for artist in result.artists] == ["Artist"]
184
185
186@pytest.mark.asyncio
187async def test_parse_item_keeps_an_untagged_stream_a_radio_station() -> None:
188 """An untagged stream without a duration is still recognized as radio."""
189 provider = _make_provider()
190 provider._get_media_info = AsyncMock( # type: ignore[method-assign]
191 return_value=DummyMediaInfo(title="Stream", duration=None)
192 )
193
194 result = await provider.parse_item(
195 "http://example.com/stream.mp3",
196 requested_media_type=MediaType.UNKNOWN,
197 )
198
199 assert isinstance(result, Radio)
200
201
202@pytest.mark.asyncio
203async def test_parse_item_keeps_an_untagged_url_a_track_when_asked_for_one() -> None:
204 """A URL stored in the library as a track stays a track, tags or not."""
205 provider = _make_provider()
206 provider._get_media_info = AsyncMock( # type: ignore[method-assign]
207 return_value=DummyMediaInfo(title="Clip", duration=3)
208 )
209
210 result = await provider.parse_item(
211 "http://example.com/clip.mp3",
212 requested_media_type=MediaType.TRACK,
213 )
214
215 assert isinstance(result, Track)
216