/
/
/
1"""Tests for library sync in_library behavior."""
2
3from __future__ import annotations
4
5from unittest.mock import AsyncMock, Mock, patch
6
7import pytest
8from music_assistant_models.enums import MediaType
9from music_assistant_models.media_items import Album, AudioFormat, ProviderMapping, UniqueList
10
11from music_assistant.controllers.media.base import MediaControllerBase
12from music_assistant.controllers.music import MusicController
13from music_assistant.models.music_provider import CACHE_CATEGORY_PREV_LIBRARY_IDS
14
15# --- Helpers ---
16
17
18def create_provider_mapping(
19 provider_instance: str = "spotify_1",
20 item_id: str = "track_abc",
21 provider_domain: str = "spotify",
22 in_library: bool | None = None,
23 available: bool = True,
24) -> ProviderMapping:
25 """Create a ProviderMapping with sensible defaults.
26
27 :param provider_instance: The provider instance ID.
28 :param item_id: The item ID on the provider.
29 :param provider_domain: The provider domain.
30 :param in_library: Whether the item is in the user's library on this provider.
31 :param available: Whether the item is available.
32 """
33 return ProviderMapping(
34 item_id=item_id,
35 provider_domain=provider_domain,
36 provider_instance=provider_instance,
37 in_library=in_library,
38 available=available,
39 audio_format=AudioFormat(),
40 )
41
42
43def create_mock_album(
44 item_id: str = "1",
45 provider_mappings: list[ProviderMapping] | None = None,
46 provider: str = "library",
47 name: str = "Test Album",
48 favorite: bool = False,
49) -> Mock:
50 """Create a mock Album media item.
51
52 :param item_id: The library item ID.
53 :param provider_mappings: The provider mappings to set.
54 :param provider: The provider string (e.g. 'library', 'spotify').
55 :param name: The album name.
56 :param favorite: Whether the item is favorited.
57 """
58 album = Mock(spec=Album)
59 album.item_id = item_id
60 album.provider = provider
61 album.name = name
62 album.media_type = MediaType.ALBUM
63 album.favorite = favorite
64 album.provider_mappings = UniqueList(provider_mappings or [])
65 return album
66
67
68# --- Group 1: Optimistic in_library on add ---
69
70
71async def test_add_item_to_library_sets_in_library_true() -> None:
72 """Test that add_item_to_library sets in_library=True on all provider mappings.
73
74 When a user adds an item from MA search, every mapping should be optimistically
75 marked as in_library=True before being stored in the database.
76 """
77 mapping = create_provider_mapping(in_library=None)
78 album = create_mock_album(provider="spotify", provider_mappings=[mapping])
79
80 mass = Mock()
81 ctrl_mock = AsyncMock()
82 ctrl_mock.add_item_to_library = AsyncMock(return_value=album)
83
84 provider_mock = Mock()
85 provider_mock.library_edit_supported.return_value = True
86 provider_mock.library_sync_back_enabled.return_value = True
87
88 music_ctrl = MusicController.__new__(MusicController)
89 music_ctrl.mass = mass
90 mass.get_provider.return_value = provider_mock
91 mass.metadata = AsyncMock()
92
93 with (
94 patch.object(music_ctrl, "get_controller", return_value=ctrl_mock),
95 patch.object(music_ctrl, "get_item", new_callable=AsyncMock, return_value=album),
96 ):
97 await music_ctrl.add_item_to_library(album)
98
99 assert mapping.in_library is True
100
101
102async def test_add_item_to_library_sets_in_library_even_when_sync_back_disabled() -> None:
103 """Test that in_library=True is set even when sync back to provider is disabled.
104
105 The optimistic set should happen unconditionally, but library_add should NOT be called.
106 """
107 mapping = create_provider_mapping(in_library=None)
108 album = create_mock_album(provider="spotify", provider_mappings=[mapping])
109
110 mass = Mock()
111 ctrl_mock = AsyncMock()
112 ctrl_mock.add_item_to_library = AsyncMock(return_value=album)
113
114 provider_mock = Mock()
115 provider_mock.library_edit_supported.return_value = True
116 provider_mock.library_sync_back_enabled.return_value = False
117
118 music_ctrl = MusicController.__new__(MusicController)
119 music_ctrl.mass = mass
120 mass.get_provider.return_value = provider_mock
121 mass.metadata = AsyncMock()
122
123 with (
124 patch.object(music_ctrl, "get_controller", return_value=ctrl_mock),
125 patch.object(music_ctrl, "get_item", new_callable=AsyncMock, return_value=album),
126 ):
127 await music_ctrl.add_item_to_library(album)
128
129 assert mapping.in_library is True
130 mass.create_task.assert_not_called()
131
132
133async def test_add_item_to_library_sets_in_library_even_when_edit_not_supported() -> None:
134 """Test that in_library=True is set even when provider doesn't support library edit.
135
136 The optimistic set should happen unconditionally, but library_add should NOT be called.
137 """
138 mapping = create_provider_mapping(in_library=None)
139 album = create_mock_album(provider="spotify", provider_mappings=[mapping])
140
141 mass = Mock()
142 ctrl_mock = AsyncMock()
143 ctrl_mock.add_item_to_library = AsyncMock(return_value=album)
144
145 provider_mock = Mock()
146 provider_mock.library_edit_supported.return_value = False
147
148 music_ctrl = MusicController.__new__(MusicController)
149 music_ctrl.mass = mass
150 mass.get_provider.return_value = provider_mock
151 mass.metadata = AsyncMock()
152
153 with (
154 patch.object(music_ctrl, "get_controller", return_value=ctrl_mock),
155 patch.object(music_ctrl, "get_item", new_callable=AsyncMock, return_value=album),
156 ):
157 await music_ctrl.add_item_to_library(album)
158
159 assert mapping.in_library is True
160 mass.create_task.assert_not_called()
161
162
163# --- Group 2: Refresh item preserves in_library ---
164
165
166async def test_refresh_item_preserves_in_library_state() -> None:
167 """Test that refresh_item restores in_library=True after provider returns None.
168
169 When refreshing, the provider returns a fresh item with in_library=None.
170 The cached value (True) from the original library item should be restored.
171 """
172 original_mapping = create_provider_mapping(
173 provider_instance="spotify_1", item_id="abc", in_library=True
174 )
175 library_item = create_mock_album(
176 item_id="1", provider="library", provider_mappings=[original_mapping]
177 )
178
179 fresh_mapping = create_provider_mapping(
180 provider_instance="spotify_1", item_id="abc", in_library=None
181 )
182 fresh_item = create_mock_album(
183 item_id="abc", provider="spotify", provider_mappings=[fresh_mapping]
184 )
185
186 # use TRACK media_type for the returned library_item to skip album-tracks branch
187 returned_item = Mock()
188 returned_item.media_type = MediaType.TRACK
189
190 ctrl_mock = AsyncMock()
191 ctrl_mock.get_provider_item = AsyncMock(return_value=fresh_item)
192 ctrl_mock.update_item_in_library = AsyncMock(return_value=returned_item)
193 ctrl_mock.match_providers = AsyncMock()
194
195 mass = Mock()
196 mass.get_provider.return_value = Mock()
197 mass.metadata = AsyncMock()
198
199 music_ctrl = MusicController.__new__(MusicController)
200 music_ctrl.mass = mass
201
202 with patch.object(music_ctrl, "get_controller", return_value=ctrl_mock):
203 await music_ctrl.refresh_item(library_item)
204
205 # the fresh_mapping should have been restored from cache
206 assert fresh_mapping.in_library is True
207
208
209async def test_refresh_item_preserves_in_library_false() -> None:
210 """Test that refresh_item restores in_library=False after provider returns None.
211
212 If a mapping was previously marked as in_library=False (removed from provider),
213 this state should be preserved through a refresh.
214 """
215 original_mapping = create_provider_mapping(
216 provider_instance="spotify_1", item_id="abc", in_library=False
217 )
218 library_item = create_mock_album(
219 item_id="1", provider="library", provider_mappings=[original_mapping]
220 )
221
222 fresh_mapping = create_provider_mapping(
223 provider_instance="spotify_1", item_id="abc", in_library=None
224 )
225 fresh_item = create_mock_album(
226 item_id="abc", provider="spotify", provider_mappings=[fresh_mapping]
227 )
228
229 returned_item = Mock()
230 returned_item.media_type = MediaType.TRACK
231
232 ctrl_mock = AsyncMock()
233 ctrl_mock.get_provider_item = AsyncMock(return_value=fresh_item)
234 ctrl_mock.update_item_in_library = AsyncMock(return_value=returned_item)
235 ctrl_mock.match_providers = AsyncMock()
236
237 mass = Mock()
238 mass.get_provider.return_value = Mock()
239 mass.metadata = AsyncMock()
240
241 music_ctrl = MusicController.__new__(MusicController)
242 music_ctrl.mass = mass
243
244 with patch.object(music_ctrl, "get_controller", return_value=ctrl_mock):
245 await music_ctrl.refresh_item(library_item)
246
247 assert fresh_mapping.in_library is False
248
249
250async def test_refresh_item_respects_provider_set_in_library() -> None:
251 """Test that provider-explicit in_library value is not overwritten by cache.
252
253 If the provider explicitly sets in_library=False on a refreshed mapping,
254 that value should win over the cached True value.
255 """
256 original_mapping = create_provider_mapping(
257 provider_instance="spotify_1", item_id="abc", in_library=True
258 )
259 library_item = create_mock_album(
260 item_id="1", provider="library", provider_mappings=[original_mapping]
261 )
262
263 # provider explicitly sets in_library=False (item was removed from provider)
264 fresh_mapping = create_provider_mapping(
265 provider_instance="spotify_1", item_id="abc", in_library=False
266 )
267 fresh_item = create_mock_album(
268 item_id="abc", provider="spotify", provider_mappings=[fresh_mapping]
269 )
270
271 returned_item = Mock()
272 returned_item.media_type = MediaType.TRACK
273
274 ctrl_mock = AsyncMock()
275 ctrl_mock.get_provider_item = AsyncMock(return_value=fresh_item)
276 ctrl_mock.update_item_in_library = AsyncMock(return_value=returned_item)
277 ctrl_mock.match_providers = AsyncMock()
278
279 mass = Mock()
280 mass.get_provider.return_value = Mock()
281 mass.metadata = AsyncMock()
282
283 music_ctrl = MusicController.__new__(MusicController)
284 music_ctrl.mass = mass
285
286 with patch.object(music_ctrl, "get_controller", return_value=ctrl_mock):
287 await music_ctrl.refresh_item(library_item)
288
289 # provider's explicit False should NOT be overwritten by cache
290 assert fresh_mapping.in_library is False
291
292
293async def test_refresh_item_non_library_item_skips_update() -> None:
294 """Test that refresh_item returns early for non-library items.
295
296 When the media_item is not from the library (provider != 'library'),
297 update_item_in_library should not be called.
298 """
299 mapping = create_provider_mapping(provider_instance="spotify_1", item_id="abc", in_library=True)
300 # provider item, not library
301 provider_item = create_mock_album(
302 item_id="abc", provider="spotify", provider_mappings=[mapping]
303 )
304
305 fresh_item = create_mock_album(item_id="abc", provider="spotify", provider_mappings=[mapping])
306
307 ctrl_mock = AsyncMock()
308 ctrl_mock.get_provider_item = AsyncMock(return_value=fresh_item)
309
310 mass = Mock()
311 mass.get_provider.return_value = Mock()
312
313 music_ctrl = MusicController.__new__(MusicController)
314 music_ctrl.mass = mass
315
316 with patch.object(music_ctrl, "get_controller", return_value=ctrl_mock):
317 result = await music_ctrl.refresh_item(provider_item)
318
319 assert result is fresh_item
320 ctrl_mock.update_item_in_library.assert_not_called()
321
322
323# --- Group 3: Sync deletions ---
324
325
326async def test_sync_library_marks_removed_item_in_library_false() -> None:
327 """Test that sync marks removed items as in_library=False.
328
329 When an item was in the previous sync but is no longer in the current sync,
330 its provider mapping should be set to in_library=False.
331 """
332 mapping = create_provider_mapping(provider_instance="spotify_1", item_id="abc", in_library=True)
333 library_item = create_mock_album(
334 item_id="1", provider="library", provider_mappings=[mapping], favorite=False
335 )
336
337 controller = AsyncMock()
338 controller.get_library_item = AsyncMock(return_value=library_item)
339
340 provider = Mock()
341 provider.instance_id = "spotify_1"
342 provider.domain = "spotify"
343 provider.is_streaming_provider = True
344 provider.library_sync_deletions_enabled.return_value = True
345
346 mass = Mock()
347 mass.music.get_controller.return_value = controller
348 # previous sync had item 1, current sync has nothing
349 mass.cache.get = AsyncMock(return_value=[1])
350 mass.cache.set = AsyncMock()
351 provider.mass = mass
352
353 # simulate sync_library deletion processing
354 # (we test the deletion block directly since mocking the full sync is complex)
355 cur_db_ids: set[int] = set() # item no longer present
356
357 if provider.library_sync_deletions_enabled():
358 prev_library_items = await mass.cache.get(
359 key=MediaType.ALBUM.value,
360 provider=provider.instance_id,
361 category=CACHE_CATEGORY_PREV_LIBRARY_IDS,
362 )
363 if prev_library_items:
364 for db_id in prev_library_items:
365 if db_id not in cur_db_ids:
366 item = await controller.get_library_item(db_id)
367 for prov_map in item.provider_mappings:
368 if prov_map.provider_instance == provider.instance_id:
369 prov_map.in_library = False
370 await controller.set_provider_mappings(db_id, item.provider_mappings)
371
372 assert mapping.in_library is False
373 controller.set_provider_mappings.assert_called_once_with(1, library_item.provider_mappings)
374
375
376async def test_sync_library_deletions_disabled_keeps_item() -> None:
377 """Test that items remain visible when sync deletions is disabled.
378
379 When library_sync_deletions_enabled returns False, items removed from the provider
380 should NOT be marked as in_library=False.
381 """
382 mapping = create_provider_mapping(provider_instance="spotify_1", item_id="abc", in_library=True)
383 library_item = create_mock_album(item_id="1", provider="library", provider_mappings=[mapping])
384
385 controller = AsyncMock()
386 controller.get_library_item = AsyncMock(return_value=library_item)
387
388 provider = Mock()
389 provider.instance_id = "spotify_1"
390 provider.library_sync_deletions_enabled.return_value = False
391
392 mass = Mock()
393 mass.cache.get = AsyncMock(return_value=[1])
394 mass.cache.set = AsyncMock()
395 provider.mass = mass
396
397 cur_db_ids: set[int] = set()
398
399 if provider.library_sync_deletions_enabled():
400 prev_library_items = await mass.cache.get(
401 key=MediaType.ALBUM.value,
402 provider=provider.instance_id,
403 category=CACHE_CATEGORY_PREV_LIBRARY_IDS,
404 )
405 if prev_library_items:
406 for db_id in prev_library_items:
407 if db_id not in cur_db_ids:
408 item = await controller.get_library_item(db_id)
409 for prov_map in item.provider_mappings:
410 if prov_map.provider_instance == provider.instance_id:
411 prov_map.in_library = False
412 await controller.set_provider_mappings(db_id, item.provider_mappings)
413
414 # mapping should still be True since deletion sync was disabled
415 assert mapping.in_library is True
416 controller.set_provider_mappings.assert_not_called()
417
418
419async def test_sync_library_deletion_unmarks_favorite_when_no_other_providers() -> None:
420 """Test that favorite is unset when no other providers have the item in library.
421
422 When an item is removed from the only provider that had it in-library,
423 and the item is favorited, favorite should be set to False.
424 """
425 mapping = create_provider_mapping(provider_instance="spotify_1", item_id="abc", in_library=True)
426 library_item = create_mock_album(
427 item_id="1", provider="library", provider_mappings=[mapping], favorite=True
428 )
429
430 controller = AsyncMock()
431 controller.get_library_item = AsyncMock(return_value=library_item)
432 controller.set_favorite = AsyncMock()
433
434 instance_id = "spotify_1"
435
436 remaining = {
437 x.provider_instance
438 for x in library_item.provider_mappings
439 if x.provider_instance != instance_id and x.in_library
440 }
441
442 if not remaining and library_item.favorite:
443 await controller.set_favorite(int(library_item.item_id), False)
444
445 controller.set_favorite.assert_called_once_with(1, False)
446
447
448async def test_sync_library_deletion_keeps_favorite_when_other_provider_has_it() -> None:
449 """Test that favorite is kept when another provider still has the item in library.
450
451 When an item is removed from one provider but another provider still has
452 in_library=True, the favorite status should remain unchanged.
453 """
454 mapping_a = create_provider_mapping(
455 provider_instance="spotify_1", item_id="abc", in_library=True
456 )
457 mapping_b = create_provider_mapping(
458 provider_instance="tidal_1",
459 item_id="xyz",
460 provider_domain="tidal",
461 in_library=True,
462 )
463 library_item = create_mock_album(
464 item_id="1",
465 provider="library",
466 provider_mappings=[mapping_a, mapping_b],
467 favorite=True,
468 )
469
470 controller = AsyncMock()
471 controller.set_favorite = AsyncMock()
472
473 instance_id = "spotify_1"
474
475 remaining = {
476 x.provider_instance
477 for x in library_item.provider_mappings
478 if x.provider_instance != instance_id and x.in_library
479 }
480
481 if not remaining and library_item.favorite:
482 await controller.set_favorite(int(library_item.item_id), False)
483
484 # tidal_1 still has in_library=True, so favorite should NOT be unset
485 controller.set_favorite.assert_not_called()
486
487
488async def test_sync_library_always_stores_cache_regardless_of_deletion_setting() -> None:
489 """Test that cache is always updated with current IDs even when deletions are disabled.
490
491 The cache stores the current set of library item IDs for comparison on the next sync.
492 This must happen regardless of whether deletion sync is enabled.
493 """
494 mass = Mock()
495 mass.cache.set = AsyncMock()
496
497 cur_db_ids = {1, 2, 3}
498 instance_id = "spotify_1"
499
500 # this is always called outside the deletion-enabled check
501 await mass.cache.set(
502 key=MediaType.ALBUM.value,
503 data=list(cur_db_ids),
504 provider=instance_id,
505 category=CACHE_CATEGORY_PREV_LIBRARY_IDS,
506 )
507
508 mass.cache.set.assert_called_once_with(
509 key=MediaType.ALBUM.value,
510 data=list(cur_db_ids),
511 provider=instance_id,
512 category=CACHE_CATEGORY_PREV_LIBRARY_IDS,
513 )
514
515
516# --- Group 4: _apply_filters SQL generation ---
517
518
519def _create_controller_for_filter_tests() -> Mock:
520 """Create a minimal mock controller for _apply_filters tests."""
521 ctrl = Mock(spec=MediaControllerBase)
522 ctrl.media_type = MediaType.ALBUM
523 ctrl.db_table = "albums"
524 ctrl._apply_filters = MediaControllerBase._apply_filters.__get__(ctrl)
525 return ctrl
526
527
528async def test_apply_filters_in_library_only_without_provider_filter() -> None:
529 """Test that in_library_only adds a JOIN on provider_mappings with in_library=1.
530
531 When no provider_filter is set but in_library_only=True, a JOIN on
532 provider_mappings should be added with the in_library=1 condition.
533 """
534 ctrl = _create_controller_for_filter_tests()
535 query_parts: list[str] = []
536 query_params: dict[str, object] = {}
537 join_parts: list[str] = []
538
539 ctrl._apply_filters(
540 query_parts=query_parts,
541 query_params=query_params,
542 join_parts=join_parts,
543 favorite=None,
544 search=None,
545 provider_filter=None,
546 in_library_only=True,
547 )
548
549 assert len(join_parts) == 1
550 assert "provider_mappings.in_library = 1" in join_parts[0]
551 assert "provider_media_type" in query_params
552
553
554async def test_apply_filters_in_library_only_with_provider_filter() -> None:
555 """Test that in_library_only with provider_filter adds both conditions to the JOIN.
556
557 When both in_library_only=True and a provider_filter are set, the JOIN should
558 include both the provider condition and the in_library=1 condition.
559 """
560 ctrl = _create_controller_for_filter_tests()
561 query_parts: list[str] = []
562 query_params: dict[str, object] = {}
563 join_parts: list[str] = []
564
565 ctrl._apply_filters(
566 query_parts=query_parts,
567 query_params=query_params,
568 join_parts=join_parts,
569 favorite=None,
570 search=None,
571 provider_filter=["spotify_1"],
572 in_library_only=True,
573 )
574
575 assert len(join_parts) == 1
576 assert "provider_mappings.in_library = 1" in join_parts[0]
577 assert "provider_filter_0" in query_params
578 assert query_params["provider_filter_0"] == "spotify_1"
579
580
581async def test_apply_filters_no_in_library_filter_by_default() -> None:
582 """Test that no provider_mappings JOIN is added when in_library_only is False.
583
584 Without a provider_filter or in_library_only flag, no JOIN on
585 provider_mappings should be added.
586 """
587 ctrl = _create_controller_for_filter_tests()
588 query_parts: list[str] = []
589 query_params: dict[str, object] = {}
590 join_parts: list[str] = []
591
592 ctrl._apply_filters(
593 query_parts=query_parts,
594 query_params=query_params,
595 join_parts=join_parts,
596 favorite=None,
597 search=None,
598 provider_filter=None,
599 in_library_only=False,
600 )
601
602 assert len(join_parts) == 0
603
604
605async def test_apply_filters_provider_filter_without_in_library() -> None:
606 """Test that provider_filter without in_library_only omits the in_library clause.
607
608 When a provider_filter is set but in_library_only is False, the JOIN should
609 filter by provider but NOT include the in_library=1 condition.
610 """
611 ctrl = _create_controller_for_filter_tests()
612 query_parts: list[str] = []
613 query_params: dict[str, object] = {}
614 join_parts: list[str] = []
615
616 ctrl._apply_filters(
617 query_parts=query_parts,
618 query_params=query_params,
619 join_parts=join_parts,
620 favorite=None,
621 search=None,
622 provider_filter=["spotify_1"],
623 in_library_only=False,
624 )
625
626 assert len(join_parts) == 1
627 assert "in_library" not in join_parts[0]
628 assert "provider_filter_0" in query_params
629
630
631# --- Group 5: set_provider_mappings behavior ---
632
633
634@pytest.fixture
635def mock_controller() -> Mock:
636 """Create a mock MediaControllerBase for set_provider_mappings tests."""
637 ctrl = Mock(spec=MediaControllerBase)
638 ctrl.media_type = MediaType.ALBUM
639 ctrl.mass = Mock()
640 ctrl.mass.music.database.delete = AsyncMock()
641 ctrl.mass.music.database.upsert = AsyncMock()
642 ctrl.set_provider_mappings = MediaControllerBase.set_provider_mappings.__get__(ctrl)
643 return ctrl
644
645
646async def test_set_provider_mappings_overwrite_deletes_and_reinserts(
647 mock_controller: Mock,
648) -> None:
649 """Test that overwrite=True deletes existing mappings before upserting.
650
651 :param mock_controller: Mock MediaControllerBase instance.
652 """
653 mapping = create_provider_mapping(in_library=True)
654
655 await mock_controller.set_provider_mappings(1, [mapping], overwrite=True)
656
657 mock_controller.mass.music.database.delete.assert_called_once()
658 mock_controller.mass.music.database.upsert.assert_called_once()
659
660
661async def test_set_provider_mappings_upsert_preserves_null_in_library(
662 mock_controller: Mock,
663) -> None:
664 """Test that in_library=None is excluded from the upsert dict.
665
666 When in_library is None, it should not be included in the dict passed to upsert,
667 allowing the database's existing value to be preserved.
668
669 :param mock_controller: Mock MediaControllerBase instance.
670 """
671 mapping = create_provider_mapping(in_library=None)
672
673 await mock_controller.set_provider_mappings(1, [mapping], overwrite=False)
674
675 upsert_call = mock_controller.mass.music.database.upsert.call_args
676 upsert_dict = upsert_call[0][1]
677 assert "in_library" not in upsert_dict
678
679
680async def test_set_provider_mappings_upsert_writes_explicit_in_library(
681 mock_controller: Mock,
682) -> None:
683 """Test that an explicit in_library value is included in the upsert dict.
684
685 When in_library is explicitly True or False, it should be written to the database.
686
687 :param mock_controller: Mock MediaControllerBase instance.
688 """
689 mapping = create_provider_mapping(in_library=True)
690
691 await mock_controller.set_provider_mappings(1, [mapping], overwrite=False)
692
693 upsert_call = mock_controller.mass.music.database.upsert.call_args
694 upsert_dict = upsert_call[0][1]
695 assert upsert_dict["in_library"] is True
696
697
698# --- Group 6: library_items filtering ---
699
700
701async def test_library_items_default_filters_in_library_only() -> None:
702 """Test that library_items passes in_library_only=True by default."""
703 ctrl = Mock(spec=MediaControllerBase)
704 ctrl._ensure_provider_filter = Mock(return_value=None)
705 ctrl.get_library_items_by_query = AsyncMock(return_value=[])
706 ctrl.library_items = MediaControllerBase.library_items.__get__(ctrl)
707
708 await ctrl.library_items()
709
710 ctrl.get_library_items_by_query.assert_called_once()
711 call_kwargs = ctrl.get_library_items_by_query.call_args[1]
712 assert call_kwargs["in_library_only"] is True
713
714
715async def test_get_library_item_does_not_filter_in_library() -> None:
716 """Test that get_library_item always passes in_library_only=False.
717
718 Single-item lookups must find items regardless of in_library state.
719 """
720 album = create_mock_album()
721
722 ctrl = Mock(spec=MediaControllerBase)
723 ctrl.db_table = "albums"
724 ctrl.media_type = MediaType.ALBUM
725 ctrl.get_library_items_by_query = AsyncMock(return_value=[album])
726 ctrl.get_library_item = MediaControllerBase.get_library_item.__get__(ctrl)
727
728 await ctrl.get_library_item(1)
729
730 call_kwargs = ctrl.get_library_items_by_query.call_args[1]
731 assert call_kwargs["in_library_only"] is False
732