/
/
/
1"""Tests for filesystem provider sync behavior when the storage misbehaves."""
2
3import asyncio
4import errno
5from typing import Any, Self, cast
6from unittest.mock import AsyncMock, MagicMock, patch
7
8from music_assistant_models.enums import MediaType
9from music_assistant_models.errors import ProviderUnavailableError
10
11from music_assistant.providers.filesystem_local import _ONDEMAND_NFO_ITEMS, LocalFileSystemProvider
12from music_assistant.providers.filesystem_local.constants import (
13 CONF_ENTRY_CONTENT_TYPE,
14 CONF_ENTRY_LIBRARY_SYNC_PLAYLISTS,
15 CONF_ENTRY_LIBRARY_SYNC_TRACKS,
16)
17from music_assistant.providers.filesystem_local.cue import make_cue_track_id
18from music_assistant.providers.filesystem_local.helpers import ScanErrors
19
20# two previously indexed tracks; the scans below only find the first one, so the
21# second is what a deletion pass would remove from the library
22FOUND_FILE = "Artist/Album/track1.mp3"
23MISSING_FILE = "Artist/Album/track2.mp3"
24
25
26def _create_provider() -> LocalFileSystemProvider:
27 """Create a music LocalFileSystemProvider with mocked dependencies."""
28 config_values = {
29 CONF_ENTRY_CONTENT_TYPE.key: "music",
30 CONF_ENTRY_LIBRARY_SYNC_TRACKS.key: True,
31 CONF_ENTRY_LIBRARY_SYNC_PLAYLISTS.key: True,
32 }
33 mock_config = MagicMock()
34 mock_config.get_value = MagicMock(side_effect=lambda key: config_values.get(key))
35
36 with patch.object(LocalFileSystemProvider, "__init__", lambda *_a, **_kw: None):
37 provider = LocalFileSystemProvider.__new__(LocalFileSystemProvider)
38
39 provider.config = mock_config
40 provider.media_content_type = "music"
41 provider.sync_running = False
42 provider.logger = MagicMock()
43 provider.available = True
44 provider.mass = MagicMock()
45 provider.mass.music.database.get_rows_from_query = AsyncMock(
46 return_value=[
47 {"provider_item_id": FOUND_FILE, "details": "1"},
48 {"provider_item_id": MISSING_FILE, "details": "1"},
49 ]
50 )
51 provider._process_deletions = AsyncMock() # type: ignore[method-assign]
52 provider._process_orphaned_albums_and_artists = AsyncMock() # type: ignore[method-assign]
53 provider._set_available = MagicMock() # type: ignore[method-assign]
54 return provider
55
56
57def _create_unavailable_provider() -> LocalFileSystemProvider:
58 """Create a provider already flagged down, with real availability handling."""
59 with patch.object(LocalFileSystemProvider, "__init__", lambda *_a, **_kw: None):
60 provider = LocalFileSystemProvider.__new__(LocalFileSystemProvider)
61 provider.config = MagicMock()
62 provider.logger = MagicMock()
63 provider.mass = MagicMock()
64 provider.base_path = "/media"
65 provider.available = False
66 provider.unloading = False
67 return provider
68
69
70def _enumerate_result(
71 *,
72 failed_dirs: int = 0,
73 failed_entries: int = 0,
74 fatal: bool = False,
75 found_files: set[str] | None = None,
76) -> Any:
77 """Build an _enumerate_files_for_sync stub with the given scan outcome."""
78
79 async def _enumerate(**kwargs: Any) -> None:
80 scan_errors: ScanErrors = kwargs["scan_errors"]
81 scan_errors.failed_dirs = failed_dirs
82 scan_errors.failed_entries = failed_entries
83 if fatal:
84 scan_errors.fatal = OSError("storage gone")
85 kwargs["cur_filenames"].update(found_files or set())
86
87 return AsyncMock(side_effect=_enumerate)
88
89
90async def test_deletions_run_on_clean_scan() -> None:
91 """A scan without errors processes deletions as usual."""
92 provider = _create_provider()
93 provider._enumerate_files_for_sync = _enumerate_result( # type: ignore[method-assign]
94 found_files={FOUND_FILE}
95 )
96
97 await provider.sync_library(MediaType.TRACK)
98
99 provider._process_deletions.assert_awaited_once_with({MISSING_FILE}) # type: ignore[attr-defined]
100 provider._process_orphaned_albums_and_artists.assert_awaited_once() # type: ignore[attr-defined]
101
102
103async def test_sync_shares_one_ondemand_listing_scope_across_the_whole_batch() -> None:
104 """
105 Every track processed in a sync shares one on-demand listing/NFO-root memo.
106
107 This is what lets a folder shared by several tracks be listed - and its NFO parsed -
108 only once for the whole sync, instead of once per track, whenever the up-front index
109 isn't trusted (before it is built, or after an incomplete scan leaves it unready).
110 """
111 provider = _create_provider()
112 item_a = MagicMock(relative_path="Artist/Album/a.flac")
113 item_b = MagicMock(relative_path="Artist/Album/b.flac")
114
115 async def _enumerate(**kwargs: Any) -> None:
116 kwargs["items_to_process"].extend([(item_a, None), (item_b, None)])
117 kwargs["cur_filenames"].update({FOUND_FILE})
118 # an incomplete scan leaves the up-front index unready for the whole sync -
119 # exactly the scenario this shared batch scope exists for
120 kwargs["scan_errors"].failed_dirs = 1
121
122 provider._enumerate_files_for_sync = _enumerate # type: ignore[method-assign]
123 provider.mass.create_task = lambda coro, **_kwargs: asyncio.ensure_future(coro) # type: ignore[method-assign]
124 captured_memos: list[Any] = []
125
126 async def _process_item_async(*_args: Any, **_kwargs: Any) -> bool:
127 captured_memos.append(_ONDEMAND_NFO_ITEMS.get())
128 return False
129
130 provider._process_item_async = _process_item_async # type: ignore[method-assign]
131
132 await provider.sync_library(MediaType.TRACK)
133
134 assert len(captured_memos) == 2
135 assert captured_memos[0] is not None
136 assert captured_memos[0] is captured_memos[1]
137
138
139async def test_deletions_skipped_when_directories_failed() -> None:
140 """A scan that could not read some directories must not delete their content."""
141 provider = _create_provider()
142 provider._enumerate_files_for_sync = _enumerate_result( # type: ignore[method-assign]
143 failed_dirs=3, found_files={FOUND_FILE}
144 )
145
146 await provider.sync_library(MediaType.TRACK)
147
148 provider._process_deletions.assert_not_called() # type: ignore[attr-defined]
149 provider._process_orphaned_albums_and_artists.assert_not_called() # type: ignore[attr-defined]
150 # the storage itself is reachable, so the provider stays available
151 provider._set_available.assert_called_once_with(True) # type: ignore[attr-defined]
152
153
154async def test_deletions_skipped_when_files_failed() -> None:
155 """A scan that could not read some files must not delete them either."""
156 provider = _create_provider()
157 provider._enumerate_files_for_sync = _enumerate_result( # type: ignore[method-assign]
158 failed_entries=2, found_files={FOUND_FILE}
159 )
160
161 await provider.sync_library(MediaType.TRACK)
162
163 provider._process_deletions.assert_not_called() # type: ignore[attr-defined]
164 provider._process_orphaned_albums_and_artists.assert_not_called() # type: ignore[attr-defined]
165 provider._set_available.assert_called_once_with(True) # type: ignore[attr-defined]
166
167
168async def test_nfo_index_not_marked_ready_after_an_incomplete_scan() -> None:
169 """
170 An incomplete scan must not make the sync-wide NFO index authoritative.
171
172 Some folders/files failing to read means the walk may have missed an NFO that does
173 exist on disk; trusting the resulting (partial) index anyway could make a changed
174 track wrongly resolve to a synthetic identity instead of retrying a direct lookup.
175 A cleanup at the very end of the sync always resets the flag, so its in-sync value
176 is captured via a `TaskManager` stand-in, the last thing entered before that cleanup.
177 """
178 provider = _create_provider()
179 provider._enumerate_files_for_sync = _enumerate_result( # type: ignore[method-assign]
180 failed_dirs=1, found_files={FOUND_FILE}
181 )
182 captured_ready_states: list[bool] = []
183
184 class _CapturingTaskManager:
185 def __init__(self, *_args: Any, **_kwargs: Any) -> None:
186 captured_ready_states.append(provider._sync_nfo_index_ready)
187
188 async def __aenter__(self) -> Self:
189 return self
190
191 async def __aexit__(self, *_exc: object) -> None:
192 return None
193
194 async def create_task_with_limit(self, _coro: Any) -> None:
195 """Discard the task; nothing to process in this scenario."""
196
197 with patch("music_assistant.providers.filesystem_local.TaskManager", _CapturingTaskManager):
198 await provider.sync_library(MediaType.TRACK)
199
200 assert captured_ready_states == [False]
201
202
203async def test_failed_item_is_kept_in_the_scan_result() -> None:
204 """A file that cannot be processed stays in the scan result so it is not deleted."""
205 provider = _create_provider()
206 provider._sync_tracks = True
207 cur_filenames: set[str] = set()
208 item = MagicMock()
209 item.ext = "mp3"
210 item.relative_path = MISSING_FILE
211 item.absolute_path = f"/media/{MISSING_FILE}"
212
213 with patch(
214 "music_assistant.providers.filesystem_local.async_parse_tags",
215 AsyncMock(side_effect=OSError(errno.EIO, "i/o error")),
216 ):
217 result = await provider._process_item_async(item, None, cur_filenames)
218
219 assert result is False
220 # the file is still on disk, so the deletion step must not treat it as removed
221 assert cur_filenames == {MISSING_FILE}
222
223
224async def test_failed_cue_keeps_its_previous_tracks() -> None:
225 """A CUE sheet that cannot be parsed keeps the track ids of the previous scan."""
226 provider = _create_provider()
227 cue_path = "Artist/Album/album.cue"
228 cue_tracks = {make_cue_track_id(cue_path, 1), make_cue_track_id(cue_path, 2)}
229 cur_filenames: set[str] = set()
230 item = MagicMock()
231 item.ext = "cue"
232 item.relative_path = cue_path
233 item.absolute_path = f"/media/{cue_path}"
234 provider._cue = MagicMock()
235 provider._cue.parse_tracks = AsyncMock(side_effect=OSError(errno.EIO, "i/o error"))
236
237 result = await provider._process_item_async(
238 item, None, cur_filenames, prev_filenames={*cue_tracks, MISSING_FILE}
239 )
240
241 assert result is False
242 # without the track ids the deletion step would drop every track of the album
243 assert cur_filenames == {cue_path, *cue_tracks}
244
245
246async def test_sync_aborts_on_fatal_scan_error() -> None:
247 """A scan aborted by the circuit breaker skips deletions and flags the provider down."""
248 provider = _create_provider()
249 provider._enumerate_files_for_sync = _enumerate_result( # type: ignore[method-assign]
250 failed_dirs=20, fatal=True
251 )
252
253 await provider.sync_library(MediaType.TRACK)
254
255 provider._process_deletions.assert_not_called() # type: ignore[attr-defined]
256 provider._process_orphaned_albums_and_artists.assert_not_called() # type: ignore[attr-defined]
257 provider._set_available.assert_called_once_with(False) # type: ignore[attr-defined]
258
259
260async def test_aborted_sync_starts_checking_for_the_storage() -> None:
261 """A scan aborted by the circuit breaker leaves a reachability check running."""
262 provider = _create_provider()
263 provider.base_path = "/media"
264 # exercise the real availability handling rather than the mock _create_provider installs
265 provider._set_available = LocalFileSystemProvider._set_available.__get__( # type: ignore[method-assign]
266 provider, LocalFileSystemProvider
267 )
268 provider._enumerate_files_for_sync = _enumerate_result( # type: ignore[method-assign]
269 failed_dirs=20, fatal=True
270 )
271
272 await provider.sync_library(MediaType.TRACK)
273
274 assert provider.available is False
275 cast("MagicMock", provider.mass.call_later).assert_called_once()
276
277
278async def test_probe_keeps_waiting_while_the_storage_is_gone() -> None:
279 """A provider whose storage is still missing stays down and checks again later."""
280 provider = _create_unavailable_provider()
281 call_later = cast("MagicMock", provider.mass.call_later)
282 provider._is_reachable = AsyncMock(return_value=False) # type: ignore[method-assign]
283
284 await provider._probe_availability()
285
286 assert provider.available is False
287 assert call_later.call_count == 1
288
289
290async def test_probe_brings_the_provider_back() -> None:
291 """The provider becomes available again as soon as its storage can be read."""
292 provider = _create_unavailable_provider()
293 call_later = cast("MagicMock", provider.mass.call_later)
294 provider._is_reachable = AsyncMock(return_value=True) # type: ignore[method-assign]
295
296 await provider._probe_availability()
297
298 assert provider.available is True
299 # recovered, so no further check is scheduled
300 assert call_later.call_count == 0
301
302
303async def test_probe_treats_an_error_as_still_unreachable() -> None:
304 """A provider whose reachability check raises keeps waiting instead of coming back."""
305 provider = _create_unavailable_provider()
306 call_later = cast("MagicMock", provider.mass.call_later)
307 provider._is_reachable = AsyncMock( # type: ignore[method-assign]
308 side_effect=ProviderUnavailableError("cloud api down")
309 )
310
311 await provider._probe_availability()
312
313 assert provider.available is False
314 assert call_later.call_count == 1
315
316
317async def test_unload_stops_checking() -> None:
318 """Unloading a provider that went down leaves no timer behind."""
319 provider = _create_unavailable_provider()
320 provider._schedule_availability_probe()
321
322 await provider.unload()
323
324 cast("MagicMock", provider.mass.cancel_timer).assert_called_once_with(
325 provider._availability_probe_id
326 )
327 # a check that already started is a task under the same id, and must stop too
328 cast("MagicMock", provider.mass.cancel_task).assert_called_once_with(
329 provider._availability_probe_id
330 )
331
332
333async def test_probe_does_not_rearm_after_the_provider_is_unloaded() -> None:
334 """A check still running when the provider is torn down must not schedule another."""
335 provider = _create_unavailable_provider()
336 call_later = cast("MagicMock", provider.mass.call_later)
337
338 async def _unload_midway() -> bool:
339 # the provider is torn down while this check is in flight
340 provider.unloading = True
341 return False
342
343 provider._is_reachable = _unload_midway # type: ignore[method-assign]
344
345 await provider._probe_availability()
346
347 assert call_later.call_count == 0
348