/
/
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 SoundEffect
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