/
/
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.controllers.metadata.controller import _duplicate_album_sibling_guard
28from music_assistant.mass import MusicAssistant
29
30_REPORT_FAILURE = "music_assistant.controllers.metadata.controller.report_current_task_failure"
31_CONTROLLER_TIME = "music_assistant.controllers.metadata.controller.time"
32
33
34def _controller() -> MetaDataController:
35 """Create a bare MetaDataController without running __init__."""
36 ctrl = MetaDataController.__new__(MetaDataController)
37 ctrl._corrupt_metadata_rows = {}
38 ctrl.logger = Mock()
39 return ctrl
40
41
42def _album_stub(item_id: str = "1", name: str = "Test Album") -> Mock:
43 """Build a lightweight stand-in for a library Album."""
44 album = Mock()
45 album.item_id = item_id
46 album.name = name
47 return album
48
49
50# --------------------------------------------------------------------------- #
51# candidate query #
52# --------------------------------------------------------------------------- #
53
54
55async def test_reconcile_duplicate_albums_query_matches_unknown_or_duplicate_and_stale() -> None:
56 """The candidate query selects unknown-typed or possibly-duplicated stale albums."""
57 ctrl = _controller()
58 mass = Mock()
59 mass.music.albums.get_library_items_by_query = AsyncMock(return_value=[])
60 ctrl.mass = mass
61
62 with patch(_CONTROLLER_TIME, return_value=1_700_000_000.0):
63 await ctrl._reconcile_duplicate_albums()
64 refresh_before = int(1_700_000_000.0 - REFRESH_INTERVAL)
65
66 _, kwargs = mass.music.albums.get_library_items_by_query.call_args
67 assert kwargs["extra_query_parts"] == [
68 f"({DB_TABLE_ALBUMS}.album_type = 'unknown' "
69 f"OR {_duplicate_album_sibling_guard()}) AND ("
70 f"json_extract({DB_TABLE_ALBUMS}.metadata,'$.last_refresh') ISNULL "
71 f"OR json_extract({DB_TABLE_ALBUMS}.metadata,'$.last_refresh') < {refresh_before})"
72 ]
73 assert kwargs["limit"] == METADATA_SCAN_BATCH_SIZE
74 assert kwargs["order_by"] == "random"
75
76
77async def test_reconcile_duplicate_albums_retries_stale_but_not_fresh_refresh(
78 mass: MusicAssistant,
79) -> None:
80 """An unknown album retries past REFRESH_INTERVAL; a recently-refreshed one does not."""
81 now = int(time())
82 stale_album = await mass.music.albums.add_item_to_library(
83 Album(
84 item_id="0",
85 provider="library",
86 name="Stale Album",
87 album_type=AlbumType.UNKNOWN,
88 artists=UniqueList(),
89 provider_mappings={
90 ProviderMapping(
91 item_id="stale-item", provider_domain="qobuz", provider_instance="qobuz_1"
92 )
93 },
94 metadata=MediaItemMetadata(last_refresh=now - REFRESH_INTERVAL - 1),
95 )
96 )
97 await mass.music.albums.add_item_to_library(
98 Album(
99 item_id="0",
100 provider="library",
101 name="Fresh Album",
102 album_type=AlbumType.UNKNOWN,
103 artists=UniqueList(),
104 provider_mappings={
105 ProviderMapping(
106 item_id="fresh-item", provider_domain="qobuz", provider_instance="qobuz_1"
107 )
108 },
109 metadata=MediaItemMetadata(last_refresh=now - 1),
110 )
111 )
112
113 with (
114 patch.object(mass.metadata, "_update_album_metadata", AsyncMock()) as update_metadata,
115 patch.object(mass.music.albums, "match_providers", AsyncMock()),
116 ):
117 await mass.metadata._reconcile_duplicate_albums()
118
119 processed_ids = {call.args[0].item_id for call in update_metadata.await_args_list}
120 assert processed_ids == {stale_album.item_id}
121
122
123async def _add_album(
124 mass: MusicAssistant,
125 name: str,
126 artist: Artist,
127 *,
128 version: str = "",
129 album_type: AlbumType = AlbumType.ALBUM,
130 provider_instance: str = "qobuz_1",
131) -> Album:
132 """Add a never-refreshed library album for the given artist."""
133 return await mass.music.albums.add_item_to_library(
134 Album(
135 item_id="0",
136 provider="library",
137 name=name,
138 version=version,
139 album_type=album_type,
140 artists=UniqueList([artist]),
141 provider_mappings={
142 ProviderMapping(
143 item_id=f"{provider_instance}-{name}-{version}",
144 provider_domain=provider_instance.rsplit("_", 1)[0],
145 provider_instance=provider_instance,
146 )
147 },
148 )
149 )
150
151
152async def _reconciled_ids(mass: MusicAssistant) -> set[str]:
153 """Run the reconciliation task and return the item ids it picked up."""
154 with (
155 patch.object(mass.metadata, "_update_album_metadata", AsyncMock()) as update_metadata,
156 patch.object(mass.music.albums, "match_providers", AsyncMock()),
157 ):
158 await mass.metadata._reconcile_duplicate_albums()
159 return {call.args[0].item_id for call in update_metadata.await_args_list}
160
161
162async def test_reconcile_duplicate_albums_selects_typed_album_with_duplicate_sibling(
163 mass: MusicAssistant,
164) -> None:
165 """A fully-typed album is reconciled when another row shares its name, artist and version."""
166 artist = await mass.music.artists.add_item_to_library(
167 Artist(
168 item_id="0",
169 provider="library",
170 name="Phil Collins",
171 provider_mappings={
172 ProviderMapping(
173 item_id="artist", provider_domain="test", provider_instance="library"
174 )
175 },
176 )
177 )
178 first = await _add_album(
179 mass, "...But Seriously", artist, version="2016 Remaster", provider_instance="spotify_1"
180 )
181 second = await _add_album(
182 mass, "...But Seriously", artist, version="2016 Remaster", provider_instance="qobuz_1"
183 )
184 # neither row is unknown-typed, so only the duplicate-sibling clause can select them
185 assert first.item_id != second.item_id
186
187 assert await _reconciled_ids(mass) == {first.item_id, second.item_id}
188
189
190async def test_reconcile_duplicate_albums_skips_row_merged_away_earlier_in_the_batch() -> None:
191 """A row already merged into its duplicate is skipped silently, not reported as a failure."""
192 ctrl = _controller()
193 mass = Mock()
194 merged_away = _album_stub("1", "Merged Away")
195 healthy = _album_stub("2", "Healthy Album")
196 reloaded_healthy = _album_stub("2", "Healthy Album")
197 mass.music.albums.get_library_items_by_query = AsyncMock(return_value=[merged_away, healthy])
198 mass.music.albums.get_library_item = AsyncMock(return_value=reloaded_healthy)
199 mass.music.albums.match_providers = AsyncMock()
200 ctrl.mass = mass
201 ctrl._update_album_metadata = AsyncMock( # type: ignore[method-assign]
202 side_effect=[MediaNotFoundError("album not found in library: 1"), None]
203 )
204
205 with patch(_REPORT_FAILURE) as report_failure:
206 await ctrl._reconcile_duplicate_albums()
207
208 report_failure.assert_not_called()
209 mass.music.albums.match_providers.assert_awaited_once_with(reloaded_healthy)
210
211
212async def test_reconcile_duplicate_albums_reports_match_providers_not_found() -> None:
213 """A not-found raised by a provider search is reported, not mistaken for a merged row."""
214 ctrl = _controller()
215 mass = Mock()
216 album = _album_stub("1", "Searched Album")
217 mass.music.albums.get_library_items_by_query = AsyncMock(return_value=[album])
218 mass.music.albums.get_library_item = AsyncMock(return_value=album)
219 mass.music.albums.match_providers = AsyncMock(
220 side_effect=MediaNotFoundError("No results for Bandcamp search")
221 )
222 ctrl.mass = mass
223 ctrl._update_album_metadata = AsyncMock() # type: ignore[method-assign]
224
225 with patch(_REPORT_FAILURE) as report_failure:
226 await ctrl._reconcile_duplicate_albums()
227
228 report_failure.assert_called_once_with("Searched Album: No results for Bandcamp search")
229
230
231@pytest.mark.parametrize(
232 ("first_version", "second_version"),
233 [
234 ("", ""),
235 ("", "Deluxe Edition"),
236 ("2011 Remaster", "Deluxe Edition 2011 Remaster"),
237 ("Remixes Pt. 1", "Remixes Pt. 2"),
238 ],
239)
240async def test_reconcile_duplicate_albums_selects_siblings_whatever_the_edition(
241 mass: MusicAssistant, first_version: str, second_version: str
242) -> None:
243 """Candidate selection is identity-only; deciding the edition is the matcher's job."""
244 artist = await mass.music.artists.add_item_to_library(
245 Artist(
246 item_id="0",
247 provider="library",
248 name="Queen",
249 provider_mappings={
250 ProviderMapping(
251 item_id="artist", provider_domain="test", provider_instance="library"
252 )
253 },
254 )
255 )
256 first = await _add_album(
257 mass, "Innuendo", artist, version=first_version, provider_instance="spotify_1"
258 )
259 second = await _add_album(
260 mass, "Innuendo", artist, version=second_version, provider_instance="qobuz_1"
261 )
262
263 assert await _reconciled_ids(mass) == {first.item_id, second.item_id}
264
265
266@pytest.mark.parametrize(("first_title", "second_title"), [("!!!", "!!!"), ("( )", "()")])
267async def test_reconcile_duplicate_albums_selects_symbol_only_titles_spelled_the_same(
268 mass: MusicAssistant, first_title: str, second_title: str
269) -> None:
270 """Titles that normalize to nothing pair up on their raw spelling, ignoring spacing."""
271 artist = await mass.music.artists.add_item_to_library(
272 Artist(
273 item_id="0",
274 provider="library",
275 name="!!!",
276 provider_mappings={
277 ProviderMapping(
278 item_id="artist", provider_domain="test", provider_instance="library"
279 )
280 },
281 )
282 )
283 first = await _add_album(mass, first_title, artist, provider_instance="spotify_1")
284 second = await _add_album(mass, second_title, artist, provider_instance="qobuz_1")
285
286 assert await _reconciled_ids(mass) == {first.item_id, second.item_id}
287
288
289async def test_reconcile_duplicate_albums_ignores_other_albums_by_the_same_artist(
290 mass: MusicAssistant,
291) -> None:
292 """Sharing an artist is not enough; the titles have to match too."""
293 artist = await mass.music.artists.add_item_to_library(
294 Artist(
295 item_id="0",
296 provider="library",
297 name="Radiohead",
298 provider_mappings={
299 ProviderMapping(
300 item_id="artist", provider_domain="test", provider_instance="library"
301 )
302 },
303 )
304 )
305 await _add_album(mass, "Kid A", artist, provider_instance="spotify_1")
306 await _add_album(mass, "Amnesiac", artist, provider_instance="qobuz_1")
307
308 assert await _reconciled_ids(mass) == set()
309
310
311async def test_reconcile_duplicate_albums_ignores_same_title_by_other_artist(
312 mass: MusicAssistant,
313) -> None:
314 """Same-titled albums by unrelated artists are not treated as duplicates."""
315 artists = [
316 await mass.music.artists.add_item_to_library(
317 Artist(
318 item_id="0",
319 provider="library",
320 name=name,
321 provider_mappings={
322 ProviderMapping(
323 item_id=f"artist-{name}",
324 provider_domain="test",
325 provider_instance="library",
326 )
327 },
328 )
329 )
330 for name in ("Tracy Chapman", "Chase & Status")
331 ]
332 for index, artist in enumerate(artists):
333 await _add_album(mass, "The Collection", artist, provider_instance=f"qobuz_{index}")
334
335 assert await _reconciled_ids(mass) == set()
336
337
338async def test_reconcile_duplicate_albums_ignores_titles_that_normalize_to_nothing(
339 mass: MusicAssistant,
340) -> None:
341 """Symbol-only titles all normalize to an empty search name and must not pair up."""
342 artist = await mass.music.artists.add_item_to_library(
343 Artist(
344 item_id="0",
345 provider="library",
346 name="Ed Sheeran",
347 provider_mappings={
348 ProviderMapping(
349 item_id="artist", provider_domain="test", provider_instance="library"
350 )
351 },
352 )
353 )
354 for index, title in enumerate(("+", "=", "÷")):
355 await _add_album(mass, title, artist, provider_instance=f"qobuz_{index}")
356
357 assert await _reconciled_ids(mass) == set()
358
359
360async def test_reconcile_duplicate_albums_empty_queue_is_a_noop() -> None:
361 """An empty candidate batch does not touch any album."""
362 ctrl = _controller()
363 mass = Mock()
364 mass.music.albums.get_library_items_by_query = AsyncMock(return_value=[])
365 ctrl.mass = mass
366
367 await ctrl._reconcile_duplicate_albums()
368
369 mass.music.albums.get_library_item.assert_not_called()
370 mass.music.albums.match_providers.assert_not_called()
371
372
373# --------------------------------------------------------------------------- #
374# enrich -> reload -> re-match flow #
375# --------------------------------------------------------------------------- #
376
377
378async def test_reconcile_duplicate_albums_enriches_then_reloads_before_matching() -> None:
379 """Each album is enriched, the library row reloaded, then re-matched with fresh data."""
380 ctrl = _controller()
381 mass = Mock()
382 album = _album_stub("1", "Original Name")
383 reloaded_album = _album_stub("1", "Enriched Name")
384 mass.music.albums.get_library_items_by_query = AsyncMock(return_value=[album])
385 mass.music.albums.get_library_item = AsyncMock(return_value=reloaded_album)
386 mass.music.albums.match_providers = AsyncMock()
387 ctrl.mass = mass
388 ctrl._update_album_metadata = AsyncMock() # type: ignore[method-assign]
389
390 await ctrl._reconcile_duplicate_albums()
391
392 ctrl._update_album_metadata.assert_awaited_once_with(album, force_refresh=False)
393 mass.music.albums.get_library_item.assert_awaited_once_with("1")
394 # match_providers must see the reloaded (enriched) object, not the stale pre-update one
395 mass.music.albums.match_providers.assert_awaited_once_with(reloaded_album)
396
397
398async def test_reconcile_duplicate_albums_never_adds_or_deletes_directly() -> None:
399 """The task never adds a new library item or deletes one outside the safe merge path."""
400 ctrl = _controller()
401 mass = Mock()
402 album = _album_stub()
403 mass.music.albums.get_library_items_by_query = AsyncMock(return_value=[album])
404 mass.music.albums.get_library_item = AsyncMock(return_value=album)
405 mass.music.albums.match_providers = AsyncMock()
406 ctrl.mass = mass
407 ctrl._update_album_metadata = AsyncMock() # type: ignore[method-assign]
408
409 await ctrl._reconcile_duplicate_albums()
410
411 mass.music.albums.add_item_to_library.assert_not_called()
412 mass.music.albums.remove_item_from_library.assert_not_called()
413 mass.music.albums.merge_library_items.assert_not_called()
414
415
416async def test_reconcile_duplicate_albums_batch_size_bound() -> None:
417 """The batch never exceeds METADATA_SCAN_BATCH_SIZE, even when more items match."""
418 ctrl = _controller()
419 mass = Mock()
420 albums = [_album_stub(str(i), f"Album {i}") for i in range(METADATA_SCAN_BATCH_SIZE)]
421 mass.music.albums.get_library_items_by_query = AsyncMock(return_value=albums)
422 mass.music.albums.get_library_item = AsyncMock(side_effect=lambda item_id: _album_stub(item_id))
423 mass.music.albums.match_providers = AsyncMock()
424 ctrl.mass = mass
425 ctrl._update_album_metadata = AsyncMock() # type: ignore[method-assign]
426
427 await ctrl._reconcile_duplicate_albums()
428
429 assert ctrl._update_album_metadata.await_count == METADATA_SCAN_BATCH_SIZE
430 assert mass.music.albums.match_providers.await_count == METADATA_SCAN_BATCH_SIZE
431
432
433# --------------------------------------------------------------------------- #
434# per-item failure isolation #
435# --------------------------------------------------------------------------- #
436
437
438@pytest.mark.parametrize(
439 "error",
440 [
441 MusicAssistantError("boom"),
442 aiohttp.ClientError("connection reset"),
443 TimeoutError("timed out"),
444 ],
445)
446async def test_reconcile_duplicate_albums_isolates_metadata_failure(error: Exception) -> None:
447 """An expected per-item metadata failure is reported and does not raise."""
448 ctrl = _controller()
449 mass = Mock()
450 album = _album_stub("1", "Failing Album")
451 mass.music.albums.get_library_items_by_query = AsyncMock(return_value=[album])
452 mass.music.albums.get_library_item = AsyncMock()
453 mass.music.albums.match_providers = AsyncMock()
454 ctrl.mass = mass
455 ctrl._update_album_metadata = AsyncMock(side_effect=error) # type: ignore[method-assign]
456
457 with patch(_REPORT_FAILURE) as report_failure:
458 await ctrl._reconcile_duplicate_albums() # must not raise
459
460 report_failure.assert_called_once_with(f"Failing Album: {error}")
461 # the failed item never reaches reload/re-match
462 mass.music.albums.get_library_item.assert_not_called()
463 mass.music.albums.match_providers.assert_not_called()
464
465
466async def test_reconcile_duplicate_albums_isolates_match_providers_failure() -> None:
467 """A provider search failure during re-matching is caught and reported, not raised."""
468 ctrl = _controller()
469 mass = Mock()
470 album = _album_stub("1", "Flaky Album")
471 reloaded = _album_stub("1", "Flaky Album")
472 mass.music.albums.get_library_items_by_query = AsyncMock(return_value=[album])
473 mass.music.albums.get_library_item = AsyncMock(return_value=reloaded)
474 mass.music.albums.match_providers = AsyncMock(
475 side_effect=aiohttp.ClientError("connection reset")
476 )
477 ctrl.mass = mass
478 ctrl._update_album_metadata = AsyncMock() # type: ignore[method-assign]
479
480 with patch(_REPORT_FAILURE) as report_failure:
481 await ctrl._reconcile_duplicate_albums() # must not raise
482
483 report_failure.assert_called_once_with("Flaky Album: connection reset")
484
485
486async def test_reconcile_duplicate_albums_failure_does_not_abort_the_batch() -> None:
487 """A failing album is isolated; the remaining albums in the batch still get processed."""
488 ctrl = _controller()
489 mass = Mock()
490 failing_album = _album_stub("1", "Failing Album")
491 healthy_album = _album_stub("2", "Healthy Album")
492 reloaded_healthy = _album_stub("2", "Healthy Album")
493 mass.music.albums.get_library_items_by_query = AsyncMock(
494 return_value=[failing_album, healthy_album]
495 )
496 mass.music.albums.get_library_item = AsyncMock(return_value=reloaded_healthy)
497 mass.music.albums.match_providers = AsyncMock()
498 ctrl.mass = mass
499 ctrl._update_album_metadata = AsyncMock( # type: ignore[method-assign]
500 side_effect=[MusicAssistantError("boom"), None]
501 )
502
503 with patch(_REPORT_FAILURE) as report_failure:
504 await ctrl._reconcile_duplicate_albums()
505
506 report_failure.assert_called_once_with("Failing Album: boom")
507 mass.music.albums.get_library_item.assert_awaited_once_with("2")
508 mass.music.albums.match_providers.assert_awaited_once_with(reloaded_healthy)
509
510
511async def test_reconcile_duplicate_albums_no_match_completes_without_failure() -> None:
512 """A normal no-match completion is not treated as a failure; the album stays attempted."""
513 ctrl = _controller()
514 mass = Mock()
515 album = _album_stub("1", "No Match Album")
516 reloaded = _album_stub("1", "No Match Album")
517 mass.music.albums.get_library_items_by_query = AsyncMock(return_value=[album])
518 mass.music.albums.get_library_item = AsyncMock(return_value=reloaded)
519 mass.music.albums.match_providers = AsyncMock(return_value=None)
520 ctrl.mass = mass
521 ctrl._update_album_metadata = AsyncMock() # type: ignore[method-assign]
522
523 with patch(_REPORT_FAILURE) as report_failure:
524 await ctrl._reconcile_duplicate_albums()
525
526 report_failure.assert_not_called()
527 ctrl._update_album_metadata.assert_awaited_once_with(album, force_refresh=False)
528 mass.music.albums.match_providers.assert_awaited_once_with(reloaded)
529
530
531# --------------------------------------------------------------------------- #
532# integration: a confirmed match owned by a duplicate merges via the safe path #
533# --------------------------------------------------------------------------- #
534
535
536async def test_reconcile_duplicate_albums_merges_conflicting_mapping_via_safe_path(
537 mass: MusicAssistant,
538) -> None:
539 """
540 A confirmed re-match already owned by another library album merges, not duplicates.
541
542 Drives the real `AlbumsController.match_providers` -> `add_provider_mappings` path
543 end to end against a real (test) database, only stubbing the provider IO boundary
544 (search/full-item fetch), to prove the reconciliation task relies on the existing
545 safe merge primitive instead of reimplementing conflict handling.
546 """
547 artist = await mass.music.artists.add_item_to_library(
548 Artist(
549 item_id="0",
550 provider="library",
551 name="Sigur Rós",
552 provider_mappings={
553 ProviderMapping(
554 item_id="artist", provider_domain="test", provider_instance="library"
555 )
556 },
557 )
558 )
559 target = await mass.music.albums.add_item_to_library(
560 Album(
561 item_id="0",
562 provider="library",
563 name="( )",
564 album_type=AlbumType.UNKNOWN,
565 artists=UniqueList([artist]),
566 provider_mappings={
567 ProviderMapping(
568 item_id="qobuz-item", provider_domain="qobuz", provider_instance="qobuz_1"
569 )
570 },
571 )
572 )
573 duplicate = await mass.music.albums.add_item_to_library(
574 Album(
575 item_id="0",
576 provider="library",
577 name="( )",
578 album_type=AlbumType.ALBUM,
579 artists=UniqueList([artist]),
580 provider_mappings={
581 ProviderMapping(
582 item_id="spotify-item",
583 provider_domain="spotify",
584 provider_instance="spotify_1",
585 )
586 },
587 )
588 )
589
590 provider = Mock()
591 provider.domain = "spotify"
592 provider.instance_id = "spotify_1"
593 provider.supported_features = {ProviderFeature.SEARCH}
594 provider.is_streaming_provider = True
595
596 search_result = Album(
597 item_id="spotify-item",
598 provider="spotify_1",
599 name=target.name,
600 artists=UniqueList([artist]),
601 provider_mappings={
602 ProviderMapping(
603 item_id="spotify-item", provider_domain="spotify", provider_instance="spotify_1"
604 )
605 },
606 )
607
608 # both providers must be "available" for the match to be considered, and for
609 # add_provider_mappings' subsequent uniqueness check to see qobuz_1's own mapping
610 await set_global_cache_values({"available_providers": {"qobuz_1", "spotify_1"}})
611
612 with (
613 patch.object(mass.music.albums, "search", AsyncMock(return_value=[search_result])),
614 patch.object(mass.music.albums, "get_provider_item", AsyncMock(return_value=search_result)),
615 patch.object(mass.music, "library_supported", Mock(return_value=True)),
616 patch.object(type(mass.music), "providers", new_callable=PropertyMock) as providers_mock,
617 ):
618 providers_mock.return_value = [provider]
619 await mass.music.albums.match_providers(target)
620
621 # the duplicate row is gone: its mapping was transferred, not recreated
622 with pytest.raises(MediaNotFoundError):
623 await mass.music.albums.get_library_item(duplicate.item_id)
624 merged = await mass.music.albums.get_library_item(target.item_id)
625 assert {m.provider_instance for m in merged.provider_mappings} == {"qobuz_1", "spotify_1"}
626