/
/
1"""Tests that a single failing item does not abort a library sync or trigger deletions."""
2
3from __future__ import annotations
4
5import asyncio
6import logging
7from typing import TYPE_CHECKING, Any
8from unittest.mock import AsyncMock, MagicMock, patch
9
10import pytest
11from music_assistant_models.enums import MediaType, ProviderType
12from music_assistant_models.errors import InvalidDataError, MediaNotFoundError
13
14from music_assistant.constants import (
15 CONF_ENTRY_LIBRARY_SYNC_ALBUM_TRACKS,
16 CONF_ENTRY_LIBRARY_SYNC_DELETIONS,
17 CONF_LOG_LEVEL,
18)
19from music_assistant.models.music_provider import (
20 MAX_LOGGED_SYNC_FAILURES,
21 MusicProvider,
22 describe_sync_error,
23)
24
25if TYPE_CHECKING:
26 from collections.abc import AsyncGenerator
27
28ALBUM_IDS = ("album_1", "album_2", "album_3")
29# db id the mocked library controller hands out per provider album
30DB_IDS = {"album_1": 1, "album_2": 2, "album_3": 3}
31
32
33class FailingAlbumProvider(MusicProvider):
34 """Provider yielding three albums, of which one may fail to sync."""
35
36 #: provider album id whose ``get_album_tracks`` raises
37 fail_album_tracks_for: str | None = None
38 #: yield the albums as provider favorites, so the sync performs its favorite work
39 mark_favorite: bool = False
40
41 async def get_library_albums(self) -> AsyncGenerator[Any]:
42 """Yield the three test albums."""
43 for item_id in ALBUM_IDS:
44 album = MagicMock()
45 album.item_id = item_id
46 album.name = f"Album {item_id}"
47 album.uri = f"test://album/{item_id}"
48 album.favorite = self.mark_favorite
49 album.metadata.genres = None
50 album.provider_mappings = [MagicMock()]
51 yield album
52
53 #: tracks handed back by ``get_album_tracks``
54 album_tracks: list[Any] | None = None
55
56 async def get_album_tracks(self, prov_album_id: str) -> list[Any]:
57 """Return the configured tracks, or raise for the album under test."""
58 if prov_album_id == self.fail_album_tracks_for:
59 raise ValueError("malformed album tracks payload")
60 return self.album_tracks or []
61
62
63def _build_provider(
64 mass: MagicMock,
65 *,
66 sync_album_tracks: bool = False,
67 sync_deletions: bool = True,
68 cls: type[FailingAlbumProvider] = FailingAlbumProvider,
69) -> FailingAlbumProvider:
70 """Return a provider instance wired to the given (mocked) mass."""
71 manifest = MagicMock()
72 manifest.type = ProviderType.MUSIC
73 manifest.domain = "test"
74 config = MagicMock()
75 config.instance_id = "test--1"
76 config.domain = "test"
77 values = {
78 CONF_LOG_LEVEL: "GLOBAL",
79 CONF_ENTRY_LIBRARY_SYNC_ALBUM_TRACKS.key: sync_album_tracks,
80 CONF_ENTRY_LIBRARY_SYNC_DELETIONS.key: sync_deletions,
81 }
82 config.get_value.side_effect = lambda key, default=None: values.get(key, default)
83 return cls(mass, manifest, config)
84
85
86def _build_mass(prev_library_ids: list[int] | None = None) -> MagicMock:
87 """Return a mocked mass whose album controller records every synced album."""
88 mass = MagicMock()
89 mass.cache = MagicMock()
90 mass.cache.get = AsyncMock(return_value=prev_library_ids)
91 mass.cache.set = AsyncMock()
92
93 albums = mass.music.albums
94 albums.get_library_item_sync_details = AsyncMock(return_value=None)
95
96 async def add_item_to_library(prov_item: Any) -> Any:
97 library_item = MagicMock()
98 library_item.item_id = DB_IDS[prov_item.item_id]
99 library_item.favorite = False
100 return library_item
101
102 albums.add_item_to_library = AsyncMock(side_effect=add_item_to_library)
103 mass.music.genres.sync_media_item_genres = AsyncMock()
104 mass.music.library_supported = MagicMock(return_value=True)
105
106 # controller used by the deletion pass
107 controller = AsyncMock()
108 mass.music.get_controller = MagicMock(return_value=controller)
109 return mass
110
111
112def _fail_add_for(mass: MagicMock, item_id: str) -> None:
113 """Make adding the given provider album raise a plain (non-MA) error."""
114 original = mass.music.albums.add_item_to_library.side_effect
115
116 async def add_item_to_library(prov_item: Any) -> Any:
117 if prov_item.item_id == item_id:
118 raise KeyError("release_date")
119 return await original(prov_item)
120
121 mass.music.albums.add_item_to_library = AsyncMock(side_effect=add_item_to_library)
122
123
124def _synced_album_ids(mass: MagicMock) -> list[str]:
125 """Return the provider album ids that were added to the library."""
126 return [call.args[0].item_id for call in mass.music.albums.add_item_to_library.await_args_list]
127
128
129async def test_unexpected_error_skips_one_item_and_continues() -> None:
130 """An error that is not a MusicAssistantError skips its item instead of the whole sync."""
131 mass = _build_mass()
132 provider = _build_provider(mass)
133
134 _fail_add_for(mass, "album_2")
135
136 await provider.sync_library(MediaType.ALBUM)
137
138 # the failing album did not stop the loop: the one after it was synced too
139 assert _synced_album_ids(mass) == list(ALBUM_IDS)
140
141
142async def test_unexpected_error_from_provider_album_tracks_does_not_abort_sync() -> None:
143 """A provider raising while returning album tracks does not take down the album sync."""
144 mass = _build_mass(prev_library_ids=[1, 2, 3, 99])
145 provider = _build_provider(mass, sync_album_tracks=True)
146 provider.fail_album_tracks_for = "album_2"
147
148 await provider.sync_library(MediaType.ALBUM)
149
150 assert _synced_album_ids(mass) == list(ALBUM_IDS)
151 # the album itself synced fine before its tracks were imported, so it stays in the result
152 assert sorted(mass.cache.set.await_args.kwargs["data"]) == [1, 2, 3]
153 # and the album id set is complete, so deletions are not held back
154 mass.music.get_controller.return_value.get_library_item.assert_awaited_once_with(99)
155
156
157async def test_deletions_skipped_when_an_item_failed() -> None:
158 """A skipped item must not be read as removed from the provider."""
159 mass = _build_mass(prev_library_ids=[1, 2, 3])
160 provider = _build_provider(mass)
161
162 _fail_add_for(mass, "album_2")
163
164 await provider.sync_library(MediaType.ALBUM)
165
166 controller = mass.music.get_controller.return_value
167 controller.get_library_item.assert_not_called()
168 controller.set_provider_mappings.assert_not_called()
169 controller.remove_item_from_library.assert_not_called()
170
171
172async def test_deletions_run_on_a_clean_sync() -> None:
173 """A sync without failures still processes items that are gone from the provider."""
174 mass = _build_mass(prev_library_ids=[1, 2, 3, 99])
175 provider = _build_provider(mass)
176
177 controller = mass.music.get_controller.return_value
178 library_item = MagicMock()
179 mapping = MagicMock()
180 mapping.provider_instance = provider.instance_id
181 mapping.in_library = True
182 library_item.provider_mappings = [mapping]
183 library_item.favorite = False
184 controller.get_library_item = AsyncMock(return_value=library_item)
185
186 await provider.sync_library(MediaType.ALBUM)
187
188 # only db id 99 is gone from the provider
189 controller.get_library_item.assert_awaited_once_with(99)
190 controller.set_provider_mappings.assert_awaited_once()
191 assert mapping.in_library is False
192
193
194async def test_library_generator_error_still_propagates() -> None:
195 """
196 A provider failing while listing its library aborts the sync.
197
198 The listing is an async generator: once it raises it is closed, so there is no item
199 to skip and no complete result set to run deletions against.
200 """
201 mass = _build_mass(prev_library_ids=[1, 2, 3])
202 provider = _build_provider(mass)
203
204 async def broken_library_albums() -> AsyncGenerator[Any]:
205 raise KeyError("items")
206 yield # type: ignore[unreachable] # pragma: no cover
207
208 provider.get_library_albums = broken_library_albums # type: ignore[method-assign]
209
210 with pytest.raises(KeyError):
211 await provider.sync_library(MediaType.ALBUM)
212
213 mass.cache.set.assert_not_called()
214 mass.music.get_controller.return_value.set_provider_mappings.assert_not_called()
215
216
217async def test_expected_error_also_holds_back_deletions() -> None:
218 """A MusicAssistantError leaves the same gap in the result set as any other error."""
219 mass = _build_mass(prev_library_ids=[1, 2, 3, 99])
220 provider = _build_provider(mass)
221 mass.music.albums.add_item_to_library = AsyncMock(side_effect=MediaNotFoundError("gone"))
222
223 await provider.sync_library(MediaType.ALBUM)
224
225 mass.music.get_controller.return_value.get_library_item.assert_not_called()
226
227
228async def test_incomplete_run_keeps_the_previous_id_snapshot() -> None:
229 """
230 A run that skipped items merges into the cached id's instead of replacing them.
231
232 Those id's are what a later run compares against; replacing them with an incomplete
233 set would drop the deletions this run could not process.
234 """
235 mass = _build_mass(prev_library_ids=[1, 2, 3, 99])
236 provider = _build_provider(mass)
237 _fail_add_for(mass, "album_2")
238
239 await provider.sync_library(MediaType.ALBUM)
240
241 # 99 (gone from the provider) and 2 (failed this run) both survive for the next run
242 assert sorted(mass.cache.set.await_args.kwargs["data"]) == [1, 2, 3, 99]
243
244
245async def test_deletions_not_reported_when_disabled() -> None:
246 """A failed item is not reported as skipped deletions when deletions are turned off."""
247 mass = _build_mass(prev_library_ids=[1, 2, 3, 99])
248 provider = _build_provider(mass, sync_deletions=False)
249 _fail_add_for(mass, "album_2")
250
251 with patch("music_assistant.models.music_provider.report_current_task_failure") as reported:
252 await provider.sync_library(MediaType.ALBUM)
253
254 assert not any("Deletions skipped" in call.args[0] for call in reported.call_args_list)
255
256
257async def test_payload_bearing_error_is_clipped() -> None:
258 """
259 A provider error carrying its whole api response is reported by type and clipped.
260
261 Those messages reach the log and, via the task failure list, every connected client.
262 """
263 mass = _build_mass()
264 provider = _build_provider(mass)
265 payload = "x" * 5000
266 mass.music.albums.add_item_to_library = AsyncMock(side_effect=KeyError(payload))
267
268 with patch("music_assistant.models.music_provider.report_current_task_failure") as reported:
269 await provider.sync_library(MediaType.ALBUM)
270
271 item_failures = [
272 call.args[0]
273 for call in reported.call_args_list
274 if call.args[0].startswith("Failed to sync")
275 ]
276 assert len(item_failures) == len(ALBUM_IDS)
277 for message in item_failures:
278 assert "KeyError" in message
279 assert len(message) < 400
280 assert payload not in message
281
282
283def test_our_own_errors_are_reported_verbatim() -> None:
284 """Our own error messages are already short and stay unchanged."""
285 assert describe_sync_error(MediaNotFoundError("album not found")) == "album not found"
286
287
288async def test_incomplete_run_keeps_newly_seen_items() -> None:
289 """An item first seen on an incomplete run is still tracked for later cleanup."""
290 # 1 and 2 were known before; 3 is new in this run
291 mass = _build_mass(prev_library_ids=[1, 2])
292 provider = _build_provider(mass)
293 _fail_add_for(mass, "album_2")
294
295 await provider.sync_library(MediaType.ALBUM)
296
297 assert 3 in mass.cache.set.await_args.kwargs["data"]
298
299
300async def test_item_lookup_failure_skips_only_that_item() -> None:
301 """A failure while resolving an item's mappings skips the item, not the whole sync."""
302 mass = _build_mass()
303 provider = _build_provider(mass)
304 lookups = {"album_2": TypeError("bad provider mapping")}
305
306 async def get_sync_details(mappings: Any) -> Any:
307 del mappings
308 return None
309
310 calls: list[str] = []
311
312 async def sync_details_for(prov_mappings: Any) -> Any:
313 del prov_mappings
314 item_id = ALBUM_IDS[len(calls)]
315 calls.append(item_id)
316 if err := lookups.get(item_id):
317 raise err
318 return await get_sync_details(None)
319
320 mass.music.albums.get_library_item_sync_details = AsyncMock(side_effect=sync_details_for)
321
322 await provider.sync_library(MediaType.ALBUM)
323
324 # all three were reached, and the two healthy ones were still added
325 assert calls == list(ALBUM_IDS)
326 assert _synced_album_ids(mass) == ["album_1", "album_3"]
327
328
329async def test_item_stays_tracked_when_ancillary_work_fails() -> None:
330 """
331 An item whose favorite work fails is still recorded as seen.
332
333 Its library row is committed regardless, so leaving it out of the id's would make it
334 an orphan no later cleanup run could ever discover.
335 """
336 mass = _build_mass()
337 provider = _build_provider(mass)
338 provider.mark_favorite = True
339 mass.music.albums.set_favorite = AsyncMock(side_effect=TypeError("bad favorite"))
340
341 await provider.sync_library(MediaType.ALBUM)
342
343 assert mass.music.albums.set_favorite.await_count == len(ALBUM_IDS)
344 assert sorted(mass.cache.set.await_args.kwargs["data"]) == [1, 2, 3]
345
346
347async def test_standalone_import_keeps_its_own_failure_state(
348 caplog: pytest.LogCaptureFixture,
349) -> None:
350 """
351 Each ad-hoc album-track import starts from a clean failure state.
352
353 They are launched as their own task when an album is added, so a shared counter would
354 silence every import after the first one had used up the logging budget.
355 """
356 mass = _build_mass()
357 provider = _build_provider(mass)
358 mass.music.tracks.get_library_item_sync_details = AsyncMock(return_value=None)
359 mass.music.tracks.add_item_to_library = AsyncMock(side_effect=KeyError("bad track"))
360 provider.album_tracks = [
361 MagicMock(item_id=f"t{i}", uri=f"test://track/{i}") for i in range(MAX_LOGGED_SYNC_FAILURES)
362 ]
363
364 await asyncio.create_task(provider.import_album_tracks("album_1"))
365 caplog.clear()
366 with caplog.at_level(logging.ERROR):
367 await asyncio.create_task(provider.import_album_tracks("album_2"))
368
369 # the second import reports its failures just like the first one did
370 assert len(caplog.records) == MAX_LOGGED_SYNC_FAILURES
371
372
373class _AuthSignal(Exception):
374 """Stands in for a provider error a wrapper around sync_library has to act on."""
375
376
377class AuthSignallingProvider(FailingAlbumProvider):
378 """Provider that declares its auth signal unskippable."""
379
380 @property
381 def unskippable_sync_errors(self) -> tuple[type[Exception], ...]:
382 """Return the errors a library sync must not swallow as an item failure."""
383 return (_AuthSignal,)
384
385
386async def test_declared_unskippable_error_is_not_swallowed() -> None:
387 """
388 An error the provider declared unskippable escapes instead of skipping the item.
389
390 Providers use these to signal something their own wrapper must handle, such as an
391 expired token that needs a reauthenticate and a retry.
392 """
393 mass = _build_mass()
394 provider = _build_provider(mass, cls=AuthSignallingProvider)
395 mass.music.albums.add_item_to_library = AsyncMock(side_effect=_AuthSignal("token expired"))
396
397 with pytest.raises(_AuthSignal):
398 await provider.sync_library(MediaType.ALBUM)
399
400 # the run aborted, so nothing was recorded as a completed sync
401 mass.cache.set.assert_not_called()
402
403
404SKIPPED_ID = "album_2"
405
406
407class SkippingAlbumProvider(FailingAlbumProvider):
408 """Provider that drops one album while listing its library."""
409
410 #: item id handed to report_skipped_sync_item, None to report an unidentifiable item
411 reported_item_id: str | None = SKIPPED_ID
412
413 async def get_library_albums(self) -> AsyncGenerator[Any]:
414 """Yield the test albums, dropping the one that cannot be read."""
415 async for album in super().get_library_albums():
416 if album.item_id == SKIPPED_ID:
417 self.report_skipped_sync_item(
418 MediaType.ALBUM, self.reported_item_id, InvalidDataError("no artist")
419 )
420 continue
421 yield album
422
423
424def _library_holds(mass: MagicMock, known: dict[str, int]) -> None:
425 """Let the deletion pass resolve the given provider item id's to library db id's."""
426
427 async def get_library_items_by_prov_id(
428 provider_instance: str, provider_item_ids: list[str], limit: int
429 ) -> list[Any]:
430 del limit
431 # only this provider instance's own mappings may resolve, never a sibling instance
432 if provider_instance != "test--1":
433 return []
434 return [
435 MagicMock(item_id=known[item_id]) for item_id in provider_item_ids if item_id in known
436 ]
437
438 mass.music.get_controller.return_value.get_library_items_by_prov_id = AsyncMock(
439 side_effect=get_library_items_by_prov_id
440 )
441
442
443async def test_skipped_item_is_reported_on_the_sync_task() -> None:
444 """An item the provider drops while listing is reported instead of vanishing silently."""
445 mass = _build_mass()
446 provider = _build_provider(mass, cls=SkippingAlbumProvider)
447 _library_holds(mass, {})
448
449 with patch("music_assistant.models.music_provider.report_current_task_failure") as reported:
450 await provider.sync_library(MediaType.ALBUM)
451
452 assert SKIPPED_ID in reported.call_args.args[0]
453 # the rest of the library still synced
454 assert _synced_album_ids(mass) == ["album_1", "album_3"]
455
456
457async def test_skipped_item_we_already_hold_survives_the_deletion_pass() -> None:
458 """
459 A skipped item stays in the result set, while the rest of the deletions still run.
460
461 The item is still in the provider's library, so it must not be read as removed - but
462 a permanently unreadable item may not disable the cleanup for everything else either.
463 """
464 mass = _build_mass(prev_library_ids=[1, 2, 3, 99])
465 provider = _build_provider(mass, cls=SkippingAlbumProvider)
466 _library_holds(mass, {SKIPPED_ID: 2})
467
468 await provider.sync_library(MediaType.ALBUM)
469
470 controller = mass.music.get_controller.return_value
471 # only the album that is really gone is processed, the skipped one is left alone
472 controller.get_library_item.assert_awaited_once_with(99)
473 assert sorted(mass.cache.set.await_args.kwargs["data"]) == [1, 2, 3]
474
475
476async def test_skipped_item_we_do_not_hold_does_not_hold_back_deletions() -> None:
477 """An item that was never imported cannot be deleted, so the cleanup runs as usual."""
478 mass = _build_mass(prev_library_ids=[1, 3, 99])
479 provider = _build_provider(mass, cls=SkippingAlbumProvider)
480 _library_holds(mass, {})
481
482 await provider.sync_library(MediaType.ALBUM)
483
484 mass.music.get_controller.return_value.get_library_item.assert_awaited_once_with(99)
485
486
487SKIPPED_TRACK_ID = "track_9"
488
489
490class TrackSkippingAlbumProvider(FailingAlbumProvider):
491 """Provider that drops tracks while listing the tracks of an album."""
492
493 async def get_album_tracks(self, prov_album_id: str) -> list[Any]:
494 """Report the tracks that could not be read instead of returning them."""
495 del prov_album_id
496 self.report_skipped_sync_item(
497 MediaType.TRACK, SKIPPED_TRACK_ID, InvalidDataError("no artist")
498 )
499 self.report_skipped_sync_item(MediaType.TRACK, None, InvalidDataError("no id"))
500 return []
501
502
503class UnidentifiedSkipProvider(SkippingAlbumProvider):
504 """Provider that drops an album it cannot identify."""
505
506 reported_item_id = None
507
508
509async def test_unidentified_skip_holds_back_deletions() -> None:
510 """A provider that cannot say what it dropped holds back the deletions for the whole run."""
511 mass = _build_mass(prev_library_ids=[1, 2, 3, 99])
512 provider = _build_provider(mass, cls=UnidentifiedSkipProvider)
513 _library_holds(mass, {})
514
515 await provider.sync_library(MediaType.ALBUM)
516
517 mass.music.get_controller.return_value.get_library_item.assert_not_called()
518
519
520async def test_skipped_item_stays_in_the_snapshot_when_deletions_are_disabled() -> None:
521 """
522 A skipped item is recorded as seen even while deletions are off.
523
524 That snapshot is what a later run compares against, so dropping the item here would
525 leave it untracked once the user turns deletions back on.
526 """
527 mass = _build_mass(prev_library_ids=[1, 2, 3, 99])
528 provider = _build_provider(mass, cls=SkippingAlbumProvider, sync_deletions=False)
529 _library_holds(mass, {SKIPPED_ID: 2})
530
531 await provider.sync_library(MediaType.ALBUM)
532
533 assert sorted(mass.cache.set.await_args.kwargs["data"]) == [1, 2, 3]
534
535
536async def test_skipped_items_resolve_against_this_provider_instance_only() -> None:
537 """
538 A skipped id is resolved against the instance that skipped it, not the whole domain.
539
540 A second instance of the same provider holds its own mappings under the same item id's,
541 and protecting one instance's item would leave the other's exposed.
542 """
543 mass = _build_mass(prev_library_ids=[1, 2, 3, 99])
544 provider = _build_provider(mass, cls=SkippingAlbumProvider)
545 _library_holds(mass, {SKIPPED_ID: 2})
546
547 await provider.sync_library(MediaType.ALBUM)
548
549 lookup = mass.music.get_controller.return_value.get_library_items_by_prov_id
550 assert lookup.await_args.kwargs["provider_instance"] == provider.instance_id
551
552
553async def test_a_skipped_track_does_not_reach_the_album_deletion_pass() -> None:
554 """
555 Skips are kept apart per media type.
556
557 Importing an album's tracks runs inside the album sync, so a track it had to skip must
558 not put a track db id into the album result set, nor hold back the album deletions.
559 """
560 mass = _build_mass(prev_library_ids=[1, 2, 3, 99])
561 provider = _build_provider(mass, cls=TrackSkippingAlbumProvider, sync_album_tracks=True)
562 _library_holds(mass, {SKIPPED_TRACK_ID: 77})
563
564 await provider.sync_library(MediaType.ALBUM)
565
566 # 77 is a track db id, so it may not end up in the album snapshot
567 assert sorted(mass.cache.set.await_args.kwargs["data"]) == [1, 2, 3]
568 # and a track that could not be named does not stop the albums from being cleaned up
569 mass.music.get_controller.return_value.get_library_item.assert_awaited_once_with(99)
570
571
572class UnskippableSkipProvider(SkippingAlbumProvider):
573 """Provider that declares the error behind its skip unskippable."""
574
575 @property
576 def unskippable_sync_errors(self) -> tuple[type[Exception], ...]:
577 """Return the errors a library sync must not swallow as an item failure."""
578 return (InvalidDataError,)
579
580
581async def test_reported_skip_respects_unskippable_errors() -> None:
582 """Reporting a skip re-raises an error the provider declared unskippable."""
583 mass = _build_mass(prev_library_ids=[1, 2, 3])
584 provider = _build_provider(mass, cls=UnskippableSkipProvider)
585
586 with pytest.raises(InvalidDataError):
587 await provider.sync_library(MediaType.ALBUM)
588
589 mass.cache.set.assert_not_called()
590