/
/
1"""
2Regression test: a no-change library re-sync must be cheap.
3
4The per-item sync loops resolve every provider item through the lightweight
5``get_library_item_sync_details`` lookup, so an unchanged item must not be
6hydrated into a full ``MediaItem`` (no mashumaro ``from_dict``) and must not
7trigger any library writes (add/update/set_favorite).
8"""
9
10from __future__ import annotations
11
12import asyncio
13from contextlib import ExitStack
14from typing import TYPE_CHECKING
15from unittest.mock import patch
16
17from music_assistant_models.media_items import Album, Artist, Audiobook, Podcast, Track
18
19from tests.common import wait_for_sync_completion
20
21if TYPE_CHECKING:
22 from music_assistant.mass import MusicAssistant
23
24
25async def _wait_until_sync_idle(mass: MusicAssistant, timeout: float = 60.0) -> None:
26 """Wait until no provider sync tasks and no genre scan are pending or running."""
27 elapsed = 0.0
28 while elapsed < timeout:
29 if not mass.music.active_sync_tasks and not mass.music.genres._genre_scan_running:
30 return
31 await asyncio.sleep(0.25)
32 elapsed += 0.25
33 raise TimeoutError("sync tasks did not become idle in time")
34
35
36async def test_no_change_resync_is_hydration_free(e2e_mass: MusicAssistant) -> None:
37 """A re-sync where nothing changed performs no writes and hydrates no media items."""
38 mass = e2e_mass
39 # the initial sync is auto-scheduled when the test provider loads;
40 # wait for it (and the follow-up genre scan) to fully complete
41 async with wait_for_sync_completion(mass):
42 await mass.music.start_sync()
43 await _wait_until_sync_idle(mass)
44
45 counts_before = {
46 ctrl.media_type: await ctrl.library_count()
47 for ctrl in (
48 mass.music.artists,
49 mass.music.albums,
50 mass.music.tracks,
51 mass.music.podcasts,
52 mass.music.audiobooks,
53 )
54 }
55 assert counts_before[mass.music.tracks.media_type] > 0
56
57 controllers = (
58 mass.music.artists,
59 mass.music.albums,
60 mass.music.tracks,
61 mass.music.podcasts,
62 mass.music.audiobooks,
63 )
64 write_spies = {}
65 from_dict_spies = {}
66 with ExitStack() as stack:
67 for ctrl in controllers:
68 for method in ("add_item_to_library", "update_item_in_library", "set_favorite"):
69 spy = stack.enter_context(patch.object(ctrl, method, wraps=getattr(ctrl, method)))
70 write_spies[f"{ctrl.media_type.value}.{method}"] = spy
71 for item_cls in (Track, Album, Artist, Audiobook):
72 spy = stack.enter_context(
73 patch.object(item_cls, "from_dict", side_effect=item_cls.from_dict)
74 )
75 from_dict_spies[item_cls.__name__] = spy
76 # the podcast episodes precache legitimately hydrates each podcast once per
77 # sync (pre-existing behavior), so podcasts are bounded rather than zero
78 podcast_spy = stack.enter_context(
79 patch.object(Podcast, "from_dict", side_effect=Podcast.from_dict)
80 )
81
82 async with wait_for_sync_completion(mass):
83 await mass.music.start_sync()
84 # keep the spies active until the follow-up genre scan has fully completed
85 await _wait_until_sync_idle(mass)
86
87 for name, spy in write_spies.items():
88 assert not spy.called, f"unexpected library write during no-change re-sync: {name}"
89 for name, spy in from_dict_spies.items():
90 assert spy.call_count == 0, (
91 f"{name}.from_dict called {spy.call_count}x during no-change re-sync"
92 )
93 assert podcast_spy.call_count <= counts_before[mass.music.podcasts.media_type]
94
95 # the library contents must be unchanged
96 for ctrl in controllers:
97 assert await ctrl.library_count() == counts_before[ctrl.media_type]
98