/
/
/
1"""Tests for filesystem provider sound effects support."""
2
3import pathlib
4from unittest.mock import AsyncMock, MagicMock, patch
5
6import pytest
7from music_assistant_models.enums import MediaType, ProviderFeature
8from music_assistant_models.media_items import SoundEffect
9
10from music_assistant.providers.filesystem_local import LocalFileSystemProvider
11from music_assistant.providers.filesystem_local.constants import CONF_ENTRY_CONTENT_TYPE
12
13
14def _create_provider(base_path: str = "/media") -> LocalFileSystemProvider:
15 """
16 Create a LocalFileSystemProvider with mocked dependencies.
17
18 :param base_path: The base path the provider operates on.
19 """
20 mock_config = MagicMock()
21 mock_config.get_value = MagicMock(
22 side_effect=lambda key: "sound_effects" if key == CONF_ENTRY_CONTENT_TYPE.key else None
23 )
24 mock_config.instance_id = "filesystem_local--test"
25
26 with patch.object(LocalFileSystemProvider, "__init__", lambda *_a, **_kw: None):
27 provider = LocalFileSystemProvider.__new__(LocalFileSystemProvider)
28
29 provider.config = mock_config
30 provider.manifest = MagicMock()
31 provider.manifest.domain = "filesystem_local"
32 provider.media_content_type = "sound_effects"
33 provider.write_access = False
34 provider.base_path = base_path
35 provider.sync_running = False
36 provider.mass = MagicMock()
37 provider.logger = MagicMock()
38 provider.cache = MagicMock()
39 provider.cache.get = AsyncMock(return_value=None)
40 provider.cache.set = AsyncMock()
41
42 return provider
43
44
45def _mock_tags(title: str) -> MagicMock:
46 """
47 Create mocked AudioTags for a sound effect file.
48
49 :param title: The title to report for the file.
50 """
51 tags = MagicMock()
52 tags.title = title
53 tags.title_sort = None
54 tags.duration = 30.0
55 tags.sample_rate = 44100
56 tags.bits_per_sample = 16
57 tags.channels = 2
58 tags.bit_rate = 320
59 tags.format = "mp3"
60 tags.has_cover_image = False
61 tags.get = MagicMock(return_value=None)
62 return tags
63
64
65def test_sound_effects_content_type_features() -> None:
66 """Sound effects content type advertises the sound effects feature, no library features."""
67 provider = _create_provider()
68 features = provider.supported_features
69 assert ProviderFeature.SOUND_EFFECTS in features
70 assert not any(x for x in features if x.value.startswith("library_"))
71
72
73@pytest.mark.asyncio
74async def test_sync_library_returns_early() -> None:
75 """Sound effects provider never syncs anything into the library."""
76 provider = _create_provider()
77 await provider.sync_library(MediaType.TRACK)
78 provider.mass.music.database.get_rows_from_query.assert_not_called() # type: ignore[attr-defined]
79
80
81@pytest.mark.asyncio
82async def test_get_sound_effects_enumerates_files(tmp_path: pathlib.Path) -> None:
83 """get_sound_effects yields a parsed SoundEffect for every audio file found."""
84 (tmp_path / "rain.mp3").write_bytes(b"fake")
85 (tmp_path / "thunder.flac").write_bytes(b"fake")
86 (tmp_path / "readme.txt").write_text("not audio")
87 provider = _create_provider(base_path=str(tmp_path))
88
89 with patch(
90 "music_assistant.providers.filesystem_local.async_parse_tags",
91 new_callable=AsyncMock,
92 side_effect=lambda path, _size: _mock_tags(pathlib.Path(path).stem),
93 ):
94 result = [x async for x in provider.get_sound_effects()]
95
96 assert [x.name for x in result] == ["rain", "thunder"]
97 assert all(isinstance(x, SoundEffect) for x in result)
98 assert result[0].item_id == "rain.mp3"
99 assert result[0].provider == "filesystem_local--test"
100 # parsed items are stored in the cache for the next enumeration
101 assert provider.cache.set.await_count == 2 # type: ignore[attr-defined]
102
103
104@pytest.mark.asyncio
105async def test_get_sound_effects_uses_cache(tmp_path: pathlib.Path) -> None:
106 """get_sound_effects returns cached items without re-parsing the file."""
107 (tmp_path / "rain.mp3").write_bytes(b"fake")
108 provider = _create_provider(base_path=str(tmp_path))
109 cached_item = SoundEffect(
110 item_id="rain.mp3",
111 provider="filesystem_local--test",
112 name="rain",
113 provider_mappings=set(),
114 )
115 provider.cache.get = AsyncMock(return_value=cached_item) # type: ignore[method-assign]
116
117 with patch(
118 "music_assistant.providers.filesystem_local.async_parse_tags",
119 new_callable=AsyncMock,
120 ) as mock_parse_tags:
121 result = [x async for x in provider.get_sound_effects()]
122
123 assert result == [cached_item]
124 mock_parse_tags.assert_not_awaited()
125
126
127@pytest.mark.asyncio
128async def test_browse_maps_files_to_sound_effects() -> None:
129 """Browse lists audio files as sound effect items and hides playlist files."""
130 provider = _create_provider()
131 audio_file = MagicMock()
132 audio_file.is_dir = False
133 audio_file.filename = "rain.mp3"
134 audio_file.ext = "mp3"
135 audio_file.relative_path = "rain.mp3"
136 audio_file.absolute_path = "/media/rain.mp3"
137 playlist_file = MagicMock()
138 playlist_file.is_dir = False
139 playlist_file.filename = "mix.m3u"
140 playlist_file.ext = "m3u"
141 playlist_file.relative_path = "mix.m3u"
142 playlist_file.absolute_path = "/media/mix.m3u"
143 provider._scandir = AsyncMock(return_value=[audio_file, playlist_file]) # type: ignore[method-assign]
144
145 result = await provider.browse("filesystem_local--test://")
146
147 assert len(result) == 1
148 assert result[0].media_type == MediaType.SOUND_EFFECT
149 assert result[0].item_id == "rain.mp3"
150 provider.mass.music.tracks.get_library_item_by_prov_id.assert_not_called() # type: ignore[attr-defined]
151
152
153@pytest.mark.asyncio
154async def test_browse_returns_library_track_for_cataloged_file() -> None:
155 """Browse returns the canonical library track for a cataloged music file."""
156 provider = _create_provider()
157 provider.media_content_type = "music"
158 audio_file = MagicMock()
159 audio_file.is_dir = False
160 audio_file.filename = "track.flac"
161 audio_file.ext = "flac"
162 audio_file.relative_path = "album/track.flac"
163 audio_file.absolute_path = "/media/album/track.flac"
164 library_track = MagicMock()
165 library_track.item_id = "42"
166 library_track.provider = "library"
167 provider._scandir = AsyncMock(return_value=[audio_file]) # type: ignore[method-assign]
168 provider.mass.music.tracks.get_library_item_by_prov_id = AsyncMock( # type: ignore[method-assign]
169 return_value=library_track
170 )
171
172 result = await provider.browse("filesystem_local--test://album")
173
174 assert result == [library_track]
175 provider.mass.music.tracks.get_library_item_by_prov_id.assert_awaited_once_with(
176 "album/track.flac", "filesystem_local--test"
177 )
178
179
180@pytest.mark.asyncio
181async def test_browse_returns_provider_mapping_for_uncataloged_file() -> None:
182 """Browse retains the filesystem mapping when a music file is not cataloged."""
183 provider = _create_provider()
184 provider.media_content_type = "music"
185 audio_file = MagicMock()
186 audio_file.is_dir = False
187 audio_file.filename = "track.flac"
188 audio_file.ext = "flac"
189 audio_file.relative_path = "album/track.flac"
190 audio_file.absolute_path = "/media/album/track.flac"
191 provider._scandir = AsyncMock(return_value=[audio_file]) # type: ignore[method-assign]
192 provider.mass.music.tracks.get_library_item_by_prov_id = AsyncMock( # type: ignore[method-assign]
193 return_value=None
194 )
195
196 result = await provider.browse("filesystem_local--test://album")
197
198 assert len(result) == 1
199 assert result[0].media_type == MediaType.TRACK
200 assert result[0].item_id == "album/track.flac"
201 assert result[0].provider == "filesystem_local--test"
202