/
/
/
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_failed_item_is_protected_without_holding_back_deletions() -> None:
158 """A failed item must survive while unrelated deletions still run."""
159 mass = _build_mass(prev_library_ids=[1, 2, 3, 99])
160 provider = _build_provider(mass)
161 _library_holds(mass, {"album_2": 2})
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_awaited_once_with(99)
168 assert sorted(mass.cache.set.await_args.kwargs["data"]) == [1, 2, 3]
169
170
171async def test_deletions_run_on_a_clean_sync() -> None:
172 """A sync without failures still processes items that are gone from the provider."""
173 mass = _build_mass(prev_library_ids=[1, 2, 3, 99])
174 provider = _build_provider(mass)
175
176 controller = mass.music.get_controller.return_value
177 library_item = MagicMock()
178 mapping = MagicMock()
179 mapping.provider_instance = provider.instance_id
180 mapping.in_library = True
181 library_item.provider_mappings = [mapping]
182 library_item.favorite = False
183 controller.get_library_item = AsyncMock(return_value=library_item)
184
185 await provider.sync_library(MediaType.ALBUM)
186
187 # only db id 99 is gone from the provider
188 controller.get_library_item.assert_awaited_once_with(99)
189 controller.set_provider_mappings.assert_awaited_once()
190 assert mapping.in_library is False
191
192
193async def test_library_generator_error_still_propagates() -> None:
194 """
195 A provider failing while listing its library aborts the sync.
196
197 The listing is an async generator: once it raises it is closed, so there is no item
198 to skip and no complete result set to run deletions against.
199 """
200 mass = _build_mass(prev_library_ids=[1, 2, 3])
201 provider = _build_provider(mass)
202
203 async def broken_library_albums() -> AsyncGenerator[Any]:
204 raise KeyError("items")
205 yield # type: ignore[unreachable] # pragma: no cover
206
207 provider.get_library_albums = broken_library_albums # type: ignore[method-assign]
208
209 with pytest.raises(KeyError):
210 await provider.sync_library(MediaType.ALBUM)
211
212 mass.cache.set.assert_not_called()
213 mass.music.get_controller.return_value.set_provider_mappings.assert_not_called()
214
215
216async def test_expected_error_also_protects_only_failed_items() -> None:
217 """A MusicAssistantError protects its items without freezing unrelated cleanup."""
218 mass = _build_mass(prev_library_ids=[1, 2, 3, 99])
219 provider = _build_provider(mass)
220 _library_holds(mass, DB_IDS)
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_awaited_once_with(99)
226 assert sorted(mass.cache.set.await_args.kwargs["data"]) == [1, 2, 3]
227
228
229async def test_failed_item_snapshot_drops_unrelated_deletions() -> None:
230 """
231 A run with an identifiable failure replaces the cached id's with its surgical result.
232
233 The failed item's id is retained, while an unrelated provider deletion must not survive
234 into the next snapshot.
235 """
236 mass = _build_mass(prev_library_ids=[1, 2, 3, 99])
237 provider = _build_provider(mass)
238 _library_holds(mass, {"album_2": 2})
239 _fail_add_for(mass, "album_2")
240
241 await provider.sync_library(MediaType.ALBUM)
242
243 assert sorted(mass.cache.set.await_args.kwargs["data"]) == [1, 2, 3]
244
245
246async def test_resolved_failed_item_does_not_need_provider_id_lookup() -> None:
247 """A known db id is protected directly when later item processing fails."""
248 mass = _build_mass(prev_library_ids=[1, 2, 3, 99])
249 provider = _build_provider(mass)
250 sync_details = MagicMock(item_id=2, favorite=False)
251 mass.music.albums.get_library_item_sync_details = AsyncMock(
252 side_effect=[None, sync_details, None]
253 )
254 provider._library_item_needs_update = MagicMock( # type: ignore[method-assign]
255 side_effect=KeyError("release_date")
256 )
257
258 await provider.sync_library(MediaType.ALBUM)
259
260 controller = mass.music.get_controller.return_value
261 controller.get_library_items_by_prov_id.assert_not_awaited()
262 controller.get_library_item.assert_awaited_once_with(99)
263 assert sorted(mass.cache.set.await_args.kwargs["data"]) == [1, 2, 3]
264
265
266async def test_deletions_not_reported_when_disabled() -> None:
267 """A failed item is not reported as skipped deletions when deletions are turned off."""
268 mass = _build_mass(prev_library_ids=[1, 2, 3, 99])
269 provider = _build_provider(mass, sync_deletions=False)
270 _fail_add_for(mass, "album_2")
271
272 with patch("music_assistant.models.music_provider.report_current_task_failure") as reported:
273 await provider.sync_library(MediaType.ALBUM)
274
275 assert not any("Deletions skipped" in call.args[0] for call in reported.call_args_list)
276
277
278async def test_payload_bearing_error_is_clipped() -> None:
279 """
280 A provider error carrying its whole api response is reported by type and clipped.
281
282 Those messages reach the log and, via the task failure list, every connected client.
283 """
284 mass = _build_mass()
285 provider = _build_provider(mass)
286 payload = "x" * 5000
287 mass.music.albums.add_item_to_library = AsyncMock(side_effect=KeyError(payload))
288
289 with patch("music_assistant.models.music_provider.report_current_task_failure") as reported:
290 await provider.sync_library(MediaType.ALBUM)
291
292 item_failures = [
293 call.args[0]
294 for call in reported.call_args_list
295 if call.args[0].startswith("Failed to sync")
296 ]
297 assert len(item_failures) == len(ALBUM_IDS)
298 for message in item_failures:
299 assert "KeyError" in message
300 assert len(message) < 400
301 assert payload not in message
302
303
304def test_our_own_errors_are_reported_verbatim() -> None:
305 """Our own error messages are already short and stay unchanged."""
306 assert describe_sync_error(MediaNotFoundError("album not found")) == "album not found"
307
308
309async def test_incomplete_run_keeps_newly_seen_items() -> None:
310 """An item first seen on an incomplete run is still tracked for later cleanup."""
311 # 1 and 2 were known before; 3 is new in this run
312 mass = _build_mass(prev_library_ids=[1, 2])
313 provider = _build_provider(mass)
314 _fail_add_for(mass, "album_2")
315
316 await provider.sync_library(MediaType.ALBUM)
317
318 assert 3 in mass.cache.set.await_args.kwargs["data"]
319
320
321async def test_item_lookup_failure_skips_only_that_item() -> None:
322 """A failure while resolving an item's mappings skips the item, not the whole sync."""
323 mass = _build_mass()
324 provider = _build_provider(mass)
325 lookups = {"album_2": TypeError("bad provider mapping")}
326
327 async def get_sync_details(mappings: Any) -> Any:
328 del mappings
329 return None
330
331 calls: list[str] = []
332
333 async def sync_details_for(prov_mappings: Any) -> Any:
334 del prov_mappings
335 item_id = ALBUM_IDS[len(calls)]
336 calls.append(item_id)
337 if err := lookups.get(item_id):
338 raise err
339 return await get_sync_details(None)
340
341 mass.music.albums.get_library_item_sync_details = AsyncMock(side_effect=sync_details_for)
342
343 await provider.sync_library(MediaType.ALBUM)
344
345 # all three were reached, and the two healthy ones were still added
346 assert calls == list(ALBUM_IDS)
347 assert _synced_album_ids(mass) == ["album_1", "album_3"]
348
349
350async def test_item_stays_tracked_when_ancillary_work_fails() -> None:
351 """
352 An item whose favorite work fails is still recorded as seen.
353
354 Its library row is committed regardless, so leaving it out of the id's would make it
355 an orphan no later cleanup run could ever discover.
356 """
357 mass = _build_mass()
358 provider = _build_provider(mass)
359 provider.mark_favorite = True
360 mass.music.albums.set_favorite = AsyncMock(side_effect=TypeError("bad favorite"))
361
362 await provider.sync_library(MediaType.ALBUM)
363
364 assert mass.music.albums.set_favorite.await_count == len(ALBUM_IDS)
365 assert sorted(mass.cache.set.await_args.kwargs["data"]) == [1, 2, 3]
366
367
368async def test_standalone_import_keeps_its_own_failure_state(
369 caplog: pytest.LogCaptureFixture,
370) -> None:
371 """
372 Each ad-hoc album-track import starts from a clean failure state.
373
374 They are launched as their own task when an album is added, so a shared counter would
375 silence every import after the first one had used up the logging budget.
376 """
377 mass = _build_mass()
378 provider = _build_provider(mass)
379 mass.music.tracks.get_library_item_sync_details = AsyncMock(return_value=None)
380 mass.music.tracks.add_item_to_library = AsyncMock(side_effect=KeyError("bad track"))
381 provider.album_tracks = [
382 MagicMock(item_id=f"t{i}", uri=f"test://track/{i}") for i in range(MAX_LOGGED_SYNC_FAILURES)
383 ]
384
385 await asyncio.create_task(provider.import_album_tracks("album_1"))
386 caplog.clear()
387 with caplog.at_level(logging.ERROR):
388 await asyncio.create_task(provider.import_album_tracks("album_2"))
389
390 # the second import reports its failures just like the first one did
391 assert len(caplog.records) == MAX_LOGGED_SYNC_FAILURES
392
393
394class _AuthSignal(Exception):
395 """Stands in for a provider error a wrapper around sync_library has to act on."""
396
397
398class AuthSignallingProvider(FailingAlbumProvider):
399 """Provider that declares its auth signal unskippable."""
400
401 @property
402 def unskippable_sync_errors(self) -> tuple[type[Exception], ...]:
403 """Return the errors a library sync must not swallow as an item failure."""
404 return (_AuthSignal,)
405
406
407async def test_declared_unskippable_error_is_not_swallowed() -> None:
408 """
409 An error the provider declared unskippable escapes instead of skipping the item.
410
411 Providers use these to signal something their own wrapper must handle, such as an
412 expired token that needs a reauthenticate and a retry.
413 """
414 mass = _build_mass()
415 provider = _build_provider(mass, cls=AuthSignallingProvider)
416 mass.music.albums.add_item_to_library = AsyncMock(side_effect=_AuthSignal("token expired"))
417
418 with pytest.raises(_AuthSignal):
419 await provider.sync_library(MediaType.ALBUM)
420
421 # the run aborted, so nothing was recorded as a completed sync
422 mass.cache.set.assert_not_called()
423
424
425SKIPPED_ID = "album_2"
426
427
428class SkippingAlbumProvider(FailingAlbumProvider):
429 """Provider that drops one album while listing its library."""
430
431 #: item id handed to report_skipped_sync_item, None to report an unidentifiable item
432 reported_item_id: str | None = SKIPPED_ID
433
434 async def get_library_albums(self) -> AsyncGenerator[Any]:
435 """Yield the test albums, dropping the one that cannot be read."""
436 async for album in super().get_library_albums():
437 if album.item_id == SKIPPED_ID:
438 self.report_skipped_sync_item(
439 MediaType.ALBUM, self.reported_item_id, InvalidDataError("no artist")
440 )
441 continue
442 yield album
443
444
445def _library_holds(mass: MagicMock, known: dict[str, int]) -> None:
446 """Let the deletion pass resolve the given provider item id's to library db id's."""
447
448 async def get_library_items_by_prov_id(
449 provider_instance: str, provider_item_ids: list[str], limit: int
450 ) -> list[Any]:
451 del limit
452 # only this provider instance's own mappings may resolve, never a sibling instance
453 if provider_instance != "test--1":
454 return []
455 return [
456 MagicMock(item_id=known[item_id]) for item_id in provider_item_ids if item_id in known
457 ]
458
459 mass.music.get_controller.return_value.get_library_items_by_prov_id = AsyncMock(
460 side_effect=get_library_items_by_prov_id
461 )
462
463
464async def test_skipped_item_is_reported_on_the_sync_task() -> None:
465 """An item the provider drops while listing is reported instead of vanishing silently."""
466 mass = _build_mass()
467 provider = _build_provider(mass, cls=SkippingAlbumProvider)
468 _library_holds(mass, {})
469
470 with patch("music_assistant.models.music_provider.report_current_task_failure") as reported:
471 await provider.sync_library(MediaType.ALBUM)
472
473 assert SKIPPED_ID in reported.call_args.args[0]
474 # the rest of the library still synced
475 assert _synced_album_ids(mass) == ["album_1", "album_3"]
476
477
478async def test_skipped_item_we_already_hold_survives_the_deletion_pass() -> None:
479 """
480 A skipped item stays in the result set, while the rest of the deletions still run.
481
482 The item is still in the provider's library, so it must not be read as removed - but
483 a permanently unreadable item may not disable the cleanup for everything else either.
484 """
485 mass = _build_mass(prev_library_ids=[1, 2, 3, 99])
486 provider = _build_provider(mass, cls=SkippingAlbumProvider)
487 _library_holds(mass, {SKIPPED_ID: 2})
488
489 await provider.sync_library(MediaType.ALBUM)
490
491 controller = mass.music.get_controller.return_value
492 # only the album that is really gone is processed, the skipped one is left alone
493 controller.get_library_item.assert_awaited_once_with(99)
494 assert sorted(mass.cache.set.await_args.kwargs["data"]) == [1, 2, 3]
495
496
497async def test_skipped_item_we_do_not_hold_does_not_hold_back_deletions() -> None:
498 """An item that was never imported cannot be deleted, so the cleanup runs as usual."""
499 mass = _build_mass(prev_library_ids=[1, 3, 99])
500 provider = _build_provider(mass, cls=SkippingAlbumProvider)
501 _library_holds(mass, {})
502
503 await provider.sync_library(MediaType.ALBUM)
504
505 mass.music.get_controller.return_value.get_library_item.assert_awaited_once_with(99)
506
507
508SKIPPED_TRACK_ID = "track_9"
509
510
511class TrackSkippingAlbumProvider(FailingAlbumProvider):
512 """Provider that drops tracks while listing the tracks of an album."""
513
514 async def get_album_tracks(self, prov_album_id: str) -> list[Any]:
515 """Report the tracks that could not be read instead of returning them."""
516 del prov_album_id
517 self.report_skipped_sync_item(
518 MediaType.TRACK, SKIPPED_TRACK_ID, InvalidDataError("no artist")
519 )
520 self.report_skipped_sync_item(MediaType.TRACK, None, InvalidDataError("no id"))
521 return []
522
523
524class UnidentifiedSkipProvider(SkippingAlbumProvider):
525 """Provider that drops an album it cannot identify."""
526
527 reported_item_id = None
528
529
530async def test_unidentified_skip_holds_back_deletions() -> None:
531 """A provider that cannot say what it dropped holds back the deletions for the whole run."""
532 mass = _build_mass(prev_library_ids=[1, 2, 3, 99])
533 provider = _build_provider(mass, cls=UnidentifiedSkipProvider)
534 _library_holds(mass, {})
535
536 await provider.sync_library(MediaType.ALBUM)
537
538 mass.music.get_controller.return_value.get_library_item.assert_not_called()
539
540
541async def test_skipped_item_stays_in_the_snapshot_when_deletions_are_disabled() -> None:
542 """
543 A skipped item is recorded as seen even while deletions are off.
544
545 That snapshot is what a later run compares against, so dropping the item here would
546 leave it untracked once the user turns deletions back on.
547 """
548 mass = _build_mass(prev_library_ids=[1, 2, 3, 99])
549 provider = _build_provider(mass, cls=SkippingAlbumProvider, sync_deletions=False)
550 _library_holds(mass, {SKIPPED_ID: 2})
551
552 await provider.sync_library(MediaType.ALBUM)
553
554 assert sorted(mass.cache.set.await_args.kwargs["data"]) == [1, 2, 3]
555
556
557async def test_skipped_items_resolve_against_this_provider_instance_only() -> None:
558 """
559 A skipped id is resolved against the instance that skipped it, not the whole domain.
560
561 A second instance of the same provider holds its own mappings under the same item id's,
562 and protecting one instance's item would leave the other's exposed.
563 """
564 mass = _build_mass(prev_library_ids=[1, 2, 3, 99])
565 provider = _build_provider(mass, cls=SkippingAlbumProvider)
566 _library_holds(mass, {SKIPPED_ID: 2})
567
568 await provider.sync_library(MediaType.ALBUM)
569
570 lookup = mass.music.get_controller.return_value.get_library_items_by_prov_id
571 assert lookup.await_args.kwargs["provider_instance"] == provider.instance_id
572
573
574async def test_a_skipped_track_does_not_reach_the_album_deletion_pass() -> None:
575 """
576 Skips are kept apart per media type.
577
578 Importing an album's tracks runs inside the album sync, so a track it had to skip must
579 not put a track db id into the album result set, nor hold back the album deletions.
580 """
581 mass = _build_mass(prev_library_ids=[1, 2, 3, 99])
582 provider = _build_provider(mass, cls=TrackSkippingAlbumProvider, sync_album_tracks=True)
583 _library_holds(mass, {SKIPPED_TRACK_ID: 77})
584
585 await provider.sync_library(MediaType.ALBUM)
586
587 # 77 is a track db id, so it may not end up in the album snapshot
588 assert sorted(mass.cache.set.await_args.kwargs["data"]) == [1, 2, 3]
589 # and a track that could not be named does not stop the albums from being cleaned up
590 mass.music.get_controller.return_value.get_library_item.assert_awaited_once_with(99)
591
592
593class UnskippableSkipProvider(SkippingAlbumProvider):
594 """Provider that declares the error behind its skip unskippable."""
595
596 @property
597 def unskippable_sync_errors(self) -> tuple[type[Exception], ...]:
598 """Return the errors a library sync must not swallow as an item failure."""
599 return (InvalidDataError,)
600
601
602async def test_reported_skip_respects_unskippable_errors() -> None:
603 """Reporting a skip re-raises an error the provider declared unskippable."""
604 mass = _build_mass(prev_library_ids=[1, 2, 3])
605 provider = _build_provider(mass, cls=UnskippableSkipProvider)
606
607 with pytest.raises(InvalidDataError):
608 await provider.sync_library(MediaType.ALBUM)
609
610 mass.cache.set.assert_not_called()
611