/
/
/
1"""Tests for library sync in_library behavior."""
2
3from __future__ import annotations
4
5import asyncio
6from collections.abc import AsyncGenerator
7from contextlib import asynccontextmanager
8from unittest.mock import AsyncMock, Mock, PropertyMock, patch
9
10import pytest
11from music_assistant_models.enums import EventType, MediaType, ProviderType
12from music_assistant_models.errors import InsufficientPermissions
13from music_assistant_models.media_items import Album, AudioFormat, ProviderMapping, UniqueList
14
15from music_assistant.constants import CONF_ENTRY_LIBRARY_SYNC_BACK
16from music_assistant.controllers.music import MusicController
17from music_assistant.controllers.music.media.base import (
18 SUPPRESS_MEDIA_ITEM_UPDATES,
19 MediaControllerBase,
20)
21from music_assistant.models.music_provider import (
22 CACHE_CATEGORY_PREV_LIBRARY_IDS,
23 MusicProvider,
24)
25
26# --- Helpers ---
27
28
29def create_provider_mapping(
30 provider_instance: str = "spotify_1",
31 item_id: str = "track_abc",
32 provider_domain: str = "spotify",
33 in_library: bool | None = None,
34 available: bool = True,
35) -> ProviderMapping:
36 """
37 Create a ProviderMapping with sensible defaults.
38
39 :param provider_instance: The provider instance ID.
40 :param item_id: The item ID on the provider.
41 :param provider_domain: The provider domain.
42 :param in_library: Whether the item is in the user's library on this provider.
43 :param available: Whether the item is available.
44 """
45 return ProviderMapping(
46 item_id=item_id,
47 provider_domain=provider_domain,
48 provider_instance=provider_instance,
49 in_library=in_library,
50 available=available,
51 audio_format=AudioFormat(),
52 )
53
54
55def create_mock_album(
56 item_id: str = "1",
57 provider_mappings: list[ProviderMapping] | None = None,
58 provider: str = "library",
59 name: str = "Test Album",
60 favorite: bool = False,
61) -> Mock:
62 """
63 Create a mock Album media item.
64
65 :param item_id: The library item ID.
66 :param provider_mappings: The provider mappings to set.
67 :param provider: The provider string (e.g. 'library', 'spotify').
68 :param name: The album name.
69 :param favorite: Whether the item is favorited.
70 """
71 album = Mock(spec=Album)
72 album.item_id = item_id
73 album.provider = provider
74 album.name = name
75 album.media_type = MediaType.ALBUM
76 album.favorite = favorite
77 album.provider_mappings = UniqueList(provider_mappings or [])
78 album.metadata = Mock(images=None)
79 return album
80
81
82@asynccontextmanager
83async def _noop_deferred_commit() -> AsyncGenerator[None]:
84 """Stand-in for DatabaseConnection.deferred_commit on mocked databases."""
85 yield
86
87
88# --- Group 1: Optimistic in_library on add ---
89
90
91async def test_add_item_to_library_sets_in_library_true() -> None:
92 """
93 Test that add_item_to_library sets in_library=True on all provider mappings.
94
95 When a user adds an item from MA search, every mapping should be optimistically
96 marked as in_library=True before being stored in the database.
97 """
98 mapping = create_provider_mapping(in_library=None)
99 album = create_mock_album(provider="spotify", provider_mappings=[mapping])
100
101 mass = Mock()
102 ctrl_mock = AsyncMock()
103 ctrl_mock.add_item_to_library = AsyncMock(return_value=album)
104
105 provider_mock = Mock()
106 provider_mock.type = ProviderType.MUSIC
107 provider_mock.supports_feature.return_value = True
108 provider_mock.config.values = {CONF_ENTRY_LIBRARY_SYNC_BACK.key: Mock()}
109 provider_mock.config.get_value.return_value = True
110
111 music_ctrl = MusicController.__new__(MusicController)
112 music_ctrl.mass = mass
113 mass.get_provider.return_value = provider_mock
114 mass.metadata = AsyncMock()
115
116 with (
117 patch.object(music_ctrl, "get_controller", return_value=ctrl_mock),
118 patch.object(music_ctrl, "get_item", new_callable=AsyncMock, return_value=album),
119 ):
120 await music_ctrl.add_item_to_library(album)
121
122 assert mapping.in_library is True
123
124
125async def test_add_item_to_library_sets_in_library_even_when_sync_back_disabled() -> None:
126 """
127 Test that in_library=True is set even when sync back to provider is disabled.
128
129 The optimistic set should happen unconditionally, but library_add should NOT be called.
130 """
131 mapping = create_provider_mapping(in_library=None)
132 album = create_mock_album(provider="spotify", provider_mappings=[mapping])
133
134 mass = Mock()
135 ctrl_mock = AsyncMock()
136 ctrl_mock.add_item_to_library = AsyncMock(return_value=album)
137
138 provider_mock = Mock()
139 provider_mock.type = ProviderType.MUSIC
140 provider_mock.supports_feature.return_value = True
141 provider_mock.config.values = {CONF_ENTRY_LIBRARY_SYNC_BACK.key: Mock()}
142 provider_mock.config.get_value.return_value = False
143
144 music_ctrl = MusicController.__new__(MusicController)
145 music_ctrl.mass = mass
146 mass.get_provider.return_value = provider_mock
147 mass.metadata = AsyncMock()
148
149 with (
150 patch.object(music_ctrl, "get_controller", return_value=ctrl_mock),
151 patch.object(music_ctrl, "get_item", new_callable=AsyncMock, return_value=album),
152 ):
153 await music_ctrl.add_item_to_library(album)
154
155 assert mapping.in_library is True
156 mass.create_task.assert_not_called()
157
158
159async def test_add_item_to_library_sets_in_library_even_when_edit_not_supported() -> None:
160 """
161 Test that in_library=True is set even when provider doesn't support library edit.
162
163 The optimistic set should happen unconditionally, but library_add should NOT be called.
164 """
165 mapping = create_provider_mapping(in_library=None)
166 album = create_mock_album(provider="spotify", provider_mappings=[mapping])
167
168 mass = Mock()
169 ctrl_mock = AsyncMock()
170 ctrl_mock.add_item_to_library = AsyncMock(return_value=album)
171
172 provider_mock = Mock()
173 provider_mock.type = ProviderType.MUSIC
174 provider_mock.supports_feature.return_value = False
175
176 music_ctrl = MusicController.__new__(MusicController)
177 music_ctrl.mass = mass
178 mass.get_provider.return_value = provider_mock
179 mass.metadata = AsyncMock()
180
181 with (
182 patch.object(music_ctrl, "get_controller", return_value=ctrl_mock),
183 patch.object(music_ctrl, "get_item", new_callable=AsyncMock, return_value=album),
184 ):
185 await music_ctrl.add_item_to_library(album)
186
187 assert mapping.in_library is True
188 mass.create_task.assert_not_called()
189
190
191async def test_add_album_imports_tracks_when_enabled() -> None:
192 """
193 Test that adding an album imports its tracks when the setting is enabled.
194
195 The "Import album tracks" behavior previously only triggered during a (scheduled)
196 library sync. Adding an album manually should mirror it when the provider has the
197 setting enabled.
198 """
199 mapping = create_provider_mapping(
200 provider_instance="qobuz_1", provider_domain="qobuz", item_id="album_xyz", in_library=None
201 )
202 album = create_mock_album(provider="qobuz", provider_mappings=[mapping])
203
204 mass = Mock()
205 ctrl_mock = AsyncMock()
206 ctrl_mock.add_item_to_library = AsyncMock(return_value=album)
207
208 provider_mock = Mock(spec=MusicProvider)
209 provider_mock.type = ProviderType.MUSIC
210 provider_mock.supports_feature.return_value = False
211 provider_mock.library_sync_album_tracks_enabled.return_value = True
212 sentinel = object()
213 provider_mock.import_album_tracks = Mock(return_value=sentinel)
214
215 music_ctrl = MusicController.__new__(MusicController)
216 music_ctrl.mass = mass
217 mass.get_provider.return_value = provider_mock
218 mass.metadata = AsyncMock()
219
220 with (
221 patch.object(music_ctrl, "get_controller", return_value=ctrl_mock),
222 patch.object(music_ctrl, "get_item", new_callable=AsyncMock, return_value=album),
223 ):
224 await music_ctrl.add_item_to_library(album)
225
226 provider_mock.import_album_tracks.assert_called_once_with("album_xyz", album)
227 mass.create_task.assert_called_once_with(sentinel)
228
229
230async def test_add_album_does_not_import_tracks_when_disabled() -> None:
231 """Test that adding an album does NOT import its tracks when the setting is disabled."""
232 mapping = create_provider_mapping(
233 provider_instance="qobuz_1", provider_domain="qobuz", item_id="album_xyz", in_library=None
234 )
235 album = create_mock_album(provider="qobuz", provider_mappings=[mapping])
236
237 mass = Mock()
238 ctrl_mock = AsyncMock()
239 ctrl_mock.add_item_to_library = AsyncMock(return_value=album)
240
241 provider_mock = Mock(spec=MusicProvider)
242 provider_mock.type = ProviderType.MUSIC
243 provider_mock.supports_feature.return_value = False
244 provider_mock.library_sync_album_tracks_enabled.return_value = False
245 provider_mock.import_album_tracks = Mock()
246
247 music_ctrl = MusicController.__new__(MusicController)
248 music_ctrl.mass = mass
249 mass.get_provider.return_value = provider_mock
250 mass.metadata = AsyncMock()
251
252 with (
253 patch.object(music_ctrl, "get_controller", return_value=ctrl_mock),
254 patch.object(music_ctrl, "get_item", new_callable=AsyncMock, return_value=album),
255 ):
256 await music_ctrl.add_item_to_library(album)
257
258 provider_mock.import_album_tracks.assert_not_called()
259 mass.create_task.assert_not_called()
260
261
262async def test_add_album_only_imports_tracks_for_added_instance() -> None:
263 """
264 Test that track import skips auto-added mappings for other provider instances.
265
266 match_provider_instances adds extra mappings (in_library=None) for sibling
267 instances of the same provider. Those must not trigger a track import; only the
268 mapping the album was actually added on (in_library=True) should.
269 """
270 added_mapping = create_provider_mapping(
271 provider_instance="qobuz_1", provider_domain="qobuz", item_id="album_xyz", in_library=True
272 )
273 sibling_mapping = create_provider_mapping(
274 provider_instance="qobuz_2", provider_domain="qobuz", item_id="album_xyz", in_library=None
275 )
276 input_album = create_mock_album(
277 provider="qobuz", provider_mappings=[create_provider_mapping(in_library=None)]
278 )
279 # the controller returns the merged library item with both mappings present
280 library_album = create_mock_album(
281 provider="library", provider_mappings=[added_mapping, sibling_mapping]
282 )
283
284 mass = Mock()
285 ctrl_mock = AsyncMock()
286 ctrl_mock.add_item_to_library = AsyncMock(return_value=library_album)
287
288 provider_mock = Mock(spec=MusicProvider)
289 provider_mock.type = ProviderType.MUSIC
290 provider_mock.supports_feature.return_value = False
291 provider_mock.library_sync_album_tracks_enabled.return_value = True
292 sentinel = object()
293 provider_mock.import_album_tracks = Mock(return_value=sentinel)
294
295 music_ctrl = MusicController.__new__(MusicController)
296 music_ctrl.mass = mass
297 mass.get_provider.return_value = provider_mock
298 mass.metadata = AsyncMock()
299
300 with (
301 patch.object(music_ctrl, "get_controller", return_value=ctrl_mock),
302 patch.object(music_ctrl, "get_item", new_callable=AsyncMock, return_value=input_album),
303 ):
304 await music_ctrl.add_item_to_library(input_album)
305
306 provider_mock.import_album_tracks.assert_called_once_with("album_xyz", library_album)
307 mass.create_task.assert_called_once_with(sentinel)
308
309
310# --- Group 2: Refresh item preserves in_library ---
311
312
313async def test_refresh_item_preserves_in_library_state() -> None:
314 """
315 Test that refresh_item restores in_library=True after provider returns None.
316
317 When refreshing, the provider returns a fresh item with in_library=None.
318 The cached value (True) from the original library item should be restored.
319 """
320 original_mapping = create_provider_mapping(
321 provider_instance="spotify_1", item_id="abc", in_library=True
322 )
323 library_item = create_mock_album(
324 item_id="1", provider="library", provider_mappings=[original_mapping]
325 )
326
327 fresh_mapping = create_provider_mapping(
328 provider_instance="spotify_1", item_id="abc", in_library=None
329 )
330 fresh_item = create_mock_album(
331 item_id="abc", provider="spotify", provider_mappings=[fresh_mapping]
332 )
333
334 # use TRACK media_type for the returned library_item to skip album-tracks branch
335 returned_item = Mock()
336 returned_item.media_type = MediaType.TRACK
337
338 ctrl_mock = AsyncMock()
339 ctrl_mock.get_provider_item = AsyncMock(return_value=fresh_item)
340 ctrl_mock.update_item_in_library = AsyncMock(return_value=returned_item)
341 ctrl_mock.match_providers = AsyncMock()
342
343 mass = Mock()
344 mass.get_provider.return_value = Mock()
345 mass.metadata = AsyncMock()
346
347 music_ctrl = MusicController.__new__(MusicController)
348 music_ctrl.mass = mass
349
350 with patch.object(music_ctrl, "get_controller", return_value=ctrl_mock):
351 await music_ctrl.refresh_item(library_item)
352
353 # the fresh_mapping should have been restored from cache
354 assert fresh_mapping.in_library is True
355
356
357async def test_refresh_item_preserves_in_library_false() -> None:
358 """
359 Test that refresh_item restores in_library=False after provider returns None.
360
361 If a mapping was previously marked as in_library=False (removed from provider),
362 this state should be preserved through a refresh.
363 """
364 original_mapping = create_provider_mapping(
365 provider_instance="spotify_1", item_id="abc", in_library=False
366 )
367 library_item = create_mock_album(
368 item_id="1", provider="library", provider_mappings=[original_mapping]
369 )
370
371 fresh_mapping = create_provider_mapping(
372 provider_instance="spotify_1", item_id="abc", in_library=None
373 )
374 fresh_item = create_mock_album(
375 item_id="abc", provider="spotify", provider_mappings=[fresh_mapping]
376 )
377
378 returned_item = Mock()
379 returned_item.media_type = MediaType.TRACK
380
381 ctrl_mock = AsyncMock()
382 ctrl_mock.get_provider_item = AsyncMock(return_value=fresh_item)
383 ctrl_mock.update_item_in_library = AsyncMock(return_value=returned_item)
384 ctrl_mock.match_providers = AsyncMock()
385
386 mass = Mock()
387 mass.get_provider.return_value = Mock()
388 mass.metadata = AsyncMock()
389
390 music_ctrl = MusicController.__new__(MusicController)
391 music_ctrl.mass = mass
392
393 with patch.object(music_ctrl, "get_controller", return_value=ctrl_mock):
394 await music_ctrl.refresh_item(library_item)
395
396 assert fresh_mapping.in_library is False
397
398
399async def test_refresh_item_respects_provider_set_in_library() -> None:
400 """
401 Test that provider-explicit in_library value is not overwritten by cache.
402
403 If the provider explicitly sets in_library=False on a refreshed mapping,
404 that value should win over the cached True value.
405 """
406 original_mapping = create_provider_mapping(
407 provider_instance="spotify_1", item_id="abc", in_library=True
408 )
409 library_item = create_mock_album(
410 item_id="1", provider="library", provider_mappings=[original_mapping]
411 )
412
413 # provider explicitly sets in_library=False (item was removed from provider)
414 fresh_mapping = create_provider_mapping(
415 provider_instance="spotify_1", item_id="abc", in_library=False
416 )
417 fresh_item = create_mock_album(
418 item_id="abc", provider="spotify", provider_mappings=[fresh_mapping]
419 )
420
421 returned_item = Mock()
422 returned_item.media_type = MediaType.TRACK
423
424 ctrl_mock = AsyncMock()
425 ctrl_mock.get_provider_item = AsyncMock(return_value=fresh_item)
426 ctrl_mock.update_item_in_library = AsyncMock(return_value=returned_item)
427 ctrl_mock.match_providers = AsyncMock()
428
429 mass = Mock()
430 mass.get_provider.return_value = Mock()
431 mass.metadata = AsyncMock()
432
433 music_ctrl = MusicController.__new__(MusicController)
434 music_ctrl.mass = mass
435
436 with patch.object(music_ctrl, "get_controller", return_value=ctrl_mock):
437 await music_ctrl.refresh_item(library_item)
438
439 # provider's explicit False should NOT be overwritten by cache
440 assert fresh_mapping.in_library is False
441
442
443async def test_refresh_item_non_library_item_skips_update() -> None:
444 """
445 Test that refresh_item returns early for non-library items.
446
447 When the media_item is not from the library (provider != 'library'),
448 update_item_in_library should not be called.
449 """
450 mapping = create_provider_mapping(provider_instance="spotify_1", item_id="abc", in_library=True)
451 # provider item, not library
452 provider_item = create_mock_album(
453 item_id="abc", provider="spotify", provider_mappings=[mapping]
454 )
455
456 fresh_item = create_mock_album(item_id="abc", provider="spotify", provider_mappings=[mapping])
457
458 ctrl_mock = AsyncMock()
459 ctrl_mock.get_provider_item = AsyncMock(return_value=fresh_item)
460
461 mass = Mock()
462 mass.get_provider.return_value = Mock()
463
464 music_ctrl = MusicController.__new__(MusicController)
465 music_ctrl.mass = mass
466
467 with patch.object(music_ctrl, "get_controller", return_value=ctrl_mock):
468 result = await music_ctrl.refresh_item(provider_item)
469
470 assert result is fresh_item
471 ctrl_mock.update_item_in_library.assert_not_called()
472
473
474# --- Group 3: Sync deletions ---
475
476
477async def test_sync_library_marks_removed_item_in_library_false() -> None:
478 """
479 Test that sync marks removed items as in_library=False.
480
481 When an item was in the previous sync but is no longer in the current sync,
482 its provider mapping should be set to in_library=False.
483 """
484 mapping = create_provider_mapping(provider_instance="spotify_1", item_id="abc", in_library=True)
485 library_item = create_mock_album(
486 item_id="1", provider="library", provider_mappings=[mapping], favorite=False
487 )
488
489 controller = AsyncMock()
490 controller.get_library_item = AsyncMock(return_value=library_item)
491
492 provider = Mock()
493 provider.instance_id = "spotify_1"
494 provider.domain = "spotify"
495 provider.is_streaming_provider = True
496 provider.library_sync_deletions_enabled.return_value = True
497
498 mass = Mock()
499 mass.music.get_controller.return_value = controller
500 # previous sync had item 1, current sync has nothing
501 mass.cache.get = AsyncMock(return_value=[1])
502 mass.cache.set = AsyncMock()
503 provider.mass = mass
504
505 # simulate sync_library deletion processing
506 # (we test the deletion block directly since mocking the full sync is complex)
507 cur_db_ids: set[int] = set() # item no longer present
508
509 if provider.library_sync_deletions_enabled():
510 prev_library_items = await mass.cache.get(
511 key=MediaType.ALBUM.value,
512 provider=provider.instance_id,
513 category=CACHE_CATEGORY_PREV_LIBRARY_IDS,
514 )
515 if prev_library_items:
516 for db_id in prev_library_items:
517 if db_id not in cur_db_ids:
518 item = await controller.get_library_item(db_id)
519 for prov_map in item.provider_mappings:
520 if prov_map.provider_instance == provider.instance_id:
521 prov_map.in_library = False
522 await controller.set_provider_mappings(db_id, item.provider_mappings)
523
524 assert mapping.in_library is False
525 controller.set_provider_mappings.assert_called_once_with(1, library_item.provider_mappings)
526
527
528async def test_sync_library_deletions_disabled_keeps_item() -> None:
529 """
530 Test that items remain visible when sync deletions is disabled.
531
532 When library_sync_deletions_enabled returns False, items removed from the provider
533 should NOT be marked as in_library=False.
534 """
535 mapping = create_provider_mapping(provider_instance="spotify_1", item_id="abc", in_library=True)
536 library_item = create_mock_album(item_id="1", provider="library", provider_mappings=[mapping])
537
538 controller = AsyncMock()
539 controller.get_library_item = AsyncMock(return_value=library_item)
540
541 provider = Mock()
542 provider.instance_id = "spotify_1"
543 provider.library_sync_deletions_enabled.return_value = False
544
545 mass = Mock()
546 mass.cache.get = AsyncMock(return_value=[1])
547 mass.cache.set = AsyncMock()
548 provider.mass = mass
549
550 cur_db_ids: set[int] = set()
551
552 if provider.library_sync_deletions_enabled():
553 prev_library_items = await mass.cache.get(
554 key=MediaType.ALBUM.value,
555 provider=provider.instance_id,
556 category=CACHE_CATEGORY_PREV_LIBRARY_IDS,
557 )
558 if prev_library_items:
559 for db_id in prev_library_items:
560 if db_id not in cur_db_ids:
561 item = await controller.get_library_item(db_id)
562 for prov_map in item.provider_mappings:
563 if prov_map.provider_instance == provider.instance_id:
564 prov_map.in_library = False
565 await controller.set_provider_mappings(db_id, item.provider_mappings)
566
567 # mapping should still be True since deletion sync was disabled
568 assert mapping.in_library is True
569 controller.set_provider_mappings.assert_not_called()
570
571
572async def test_sync_library_deletion_unmarks_favorite_when_no_other_providers() -> None:
573 """
574 Test that favorite is unset when no other providers have the item in library.
575
576 When an item is removed from the only provider that had it in-library,
577 and the item is favorited, favorite should be set to False.
578 """
579 mapping = create_provider_mapping(provider_instance="spotify_1", item_id="abc", in_library=True)
580 library_item = create_mock_album(
581 item_id="1", provider="library", provider_mappings=[mapping], favorite=True
582 )
583
584 controller = AsyncMock()
585 controller.get_library_item = AsyncMock(return_value=library_item)
586 controller.set_favorite = AsyncMock()
587
588 instance_id = "spotify_1"
589
590 remaining = {
591 x.provider_instance
592 for x in library_item.provider_mappings
593 if x.provider_instance != instance_id and x.in_library
594 }
595
596 if not remaining and library_item.favorite:
597 await controller.set_favorite(int(library_item.item_id), False)
598
599 controller.set_favorite.assert_called_once_with(1, False)
600
601
602async def test_sync_library_deletion_keeps_favorite_when_other_provider_has_it() -> None:
603 """
604 Test that favorite is kept when another provider still has the item in library.
605
606 When an item is removed from one provider but another provider still has
607 in_library=True, the favorite status should remain unchanged.
608 """
609 mapping_a = create_provider_mapping(
610 provider_instance="spotify_1", item_id="abc", in_library=True
611 )
612 mapping_b = create_provider_mapping(
613 provider_instance="tidal_1",
614 item_id="xyz",
615 provider_domain="tidal",
616 in_library=True,
617 )
618 library_item = create_mock_album(
619 item_id="1",
620 provider="library",
621 provider_mappings=[mapping_a, mapping_b],
622 favorite=True,
623 )
624
625 controller = AsyncMock()
626 controller.set_favorite = AsyncMock()
627
628 instance_id = "spotify_1"
629
630 remaining = {
631 x.provider_instance
632 for x in library_item.provider_mappings
633 if x.provider_instance != instance_id and x.in_library
634 }
635
636 if not remaining and library_item.favorite:
637 await controller.set_favorite(int(library_item.item_id), False)
638
639 # tidal_1 still has in_library=True, so favorite should NOT be unset
640 controller.set_favorite.assert_not_called()
641
642
643async def test_sync_library_always_stores_cache_regardless_of_deletion_setting() -> None:
644 """
645 Test that cache is always updated with current IDs even when deletions are disabled.
646
647 The cache stores the current set of library item IDs for comparison on the next sync.
648 This must happen regardless of whether deletion sync is enabled.
649 """
650 mass = Mock()
651 mass.cache.set = AsyncMock()
652
653 cur_db_ids = {1, 2, 3}
654 instance_id = "spotify_1"
655
656 # this is always called outside the deletion-enabled check
657 await mass.cache.set(
658 key=MediaType.ALBUM.value,
659 data=list(cur_db_ids),
660 provider=instance_id,
661 category=CACHE_CATEGORY_PREV_LIBRARY_IDS,
662 )
663
664 mass.cache.set.assert_called_once_with(
665 key=MediaType.ALBUM.value,
666 data=list(cur_db_ids),
667 provider=instance_id,
668 category=CACHE_CATEGORY_PREV_LIBRARY_IDS,
669 )
670
671
672# --- Group 4: _apply_filters SQL generation ---
673
674
675def _create_controller_for_filter_tests() -> Mock:
676 """Create a minimal mock controller for _apply_filters tests."""
677 ctrl = Mock(spec=MediaControllerBase)
678 ctrl.media_type = MediaType.ALBUM
679 ctrl.db_table = "albums"
680 ctrl._apply_filters = MediaControllerBase._apply_filters.__get__(ctrl)
681 ctrl._provider_filter_clause = MediaControllerBase._provider_filter_clause.__get__(ctrl)
682 return ctrl
683
684
685async def test_apply_filters_in_library_only_without_provider_filter() -> None:
686 """
687 Test that in_library_only adds an EXISTS filter on provider_mappings with in_library=1.
688
689 When no provider_filter is set but in_library_only=True, an EXISTS subquery on
690 provider_mappings should be added with the in_library=1 condition.
691 """
692 ctrl = _create_controller_for_filter_tests()
693 query_parts: list[str] = []
694 query_params: dict[str, object] = {}
695
696 ctrl._apply_filters(
697 query_parts=query_parts,
698 query_params=query_params,
699 favorite=None,
700 search=None,
701 genre_ids=None,
702 provider_filter=None,
703 in_library_only=True,
704 )
705
706 assert len(query_parts) == 1
707 assert "provider_media_type" in query_params
708 # pin the exact clause: library_count() shares this builder
709 assert query_parts[0] == (
710 "EXISTS(SELECT 1 FROM provider_mappings "
711 "WHERE provider_mappings.item_id = albums.item_id "
712 "AND provider_mappings.media_type = :provider_media_type "
713 "AND provider_mappings.in_library = 1)"
714 )
715
716
717async def test_apply_filters_in_library_only_with_provider_filter() -> None:
718 """
719 Test that in_library_only with provider_filter adds both conditions to the EXISTS.
720
721 When both in_library_only=True and a provider_filter are set, the EXISTS subquery
722 should include both the provider condition and the in_library=1 condition.
723 """
724 ctrl = _create_controller_for_filter_tests()
725 query_parts: list[str] = []
726 query_params: dict[str, object] = {}
727
728 ctrl._apply_filters(
729 query_parts=query_parts,
730 query_params=query_params,
731 favorite=None,
732 search=None,
733 genre_ids=None,
734 provider_filter=["spotify_1"],
735 in_library_only=True,
736 )
737
738 assert len(query_parts) == 1
739 assert query_params["provider_filter_0"] == "spotify_1"
740 # pin the exact clause: library_count() shares this builder
741 assert query_parts[0] == (
742 "EXISTS(SELECT 1 FROM provider_mappings "
743 "WHERE provider_mappings.item_id = albums.item_id "
744 "AND provider_mappings.media_type = :provider_media_type "
745 "AND provider_mappings.in_library = 1 "
746 "AND (provider_mappings.provider_instance = :provider_filter_0))"
747 )
748
749
750async def test_apply_filters_no_in_library_filter_by_default() -> None:
751 """
752 Test that no provider_mappings filter is added when in_library_only is False.
753
754 Without a provider_filter or in_library_only flag, no filter on
755 provider_mappings should be added.
756 """
757 ctrl = _create_controller_for_filter_tests()
758 query_parts: list[str] = []
759 query_params: dict[str, object] = {}
760
761 ctrl._apply_filters(
762 query_parts=query_parts,
763 query_params=query_params,
764 favorite=None,
765 search=None,
766 genre_ids=None,
767 provider_filter=None,
768 in_library_only=False,
769 )
770
771 assert len(query_parts) == 0
772
773
774async def test_apply_filters_provider_filter_without_in_library() -> None:
775 """
776 Test that provider_filter without in_library_only omits the in_library clause.
777
778 When a provider_filter is set but in_library_only is False, the EXISTS subquery
779 should filter by provider but NOT include the in_library=1 condition.
780 """
781 ctrl = _create_controller_for_filter_tests()
782 query_parts: list[str] = []
783 query_params: dict[str, object] = {}
784
785 ctrl._apply_filters(
786 query_parts=query_parts,
787 query_params=query_params,
788 favorite=None,
789 search=None,
790 genre_ids=None,
791 provider_filter=["spotify_1"],
792 in_library_only=False,
793 )
794
795 assert len(query_parts) == 1
796 assert query_params["provider_filter_0"] == "spotify_1"
797 # pin the exact clause: library_count() shares this builder
798 assert query_parts[0] == (
799 "EXISTS(SELECT 1 FROM provider_mappings "
800 "WHERE provider_mappings.item_id = albums.item_id "
801 "AND provider_mappings.media_type = :provider_media_type "
802 "AND (provider_mappings.provider_instance = :provider_filter_0))"
803 )
804
805
806# --- Group 5: set_provider_mappings behavior ---
807
808
809@pytest.fixture
810def mock_controller() -> Mock:
811 """Create a mock MediaControllerBase for set_provider_mappings tests."""
812 ctrl = Mock(spec=MediaControllerBase)
813 ctrl.media_type = MediaType.ALBUM
814 ctrl.logger = Mock()
815 ctrl.mass = Mock()
816 ctrl.mass.music.database.delete = AsyncMock()
817 ctrl.mass.music.database.upsert_many = AsyncMock()
818 ctrl.set_provider_mappings = MediaControllerBase.set_provider_mappings.__get__(ctrl)
819 return ctrl
820
821
822async def test_set_provider_mappings_overwrite_deletes_and_reinserts(
823 mock_controller: Mock,
824) -> None:
825 """
826 Test that overwrite=True deletes existing mappings before upserting.
827
828 :param mock_controller: Mock MediaControllerBase instance.
829 """
830 mapping = create_provider_mapping(in_library=True)
831
832 await mock_controller.set_provider_mappings(1, [mapping], overwrite=True)
833
834 mock_controller.mass.music.database.delete.assert_called_once()
835 mock_controller.mass.music.database.upsert_many.assert_called_once()
836
837
838async def test_set_provider_mappings_overwrite_keeps_existing_when_empty(
839 mock_controller: Mock,
840) -> None:
841 """
842 Test that overwrite=True with no mappings leaves the existing mappings untouched.
843
844 :param mock_controller: Mock MediaControllerBase instance.
845 """
846 await mock_controller.set_provider_mappings(1, [], overwrite=True)
847
848 mock_controller.mass.music.database.delete.assert_not_called()
849 mock_controller.mass.music.database.upsert_many.assert_not_called()
850 mock_controller.logger.warning.assert_called_once()
851
852
853async def test_set_provider_mappings_no_mappings_is_noop(mock_controller: Mock) -> None:
854 """
855 Test that an empty mappings set without overwrite writes nothing.
856
857 :param mock_controller: Mock MediaControllerBase instance.
858 """
859 await mock_controller.set_provider_mappings(1, [], overwrite=False)
860
861 mock_controller.mass.music.database.delete.assert_not_called()
862 mock_controller.mass.music.database.upsert_many.assert_not_called()
863 mock_controller.logger.warning.assert_not_called()
864
865
866async def test_set_provider_mappings_upsert_preserves_null_in_library(
867 mock_controller: Mock,
868) -> None:
869 """
870 Test that in_library=None is excluded from the upsert dict.
871
872 When in_library is None, it should not be included in the dict passed to upsert,
873 allowing the database's existing value to be preserved.
874
875 :param mock_controller: Mock MediaControllerBase instance.
876 """
877 mapping = create_provider_mapping(in_library=None)
878
879 await mock_controller.set_provider_mappings(1, [mapping], overwrite=False)
880
881 upsert_call = mock_controller.mass.music.database.upsert_many.call_args
882 upsert_rows = upsert_call[0][1]
883 assert len(upsert_rows) == 1
884 assert "in_library" not in upsert_rows[0]
885
886
887async def test_set_provider_mappings_upsert_writes_explicit_in_library(
888 mock_controller: Mock,
889) -> None:
890 """
891 Test that an explicit in_library value is included in the upsert dict.
892
893 When in_library is explicitly True or False, it should be written to the database.
894
895 :param mock_controller: Mock MediaControllerBase instance.
896 """
897 mapping = create_provider_mapping(in_library=True)
898
899 await mock_controller.set_provider_mappings(1, [mapping], overwrite=False)
900
901 upsert_call = mock_controller.mass.music.database.upsert_many.call_args
902 upsert_rows = upsert_call[0][1]
903 assert len(upsert_rows) == 1
904 assert upsert_rows[0]["in_library"] is True
905
906
907# --- Group 6: library_items filtering ---
908
909
910async def test_library_items_default_filters_in_library_only() -> None:
911 """Test that library_items passes in_library_only=True by default."""
912 ctrl = Mock(spec=MediaControllerBase)
913 ctrl._ensure_provider_filter = Mock(return_value=None)
914 ctrl.get_library_items_by_query = AsyncMock(return_value=[])
915 ctrl.library_items = MediaControllerBase.library_items.__get__(ctrl)
916
917 await ctrl.library_items()
918
919 ctrl.get_library_items_by_query.assert_called_once()
920 call_kwargs = ctrl.get_library_items_by_query.call_args[1]
921 assert call_kwargs["in_library_only"] is True
922
923
924async def test_library_items_defaults_to_summary() -> None:
925 """library_items defaults to summary=True so list endpoints return slim rows."""
926 ctrl = Mock(spec=MediaControllerBase)
927 ctrl._ensure_provider_filter = Mock(return_value=None)
928 ctrl.get_library_items_by_query = AsyncMock(return_value=[])
929 ctrl.library_items = MediaControllerBase.library_items.__get__(ctrl)
930
931 await ctrl.library_items()
932
933 call_kwargs = ctrl.get_library_items_by_query.call_args[1]
934 assert call_kwargs["summary"] is True
935
936
937def test_ensure_provider_filter_keeps_plugin_provider_mappings() -> None:
938 """Test that plugin providers are kept when a user music-provider filter is active."""
939 ctrl = Mock(spec=MediaControllerBase)
940 ctrl.mass = Mock()
941 ctrl.mass.providers = [
942 Mock(instance_id="spotify_1", type=ProviderType.MUSIC),
943 Mock(instance_id="smart_playlist_1", type=ProviderType.PLUGIN),
944 ]
945 ctrl._ensure_provider_filter = MediaControllerBase._ensure_provider_filter.__get__(ctrl)
946
947 with patch(
948 "music_assistant.controllers.music.media.base.get_current_user",
949 return_value=Mock(provider_filter=["spotify_1"]),
950 ):
951 result = ctrl._ensure_provider_filter(None)
952
953 assert result is not None
954 assert "spotify_1" in result
955 assert "smart_playlist_1" in result
956
957
958def test_ensure_provider_filter_rejects_unallowed_music_provider() -> None:
959 """Test that requesting a disallowed music provider still raises permissions error."""
960 ctrl = Mock(spec=MediaControllerBase)
961 ctrl.mass = Mock()
962 ctrl.mass.providers = [
963 Mock(instance_id="spotify_1", type=ProviderType.MUSIC),
964 Mock(instance_id="smart_playlist_1", type=ProviderType.PLUGIN),
965 ]
966 ctrl._ensure_provider_filter = MediaControllerBase._ensure_provider_filter.__get__(ctrl)
967
968 with (
969 patch(
970 "music_assistant.controllers.music.media.base.get_current_user",
971 return_value=Mock(provider_filter=["spotify_1"]),
972 ),
973 pytest.raises(InsufficientPermissions),
974 ):
975 ctrl._ensure_provider_filter("qobuz_1")
976
977
978def test_ensure_provider_filter_allows_explicit_non_music_provider() -> None:
979 """Test that explicitly requesting a plugin provider is allowed for filtered users."""
980 ctrl = Mock(spec=MediaControllerBase)
981 ctrl.mass = Mock()
982 ctrl.mass.providers = [
983 Mock(instance_id="spotify_1", type=ProviderType.MUSIC),
984 Mock(instance_id="smart_playlist_1", type=ProviderType.PLUGIN),
985 ]
986 ctrl._ensure_provider_filter = MediaControllerBase._ensure_provider_filter.__get__(ctrl)
987
988 with patch(
989 "music_assistant.controllers.music.media.base.get_current_user",
990 return_value=Mock(provider_filter=["spotify_1"]),
991 ):
992 result = ctrl._ensure_provider_filter("smart_playlist_1")
993
994 assert result == ["smart_playlist_1"]
995
996
997def test_ensure_provider_filter_does_not_auto_allow_other_non_music_providers() -> None:
998 """Test that only plugin providers are auto-allowed when user filter is active."""
999 ctrl = Mock(spec=MediaControllerBase)
1000 ctrl.mass = Mock()
1001 ctrl.mass.providers = [
1002 Mock(instance_id="spotify_1", type=ProviderType.MUSIC),
1003 Mock(instance_id="smart_playlist_1", type=ProviderType.PLUGIN),
1004 Mock(instance_id="meta_1", type=ProviderType.METADATA),
1005 ]
1006 ctrl._ensure_provider_filter = MediaControllerBase._ensure_provider_filter.__get__(ctrl)
1007
1008 with patch(
1009 "music_assistant.controllers.music.media.base.get_current_user",
1010 return_value=Mock(provider_filter=["spotify_1"]),
1011 ):
1012 result = ctrl._ensure_provider_filter(None)
1013
1014 assert result is not None
1015 assert "spotify_1" in result
1016 assert "smart_playlist_1" in result
1017 assert "meta_1" not in result
1018
1019
1020def test_select_provider_id_prefers_allowed_music_over_plugin() -> None:
1021 """Test that allowed music mappings are preferred over plugin mappings."""
1022 ctrl = Mock(spec=MediaControllerBase)
1023 ctrl.mass = Mock()
1024 ctrl.mass.get_provider = Mock(
1025 side_effect=lambda instance: {
1026 "smart_playlist_1": Mock(type=ProviderType.PLUGIN),
1027 "spotify_1": Mock(type=ProviderType.MUSIC),
1028 }.get(instance)
1029 )
1030 ctrl._select_provider_id = MediaControllerBase._select_provider_id.__get__(ctrl)
1031
1032 item = create_mock_album(
1033 provider_mappings=[
1034 create_provider_mapping(
1035 provider_instance="smart_playlist_1",
1036 provider_domain="smart_playlist",
1037 item_id="plugin_item",
1038 ),
1039 create_provider_mapping(
1040 provider_instance="spotify_1",
1041 provider_domain="spotify",
1042 item_id="music_item",
1043 ),
1044 ]
1045 )
1046
1047 with patch(
1048 "music_assistant.controllers.music.media.base.get_current_user",
1049 return_value=Mock(provider_filter=["spotify_1"]),
1050 ):
1051 provider_instance, provider_item = ctrl._select_provider_id(item)
1052
1053 assert provider_instance == "spotify_1"
1054 assert provider_item == "music_item"
1055
1056
1057def test_select_provider_id_falls_back_to_plugin_when_no_allowed_music() -> None:
1058 """Test that plugin mapping is selected if no allowed music mapping exists."""
1059 ctrl = Mock(spec=MediaControllerBase)
1060 ctrl.mass = Mock()
1061 ctrl.mass.get_provider = Mock(
1062 side_effect=lambda instance: {
1063 "smart_playlist_1": Mock(type=ProviderType.PLUGIN),
1064 "qobuz_1": Mock(type=ProviderType.MUSIC),
1065 }.get(instance)
1066 )
1067 ctrl._select_provider_id = MediaControllerBase._select_provider_id.__get__(ctrl)
1068
1069 item = create_mock_album(
1070 provider_mappings=[
1071 create_provider_mapping(
1072 provider_instance="smart_playlist_1",
1073 provider_domain="smart_playlist",
1074 item_id="plugin_item",
1075 ),
1076 create_provider_mapping(
1077 provider_instance="qobuz_1",
1078 provider_domain="qobuz",
1079 item_id="music_item",
1080 ),
1081 ]
1082 )
1083
1084 with patch(
1085 "music_assistant.controllers.music.media.base.get_current_user",
1086 return_value=Mock(provider_filter=["spotify_1"]),
1087 ):
1088 provider_instance, provider_item = ctrl._select_provider_id(item)
1089
1090 assert provider_instance == "smart_playlist_1"
1091 assert provider_item == "plugin_item"
1092
1093
1094async def test_get_library_item_does_not_filter_in_library() -> None:
1095 """
1096 Test that get_library_item always passes in_library_only=False.
1097
1098 Single-item lookups must find items regardless of in_library state.
1099 """
1100 album = create_mock_album()
1101
1102 ctrl = Mock(spec=MediaControllerBase)
1103 ctrl.db_table = "albums"
1104 ctrl.media_type = MediaType.ALBUM
1105 ctrl.get_library_items_by_query = AsyncMock(return_value=[album])
1106 ctrl.get_library_item = MediaControllerBase.get_library_item.__get__(ctrl)
1107
1108 await ctrl.get_library_item(1)
1109
1110 call_kwargs = ctrl.get_library_items_by_query.call_args[1]
1111 assert call_kwargs["in_library_only"] is False
1112
1113
1114async def test_update_item_in_library_skips_non_music_providers() -> None:
1115 """Test update callback dispatch skips provider mappings that are not music providers."""
1116 ctrl = Mock(spec=MediaControllerBase)
1117 ctrl._update_library_item = AsyncMock()
1118 ctrl.get_library_item = AsyncMock(
1119 return_value=Mock(
1120 uri="library://album/1",
1121 provider_mappings=[
1122 create_provider_mapping(
1123 provider_instance="smart_playlist_1",
1124 provider_domain="smart_playlist",
1125 item_id="abc",
1126 )
1127 ],
1128 metadata=Mock(images=None),
1129 )
1130 )
1131
1132 mass = Mock()
1133 mass.music = Mock()
1134 mass.music.match_provider_instances = Mock()
1135 mass.music.database.deferred_commit = _noop_deferred_commit
1136 mass.signal_event = Mock()
1137 mass.get_provider = Mock(return_value=Mock(type=ProviderType.PLUGIN))
1138 ctrl.mass = mass
1139
1140 ctrl.update_item_in_library = MediaControllerBase.update_item_in_library.__get__(ctrl)
1141
1142 update = create_mock_album(item_id="1")
1143
1144 updated = await ctrl.update_item_in_library(item_id=1, update=update, overwrite=False)
1145
1146 assert updated is not None
1147 ctrl._update_library_item.assert_called_once()
1148 mass.music.match_provider_instances.assert_called_once_with(update)
1149 mass.get_provider.assert_called_once_with("smart_playlist_1")
1150
1151
1152# --- Group 7: Per-item event suppression during provider sync ---
1153
1154
1155def _create_event_capture_controller(
1156 library_item: Mock, events: list[EventType]
1157) -> tuple[Mock, Mock]:
1158 """Build a controller mock with real add/update methods bound; events records signalled types."""
1159 mass = Mock()
1160 mass.signal_event = Mock(side_effect=lambda event, *_args, **_kwargs: events.append(event))
1161 mass.music.database.deferred_commit = _noop_deferred_commit
1162 ctrl = Mock(spec=MediaControllerBase)
1163 ctrl.mass = mass
1164 ctrl._db_add_lock = asyncio.Lock()
1165 ctrl._get_library_item_by_match = AsyncMock(return_value=None)
1166 ctrl._add_library_item = AsyncMock(return_value=1)
1167 ctrl._update_library_item = AsyncMock()
1168 ctrl.get_library_item = AsyncMock(return_value=library_item)
1169 ctrl.add_item_to_library = MediaControllerBase.add_item_to_library.__get__(ctrl)
1170 ctrl.update_item_in_library = MediaControllerBase.update_item_in_library.__get__(ctrl)
1171 return ctrl, mass
1172
1173
1174async def test_add_and_update_item_emit_events_outside_sync() -> None:
1175 """Regular add/update calls emit per-item events and run the provider write-back."""
1176 mapping = create_provider_mapping()
1177 library_item = create_mock_album(provider="library", provider_mappings=[mapping])
1178 library_item.uri = "library://album/1"
1179 events: list[EventType] = []
1180 ctrl, mass = _create_event_capture_controller(library_item, events)
1181
1182 provider = Mock(type=ProviderType.MUSIC)
1183 provider.on_item_updated = AsyncMock()
1184 mass.get_provider.return_value = provider
1185
1186 await ctrl.add_item_to_library(create_mock_album(provider="spotify"))
1187 assert events == [EventType.MEDIA_ITEM_ADDED]
1188
1189 await ctrl.update_item_in_library(1, create_mock_album(provider="spotify"))
1190 assert events == [EventType.MEDIA_ITEM_ADDED, EventType.MEDIA_ITEM_UPDATED]
1191 provider.on_item_updated.assert_awaited_once_with(library_item)
1192
1193
1194async def test_provider_sync_suppresses_per_item_events() -> None:
1195 """A provider sync emits only MUSIC_SYNC_COMPLETED; per-item events resume afterwards."""
1196 library_item = create_mock_album(provider="library")
1197 library_item.uri = "library://album/1"
1198 events: list[EventType] = []
1199 ctrl, mass = _create_event_capture_controller(library_item, events)
1200 # run the deferred completion check inline instead of on the event loop
1201 mass.call_later = Mock(side_effect=lambda _delay, target, **_kwargs: target())
1202
1203 music_ctrl = MusicController.__new__(MusicController)
1204 music_ctrl.mass = mass
1205 music_ctrl._sync_lock = asyncio.Lock()
1206
1207 provider = Mock()
1208
1209 async def fake_sync_library(_media_type: MediaType) -> None:
1210 # stand in for the per-mediatype sync loops adding/updating items
1211 await ctrl.add_item_to_library(create_mock_album(provider="spotify"))
1212 await ctrl.update_item_in_library(1, create_mock_album(provider="spotify"))
1213
1214 provider.sync_library = fake_sync_library
1215
1216 run_sync = music_ctrl._create_provider_sync_handler(provider, MediaType.ALBUM)
1217 with (
1218 patch.object(MusicController, "active_sync_tasks", new_callable=PropertyMock) as tasks,
1219 patch.object(music_ctrl, "_queue_database_cleanup_task"),
1220 ):
1221 tasks.return_value = []
1222 await run_sync()
1223
1224 assert events == [EventType.MUSIC_SYNC_COMPLETED]
1225 # write-back was skipped too (provider lookup never happened)
1226 mass.get_provider.assert_not_called()
1227 # suppression must not leak past the handler
1228 assert SUPPRESS_MEDIA_ITEM_UPDATES.get() is False
1229 await ctrl.add_item_to_library(create_mock_album(provider="spotify"))
1230 assert events == [EventType.MUSIC_SYNC_COMPLETED, EventType.MEDIA_ITEM_ADDED]
1231
1232
1233async def test_concurrent_add_rechecks_match_inside_lock() -> None:
1234 """Concurrent adds create one row and update it with the second provider item."""
1235 library_item = create_mock_album(provider="library")
1236 library_item.uri = "library://album/1"
1237 ctrl, _mass = _create_event_capture_controller(library_item, [])
1238 both_initial_checks_complete = asyncio.Event()
1239 initial_checks = 0
1240 library_id: int | None = None
1241
1242 async def get_match(_item: Mock) -> int | None:
1243 nonlocal initial_checks
1244 if library_id is not None:
1245 return library_id
1246 initial_checks += 1
1247 if initial_checks <= 2:
1248 if initial_checks == 2:
1249 both_initial_checks_complete.set()
1250 await both_initial_checks_complete.wait()
1251 return None
1252 return library_id
1253
1254 async def add_item(_item: Mock) -> int:
1255 nonlocal library_id
1256 library_id = 1
1257 return library_id
1258
1259 ctrl._get_library_item_by_match.side_effect = get_match
1260 ctrl._add_library_item.side_effect = add_item
1261
1262 results = await asyncio.gather(
1263 ctrl.add_item_to_library(create_mock_album(provider="spotify")),
1264 ctrl.add_item_to_library(create_mock_album(provider="qobuz")),
1265 )
1266
1267 assert len(results) == 2
1268 assert all(result is library_item for result in results)
1269 ctrl._add_library_item.assert_awaited_once()
1270 ctrl._update_library_item.assert_awaited_once()
1271