/
/
/
1"""Tests for dynamic-playlist handling in the metadata refresh scan and provider sync."""
2
3from __future__ import annotations
4
5import sqlite3
6from collections.abc import AsyncGenerator, AsyncIterator
7from contextlib import asynccontextmanager
8from unittest.mock import AsyncMock, Mock, patch
9
10import pytest
11from music_assistant_models.enums import MediaType
12from music_assistant_models.media_items import ProviderMapping, Track, UniqueList
13
14from music_assistant.constants import DB_TABLE_PLAYLISTS
15from music_assistant.controllers.metadata import MetaDataController
16from music_assistant.controllers.metadata.enrichment import MetadataEnrichmentMixin
17from music_assistant.models.music_provider import MusicProvider
18
19
20def _controller() -> MetaDataController:
21 """Create a bare MetaDataController without running __init__."""
22 ctrl = MetaDataController.__new__(MetaDataController)
23 ctrl._corrupt_metadata_rows = {}
24 return ctrl
25
26
27def _provider() -> MusicProvider:
28 """Create a bare MusicProvider without running __init__."""
29 return MusicProvider.__new__(MusicProvider)
30
31
32@asynccontextmanager
33async def _noop_deferred_commit() -> AsyncGenerator[None]:
34 """Stand-in for DatabaseConnection.deferred_commit on mocked databases."""
35 yield
36
37
38def _provider_mapping(item_id: str = "station_1") -> ProviderMapping:
39 """Build a minimal ProviderMapping for a Pandora-style station."""
40 return ProviderMapping(
41 item_id=item_id,
42 provider_domain="pandora",
43 provider_instance="pandora_1",
44 in_library=True,
45 )
46
47
48# --------------------------------------------------------------------------- #
49# _update_playlist_metadata processes all playlists when called directly #
50# --------------------------------------------------------------------------- #
51
52
53async def test_update_playlist_metadata_processes_dynamic_playlists() -> None:
54 """_update_playlist_metadata processes dynamic playlists; providers decide filtering."""
55 mixin = MetadataEnrichmentMixin()
56 mixin.mass = Mock()
57 mixin.logger = Mock()
58 mixin.providers = [] # type: ignore[misc]
59
60 async def mock_tracks(
61 item_id: str, # noqa: ARG001
62 provider: str, # noqa: ARG001
63 ) -> AsyncIterator[Track]:
64 """Empty async generator."""
65 if False:
66 yield # type: ignore[unreachable] # pragma: no cover
67
68 mixin.mass.music.playlists.tracks = mock_tracks
69 mixin.mass.music.playlists.update_item_in_library = AsyncMock()
70
71 playlist = Mock()
72 playlist.is_dynamic = True
73 playlist.provider_mappings = {_provider_mapping()}
74 playlist.provider = "pandora"
75 playlist.item_id = "test_dynamic"
76 playlist.name = "Test Dynamic Playlist"
77 playlist.metadata = Mock()
78 playlist.metadata.last_refresh = 0
79 playlist.metadata.genres = set()
80
81 await mixin._update_playlist_metadata(playlist, force_refresh=True)
82
83 # Track scanning should happen (providers decide whether to use this data)
84 mixin.logger.debug.assert_called()
85
86
87async def test_refresh_playlist_metadata_batch_query_excludes_dynamic_playlists() -> None:
88 """Batch query filters is_dynamic playlists; single updates process all playlists."""
89 ctrl = _controller()
90 mass = Mock()
91 mass.music.playlists.get_library_items_by_query = AsyncMock(return_value=[])
92 ctrl.mass = mass
93
94 await ctrl._refresh_playlist_metadata_batch()
95
96 _, kwargs = mass.music.playlists.get_library_items_by_query.call_args
97 query_parts = kwargs["extra_query_parts"]
98 # Batch query should still filter dynamic playlists (automated refresh)
99 assert any(f"{DB_TABLE_PLAYLISTS}.is_dynamic = 0" in part for part in query_parts)
100
101
102async def test_refresh_playlist_metadata_batch_reports_corrupt_row_reactively() -> None:
103 """A malformed-JSON row aborts the scan, gets reported, and the scan retries guarded."""
104 ctrl = _controller()
105 ctrl.logger = Mock()
106 mass = Mock()
107 # first (guard-free) query aborts on the corrupt row; guarded retry succeeds
108 mass.music.playlists.get_library_items_by_query = AsyncMock(
109 side_effect=[sqlite3.OperationalError("malformed JSON"), []]
110 )
111 mass.music.database.get_rows_from_query = AsyncMock(
112 return_value=[{"item_id": 7, "name": "Broken"}]
113 )
114 ctrl.mass = mass
115
116 await ctrl._refresh_playlist_metadata_batch()
117
118 assert mass.music.playlists.get_library_items_by_query.await_count == 2
119 retry_parts = mass.music.playlists.get_library_items_by_query.call_args_list[1].kwargs[
120 "extra_query_parts"
121 ]
122 # only the retry carries the json_valid guard
123 assert any("json_valid" in part for part in retry_parts)
124 assert ctrl._corrupt_metadata_rows[DB_TABLE_PLAYLISTS] == [{"item_id": 7, "name": "Broken"}]
125
126
127async def test_refresh_playlist_metadata_batch_clears_stale_corrupt_rows() -> None:
128 """A clean scan clears corrupt rows recorded on a previous pass."""
129 ctrl = _controller()
130 ctrl._corrupt_metadata_rows = {DB_TABLE_PLAYLISTS: [{"item_id": 7, "name": "Broken"}]}
131 mass = Mock()
132 mass.music.playlists.get_library_items_by_query = AsyncMock(return_value=[])
133 ctrl.mass = mass
134
135 await ctrl._refresh_playlist_metadata_batch()
136
137 assert DB_TABLE_PLAYLISTS not in ctrl._corrupt_metadata_rows
138
139
140async def test_refresh_playlist_metadata_batch_reraises_unrelated_db_error() -> None:
141 """A non-malformed OperationalError is not swallowed by the corrupt-row handling."""
142 ctrl = _controller()
143 mass = Mock()
144 mass.music.playlists.get_library_items_by_query = AsyncMock(
145 side_effect=sqlite3.OperationalError("database is locked")
146 )
147 ctrl.mass = mass
148
149 with pytest.raises(sqlite3.OperationalError, match="database is locked"):
150 await ctrl._refresh_playlist_metadata_batch()
151
152
153# --------------------------------------------------------------------------- #
154# Provider sync overwrites name/images for non-editable *dynamic* playlists #
155# --------------------------------------------------------------------------- #
156
157
158def _build_sync_fixture(
159 *,
160 library_is_editable: bool,
161 library_name: str,
162 prov_name: str,
163 library_images: list[str],
164 prov_images: list[str],
165 is_dynamic: bool = True,
166) -> tuple[MusicProvider, AsyncMock, Mock]:
167 """Build a MusicProvider + mocked playlists controller wired for one sync pass."""
168 mapping = _provider_mapping()
169
170 library_item = Mock()
171 library_item.item_id = "1"
172 library_item.is_editable = library_is_editable
173 library_item.name = library_name
174 library_item.metadata = Mock(images=library_images)
175 library_item.date_added = None
176 library_item.supported_mediatypes = [MediaType.TRACK]
177 library_item.favorite = True
178 library_item.is_dynamic = is_dynamic
179
180 prov_item = Mock()
181 prov_item.item_id = "station_1"
182 prov_item.name = prov_name
183 prov_item.metadata = Mock(images=prov_images)
184 prov_item.date_added = None
185 prov_item.supported_mediatypes = [MediaType.TRACK]
186 prov_item.favorite = True
187 prov_item.provider_mappings = UniqueList([mapping])
188 prov_item.uri = "pandora://station_1"
189 prov_item.is_dynamic = is_dynamic
190
191 updated_item = Mock()
192 updated_item.item_id = "1"
193 updated_item.favorite = True
194
195 playlists_ctrl = AsyncMock()
196 playlists_ctrl.get_library_item_by_prov_mappings = AsyncMock(return_value=library_item)
197 playlists_ctrl.update_item_in_library = AsyncMock(return_value=updated_item)
198
199 mass = Mock()
200 mass.music.playlists = playlists_ctrl
201 mass.music.database.deferred_commit = _noop_deferred_commit
202
203 provider = _provider()
204 provider.mass = mass
205 provider.config = Mock(get_value=Mock(return_value=[]))
206 provider.logger = Mock()
207
208 return provider, playlists_ctrl, prov_item
209
210
211async def _get_single_playlist(prov_item: Mock) -> AsyncIterator[Mock]:
212 """Yield a single provider playlist item, mirroring get_library_playlists()."""
213 yield prov_item
214
215
216async def _run_sync(provider: MusicProvider, prov_item: Mock) -> None:
217 """Run _sync_library_playlists() with its provider-side hooks patched out."""
218 with patch.multiple(
219 provider,
220 get_library_playlists=Mock(return_value=_get_single_playlist(prov_item)),
221 _check_provider_mappings=Mock(return_value=True),
222 _update_sync_task_item_status=Mock(),
223 _handle_sync_item_failure=Mock(),
224 ):
225 await provider._sync_library_playlists()
226
227
228async def test_sync_overwrites_name_for_noneditable_dynamic_playlist() -> None:
229 """A non-editable playlist whose name changed on the provider gets overwritten."""
230 provider, playlists_ctrl, prov_item = _build_sync_fixture(
231 library_is_editable=False,
232 library_name="Old Station Name",
233 prov_name="New Station Name",
234 library_images=["same.jpg"],
235 prov_images=["same.jpg"],
236 )
237
238 await _run_sync(provider, prov_item)
239
240 playlists_ctrl.update_item_in_library.assert_called_once_with("1", prov_item, overwrite=True)
241
242
243async def test_sync_overwrites_images_for_noneditable_dynamic_playlist() -> None:
244 """A non-editable playlist whose images changed on the provider gets overwritten."""
245 provider, playlists_ctrl, prov_item = _build_sync_fixture(
246 library_is_editable=False,
247 library_name="Same Name",
248 prov_name="Same Name",
249 library_images=["old.jpg"],
250 prov_images=["new.jpg"],
251 )
252
253 await _run_sync(provider, prov_item)
254
255 playlists_ctrl.update_item_in_library.assert_called_once_with("1", prov_item, overwrite=True)
256
257
258async def test_sync_does_not_overwrite_editable_playlist_on_name_change() -> None:
259 """User-editable playlists are never force-overwritten, even if names diverge."""
260 provider, playlists_ctrl, prov_item = _build_sync_fixture(
261 library_is_editable=True,
262 library_name="Old Name",
263 prov_name="New Name",
264 library_images=["same.jpg"],
265 prov_images=["same.jpg"],
266 )
267
268 await _run_sync(provider, prov_item)
269
270 playlists_ctrl.update_item_in_library.assert_not_called()
271
272
273async def test_sync_does_not_overwrite_noneditable_static_playlist_on_name_change() -> None:
274 """
275 Non-editable, non-dynamic playlists (e.g. a provider's "Favorites") are spared.
276
277 Only non-editable *dynamic* playlists are treated as provider-owned; a static
278 non-editable playlist keeps its locally-enriched metadata/images.
279 """
280 provider, playlists_ctrl, prov_item = _build_sync_fixture(
281 library_is_editable=False,
282 library_name="Old Name",
283 prov_name="New Name",
284 library_images=["same.jpg"],
285 prov_images=["same.jpg"],
286 is_dynamic=False,
287 )
288
289 await _run_sync(provider, prov_item)
290
291 playlists_ctrl.update_item_in_library.assert_not_called()
292
293
294async def test_sync_skips_update_when_noneditable_playlist_unchanged() -> None:
295 """No spurious update is issued when name/images already match."""
296 provider, playlists_ctrl, prov_item = _build_sync_fixture(
297 library_is_editable=False,
298 library_name="Same Name",
299 prov_name="Same Name",
300 library_images=["same.jpg"],
301 prov_images=["same.jpg"],
302 )
303
304 await _run_sync(provider, prov_item)
305
306 playlists_ctrl.update_item_in_library.assert_not_called()
307