/
/
/
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 # wait for the initial sync (and the follow-up genre scan) to fully complete
40 async with wait_for_sync_completion(mass):
41 await mass.music.start_sync()
42 await _wait_until_sync_idle(mass)
43
44 counts_before = {
45 ctrl.media_type: await ctrl.library_count()
46 for ctrl in (
47 mass.music.artists,
48 mass.music.albums,
49 mass.music.tracks,
50 mass.music.podcasts,
51 mass.music.audiobooks,
52 )
53 }
54 assert counts_before[mass.music.tracks.media_type] > 0
55
56 controllers = (
57 mass.music.artists,
58 mass.music.albums,
59 mass.music.tracks,
60 mass.music.podcasts,
61 mass.music.audiobooks,
62 )
63 write_spies = {}
64 from_dict_spies = {}
65 with ExitStack() as stack:
66 for ctrl in controllers:
67 for method in ("add_item_to_library", "update_item_in_library", "set_favorite"):
68 spy = stack.enter_context(patch.object(ctrl, method, wraps=getattr(ctrl, method)))
69 write_spies[f"{ctrl.media_type.value}.{method}"] = spy
70 for item_cls in (Track, Album, Artist, Audiobook):
71 spy = stack.enter_context(
72 patch.object(item_cls, "from_dict", side_effect=item_cls.from_dict)
73 )
74 from_dict_spies[item_cls.__name__] = spy
75 # the podcast episodes precache legitimately hydrates each podcast once per
76 # sync (pre-existing behavior), so podcasts are bounded rather than zero
77 podcast_spy = stack.enter_context(
78 patch.object(Podcast, "from_dict", side_effect=Podcast.from_dict)
79 )
80
81 async with wait_for_sync_completion(mass):
82 await mass.music.start_sync()
83 # keep the spies active until the follow-up genre scan has fully completed
84 await _wait_until_sync_idle(mass)
85
86 for name, spy in write_spies.items():
87 assert not spy.called, f"unexpected library write during no-change re-sync: {name}"
88 for name, spy in from_dict_spies.items():
89 assert spy.call_count == 0, (
90 f"{name}.from_dict called {spy.call_count}x during no-change re-sync"
91 )
92 assert podcast_spy.call_count <= counts_before[mass.music.podcasts.media_type]
93
94 # the library contents must be unchanged
95 for ctrl in controllers:
96 assert await ctrl.library_count() == counts_before[ctrl.media_type]
97