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