/
/
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, MediaType, 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_selects_spelled_out_retail_suffix_sibling(
290 mass: MusicAssistant,
291) -> None:
292 """A leftover 'X - EP' row is paired with its plain-title sibling, which now stores it."""
293 artist = await mass.music.artists.add_item_to_library(
294 Artist(
295 item_id="0",
296 provider="library",
297 name="Kygo",
298 provider_mappings={
299 ProviderMapping(
300 item_id="artist", provider_domain="test", provider_instance="library"
301 )
302 },
303 )
304 )
305 plain = await _add_album(mass, "Stargazing", artist, provider_instance="spotify_1")
306 suffixed = await _add_album(
307 mass,
308 "Stargazing - EP",
309 artist,
310 album_type=AlbumType.SINGLE,
311 provider_instance="apple_music_1",
312 )
313 assert plain.item_id != suffixed.item_id
314
315 assert await _reconciled_ids(mass) == {plain.item_id, suffixed.item_id}
316
317
318async def test_reconcile_duplicate_albums_selects_retail_suffix_on_a_symbol_only_title(
319 mass: MusicAssistant,
320) -> None:
321 """A retail suffix on a title that normalizes to nothing still reaches the matcher."""
322 artist = await mass.music.artists.add_item_to_library(
323 Artist(
324 item_id="0",
325 provider="library",
326 name="Nils Frahm",
327 provider_mappings={
328 ProviderMapping(
329 item_id="artist", provider_domain="test", provider_instance="library"
330 )
331 },
332 )
333 )
334 await _add_album(mass, "...", artist, provider_instance="spotify_1")
335 suffixed = await _add_album(
336 mass,
337 "... - EP",
338 artist,
339 album_type=AlbumType.SINGLE,
340 provider_instance="apple_music_1",
341 )
342
343 assert await _reconciled_ids(mass) == {suffixed.item_id}
344
345
346async def test_reconcile_duplicate_albums_ignores_a_title_merely_ending_in_a_suffix_word(
347 mass: MusicAssistant,
348) -> None:
349 """An ordinary title that just ends in the suffix letters is not a retail-suffix sibling."""
350 artist = await mass.music.artists.add_item_to_library(
351 Artist(
352 item_id="0",
353 provider="library",
354 name="Vangelis",
355 provider_mappings={
356 ProviderMapping(
357 item_id="artist", provider_domain="test", provider_instance="library"
358 )
359 },
360 )
361 )
362 await _add_album(mass, "Step", artist, provider_instance="spotify_1")
363 await _add_album(mass, "St", artist, provider_instance="qobuz_1")
364
365 assert await _reconciled_ids(mass) == set()
366
367
368async def test_reconcile_duplicate_albums_ignores_other_albums_by_the_same_artist(
369 mass: MusicAssistant,
370) -> None:
371 """Sharing an artist is not enough; the titles have to match too."""
372 artist = await mass.music.artists.add_item_to_library(
373 Artist(
374 item_id="0",
375 provider="library",
376 name="Radiohead",
377 provider_mappings={
378 ProviderMapping(
379 item_id="artist", provider_domain="test", provider_instance="library"
380 )
381 },
382 )
383 )
384 await _add_album(mass, "Kid A", artist, provider_instance="spotify_1")
385 await _add_album(mass, "Amnesiac", artist, provider_instance="qobuz_1")
386
387 assert await _reconciled_ids(mass) == set()
388
389
390async def test_reconcile_duplicate_albums_ignores_same_title_by_other_artist(
391 mass: MusicAssistant,
392) -> None:
393 """Same-titled albums by unrelated artists are not treated as duplicates."""
394 artists = [
395 await mass.music.artists.add_item_to_library(
396 Artist(
397 item_id="0",
398 provider="library",
399 name=name,
400 provider_mappings={
401 ProviderMapping(
402 item_id=f"artist-{name}",
403 provider_domain="test",
404 provider_instance="library",
405 )
406 },
407 )
408 )
409 for name in ("Tracy Chapman", "Chase & Status")
410 ]
411 for index, artist in enumerate(artists):
412 await _add_album(mass, "The Collection", artist, provider_instance=f"qobuz_{index}")
413
414 assert await _reconciled_ids(mass) == set()
415
416
417async def test_reconcile_duplicate_albums_ignores_titles_that_normalize_to_nothing(
418 mass: MusicAssistant,
419) -> None:
420 """Symbol-only titles all normalize to an empty search name and must not pair up."""
421 artist = await mass.music.artists.add_item_to_library(
422 Artist(
423 item_id="0",
424 provider="library",
425 name="Ed Sheeran",
426 provider_mappings={
427 ProviderMapping(
428 item_id="artist", provider_domain="test", provider_instance="library"
429 )
430 },
431 )
432 )
433 for index, title in enumerate(("+", "=", "÷")):
434 await _add_album(mass, title, artist, provider_instance=f"qobuz_{index}")
435
436 assert await _reconciled_ids(mass) == set()
437
438
439async def test_reconcile_duplicate_albums_empty_queue_is_a_noop() -> None:
440 """An empty candidate batch does not touch any album."""
441 ctrl = _controller()
442 mass = Mock()
443 mass.music.albums.get_library_items_by_query = AsyncMock(return_value=[])
444 ctrl.mass = mass
445
446 await ctrl._reconcile_duplicate_albums()
447
448 mass.music.albums.get_library_item.assert_not_called()
449 mass.music.albums.match_providers.assert_not_called()
450
451
452# --------------------------------------------------------------------------- #
453# enrich -> reload -> re-match flow #
454# --------------------------------------------------------------------------- #
455
456
457async def test_reconcile_duplicate_albums_enriches_then_reloads_before_matching() -> None:
458 """Each album is enriched, the library row reloaded, then re-matched with fresh data."""
459 ctrl = _controller()
460 mass = Mock()
461 album = _album_stub("1", "Original Name")
462 reloaded_album = _album_stub("1", "Enriched Name")
463 mass.music.albums.get_library_items_by_query = AsyncMock(return_value=[album])
464 mass.music.albums.get_library_item = AsyncMock(return_value=reloaded_album)
465 mass.music.albums.match_providers = AsyncMock()
466 ctrl.mass = mass
467 ctrl._update_album_metadata = AsyncMock() # type: ignore[method-assign]
468
469 await ctrl._reconcile_duplicate_albums()
470
471 ctrl._update_album_metadata.assert_awaited_once_with(album, force_refresh=False)
472 mass.music.albums.get_library_item.assert_awaited_once_with("1")
473 # match_providers must see the reloaded (enriched) object, not the stale pre-update one
474 mass.music.albums.match_providers.assert_awaited_once_with(reloaded_album)
475
476
477async def test_reconcile_duplicate_albums_never_adds_or_deletes_directly() -> None:
478 """The task never adds a new library item or deletes one outside the safe merge path."""
479 ctrl = _controller()
480 mass = Mock()
481 album = _album_stub()
482 mass.music.albums.get_library_items_by_query = AsyncMock(return_value=[album])
483 mass.music.albums.get_library_item = AsyncMock(return_value=album)
484 mass.music.albums.match_providers = AsyncMock()
485 ctrl.mass = mass
486 ctrl._update_album_metadata = AsyncMock() # type: ignore[method-assign]
487
488 await ctrl._reconcile_duplicate_albums()
489
490 mass.music.albums.add_item_to_library.assert_not_called()
491 mass.music.albums.remove_item_from_library.assert_not_called()
492 mass.music.albums.merge_library_items.assert_not_called()
493
494
495async def test_reconcile_duplicate_albums_batch_size_bound() -> None:
496 """The batch never exceeds METADATA_SCAN_BATCH_SIZE, even when more items match."""
497 ctrl = _controller()
498 mass = Mock()
499 albums = [_album_stub(str(i), f"Album {i}") for i in range(METADATA_SCAN_BATCH_SIZE)]
500 mass.music.albums.get_library_items_by_query = AsyncMock(return_value=albums)
501 mass.music.albums.get_library_item = AsyncMock(side_effect=lambda item_id: _album_stub(item_id))
502 mass.music.albums.match_providers = AsyncMock()
503 ctrl.mass = mass
504 ctrl._update_album_metadata = AsyncMock() # type: ignore[method-assign]
505
506 await ctrl._reconcile_duplicate_albums()
507
508 assert ctrl._update_album_metadata.await_count == METADATA_SCAN_BATCH_SIZE
509 assert mass.music.albums.match_providers.await_count == METADATA_SCAN_BATCH_SIZE
510
511
512# --------------------------------------------------------------------------- #
513# per-item failure isolation #
514# --------------------------------------------------------------------------- #
515
516
517@pytest.mark.parametrize(
518 "error",
519 [
520 MusicAssistantError("boom"),
521 aiohttp.ClientError("connection reset"),
522 TimeoutError("timed out"),
523 ],
524)
525async def test_reconcile_duplicate_albums_isolates_metadata_failure(error: Exception) -> None:
526 """An expected per-item metadata failure is reported and does not raise."""
527 ctrl = _controller()
528 mass = Mock()
529 album = _album_stub("1", "Failing Album")
530 mass.music.albums.get_library_items_by_query = AsyncMock(return_value=[album])
531 mass.music.albums.get_library_item = AsyncMock()
532 mass.music.albums.match_providers = AsyncMock()
533 ctrl.mass = mass
534 ctrl._update_album_metadata = AsyncMock(side_effect=error) # type: ignore[method-assign]
535
536 with patch(_REPORT_FAILURE) as report_failure:
537 await ctrl._reconcile_duplicate_albums() # must not raise
538
539 report_failure.assert_called_once_with(f"Failing Album: {error}")
540 # the failed item never reaches reload/re-match
541 mass.music.albums.get_library_item.assert_not_called()
542 mass.music.albums.match_providers.assert_not_called()
543
544
545async def test_reconcile_duplicate_albums_isolates_match_providers_failure() -> None:
546 """A provider search failure during re-matching is caught and reported, not raised."""
547 ctrl = _controller()
548 mass = Mock()
549 album = _album_stub("1", "Flaky Album")
550 reloaded = _album_stub("1", "Flaky Album")
551 mass.music.albums.get_library_items_by_query = AsyncMock(return_value=[album])
552 mass.music.albums.get_library_item = AsyncMock(return_value=reloaded)
553 mass.music.albums.match_providers = AsyncMock(
554 side_effect=aiohttp.ClientError("connection reset")
555 )
556 ctrl.mass = mass
557 ctrl._update_album_metadata = AsyncMock() # type: ignore[method-assign]
558
559 with patch(_REPORT_FAILURE) as report_failure:
560 await ctrl._reconcile_duplicate_albums() # must not raise
561
562 report_failure.assert_called_once_with("Flaky Album: connection reset")
563
564
565async def test_reconcile_duplicate_albums_failure_does_not_abort_the_batch() -> None:
566 """A failing album is isolated; the remaining albums in the batch still get processed."""
567 ctrl = _controller()
568 mass = Mock()
569 failing_album = _album_stub("1", "Failing Album")
570 healthy_album = _album_stub("2", "Healthy Album")
571 reloaded_healthy = _album_stub("2", "Healthy Album")
572 mass.music.albums.get_library_items_by_query = AsyncMock(
573 return_value=[failing_album, healthy_album]
574 )
575 mass.music.albums.get_library_item = AsyncMock(return_value=reloaded_healthy)
576 mass.music.albums.match_providers = AsyncMock()
577 ctrl.mass = mass
578 ctrl._update_album_metadata = AsyncMock( # type: ignore[method-assign]
579 side_effect=[MusicAssistantError("boom"), None]
580 )
581
582 with patch(_REPORT_FAILURE) as report_failure:
583 await ctrl._reconcile_duplicate_albums()
584
585 report_failure.assert_called_once_with("Failing Album: boom")
586 mass.music.albums.get_library_item.assert_awaited_once_with("2")
587 mass.music.albums.match_providers.assert_awaited_once_with(reloaded_healthy)
588
589
590async def test_reconcile_duplicate_albums_no_match_completes_without_failure() -> None:
591 """A normal no-match completion is not treated as a failure; the album stays attempted."""
592 ctrl = _controller()
593 mass = Mock()
594 album = _album_stub("1", "No Match Album")
595 reloaded = _album_stub("1", "No Match Album")
596 mass.music.albums.get_library_items_by_query = AsyncMock(return_value=[album])
597 mass.music.albums.get_library_item = AsyncMock(return_value=reloaded)
598 mass.music.albums.match_providers = AsyncMock(return_value=None)
599 ctrl.mass = mass
600 ctrl._update_album_metadata = AsyncMock() # type: ignore[method-assign]
601
602 with patch(_REPORT_FAILURE) as report_failure:
603 await ctrl._reconcile_duplicate_albums()
604
605 report_failure.assert_not_called()
606 ctrl._update_album_metadata.assert_awaited_once_with(album, force_refresh=False)
607 mass.music.albums.match_providers.assert_awaited_once_with(reloaded)
608
609
610# --------------------------------------------------------------------------- #
611# integration: a confirmed match owned by a duplicate merges via the safe path #
612# --------------------------------------------------------------------------- #
613
614
615async def test_reconcile_duplicate_albums_merges_conflicting_mapping_via_safe_path(
616 mass: MusicAssistant,
617) -> None:
618 """
619 A confirmed re-match already owned by another library album merges, not duplicates.
620
621 Drives the real `AlbumsController.match_providers` -> `add_provider_mappings` path
622 end to end against a real (test) database, only stubbing the provider IO boundary
623 (search/full-item fetch), to prove the reconciliation task relies on the existing
624 safe merge primitive instead of reimplementing conflict handling.
625 """
626 artist = await mass.music.artists.add_item_to_library(
627 Artist(
628 item_id="0",
629 provider="library",
630 name="Sigur Rós",
631 provider_mappings={
632 ProviderMapping(
633 item_id="artist", provider_domain="test", provider_instance="library"
634 )
635 },
636 )
637 )
638 target = await mass.music.albums.add_item_to_library(
639 Album(
640 item_id="0",
641 provider="library",
642 name="( )",
643 album_type=AlbumType.UNKNOWN,
644 artists=UniqueList([artist]),
645 provider_mappings={
646 ProviderMapping(
647 item_id="qobuz-item", provider_domain="qobuz", provider_instance="qobuz_1"
648 )
649 },
650 )
651 )
652 duplicate = await mass.music.albums.add_item_to_library(
653 Album(
654 item_id="0",
655 provider="library",
656 name="( )",
657 album_type=AlbumType.ALBUM,
658 artists=UniqueList([artist]),
659 provider_mappings={
660 ProviderMapping(
661 item_id="spotify-item",
662 provider_domain="spotify",
663 provider_instance="spotify_1",
664 )
665 },
666 )
667 )
668
669 provider = Mock()
670 provider.domain = "spotify"
671 provider.instance_id = "spotify_1"
672 provider.supported_features = {ProviderFeature.SEARCH}
673 provider.supported_media_types = {MediaType.ALBUM}
674 provider.is_streaming_provider = True
675
676 search_result = Album(
677 item_id="spotify-item",
678 provider="spotify_1",
679 name=target.name,
680 artists=UniqueList([artist]),
681 provider_mappings={
682 ProviderMapping(
683 item_id="spotify-item", provider_domain="spotify", provider_instance="spotify_1"
684 )
685 },
686 )
687
688 # both providers must be "available" for the match to be considered, and for
689 # add_provider_mappings' subsequent uniqueness check to see qobuz_1's own mapping
690 await set_global_cache_values({"available_providers": {"qobuz_1", "spotify_1"}})
691
692 with (
693 patch.object(mass.music.albums, "search", AsyncMock(return_value=[search_result])),
694 patch.object(mass.music.albums, "get_provider_item", AsyncMock(return_value=search_result)),
695 patch.object(type(mass.music), "providers", new_callable=PropertyMock) as providers_mock,
696 ):
697 providers_mock.return_value = [provider]
698 await mass.music.albums.match_providers(target)
699
700 # the duplicate row is gone: its mapping was transferred, not recreated
701 with pytest.raises(MediaNotFoundError):
702 await mass.music.albums.get_library_item(duplicate.item_id)
703 merged = await mass.music.albums.get_library_item(target.item_id)
704 assert {m.provider_instance for m in merged.provider_mappings} == {"qobuz_1", "spotify_1"}
705