/
/
/
1"""Tests for the Ambient Sounds provider."""
2
3import pathlib
4from unittest.mock import MagicMock, patch
5
6import pytest
7from music_assistant_models.enums import MediaType, ProviderFeature, StreamType
8from music_assistant_models.errors import AudioError, MediaNotFoundError
9
10from music_assistant.providers import ambient_sounds
11from music_assistant.providers.ambient_sounds import PRESETS, AmbientSoundsProvider
12
13
14def _create_provider(cache_path: str) -> AmbientSoundsProvider:
15 """
16 Create an AmbientSoundsProvider with mocked dependencies.
17
18 :param cache_path: The cache directory the provider renders loop files into.
19 """
20 with patch.object(AmbientSoundsProvider, "__init__", lambda *_a, **_kw: None):
21 provider = AmbientSoundsProvider.__new__(AmbientSoundsProvider)
22
23 provider.config = MagicMock()
24 provider.config.instance_id = "ambient_sounds"
25 provider.manifest = MagicMock()
26 provider.manifest.domain = "ambient_sounds"
27 provider.mass = MagicMock()
28 provider.mass.cache_path = cache_path
29 provider.logger = MagicMock()
30
31 return provider
32
33
34async def test_sound_effects_enumeration(tmp_path: pathlib.Path) -> None:
35 """All presets are enumerated as sound effect items with correct metadata."""
36 provider = _create_provider(str(tmp_path))
37 items = [item async for item in provider.get_sound_effects()]
38 assert len(items) == len(PRESETS)
39 assert {item.item_id for item in items} == set(PRESETS)
40 for item in items:
41 assert item.media_type == MediaType.SOUND_EFFECT
42 assert item.name
43 assert item.translation_key == item.item_id
44 assert item.metadata.description
45 assert item.duration == ambient_sounds.LOOP_DURATION
46
47
48async def test_get_sound_effect(tmp_path: pathlib.Path) -> None:
49 """A single sound effect resolves by id; unknown ids raise MediaNotFoundError."""
50 provider = _create_provider(str(tmp_path))
51 item = await provider.get_sound_effect("white_noise")
52 assert item.name == "White noise"
53 assert item.provider_mappings
54 with pytest.raises(MediaNotFoundError):
55 await provider.get_sound_effect("unknown_preset")
56
57
58async def test_supported_features() -> None:
59 """The provider advertises sound effects and browse, no library features."""
60 assert ProviderFeature.SOUND_EFFECTS in ambient_sounds.SUPPORTED_FEATURES
61 assert ProviderFeature.BROWSE in ambient_sounds.SUPPORTED_FEATURES
62 assert not any(x for x in ambient_sounds.SUPPORTED_FEATURES if x.value.startswith("library_"))
63
64
65async def test_stream_details_renders_loop(
66 tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
67) -> None:
68 """Streamdetails render the loop file once and reuse it on subsequent calls."""
69 # shrink the loop so the test renders quickly
70 monkeypatch.setattr(ambient_sounds, "LOOP_DURATION", 2)
71 monkeypatch.setattr(ambient_sounds, "CROSSFADE_DURATION", 1)
72 provider = _create_provider(str(tmp_path))
73 await provider.handle_async_init()
74
75 stream_details = await provider.get_stream_details("ocean_waves")
76 assert stream_details.stream_type == StreamType.LOCAL_FILE
77 assert stream_details.media_type == MediaType.SOUND_EFFECT
78 assert stream_details.duration == 2
79 assert isinstance(stream_details.path, str)
80 loop_file = pathlib.Path(stream_details.path)
81 assert loop_file.is_file()
82 assert loop_file.read_bytes()[:4] == b"fLaC"
83 # no leftover temp file from the render
84 assert not loop_file.with_name(f"{loop_file.name}.tmp").exists()
85
86 # a second call must reuse the rendered file instead of rendering again
87 mtime = loop_file.stat().st_mtime
88 stream_details_2 = await provider.get_stream_details("ocean_waves")
89 assert stream_details_2.path == stream_details.path
90 assert loop_file.stat().st_mtime == mtime
91
92
93async def test_stream_details_unknown_preset(tmp_path: pathlib.Path) -> None:
94 """Streamdetails for an unknown preset raise MediaNotFoundError."""
95 provider = _create_provider(str(tmp_path))
96 await provider.handle_async_init()
97 with pytest.raises(MediaNotFoundError):
98 await provider.get_stream_details("unknown_preset")
99
100
101async def test_failed_render_leaves_no_temp_file(
102 tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
103) -> None:
104 """A failed render raises AudioError and cleans up its partial temp file."""
105
106 async def fake_check_output(*args: str) -> tuple[int, bytes]:
107 # simulate ffmpeg dying halfway: partial output written, non-zero exit
108 pathlib.Path(args[-1]).write_bytes(b"partial")
109 return 1, b"boom"
110
111 monkeypatch.setattr(ambient_sounds, "check_output", fake_check_output)
112 provider = _create_provider(str(tmp_path))
113 await provider.handle_async_init()
114 with pytest.raises(AudioError):
115 await provider.get_stream_details("white_noise")
116 assert not list(tmp_path.rglob("*.tmp"))
117