music-assistant-server

25.4 KBPY
test_library_sync.py
25.4 KB736 lines • python
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        genre_ids=None,
546        provider_filter=None,
547        in_library_only=True,
548    )
549
550    assert len(join_parts) == 1
551    assert "provider_mappings.in_library = 1" in join_parts[0]
552    assert "provider_media_type" in query_params
553
554
555async def test_apply_filters_in_library_only_with_provider_filter() -> None:
556    """Test that in_library_only with provider_filter adds both conditions to the JOIN.
557
558    When both in_library_only=True and a provider_filter are set, the JOIN should
559    include both the provider condition and the in_library=1 condition.
560    """
561    ctrl = _create_controller_for_filter_tests()
562    query_parts: list[str] = []
563    query_params: dict[str, object] = {}
564    join_parts: list[str] = []
565
566    ctrl._apply_filters(
567        query_parts=query_parts,
568        query_params=query_params,
569        join_parts=join_parts,
570        favorite=None,
571        search=None,
572        genre_ids=None,
573        provider_filter=["spotify_1"],
574        in_library_only=True,
575    )
576
577    assert len(join_parts) == 1
578    assert "provider_mappings.in_library = 1" in join_parts[0]
579    assert "provider_filter_0" in query_params
580    assert query_params["provider_filter_0"] == "spotify_1"
581
582
583async def test_apply_filters_no_in_library_filter_by_default() -> None:
584    """Test that no provider_mappings JOIN is added when in_library_only is False.
585
586    Without a provider_filter or in_library_only flag, no JOIN on
587    provider_mappings should be added.
588    """
589    ctrl = _create_controller_for_filter_tests()
590    query_parts: list[str] = []
591    query_params: dict[str, object] = {}
592    join_parts: list[str] = []
593
594    ctrl._apply_filters(
595        query_parts=query_parts,
596        query_params=query_params,
597        join_parts=join_parts,
598        favorite=None,
599        search=None,
600        genre_ids=None,
601        provider_filter=None,
602        in_library_only=False,
603    )
604
605    assert len(join_parts) == 0
606
607
608async def test_apply_filters_provider_filter_without_in_library() -> None:
609    """Test that provider_filter without in_library_only omits the in_library clause.
610
611    When a provider_filter is set but in_library_only is False, the JOIN should
612    filter by provider but NOT include the in_library=1 condition.
613    """
614    ctrl = _create_controller_for_filter_tests()
615    query_parts: list[str] = []
616    query_params: dict[str, object] = {}
617    join_parts: list[str] = []
618
619    ctrl._apply_filters(
620        query_parts=query_parts,
621        query_params=query_params,
622        join_parts=join_parts,
623        favorite=None,
624        search=None,
625        genre_ids=None,
626        provider_filter=["spotify_1"],
627        in_library_only=False,
628    )
629
630    assert len(join_parts) == 1
631    assert "in_library" not in join_parts[0]
632    assert "provider_filter_0" in query_params
633
634
635# --- Group 5: set_provider_mappings behavior ---
636
637
638@pytest.fixture
639def mock_controller() -> Mock:
640    """Create a mock MediaControllerBase for set_provider_mappings tests."""
641    ctrl = Mock(spec=MediaControllerBase)
642    ctrl.media_type = MediaType.ALBUM
643    ctrl.mass = Mock()
644    ctrl.mass.music.database.delete = AsyncMock()
645    ctrl.mass.music.database.upsert = AsyncMock()
646    ctrl.set_provider_mappings = MediaControllerBase.set_provider_mappings.__get__(ctrl)
647    return ctrl
648
649
650async def test_set_provider_mappings_overwrite_deletes_and_reinserts(
651    mock_controller: Mock,
652) -> None:
653    """Test that overwrite=True deletes existing mappings before upserting.
654
655    :param mock_controller: Mock MediaControllerBase instance.
656    """
657    mapping = create_provider_mapping(in_library=True)
658
659    await mock_controller.set_provider_mappings(1, [mapping], overwrite=True)
660
661    mock_controller.mass.music.database.delete.assert_called_once()
662    mock_controller.mass.music.database.upsert.assert_called_once()
663
664
665async def test_set_provider_mappings_upsert_preserves_null_in_library(
666    mock_controller: Mock,
667) -> None:
668    """Test that in_library=None is excluded from the upsert dict.
669
670    When in_library is None, it should not be included in the dict passed to upsert,
671    allowing the database's existing value to be preserved.
672
673    :param mock_controller: Mock MediaControllerBase instance.
674    """
675    mapping = create_provider_mapping(in_library=None)
676
677    await mock_controller.set_provider_mappings(1, [mapping], overwrite=False)
678
679    upsert_call = mock_controller.mass.music.database.upsert.call_args
680    upsert_dict = upsert_call[0][1]
681    assert "in_library" not in upsert_dict
682
683
684async def test_set_provider_mappings_upsert_writes_explicit_in_library(
685    mock_controller: Mock,
686) -> None:
687    """Test that an explicit in_library value is included in the upsert dict.
688
689    When in_library is explicitly True or False, it should be written to the database.
690
691    :param mock_controller: Mock MediaControllerBase instance.
692    """
693    mapping = create_provider_mapping(in_library=True)
694
695    await mock_controller.set_provider_mappings(1, [mapping], overwrite=False)
696
697    upsert_call = mock_controller.mass.music.database.upsert.call_args
698    upsert_dict = upsert_call[0][1]
699    assert upsert_dict["in_library"] is True
700
701
702# --- Group 6: library_items filtering ---
703
704
705async def test_library_items_default_filters_in_library_only() -> None:
706    """Test that library_items passes in_library_only=True by default."""
707    ctrl = Mock(spec=MediaControllerBase)
708    ctrl._ensure_provider_filter = Mock(return_value=None)
709    ctrl.get_library_items_by_query = AsyncMock(return_value=[])
710    ctrl.library_items = MediaControllerBase.library_items.__get__(ctrl)
711
712    await ctrl.library_items()
713
714    ctrl.get_library_items_by_query.assert_called_once()
715    call_kwargs = ctrl.get_library_items_by_query.call_args[1]
716    assert call_kwargs["in_library_only"] is True
717
718
719async def test_get_library_item_does_not_filter_in_library() -> None:
720    """Test that get_library_item always passes in_library_only=False.
721
722    Single-item lookups must find items regardless of in_library state.
723    """
724    album = create_mock_album()
725
726    ctrl = Mock(spec=MediaControllerBase)
727    ctrl.db_table = "albums"
728    ctrl.media_type = MediaType.ALBUM
729    ctrl.get_library_items_by_query = AsyncMock(return_value=[album])
730    ctrl.get_library_item = MediaControllerBase.get_library_item.__get__(ctrl)
731
732    await ctrl.get_library_item(1)
733
734    call_kwargs = ctrl.get_library_items_by_query.call_args[1]
735    assert call_kwargs["in_library_only"] is False
736