/
/
1"""Tests for the hourly album reconciliation maintenance task."""
2
3from __future__ import annotations
4
5from time import time
6from unittest.mock import AsyncMock, Mock, PropertyMock, patch
7
8import aiohttp
9import pytest
10from music_assistant_models.enums import AlbumType, ProviderFeature
11from music_assistant_models.errors import MediaNotFoundError, MusicAssistantError
12from music_assistant_models.helpers import set_global_cache_values
13from music_assistant_models.media_items import (
14 Album,
15 Artist,
16 MediaItemMetadata,
17 ProviderMapping,
18 UniqueList,
19)
20
21from music_assistant.constants import DB_TABLE_ALBUMS
22from music_assistant.controllers.metadata import MetaDataController
23from music_assistant.controllers.metadata.constants import (
24 METADATA_SCAN_BATCH_SIZE,
25 REFRESH_INTERVAL,
26)
27from music_assistant.mass import MusicAssistant
28
29_REPORT_FAILURE = "music_assistant.controllers.metadata.controller.report_current_task_failure"
30_CONTROLLER_TIME = "music_assistant.controllers.metadata.controller.time"
31
32
33def _controller() -> MetaDataController:
34 """Create a bare MetaDataController without running __init__."""
35 ctrl = MetaDataController.__new__(MetaDataController)
36 ctrl._corrupt_metadata_rows = {}
37 ctrl.logger = Mock()
38 return ctrl
39
40
41def _album_stub(item_id: str = "1", name: str = "Test Album") -> Mock:
42 """Build a lightweight stand-in for a library Album."""
43 album = Mock()
44 album.item_id = item_id
45 album.name = name
46 return album
47
48
49# --------------------------------------------------------------------------- #
50# candidate query #
51# --------------------------------------------------------------------------- #
52
53
54async def test_reconcile_duplicate_albums_query_matches_unknown_stale_or_null_refresh() -> None:
55 """The candidate query selects unknown-typed albums that are stale or never refreshed."""
56 ctrl = _controller()
57 mass = Mock()
58 mass.music.albums.get_library_items_by_query = AsyncMock(return_value=[])
59 ctrl.mass = mass
60
61 with patch(_CONTROLLER_TIME, return_value=1_700_000_000.0):
62 await ctrl._reconcile_duplicate_albums()
63 refresh_before = int(1_700_000_000.0 - REFRESH_INTERVAL)
64
65 _, kwargs = mass.music.albums.get_library_items_by_query.call_args
66 assert kwargs["extra_query_parts"] == [
67 f"{DB_TABLE_ALBUMS}.album_type = 'unknown' AND ("
68 f"json_extract({DB_TABLE_ALBUMS}.metadata,'$.last_refresh') ISNULL "
69 f"OR json_extract({DB_TABLE_ALBUMS}.metadata,'$.last_refresh') < {refresh_before})"
70 ]
71 assert kwargs["limit"] == METADATA_SCAN_BATCH_SIZE
72 assert kwargs["order_by"] == "random"
73
74
75async def test_reconcile_duplicate_albums_retries_stale_but_not_fresh_refresh(
76 mass: MusicAssistant,
77) -> None:
78 """An unknown album retries past REFRESH_INTERVAL; a recently-refreshed one does not."""
79 now = int(time())
80 stale_album = await mass.music.albums.add_item_to_library(
81 Album(
82 item_id="0",
83 provider="library",
84 name="Stale Album",
85 album_type=AlbumType.UNKNOWN,
86 artists=UniqueList(),
87 provider_mappings={
88 ProviderMapping(
89 item_id="stale-item", provider_domain="qobuz", provider_instance="qobuz_1"
90 )
91 },
92 metadata=MediaItemMetadata(last_refresh=now - REFRESH_INTERVAL - 1),
93 )
94 )
95 await mass.music.albums.add_item_to_library(
96 Album(
97 item_id="0",
98 provider="library",
99 name="Fresh Album",
100 album_type=AlbumType.UNKNOWN,
101 artists=UniqueList(),
102 provider_mappings={
103 ProviderMapping(
104 item_id="fresh-item", provider_domain="qobuz", provider_instance="qobuz_1"
105 )
106 },
107 metadata=MediaItemMetadata(last_refresh=now - 1),
108 )
109 )
110
111 with (
112 patch.object(mass.metadata, "_update_album_metadata", AsyncMock()) as update_metadata,
113 patch.object(mass.music.albums, "match_providers", AsyncMock()),
114 ):
115 await mass.metadata._reconcile_duplicate_albums()
116
117 processed_ids = {call.args[0].item_id for call in update_metadata.await_args_list}
118 assert processed_ids == {stale_album.item_id}
119
120
121async def test_reconcile_duplicate_albums_empty_queue_is_a_noop() -> None:
122 """An empty candidate batch does not touch any album."""
123 ctrl = _controller()
124 mass = Mock()
125 mass.music.albums.get_library_items_by_query = AsyncMock(return_value=[])
126 ctrl.mass = mass
127
128 await ctrl._reconcile_duplicate_albums()
129
130 mass.music.albums.get_library_item.assert_not_called()
131 mass.music.albums.match_providers.assert_not_called()
132
133
134# --------------------------------------------------------------------------- #
135# enrich -> reload -> re-match flow #
136# --------------------------------------------------------------------------- #
137
138
139async def test_reconcile_duplicate_albums_enriches_then_reloads_before_matching() -> None:
140 """Each album is enriched, the library row reloaded, then re-matched with fresh data."""
141 ctrl = _controller()
142 mass = Mock()
143 album = _album_stub("1", "Original Name")
144 reloaded_album = _album_stub("1", "Enriched Name")
145 mass.music.albums.get_library_items_by_query = AsyncMock(return_value=[album])
146 mass.music.albums.get_library_item = AsyncMock(return_value=reloaded_album)
147 mass.music.albums.match_providers = AsyncMock()
148 ctrl.mass = mass
149 ctrl._update_album_metadata = AsyncMock() # type: ignore[method-assign]
150
151 await ctrl._reconcile_duplicate_albums()
152
153 ctrl._update_album_metadata.assert_awaited_once_with(album, force_refresh=False)
154 mass.music.albums.get_library_item.assert_awaited_once_with("1")
155 # match_providers must see the reloaded (enriched) object, not the stale pre-update one
156 mass.music.albums.match_providers.assert_awaited_once_with(reloaded_album)
157
158
159async def test_reconcile_duplicate_albums_never_adds_or_deletes_directly() -> None:
160 """The task never adds a new library item or deletes one outside the safe merge path."""
161 ctrl = _controller()
162 mass = Mock()
163 album = _album_stub()
164 mass.music.albums.get_library_items_by_query = AsyncMock(return_value=[album])
165 mass.music.albums.get_library_item = AsyncMock(return_value=album)
166 mass.music.albums.match_providers = AsyncMock()
167 ctrl.mass = mass
168 ctrl._update_album_metadata = AsyncMock() # type: ignore[method-assign]
169
170 await ctrl._reconcile_duplicate_albums()
171
172 mass.music.albums.add_item_to_library.assert_not_called()
173 mass.music.albums.remove_item_from_library.assert_not_called()
174 mass.music.albums.merge_library_items.assert_not_called()
175
176
177async def test_reconcile_duplicate_albums_batch_size_bound() -> None:
178 """The batch never exceeds METADATA_SCAN_BATCH_SIZE, even when more items match."""
179 ctrl = _controller()
180 mass = Mock()
181 albums = [_album_stub(str(i), f"Album {i}") for i in range(METADATA_SCAN_BATCH_SIZE)]
182 mass.music.albums.get_library_items_by_query = AsyncMock(return_value=albums)
183 mass.music.albums.get_library_item = AsyncMock(side_effect=lambda item_id: _album_stub(item_id))
184 mass.music.albums.match_providers = AsyncMock()
185 ctrl.mass = mass
186 ctrl._update_album_metadata = AsyncMock() # type: ignore[method-assign]
187
188 await ctrl._reconcile_duplicate_albums()
189
190 assert ctrl._update_album_metadata.await_count == METADATA_SCAN_BATCH_SIZE
191 assert mass.music.albums.match_providers.await_count == METADATA_SCAN_BATCH_SIZE
192
193
194# --------------------------------------------------------------------------- #
195# per-item failure isolation #
196# --------------------------------------------------------------------------- #
197
198
199@pytest.mark.parametrize(
200 "error",
201 [
202 MusicAssistantError("boom"),
203 aiohttp.ClientError("connection reset"),
204 TimeoutError("timed out"),
205 ],
206)
207async def test_reconcile_duplicate_albums_isolates_metadata_failure(error: Exception) -> None:
208 """An expected per-item metadata failure is reported and does not raise."""
209 ctrl = _controller()
210 mass = Mock()
211 album = _album_stub("1", "Failing Album")
212 mass.music.albums.get_library_items_by_query = AsyncMock(return_value=[album])
213 mass.music.albums.get_library_item = AsyncMock()
214 mass.music.albums.match_providers = AsyncMock()
215 ctrl.mass = mass
216 ctrl._update_album_metadata = AsyncMock(side_effect=error) # type: ignore[method-assign]
217
218 with patch(_REPORT_FAILURE) as report_failure:
219 await ctrl._reconcile_duplicate_albums() # must not raise
220
221 report_failure.assert_called_once_with(f"Failing Album: {error}")
222 # the failed item never reaches reload/re-match
223 mass.music.albums.get_library_item.assert_not_called()
224 mass.music.albums.match_providers.assert_not_called()
225
226
227async def test_reconcile_duplicate_albums_isolates_match_providers_failure() -> None:
228 """A provider search failure during re-matching is caught and reported, not raised."""
229 ctrl = _controller()
230 mass = Mock()
231 album = _album_stub("1", "Flaky Album")
232 reloaded = _album_stub("1", "Flaky Album")
233 mass.music.albums.get_library_items_by_query = AsyncMock(return_value=[album])
234 mass.music.albums.get_library_item = AsyncMock(return_value=reloaded)
235 mass.music.albums.match_providers = AsyncMock(
236 side_effect=aiohttp.ClientError("connection reset")
237 )
238 ctrl.mass = mass
239 ctrl._update_album_metadata = AsyncMock() # type: ignore[method-assign]
240
241 with patch(_REPORT_FAILURE) as report_failure:
242 await ctrl._reconcile_duplicate_albums() # must not raise
243
244 report_failure.assert_called_once_with("Flaky Album: connection reset")
245
246
247async def test_reconcile_duplicate_albums_failure_does_not_abort_the_batch() -> None:
248 """A failing album is isolated; the remaining albums in the batch still get processed."""
249 ctrl = _controller()
250 mass = Mock()
251 failing_album = _album_stub("1", "Failing Album")
252 healthy_album = _album_stub("2", "Healthy Album")
253 reloaded_healthy = _album_stub("2", "Healthy Album")
254 mass.music.albums.get_library_items_by_query = AsyncMock(
255 return_value=[failing_album, healthy_album]
256 )
257 mass.music.albums.get_library_item = AsyncMock(return_value=reloaded_healthy)
258 mass.music.albums.match_providers = AsyncMock()
259 ctrl.mass = mass
260 ctrl._update_album_metadata = AsyncMock( # type: ignore[method-assign]
261 side_effect=[MusicAssistantError("boom"), None]
262 )
263
264 with patch(_REPORT_FAILURE) as report_failure:
265 await ctrl._reconcile_duplicate_albums()
266
267 report_failure.assert_called_once_with("Failing Album: boom")
268 mass.music.albums.get_library_item.assert_awaited_once_with("2")
269 mass.music.albums.match_providers.assert_awaited_once_with(reloaded_healthy)
270
271
272async def test_reconcile_duplicate_albums_no_match_completes_without_failure() -> None:
273 """A normal no-match completion is not treated as a failure; the album stays attempted."""
274 ctrl = _controller()
275 mass = Mock()
276 album = _album_stub("1", "No Match Album")
277 reloaded = _album_stub("1", "No Match Album")
278 mass.music.albums.get_library_items_by_query = AsyncMock(return_value=[album])
279 mass.music.albums.get_library_item = AsyncMock(return_value=reloaded)
280 mass.music.albums.match_providers = AsyncMock(return_value=None)
281 ctrl.mass = mass
282 ctrl._update_album_metadata = AsyncMock() # type: ignore[method-assign]
283
284 with patch(_REPORT_FAILURE) as report_failure:
285 await ctrl._reconcile_duplicate_albums()
286
287 report_failure.assert_not_called()
288 ctrl._update_album_metadata.assert_awaited_once_with(album, force_refresh=False)
289 mass.music.albums.match_providers.assert_awaited_once_with(reloaded)
290
291
292# --------------------------------------------------------------------------- #
293# integration: a confirmed match owned by a duplicate merges via the safe path #
294# --------------------------------------------------------------------------- #
295
296
297async def test_reconcile_duplicate_albums_merges_conflicting_mapping_via_safe_path(
298 mass: MusicAssistant,
299) -> None:
300 """
301 A confirmed re-match already owned by another library album merges, not duplicates.
302
303 Drives the real `AlbumsController.match_providers` -> `add_provider_mappings` path
304 end to end against a real (test) database, only stubbing the provider IO boundary
305 (search/full-item fetch), to prove the reconciliation task relies on the existing
306 safe merge primitive instead of reimplementing conflict handling.
307 """
308 artist = await mass.music.artists.add_item_to_library(
309 Artist(
310 item_id="0",
311 provider="library",
312 name="Sigur Rós",
313 provider_mappings={
314 ProviderMapping(
315 item_id="artist", provider_domain="test", provider_instance="library"
316 )
317 },
318 )
319 )
320 target = await mass.music.albums.add_item_to_library(
321 Album(
322 item_id="0",
323 provider="library",
324 name="( )",
325 album_type=AlbumType.UNKNOWN,
326 artists=UniqueList([artist]),
327 provider_mappings={
328 ProviderMapping(
329 item_id="qobuz-item", provider_domain="qobuz", provider_instance="qobuz_1"
330 )
331 },
332 )
333 )
334 duplicate = await mass.music.albums.add_item_to_library(
335 Album(
336 item_id="0",
337 provider="library",
338 name="( )",
339 album_type=AlbumType.ALBUM,
340 artists=UniqueList([artist]),
341 provider_mappings={
342 ProviderMapping(
343 item_id="spotify-item",
344 provider_domain="spotify",
345 provider_instance="spotify_1",
346 )
347 },
348 )
349 )
350
351 provider = Mock()
352 provider.domain = "spotify"
353 provider.instance_id = "spotify_1"
354 provider.supported_features = {ProviderFeature.SEARCH}
355 provider.is_streaming_provider = True
356
357 search_result = Album(
358 item_id="spotify-item",
359 provider="spotify_1",
360 name=target.name,
361 artists=UniqueList([artist]),
362 provider_mappings={
363 ProviderMapping(
364 item_id="spotify-item", provider_domain="spotify", provider_instance="spotify_1"
365 )
366 },
367 )
368
369 # both providers must be "available" for the match to be considered, and for
370 # add_provider_mappings' subsequent uniqueness check to see qobuz_1's own mapping
371 await set_global_cache_values({"available_providers": {"qobuz_1", "spotify_1"}})
372
373 with (
374 patch.object(mass.music.albums, "search", AsyncMock(return_value=[search_result])),
375 patch.object(mass.music.albums, "get_provider_item", AsyncMock(return_value=search_result)),
376 patch.object(mass.music, "library_supported", Mock(return_value=True)),
377 patch.object(type(mass.music), "providers", new_callable=PropertyMock) as providers_mock,
378 ):
379 providers_mock.return_value = [provider]
380 await mass.music.albums.match_providers(target)
381
382 # the duplicate row is gone: its mapping was transferred, not recreated
383 with pytest.raises(MediaNotFoundError):
384 await mass.music.albums.get_library_item(duplicate.item_id)
385 merged = await mass.music.albums.get_library_item(target.item_id)
386 assert {m.provider_instance for m in merged.provider_mappings} == {"qobuz_1", "spotify_1"}
387