/
/
1"""Integration tests for non-destructive library item merges."""
2
3from __future__ import annotations
4
5from unittest.mock import AsyncMock, patch
6
7import pytest
8from music_assistant_models.enums import AlbumType, ArtistType, ExternalID, MediaType
9from music_assistant_models.errors import InvalidDataError, MediaNotFoundError, MusicAssistantError
10from music_assistant_models.media_items import (
11 Album,
12 Artist,
13 Audiobook,
14 Genre,
15 ProviderMapping,
16 Track,
17 UniqueList,
18)
19
20from music_assistant.constants import (
21 DB_TABLE_ALBUM_ARTISTS,
22 DB_TABLE_ALBUM_TRACKS,
23 DB_TABLE_ALBUMS,
24 DB_TABLE_GENRE_MEDIA_ITEM_EXCLUSION,
25 DB_TABLE_GENRE_MEDIA_ITEM_MAPPING,
26 DB_TABLE_PLAYLOG,
27 DB_TABLE_PROVIDER_MAPPINGS,
28 DB_TABLE_TRACK_ARTISTS,
29 DB_TABLE_TRACKS,
30)
31from music_assistant.controllers.music.media.base import (
32 SUPPRESS_MEDIA_ITEM_UPDATES,
33 MediaControllerBase,
34)
35from music_assistant.mass import MusicAssistant
36
37
38def _mapping(provider_instance: str, item_id: str) -> ProviderMapping:
39 """Create a provider mapping for a library fixture item."""
40 return ProviderMapping(
41 item_id=item_id,
42 provider_domain=provider_instance.removesuffix("_instance"),
43 provider_instance=provider_instance,
44 in_library=True,
45 )
46
47
48async def _add_artist(
49 mass: MusicAssistant, name: str, provider_instance: str, provider_item_id: str
50) -> Artist:
51 """Create and store a fixture artist."""
52 return await mass.music.artists.add_item_to_library(
53 Artist(
54 item_id="0",
55 provider="library",
56 name=name,
57 provider_mappings={_mapping(provider_instance, provider_item_id)},
58 )
59 )
60
61
62async def _add_album(
63 mass: MusicAssistant,
64 name: str,
65 provider_instance: str,
66 provider_item_id: str,
67 artist: Artist,
68 external_id: str,
69) -> Album:
70 """Create and store a fixture album."""
71 return await mass.music.albums.add_item_to_library(
72 Album(
73 item_id="0",
74 provider="library",
75 name=name,
76 album_type=AlbumType.ALBUM,
77 provider_mappings={_mapping(provider_instance, provider_item_id)},
78 external_ids={(ExternalID.BARCODE, external_id)},
79 artists=UniqueList([artist]),
80 )
81 )
82
83
84async def _add_track(
85 mass: MusicAssistant,
86 name: str,
87 provider_instance: str,
88 provider_item_id: str,
89 artist: Artist,
90 album: Album,
91) -> Track:
92 """Create and store a fixture track."""
93 return await mass.music.tracks.add_item_to_library(
94 Track(
95 item_id="0",
96 provider="library",
97 name=name,
98 provider_mappings={_mapping(provider_instance, provider_item_id)},
99 artists=UniqueList([artist]),
100 album=album,
101 disc_number=1,
102 track_number=1,
103 )
104 )
105
106
107async def _add_playlog(
108 mass: MusicAssistant,
109 item_id: str | int,
110 provider: str,
111 media_type: MediaType,
112 *,
113 timestamp: int,
114 seconds_played: int,
115 user_initiated: bool,
116) -> None:
117 """Insert a playlog row for merge coverage."""
118 await mass.music.database.insert(
119 DB_TABLE_PLAYLOG,
120 {
121 "item_id": item_id,
122 "provider": provider,
123 "media_type": media_type.value,
124 "name": "Merge test",
125 "timestamp": timestamp,
126 "seconds_played": seconds_played,
127 "userid": "test-user",
128 "user_initiated": user_initiated,
129 },
130 )
131
132
133async def test_mapping_conflict_merges_albums_without_deleting_tracks(
134 mass: MusicAssistant,
135) -> None:
136 """A mapping conflict merges albums without recursively deleting their tracks."""
137 target_artist = await _add_artist(mass, "Target Artist", "target_instance", "target-artist")
138 source_artist = await _add_artist(mass, "Source Artist", "source_instance", "source-artist")
139 target = await _add_album(
140 mass,
141 "Target Album",
142 "target_instance",
143 "target-album",
144 target_artist,
145 "target-barcode",
146 )
147 source = await _add_album(
148 mass,
149 "Source Album",
150 "source_instance",
151 "source-album",
152 source_artist,
153 "source-barcode",
154 )
155 shared_track = await _add_track(
156 mass,
157 "Shared Track",
158 "shared_instance",
159 "shared-track",
160 target_artist,
161 target,
162 )
163 source_track = await _add_track(
164 mass,
165 "Source Track",
166 "source-track_instance",
167 "source-track",
168 source_artist,
169 source,
170 )
171 await mass.music.database.insert(
172 DB_TABLE_ALBUM_TRACKS,
173 {
174 "track_id": int(shared_track.item_id),
175 "album_id": int(source.item_id),
176 "disc_number": 1,
177 "track_number": 1,
178 },
179 )
180 await mass.music.database.update(
181 DB_TABLE_ALBUMS,
182 {"item_id": int(target.item_id)},
183 {
184 "favorite": False,
185 "play_count": 2,
186 "last_played": 20,
187 "timestamp_added": 20,
188 "timestamp_modified": 20,
189 },
190 )
191 await mass.music.database.update(
192 DB_TABLE_ALBUMS,
193 {"item_id": int(source.item_id)},
194 {
195 "favorite": True,
196 "play_count": 3,
197 "last_played": 30,
198 "timestamp_added": 10,
199 "timestamp_modified": 30,
200 },
201 )
202 genre = await mass.music.genres.add_item_to_library(
203 Genre(item_id="0", provider="library", name="Merge Genre", provider_mappings=set())
204 )
205 await mass.music.database.insert(
206 DB_TABLE_GENRE_MEDIA_ITEM_MAPPING,
207 {
208 "genre_id": int(genre.item_id),
209 "media_id": int(target.item_id),
210 "media_type": MediaType.ALBUM.value,
211 "alias": "target",
212 "is_derived": True,
213 "is_manual": False,
214 },
215 )
216 await mass.music.database.insert(
217 DB_TABLE_GENRE_MEDIA_ITEM_MAPPING,
218 {
219 "genre_id": int(genre.item_id),
220 "media_id": int(source.item_id),
221 "media_type": MediaType.ALBUM.value,
222 "alias": "source",
223 "is_derived": False,
224 "is_manual": True,
225 },
226 )
227 await mass.music.database.insert(
228 DB_TABLE_GENRE_MEDIA_ITEM_EXCLUSION,
229 {
230 "genre_id": int(genre.item_id),
231 "media_id": int(source.item_id),
232 "media_type": MediaType.ALBUM.value,
233 },
234 )
235 await _add_playlog(
236 mass,
237 target.item_id,
238 "library",
239 MediaType.ALBUM,
240 timestamp=20,
241 seconds_played=20,
242 user_initiated=False,
243 )
244 await _add_playlog(
245 mass,
246 source.item_id,
247 "library",
248 MediaType.ALBUM,
249 timestamp=30,
250 seconds_played=30,
251 user_initiated=True,
252 )
253 await _add_playlog(
254 mass,
255 "source-album",
256 "source_instance",
257 MediaType.ALBUM,
258 timestamp=30,
259 seconds_played=30,
260 user_initiated=True,
261 )
262
263 original_remove = MediaControllerBase.remove_item_from_library
264
265 async def assert_transferred_before_delete(
266 controller: MediaControllerBase[Album], item_id: str | int, recursive: bool = True
267 ) -> None:
268 if int(item_id) == int(source.item_id):
269 assert await mass.music.database.get_row(
270 DB_TABLE_PROVIDER_MAPPINGS,
271 {
272 "media_type": MediaType.ALBUM.value,
273 "item_id": int(target.item_id),
274 "provider_instance": "source_instance",
275 "provider_item_id": "source-album",
276 },
277 )
278 assert await mass.music.database.get_row(
279 DB_TABLE_ALBUM_TRACKS,
280 {"track_id": int(source_track.item_id), "album_id": int(target.item_id)},
281 )
282 await original_remove(controller, item_id, recursive)
283
284 with (
285 patch.object(
286 MediaControllerBase, "remove_item_from_library", assert_transferred_before_delete
287 ),
288 patch.object(
289 mass.music.albums,
290 "update_item_in_library",
291 wraps=mass.music.albums.update_item_in_library,
292 ) as update_item,
293 ):
294 await mass.music.albums.add_provider_mappings(target.item_id, source.provider_mappings)
295 update_item.assert_not_awaited()
296
297 await _assert_album_merge_result(
298 mass,
299 target,
300 source,
301 shared_track,
302 source_track,
303 target_artist,
304 source_artist,
305 genre,
306 )
307
308
309async def test_track_merge_preserves_album_and_artist_relations(mass: MusicAssistant) -> None:
310 """A track merge transfers all album and artist relations without deleting albums."""
311 target_artist = await _add_artist(mass, "Target Artist", "target_instance", "target-artist")
312 source_artist = await _add_artist(mass, "Source Artist", "source_instance", "source-artist")
313 target_album = await _add_album(
314 mass,
315 "Target Album",
316 "target_instance",
317 "target-album",
318 target_artist,
319 "target-barcode",
320 )
321 source_album = await _add_album(
322 mass,
323 "Source Album",
324 "source_instance",
325 "source-album",
326 source_artist,
327 "source-barcode",
328 )
329 target = await _add_track(
330 mass,
331 "Target Track",
332 "target_instance",
333 "target-track",
334 target_artist,
335 target_album,
336 )
337 source = await _add_track(
338 mass,
339 "Source Track",
340 "source_instance",
341 "source-track",
342 source_artist,
343 source_album,
344 )
345 await mass.music.database.insert(
346 DB_TABLE_ALBUM_TRACKS,
347 {
348 "track_id": int(target.item_id),
349 "album_id": int(source_album.item_id),
350 "disc_number": 7,
351 "track_number": 8,
352 },
353 )
354
355 await mass.music.tracks.merge_library_items(target.item_id, source.item_id)
356
357 with pytest.raises(MediaNotFoundError):
358 await mass.music.tracks.get_library_item(source.item_id)
359 assert await mass.music.albums.get_library_item(target_album.item_id)
360 assert await mass.music.albums.get_library_item(source_album.item_id)
361 assert {
362 int(row["album_id"])
363 for row in await mass.music.database.get_rows(
364 DB_TABLE_ALBUM_TRACKS, {"track_id": int(target.item_id)}
365 )
366 } == {int(target_album.item_id), int(source_album.item_id)}
367 assert {
368 int(row["artist_id"])
369 for row in await mass.music.database.get_rows(
370 DB_TABLE_TRACK_ARTISTS, {"track_id": int(target.item_id)}
371 )
372 } == {int(target_artist.item_id), int(source_artist.item_id)}
373 source_album_track = await mass.music.database.get_row(
374 DB_TABLE_ALBUM_TRACKS,
375 {"track_id": int(target.item_id), "album_id": int(source_album.item_id)},
376 )
377 assert source_album_track is not None
378 assert source_album_track["disc_number"] == 7
379 assert source_album_track["track_number"] == 8
380 with pytest.raises(MediaNotFoundError):
381 await mass.music.tracks.merge_library_items(target.item_id, source.item_id)
382
383
384async def test_audiobook_merge_keeps_per_user_resume_state(mass: MusicAssistant) -> None:
385 """An audiobook merge leaves resume transfer to the library playlog merge."""
386 target = await mass.music.audiobooks.add_item_to_library(
387 Audiobook(
388 item_id="0",
389 provider="library",
390 name="Target Book",
391 duration=3600,
392 provider_mappings={_mapping("target_instance", "target-book")},
393 )
394 )
395 source = await mass.music.audiobooks.add_item_to_library(
396 Audiobook(
397 item_id="0",
398 provider="library",
399 name="Source Book",
400 duration=3600,
401 provider_mappings={_mapping("source_instance", "source-book")},
402 )
403 )
404 await _add_playlog(
405 mass,
406 target.item_id,
407 "library",
408 MediaType.AUDIOBOOK,
409 timestamp=20,
410 seconds_played=20,
411 user_initiated=False,
412 )
413 await _add_playlog(
414 mass,
415 source.item_id,
416 "library",
417 MediaType.AUDIOBOOK,
418 timestamp=30,
419 seconds_played=30,
420 user_initiated=True,
421 )
422
423 with patch.object(
424 mass.music.audiobooks,
425 "_set_playlog",
426 wraps=mass.music.audiobooks._set_playlog,
427 ) as set_playlog:
428 await mass.music.audiobooks.merge_library_items(target.item_id, source.item_id)
429
430 assert isinstance(set_playlog, AsyncMock)
431 set_playlog.assert_not_awaited()
432 playlog = await mass.music.database.get_row(
433 DB_TABLE_PLAYLOG,
434 {
435 "item_id": target.item_id,
436 "provider": "library",
437 "media_type": MediaType.AUDIOBOOK.value,
438 "userid": "test-user",
439 },
440 )
441 assert playlog is not None
442 assert playlog["timestamp"] == 30
443 assert playlog["seconds_played"] == 30
444 assert playlog["user_initiated"] == 1
445
446
447async def test_genre_merge_transfers_genre_references(mass: MusicAssistant) -> None:
448 """A genre merge reassigns every media mapping and exclusion to the target genre."""
449 target = await mass.music.genres.add_item_to_library(
450 Genre(item_id="0", provider="library", name="Target Genre", provider_mappings=set())
451 )
452 source = await mass.music.genres.add_item_to_library(
453 Genre(item_id="0", provider="library", name="Source Genre", provider_mappings=set())
454 )
455 await mass.music.database.insert(
456 DB_TABLE_GENRE_MEDIA_ITEM_MAPPING,
457 {
458 "genre_id": int(target.item_id),
459 "media_id": 1,
460 "media_type": MediaType.TRACK.value,
461 "alias": "target",
462 "is_derived": True,
463 "is_manual": False,
464 },
465 )
466 await mass.music.database.insert(
467 DB_TABLE_GENRE_MEDIA_ITEM_MAPPING,
468 {
469 "genre_id": int(source.item_id),
470 "media_id": 1,
471 "media_type": MediaType.TRACK.value,
472 "alias": "source",
473 "is_derived": False,
474 "is_manual": True,
475 },
476 )
477 await mass.music.database.insert(
478 DB_TABLE_GENRE_MEDIA_ITEM_EXCLUSION,
479 {
480 "genre_id": int(source.item_id),
481 "media_id": 2,
482 "media_type": MediaType.TRACK.value,
483 },
484 )
485
486 await mass.music.genres.merge_library_items(target.item_id, source.item_id)
487
488 genre_mapping = await mass.music.database.get_row(
489 DB_TABLE_GENRE_MEDIA_ITEM_MAPPING,
490 {
491 "genre_id": int(target.item_id),
492 "media_id": 1,
493 "media_type": MediaType.TRACK.value,
494 },
495 )
496 assert genre_mapping is not None
497 assert dict(genre_mapping) == {
498 "genre_id": int(target.item_id),
499 "media_id": 1,
500 "media_type": MediaType.TRACK.value,
501 "alias": "source",
502 "is_derived": 1,
503 "is_manual": 1,
504 }
505 assert await mass.music.database.get_row(
506 DB_TABLE_GENRE_MEDIA_ITEM_EXCLUSION,
507 {
508 "genre_id": int(target.item_id),
509 "media_id": 2,
510 "media_type": MediaType.TRACK.value,
511 },
512 )
513 assert not await mass.music.database.get_rows(
514 DB_TABLE_GENRE_MEDIA_ITEM_MAPPING, {"genre_id": int(source.item_id)}
515 )
516 assert not await mass.music.database.get_rows(
517 DB_TABLE_GENRE_MEDIA_ITEM_EXCLUSION, {"genre_id": int(source.item_id)}
518 )
519
520
521async def test_merge_honors_outer_event_suppression(mass: MusicAssistant) -> None:
522 """A merge inside a suppressed update scope does not emit item events."""
523 target = await mass.music.genres.add_item_to_library(
524 Genre(item_id="0", provider="library", name="Target Genre", provider_mappings=set())
525 )
526 source = await mass.music.genres.add_item_to_library(
527 Genre(item_id="0", provider="library", name="Source Genre", provider_mappings=set())
528 )
529 token = SUPPRESS_MEDIA_ITEM_UPDATES.set(True)
530 try:
531 with patch.object(mass, "signal_event") as signal_event:
532 await mass.music.genres.merge_library_items(target.item_id, source.item_id)
533 finally:
534 SUPPRESS_MEDIA_ITEM_UPDATES.reset(token)
535 # unrelated background chatter (a debounced TASKS_UPDATED) can land in the same window,
536 # so assert on the media item events this test is actually about
537 assert [
538 call.args[0]
539 for call in signal_event.call_args_list
540 if call.args and str(call.args[0].value).startswith("media_item")
541 ] == []
542
543
544async def test_genre_merge_rejects_different_taxonomies(mass: MusicAssistant) -> None:
545 """A genre merge cannot cross music and podcast taxonomies."""
546 target = await mass.music.genres.add_item_to_library(
547 Genre(
548 item_id="0",
549 provider="library",
550 name="Podcast Genre",
551 content_type=MediaType.PODCAST,
552 provider_mappings=set(),
553 )
554 )
555 source = await mass.music.genres.add_item_to_library(
556 Genre(item_id="0", provider="library", name="Music Genre", provider_mappings=set())
557 )
558
559 with pytest.raises(InvalidDataError, match="same taxonomy"):
560 await mass.music.genres.merge_library_items(target.item_id, source.item_id)
561
562 assert await mass.music.genres.get_library_item(target.item_id)
563 assert await mass.music.genres.get_library_item(source.item_id)
564
565
566async def test_artist_merge_rejects_different_roles(mass: MusicAssistant) -> None:
567 """An artist merge cannot cross music and spoken-word roles."""
568 target = await mass.music.artists.add_item_to_library(
569 Artist(
570 item_id="0",
571 provider="library",
572 name="Singer",
573 artist_type=ArtistType.SINGER,
574 provider_mappings={_mapping("target_instance", "target-artist")},
575 )
576 )
577 source = await mass.music.artists.add_item_to_library(
578 Artist(
579 item_id="0",
580 provider="library",
581 name="Author",
582 artist_type=ArtistType.AUTHOR,
583 provider_mappings={_mapping("source_instance", "source-artist")},
584 )
585 )
586
587 with pytest.raises(InvalidDataError, match="same role"):
588 await mass.music.artists.merge_library_items(target.item_id, source.item_id)
589
590 assert await mass.music.artists.get_library_item(target.item_id)
591 assert await mass.music.artists.get_library_item(source.item_id)
592
593
594async def test_merge_retry_does_not_double_play_count(mass: MusicAssistant) -> None:
595 """A retry after a later transfer failure preserves the merged play count."""
596 artist = await _add_artist(mass, "Artist", "target_instance", "target-artist")
597 target_album = await _add_album(
598 mass,
599 "Target Album",
600 "target_instance",
601 "target-album",
602 artist,
603 "target-barcode",
604 )
605 source_album = await _add_album(
606 mass,
607 "Source Album",
608 "source_instance",
609 "source-album",
610 artist,
611 "source-barcode",
612 )
613 target = await _add_track(
614 mass,
615 "Target Track",
616 "target_instance",
617 "target-track",
618 artist,
619 target_album,
620 )
621 source = await _add_track(
622 mass,
623 "Source Track",
624 "source_instance",
625 "source-track",
626 artist,
627 source_album,
628 )
629 await mass.music.database.update(
630 DB_TABLE_TRACKS, {"item_id": int(target.item_id)}, {"play_count": 2}
631 )
632 await mass.music.database.update(
633 DB_TABLE_TRACKS, {"item_id": int(source.item_id)}, {"play_count": 3}
634 )
635
636 with (
637 patch.object(
638 mass.music.tracks,
639 "_merge_genre_mappings",
640 AsyncMock(side_effect=MusicAssistantError("merge failure")),
641 ),
642 pytest.raises(MusicAssistantError, match="merge failure"),
643 ):
644 await mass.music.tracks.merge_library_items(target.item_id, source.item_id)
645
646 target_row = await mass.music.database.get_row(
647 DB_TABLE_TRACKS, {"item_id": int(target.item_id)}
648 )
649 source_row = await mass.music.database.get_row(
650 DB_TABLE_TRACKS, {"item_id": int(source.item_id)}
651 )
652 assert target_row is not None
653 assert source_row is not None
654 assert target_row["play_count"] == 5
655 assert source_row["play_count"] == 0
656
657 await mass.music.tracks.merge_library_items(target.item_id, source.item_id)
658
659 merged_row = await mass.music.database.get_row(
660 DB_TABLE_TRACKS, {"item_id": int(target.item_id)}
661 )
662 assert merged_row is not None
663 assert merged_row["play_count"] == 5
664
665
666async def _assert_album_merge_result(
667 mass: MusicAssistant,
668 target: Album,
669 source: Album,
670 shared_track: Track,
671 source_track: Track,
672 target_artist: Artist,
673 source_artist: Artist,
674 genre: Genre,
675) -> None:
676 """Assert that an album merge preserved the source state on the target."""
677 with pytest.raises(MediaNotFoundError):
678 await mass.music.albums.get_library_item(source.item_id)
679 assert await mass.music.tracks.get_library_item(shared_track.item_id)
680 assert await mass.music.tracks.get_library_item(source_track.item_id)
681 merged = await mass.music.albums.get_library_item(target.item_id)
682 assert merged.favorite is True
683 merged_row = await mass.music.database.get_row(
684 DB_TABLE_ALBUMS, {"item_id": int(target.item_id)}
685 )
686 assert merged_row is not None
687 assert merged_row["play_count"] == 5
688 assert merged_row["last_played"] == 30
689 assert merged_row["timestamp_added"] == 10
690 assert {(kind, value) for kind, value in merged.external_ids} == {
691 (ExternalID.BARCODE, "target-barcode"),
692 (ExternalID.BARCODE, "source-barcode"),
693 }
694 assert {
695 int(row["track_id"])
696 for row in await mass.music.database.get_rows(
697 DB_TABLE_ALBUM_TRACKS, {"album_id": int(target.item_id)}
698 )
699 } == {int(shared_track.item_id), int(source_track.item_id)}
700 assert not await mass.music.database.get_rows(
701 DB_TABLE_ALBUM_TRACKS, {"album_id": int(source.item_id)}
702 )
703 assert {
704 int(row["artist_id"])
705 for row in await mass.music.database.get_rows(
706 DB_TABLE_ALBUM_ARTISTS, {"album_id": int(target.item_id)}
707 )
708 } == {int(target_artist.item_id), int(source_artist.item_id)}
709 genre_mapping = await mass.music.database.get_row(
710 DB_TABLE_GENRE_MEDIA_ITEM_MAPPING,
711 {
712 "genre_id": int(genre.item_id),
713 "media_id": int(target.item_id),
714 "media_type": MediaType.ALBUM.value,
715 },
716 )
717 assert genre_mapping is not None
718 assert dict(genre_mapping) == {
719 "genre_id": int(genre.item_id),
720 "media_id": int(target.item_id),
721 "media_type": MediaType.ALBUM.value,
722 "alias": "source",
723 "is_derived": 1,
724 "is_manual": 1,
725 }
726 assert await mass.music.database.get_row(
727 DB_TABLE_GENRE_MEDIA_ITEM_EXCLUSION,
728 {
729 "genre_id": int(genre.item_id),
730 "media_id": int(target.item_id),
731 "media_type": MediaType.ALBUM.value,
732 },
733 )
734 playlog = await mass.music.database.get_row(
735 DB_TABLE_PLAYLOG,
736 {
737 "item_id": target.item_id,
738 "provider": "library",
739 "media_type": MediaType.ALBUM.value,
740 "userid": "test-user",
741 },
742 )
743 assert playlog is not None
744 assert playlog["timestamp"] == 30
745 assert playlog["seconds_played"] == 30
746 assert playlog["user_initiated"] == 1
747 assert await mass.music.database.get_row(
748 DB_TABLE_PLAYLOG,
749 {
750 "item_id": "source-album",
751 "provider": "source_instance",
752 "media_type": MediaType.ALBUM.value,
753 "userid": "test-user",
754 },
755 )
756