/
/
/
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.name)
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.name)
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.mass = Mock()
815 ctrl.mass.music.database.delete = AsyncMock()
816 ctrl.mass.music.database.upsert_many = AsyncMock()
817 ctrl.set_provider_mappings = MediaControllerBase.set_provider_mappings.__get__(ctrl)
818 return ctrl
819
820
821async def test_set_provider_mappings_overwrite_deletes_and_reinserts(
822 mock_controller: Mock,
823) -> None:
824 """
825 Test that overwrite=True deletes existing mappings before upserting.
826
827 :param mock_controller: Mock MediaControllerBase instance.
828 """
829 mapping = create_provider_mapping(in_library=True)
830
831 await mock_controller.set_provider_mappings(1, [mapping], overwrite=True)
832
833 mock_controller.mass.music.database.delete.assert_called_once()
834 mock_controller.mass.music.database.upsert_many.assert_called_once()
835
836
837async def test_set_provider_mappings_upsert_preserves_null_in_library(
838 mock_controller: Mock,
839) -> None:
840 """
841 Test that in_library=None is excluded from the upsert dict.
842
843 When in_library is None, it should not be included in the dict passed to upsert,
844 allowing the database's existing value to be preserved.
845
846 :param mock_controller: Mock MediaControllerBase instance.
847 """
848 mapping = create_provider_mapping(in_library=None)
849
850 await mock_controller.set_provider_mappings(1, [mapping], overwrite=False)
851
852 upsert_call = mock_controller.mass.music.database.upsert_many.call_args
853 upsert_rows = upsert_call[0][1]
854 assert len(upsert_rows) == 1
855 assert "in_library" not in upsert_rows[0]
856
857
858async def test_set_provider_mappings_upsert_writes_explicit_in_library(
859 mock_controller: Mock,
860) -> None:
861 """
862 Test that an explicit in_library value is included in the upsert dict.
863
864 When in_library is explicitly True or False, it should be written to the database.
865
866 :param mock_controller: Mock MediaControllerBase instance.
867 """
868 mapping = create_provider_mapping(in_library=True)
869
870 await mock_controller.set_provider_mappings(1, [mapping], overwrite=False)
871
872 upsert_call = mock_controller.mass.music.database.upsert_many.call_args
873 upsert_rows = upsert_call[0][1]
874 assert len(upsert_rows) == 1
875 assert upsert_rows[0]["in_library"] is True
876
877
878# --- Group 6: library_items filtering ---
879
880
881async def test_library_items_default_filters_in_library_only() -> None:
882 """Test that library_items passes in_library_only=True by default."""
883 ctrl = Mock(spec=MediaControllerBase)
884 ctrl._ensure_provider_filter = Mock(return_value=None)
885 ctrl.get_library_items_by_query = AsyncMock(return_value=[])
886 ctrl.library_items = MediaControllerBase.library_items.__get__(ctrl)
887
888 await ctrl.library_items()
889
890 ctrl.get_library_items_by_query.assert_called_once()
891 call_kwargs = ctrl.get_library_items_by_query.call_args[1]
892 assert call_kwargs["in_library_only"] is True
893
894
895async def test_library_items_defaults_to_summary() -> None:
896 """library_items defaults to summary=True so list endpoints return slim rows."""
897 ctrl = Mock(spec=MediaControllerBase)
898 ctrl._ensure_provider_filter = Mock(return_value=None)
899 ctrl.get_library_items_by_query = AsyncMock(return_value=[])
900 ctrl.library_items = MediaControllerBase.library_items.__get__(ctrl)
901
902 await ctrl.library_items()
903
904 call_kwargs = ctrl.get_library_items_by_query.call_args[1]
905 assert call_kwargs["summary"] is True
906
907
908def test_ensure_provider_filter_keeps_plugin_provider_mappings() -> None:
909 """Test that plugin providers are kept when a user music-provider filter is active."""
910 ctrl = Mock(spec=MediaControllerBase)
911 ctrl.mass = Mock()
912 ctrl.mass.providers = [
913 Mock(instance_id="spotify_1", type=ProviderType.MUSIC),
914 Mock(instance_id="smart_playlist_1", type=ProviderType.PLUGIN),
915 ]
916 ctrl._ensure_provider_filter = MediaControllerBase._ensure_provider_filter.__get__(ctrl)
917
918 with patch(
919 "music_assistant.controllers.music.media.base.get_current_user",
920 return_value=Mock(provider_filter=["spotify_1"]),
921 ):
922 result = ctrl._ensure_provider_filter(None)
923
924 assert result is not None
925 assert "spotify_1" in result
926 assert "smart_playlist_1" in result
927
928
929def test_ensure_provider_filter_rejects_unallowed_music_provider() -> None:
930 """Test that requesting a disallowed music provider still raises permissions error."""
931 ctrl = Mock(spec=MediaControllerBase)
932 ctrl.mass = Mock()
933 ctrl.mass.providers = [
934 Mock(instance_id="spotify_1", type=ProviderType.MUSIC),
935 Mock(instance_id="smart_playlist_1", type=ProviderType.PLUGIN),
936 ]
937 ctrl._ensure_provider_filter = MediaControllerBase._ensure_provider_filter.__get__(ctrl)
938
939 with (
940 patch(
941 "music_assistant.controllers.music.media.base.get_current_user",
942 return_value=Mock(provider_filter=["spotify_1"]),
943 ),
944 pytest.raises(InsufficientPermissions),
945 ):
946 ctrl._ensure_provider_filter("qobuz_1")
947
948
949def test_ensure_provider_filter_allows_explicit_non_music_provider() -> None:
950 """Test that explicitly requesting a plugin provider is allowed for filtered users."""
951 ctrl = Mock(spec=MediaControllerBase)
952 ctrl.mass = Mock()
953 ctrl.mass.providers = [
954 Mock(instance_id="spotify_1", type=ProviderType.MUSIC),
955 Mock(instance_id="smart_playlist_1", type=ProviderType.PLUGIN),
956 ]
957 ctrl._ensure_provider_filter = MediaControllerBase._ensure_provider_filter.__get__(ctrl)
958
959 with patch(
960 "music_assistant.controllers.music.media.base.get_current_user",
961 return_value=Mock(provider_filter=["spotify_1"]),
962 ):
963 result = ctrl._ensure_provider_filter("smart_playlist_1")
964
965 assert result == ["smart_playlist_1"]
966
967
968def test_ensure_provider_filter_does_not_auto_allow_other_non_music_providers() -> None:
969 """Test that only plugin providers are auto-allowed when user filter is active."""
970 ctrl = Mock(spec=MediaControllerBase)
971 ctrl.mass = Mock()
972 ctrl.mass.providers = [
973 Mock(instance_id="spotify_1", type=ProviderType.MUSIC),
974 Mock(instance_id="smart_playlist_1", type=ProviderType.PLUGIN),
975 Mock(instance_id="meta_1", type=ProviderType.METADATA),
976 ]
977 ctrl._ensure_provider_filter = MediaControllerBase._ensure_provider_filter.__get__(ctrl)
978
979 with patch(
980 "music_assistant.controllers.music.media.base.get_current_user",
981 return_value=Mock(provider_filter=["spotify_1"]),
982 ):
983 result = ctrl._ensure_provider_filter(None)
984
985 assert result is not None
986 assert "spotify_1" in result
987 assert "smart_playlist_1" in result
988 assert "meta_1" not in result
989
990
991def test_select_provider_id_prefers_allowed_music_over_plugin() -> None:
992 """Test that allowed music mappings are preferred over plugin mappings."""
993 ctrl = Mock(spec=MediaControllerBase)
994 ctrl.mass = Mock()
995 ctrl.mass.get_provider = Mock(
996 side_effect=lambda instance: {
997 "smart_playlist_1": Mock(type=ProviderType.PLUGIN),
998 "spotify_1": Mock(type=ProviderType.MUSIC),
999 }.get(instance)
1000 )
1001 ctrl._select_provider_id = MediaControllerBase._select_provider_id.__get__(ctrl)
1002
1003 item = create_mock_album(
1004 provider_mappings=[
1005 create_provider_mapping(
1006 provider_instance="smart_playlist_1",
1007 provider_domain="smart_playlist",
1008 item_id="plugin_item",
1009 ),
1010 create_provider_mapping(
1011 provider_instance="spotify_1",
1012 provider_domain="spotify",
1013 item_id="music_item",
1014 ),
1015 ]
1016 )
1017
1018 with patch(
1019 "music_assistant.controllers.music.media.base.get_current_user",
1020 return_value=Mock(provider_filter=["spotify_1"]),
1021 ):
1022 provider_instance, provider_item = ctrl._select_provider_id(item)
1023
1024 assert provider_instance == "spotify_1"
1025 assert provider_item == "music_item"
1026
1027
1028def test_select_provider_id_falls_back_to_plugin_when_no_allowed_music() -> None:
1029 """Test that plugin mapping is selected if no allowed music mapping exists."""
1030 ctrl = Mock(spec=MediaControllerBase)
1031 ctrl.mass = Mock()
1032 ctrl.mass.get_provider = Mock(
1033 side_effect=lambda instance: {
1034 "smart_playlist_1": Mock(type=ProviderType.PLUGIN),
1035 "qobuz_1": Mock(type=ProviderType.MUSIC),
1036 }.get(instance)
1037 )
1038 ctrl._select_provider_id = MediaControllerBase._select_provider_id.__get__(ctrl)
1039
1040 item = create_mock_album(
1041 provider_mappings=[
1042 create_provider_mapping(
1043 provider_instance="smart_playlist_1",
1044 provider_domain="smart_playlist",
1045 item_id="plugin_item",
1046 ),
1047 create_provider_mapping(
1048 provider_instance="qobuz_1",
1049 provider_domain="qobuz",
1050 item_id="music_item",
1051 ),
1052 ]
1053 )
1054
1055 with patch(
1056 "music_assistant.controllers.music.media.base.get_current_user",
1057 return_value=Mock(provider_filter=["spotify_1"]),
1058 ):
1059 provider_instance, provider_item = ctrl._select_provider_id(item)
1060
1061 assert provider_instance == "smart_playlist_1"
1062 assert provider_item == "plugin_item"
1063
1064
1065async def test_get_library_item_does_not_filter_in_library() -> None:
1066 """
1067 Test that get_library_item always passes in_library_only=False.
1068
1069 Single-item lookups must find items regardless of in_library state.
1070 """
1071 album = create_mock_album()
1072
1073 ctrl = Mock(spec=MediaControllerBase)
1074 ctrl.db_table = "albums"
1075 ctrl.media_type = MediaType.ALBUM
1076 ctrl.get_library_items_by_query = AsyncMock(return_value=[album])
1077 ctrl.get_library_item = MediaControllerBase.get_library_item.__get__(ctrl)
1078
1079 await ctrl.get_library_item(1)
1080
1081 call_kwargs = ctrl.get_library_items_by_query.call_args[1]
1082 assert call_kwargs["in_library_only"] is False
1083
1084
1085async def test_update_item_in_library_skips_non_music_providers() -> None:
1086 """Test update callback dispatch skips provider mappings that are not music providers."""
1087 ctrl = Mock(spec=MediaControllerBase)
1088 ctrl._update_library_item = AsyncMock()
1089 ctrl.get_library_item = AsyncMock(
1090 return_value=Mock(
1091 uri="library://album/1",
1092 provider_mappings=[
1093 create_provider_mapping(
1094 provider_instance="smart_playlist_1",
1095 provider_domain="smart_playlist",
1096 item_id="abc",
1097 )
1098 ],
1099 metadata=Mock(images=None),
1100 )
1101 )
1102
1103 mass = Mock()
1104 mass.music = Mock()
1105 mass.music.match_provider_instances = Mock()
1106 mass.music.database.deferred_commit = _noop_deferred_commit
1107 mass.signal_event = Mock()
1108 mass.get_provider = Mock(return_value=Mock(type=ProviderType.PLUGIN))
1109 ctrl.mass = mass
1110
1111 ctrl.update_item_in_library = MediaControllerBase.update_item_in_library.__get__(ctrl)
1112
1113 update = create_mock_album(item_id="1")
1114
1115 updated = await ctrl.update_item_in_library(item_id=1, update=update, overwrite=False)
1116
1117 assert updated is not None
1118 ctrl._update_library_item.assert_called_once()
1119 mass.music.match_provider_instances.assert_called_once_with(update)
1120 mass.get_provider.assert_called_once_with("smart_playlist_1")
1121
1122
1123# --- Group 7: Per-item event suppression during provider sync ---
1124
1125
1126def _create_event_capture_controller(
1127 library_item: Mock, events: list[EventType]
1128) -> tuple[Mock, Mock]:
1129 """Build a controller mock with real add/update methods bound; events records signalled types."""
1130 mass = Mock()
1131 mass.signal_event = Mock(side_effect=lambda event, *_args, **_kwargs: events.append(event))
1132 mass.music.database.deferred_commit = _noop_deferred_commit
1133 ctrl = Mock(spec=MediaControllerBase)
1134 ctrl.mass = mass
1135 ctrl._db_add_lock = asyncio.Lock()
1136 ctrl._get_library_item_by_match = AsyncMock(return_value=None)
1137 ctrl._add_library_item = AsyncMock(return_value=1)
1138 ctrl._update_library_item = AsyncMock()
1139 ctrl.get_library_item = AsyncMock(return_value=library_item)
1140 ctrl.add_item_to_library = MediaControllerBase.add_item_to_library.__get__(ctrl)
1141 ctrl.update_item_in_library = MediaControllerBase.update_item_in_library.__get__(ctrl)
1142 return ctrl, mass
1143
1144
1145async def test_add_and_update_item_emit_events_outside_sync() -> None:
1146 """Regular add/update calls emit per-item events and run the provider write-back."""
1147 mapping = create_provider_mapping()
1148 library_item = create_mock_album(provider="library", provider_mappings=[mapping])
1149 library_item.uri = "library://album/1"
1150 events: list[EventType] = []
1151 ctrl, mass = _create_event_capture_controller(library_item, events)
1152
1153 provider = Mock(type=ProviderType.MUSIC)
1154 provider.on_item_updated = AsyncMock()
1155 mass.get_provider.return_value = provider
1156
1157 await ctrl.add_item_to_library(create_mock_album(provider="spotify"))
1158 assert events == [EventType.MEDIA_ITEM_ADDED]
1159
1160 await ctrl.update_item_in_library(1, create_mock_album(provider="spotify"))
1161 assert events == [EventType.MEDIA_ITEM_ADDED, EventType.MEDIA_ITEM_UPDATED]
1162 provider.on_item_updated.assert_awaited_once_with(library_item)
1163
1164
1165async def test_provider_sync_suppresses_per_item_events() -> None:
1166 """A provider sync emits only MUSIC_SYNC_COMPLETED; per-item events resume afterwards."""
1167 library_item = create_mock_album(provider="library")
1168 library_item.uri = "library://album/1"
1169 events: list[EventType] = []
1170 ctrl, mass = _create_event_capture_controller(library_item, events)
1171 # run the deferred completion check inline instead of on the event loop
1172 mass.call_later = Mock(side_effect=lambda _delay, target, **_kwargs: target())
1173
1174 music_ctrl = MusicController.__new__(MusicController)
1175 music_ctrl.mass = mass
1176 music_ctrl._sync_lock = asyncio.Lock()
1177
1178 provider = Mock()
1179
1180 async def fake_sync_library(_media_type: MediaType) -> None:
1181 # stand in for the per-mediatype sync loops adding/updating items
1182 await ctrl.add_item_to_library(create_mock_album(provider="spotify"))
1183 await ctrl.update_item_in_library(1, create_mock_album(provider="spotify"))
1184
1185 provider.sync_library = fake_sync_library
1186
1187 run_sync = music_ctrl._create_provider_sync_handler(provider, MediaType.ALBUM)
1188 with (
1189 patch.object(MusicController, "active_sync_tasks", new_callable=PropertyMock) as tasks,
1190 patch.object(music_ctrl, "_queue_database_cleanup_task"),
1191 ):
1192 tasks.return_value = []
1193 await run_sync()
1194
1195 assert events == [EventType.MUSIC_SYNC_COMPLETED]
1196 # write-back was skipped too (provider lookup never happened)
1197 mass.get_provider.assert_not_called()
1198 # suppression must not leak past the handler
1199 assert SUPPRESS_MEDIA_ITEM_UPDATES.get() is False
1200 await ctrl.add_item_to_library(create_mock_album(provider="spotify"))
1201 assert events == [EventType.MUSIC_SYNC_COMPLETED, EventType.MEDIA_ITEM_ADDED]
1202