/
/
1"""Tests for filesystem provider sync behavior when the storage misbehaves."""
2
3import errno
4from typing import Any, cast
5from unittest.mock import AsyncMock, MagicMock, patch
6
7from music_assistant_models.enums import MediaType
8from music_assistant_models.errors import ProviderUnavailableError
9
10from music_assistant.providers.filesystem_local import LocalFileSystemProvider
11from music_assistant.providers.filesystem_local.constants import (
12 CONF_ENTRY_CONTENT_TYPE,
13 CONF_ENTRY_LIBRARY_SYNC_PLAYLISTS,
14 CONF_ENTRY_LIBRARY_SYNC_TRACKS,
15)
16from music_assistant.providers.filesystem_local.cue import make_cue_track_id
17from music_assistant.providers.filesystem_local.helpers import ScanErrors
18
19# two previously indexed tracks; the scans below only find the first one, so the
20# second is what a deletion pass would remove from the library
21FOUND_FILE = "Artist/Album/track1.mp3"
22MISSING_FILE = "Artist/Album/track2.mp3"
23
24
25def _create_provider() -> LocalFileSystemProvider:
26 """Create a music LocalFileSystemProvider with mocked dependencies."""
27 config_values = {
28 CONF_ENTRY_CONTENT_TYPE.key: "music",
29 CONF_ENTRY_LIBRARY_SYNC_TRACKS.key: True,
30 CONF_ENTRY_LIBRARY_SYNC_PLAYLISTS.key: True,
31 }
32 mock_config = MagicMock()
33 mock_config.get_value = MagicMock(side_effect=lambda key: config_values.get(key))
34
35 with patch.object(LocalFileSystemProvider, "__init__", lambda *_a, **_kw: None):
36 provider = LocalFileSystemProvider.__new__(LocalFileSystemProvider)
37
38 provider.config = mock_config
39 provider.media_content_type = "music"
40 provider.sync_running = False
41 provider.logger = MagicMock()
42 provider.available = True
43 provider.mass = MagicMock()
44 provider.mass.music.database.get_rows_from_query = AsyncMock(
45 return_value=[
46 {"provider_item_id": FOUND_FILE, "details": "1"},
47 {"provider_item_id": MISSING_FILE, "details": "1"},
48 ]
49 )
50 provider._process_deletions = AsyncMock() # type: ignore[method-assign]
51 provider._process_orphaned_albums_and_artists = AsyncMock() # type: ignore[method-assign]
52 provider._set_available = MagicMock() # type: ignore[method-assign]
53 return provider
54
55
56def _create_unavailable_provider() -> LocalFileSystemProvider:
57 """Create a provider already flagged down, with real availability handling."""
58 with patch.object(LocalFileSystemProvider, "__init__", lambda *_a, **_kw: None):
59 provider = LocalFileSystemProvider.__new__(LocalFileSystemProvider)
60 provider.config = MagicMock()
61 provider.logger = MagicMock()
62 provider.mass = MagicMock()
63 provider.base_path = "/media"
64 provider.available = False
65 provider.unloading = False
66 return provider
67
68
69def _enumerate_result(
70 *,
71 failed_dirs: int = 0,
72 failed_entries: int = 0,
73 fatal: bool = False,
74 found_files: set[str] | None = None,
75) -> Any:
76 """Build an _enumerate_files_for_sync stub with the given scan outcome."""
77
78 async def _enumerate(**kwargs: Any) -> None:
79 scan_errors: ScanErrors = kwargs["scan_errors"]
80 scan_errors.failed_dirs = failed_dirs
81 scan_errors.failed_entries = failed_entries
82 if fatal:
83 scan_errors.fatal = OSError("storage gone")
84 kwargs["cur_filenames"].update(found_files or set())
85
86 return AsyncMock(side_effect=_enumerate)
87
88
89async def test_deletions_run_on_clean_scan() -> None:
90 """A scan without errors processes deletions as usual."""
91 provider = _create_provider()
92 provider._enumerate_files_for_sync = _enumerate_result( # type: ignore[method-assign]
93 found_files={FOUND_FILE}
94 )
95
96 await provider.sync_library(MediaType.TRACK)
97
98 provider._process_deletions.assert_awaited_once_with({MISSING_FILE}) # type: ignore[attr-defined]
99 provider._process_orphaned_albums_and_artists.assert_awaited_once() # type: ignore[attr-defined]
100
101
102async def test_deletions_skipped_when_directories_failed() -> None:
103 """A scan that could not read some directories must not delete their content."""
104 provider = _create_provider()
105 provider._enumerate_files_for_sync = _enumerate_result( # type: ignore[method-assign]
106 failed_dirs=3, found_files={FOUND_FILE}
107 )
108
109 await provider.sync_library(MediaType.TRACK)
110
111 provider._process_deletions.assert_not_called() # type: ignore[attr-defined]
112 provider._process_orphaned_albums_and_artists.assert_not_called() # type: ignore[attr-defined]
113 # the storage itself is reachable, so the provider stays available
114 provider._set_available.assert_called_once_with(True) # type: ignore[attr-defined]
115
116
117async def test_deletions_skipped_when_files_failed() -> None:
118 """A scan that could not read some files must not delete them either."""
119 provider = _create_provider()
120 provider._enumerate_files_for_sync = _enumerate_result( # type: ignore[method-assign]
121 failed_entries=2, found_files={FOUND_FILE}
122 )
123
124 await provider.sync_library(MediaType.TRACK)
125
126 provider._process_deletions.assert_not_called() # type: ignore[attr-defined]
127 provider._process_orphaned_albums_and_artists.assert_not_called() # type: ignore[attr-defined]
128 provider._set_available.assert_called_once_with(True) # type: ignore[attr-defined]
129
130
131async def test_failed_item_is_kept_in_the_scan_result() -> None:
132 """A file that cannot be processed stays in the scan result so it is not deleted."""
133 provider = _create_provider()
134 provider._sync_tracks = True
135 cur_filenames: set[str] = set()
136 item = MagicMock()
137 item.ext = "mp3"
138 item.relative_path = MISSING_FILE
139 item.absolute_path = f"/media/{MISSING_FILE}"
140
141 with patch(
142 "music_assistant.providers.filesystem_local.async_parse_tags",
143 AsyncMock(side_effect=OSError(errno.EIO, "i/o error")),
144 ):
145 result = await provider._process_item_async(item, None, cur_filenames)
146
147 assert result is False
148 # the file is still on disk, so the deletion step must not treat it as removed
149 assert cur_filenames == {MISSING_FILE}
150
151
152async def test_failed_cue_keeps_its_previous_tracks() -> None:
153 """A CUE sheet that cannot be parsed keeps the track ids of the previous scan."""
154 provider = _create_provider()
155 cue_path = "Artist/Album/album.cue"
156 cue_tracks = {make_cue_track_id(cue_path, 1), make_cue_track_id(cue_path, 2)}
157 cur_filenames: set[str] = set()
158 item = MagicMock()
159 item.ext = "cue"
160 item.relative_path = cue_path
161 item.absolute_path = f"/media/{cue_path}"
162 provider._cue = MagicMock()
163 provider._cue.parse_tracks = AsyncMock(side_effect=OSError(errno.EIO, "i/o error"))
164
165 result = await provider._process_item_async(
166 item, None, cur_filenames, prev_filenames={*cue_tracks, MISSING_FILE}
167 )
168
169 assert result is False
170 # without the track ids the deletion step would drop every track of the album
171 assert cur_filenames == {cue_path, *cue_tracks}
172
173
174async def test_sync_aborts_on_fatal_scan_error() -> None:
175 """A scan aborted by the circuit breaker skips deletions and flags the provider down."""
176 provider = _create_provider()
177 provider._enumerate_files_for_sync = _enumerate_result( # type: ignore[method-assign]
178 failed_dirs=20, fatal=True
179 )
180
181 await provider.sync_library(MediaType.TRACK)
182
183 provider._process_deletions.assert_not_called() # type: ignore[attr-defined]
184 provider._process_orphaned_albums_and_artists.assert_not_called() # type: ignore[attr-defined]
185 provider._set_available.assert_called_once_with(False) # type: ignore[attr-defined]
186
187
188async def test_aborted_sync_starts_checking_for_the_storage() -> None:
189 """A scan aborted by the circuit breaker leaves a reachability check running."""
190 provider = _create_provider()
191 provider.base_path = "/media"
192 # exercise the real availability handling rather than the mock _create_provider installs
193 provider._set_available = LocalFileSystemProvider._set_available.__get__( # type: ignore[method-assign]
194 provider, LocalFileSystemProvider
195 )
196 provider._enumerate_files_for_sync = _enumerate_result( # type: ignore[method-assign]
197 failed_dirs=20, fatal=True
198 )
199
200 await provider.sync_library(MediaType.TRACK)
201
202 assert provider.available is False
203 cast("MagicMock", provider.mass.call_later).assert_called_once()
204
205
206async def test_probe_keeps_waiting_while_the_storage_is_gone() -> None:
207 """A provider whose storage is still missing stays down and checks again later."""
208 provider = _create_unavailable_provider()
209 call_later = cast("MagicMock", provider.mass.call_later)
210 provider._is_reachable = AsyncMock(return_value=False) # type: ignore[method-assign]
211
212 await provider._probe_availability()
213
214 assert provider.available is False
215 assert call_later.call_count == 1
216
217
218async def test_probe_brings_the_provider_back() -> None:
219 """The provider becomes available again as soon as its storage can be read."""
220 provider = _create_unavailable_provider()
221 call_later = cast("MagicMock", provider.mass.call_later)
222 provider._is_reachable = AsyncMock(return_value=True) # type: ignore[method-assign]
223
224 await provider._probe_availability()
225
226 assert provider.available is True
227 # recovered, so no further check is scheduled
228 assert call_later.call_count == 0
229
230
231async def test_probe_treats_an_error_as_still_unreachable() -> None:
232 """A provider whose reachability check raises keeps waiting instead of coming back."""
233 provider = _create_unavailable_provider()
234 call_later = cast("MagicMock", provider.mass.call_later)
235 provider._is_reachable = AsyncMock( # type: ignore[method-assign]
236 side_effect=ProviderUnavailableError("cloud api down")
237 )
238
239 await provider._probe_availability()
240
241 assert provider.available is False
242 assert call_later.call_count == 1
243
244
245async def test_unload_stops_checking() -> None:
246 """Unloading a provider that went down leaves no timer behind."""
247 provider = _create_unavailable_provider()
248 provider._schedule_availability_probe()
249
250 await provider.unload()
251
252 cast("MagicMock", provider.mass.cancel_timer).assert_called_once_with(
253 provider._availability_probe_id
254 )
255 # a check that already started is a task under the same id, and must stop too
256 cast("MagicMock", provider.mass.cancel_task).assert_called_once_with(
257 provider._availability_probe_id
258 )
259
260
261async def test_probe_does_not_rearm_after_the_provider_is_unloaded() -> None:
262 """A check still running when the provider is torn down must not schedule another."""
263 provider = _create_unavailable_provider()
264 call_later = cast("MagicMock", provider.mass.call_later)
265
266 async def _unload_midway() -> bool:
267 # the provider is torn down while this check is in flight
268 provider.unloading = True
269 return False
270
271 provider._is_reachable = _unload_midway # type: ignore[method-assign]
272
273 await provider._probe_availability()
274
275 assert call_later.call_count == 0
276