GitHub issue #505 (PopeBruhLXIX): users with multiple Plex music libraries (e.g. one per Plex Home user, or two folder roots split across separate library sections) only saw one library inside SoulSync because the connection settings forced you to pick a single library section. SoulSync's PlexClient stored exactly one ``self.music_library`` section reference and every read scanned only that one. This change adds an opt-in "All Libraries (combined)" dropdown option that flips the client into a server-wide read mode where every read method (``get_all_artists`` / ``get_all_album_ids`` / ``search_tracks`` / ``get_library_stats`` / etc) dispatches through ``server.library.search(libtype=...)`` instead of querying a single section. One Plex API call replaces N per-section iterations; Plex handles the aggregation server-side. Implementation: - ``ALL_LIBRARIES_SENTINEL`` (``'__all_libraries__'``) — module-level constant used as the saved DB preference value when the user picks the synthetic "All Libraries" entry. Detection is one string compare in ``_find_music_library`` / ``set_music_library_by_name``. Existing preferences (real library names) are unaffected. - ``self._all_libraries_mode`` (private flag) + ``is_all_libraries_mode()`` (public accessor for external callers). When True, ``music_library`` may stay None — ``is_fully_configured()`` recognizes the mode and still returns True so dispatch sites don't bail. - New private helpers ``_can_query``, ``_get_music_sections``, ``_all_artists``, ``_all_albums``, ``_all_tracks``, ``_search_general``, ``_search_artists_by_name``. Single dispatch point for the section-vs-server branch — every read method funnels through them so future drift fails at one place. - New public helpers for downstream callers: - ``get_recently_added_albums(maxresults, libtype)`` — used by DatabaseUpdateWorker's deep-scan recent-content sweep - ``get_recently_updated_albums(limit)`` — same - ``get_music_library_locations()`` — returns folder roots, used by web_server.py's file-path resolver - ``trigger_library_scan`` and ``is_library_scanning`` fan out across every music section in all-libraries mode. - ``get_available_music_libraries`` prepends a synthetic ``{'title': 'All Libraries (combined)', 'value': sentinel}`` entry ONLY when more than one music library exists. Single-library users don't get the extra option. ``value`` field is the canonical identifier the frontend submits to ``/api/plex/select-music-library`` (real libraries: title; synthetic: sentinel string). Backward- compatible — entries without ``value`` fall back to ``title``. Three crash points fixed in downstream consumers (would have failed during a deep scan after the user picked all-libraries mode): 1. ``database_update_worker.py:411`` — bailed out with "No music library found in Plex" because ``not self.media_client.music_library`` evaluated True in all-libraries mode (music_library is None there). Now uses ``is_fully_configured()`` which recognizes the mode. This was the root cause of the deep scan never starting. 2. ``database_update_worker.py:_get_recent_albums_plex`` — reached ``self.media_client.music_library.recentlyAdded()`` / ``.search()`` directly, AttributeError in all-libraries mode. Now routes through the new helper methods. 3. ``web_server.py:10947`` (file-path resolver) — accessed ``music_library.locations``; gated on ``music_library`` truthy so it didn't crash, but silently skipped all-libraries-mode locations. Now uses ``get_music_library_locations()`` which unions across sections. Plus polish: - ``/api/plex/clear-library`` also resets ``_all_libraries_mode`` so a fresh "select library" flow doesn't inherit stale mode state. - ``/api/plex/music-libraries`` surfaces "All Libraries (combined)" as ``current_library`` when in mode (settings UI displays correctly). - Frontend ``loadPlexMusicLibraries`` uses ``library.value || library.title`` so the sentinel-keyed option submits the sentinel string, not the human-readable label. Pre-select match handles both paths. Honest tradeoffs (documented as known limitations): - Same artist appearing in multiple Plex sections shows as separate entries in SoulSync (no dedup). Plex returns distinct ratingKeys for each. Cosmetic; revisit if it bites users. - Write-back (genre / poster updates) targets one ratingKey at a time — only updates that section's copy. Other sections' copies stay unchanged. - All-libraries mode includes any audiobook library that Plex classifies as ``type='artist'``. Edge case, opt-in only. Tests: 21 new tests in tests/media_server/test_plex_all_libraries.py pin both single-library mode (regression guard) and all-libraries mode for every refactored method. Existing test_plex_pinning.py fixture updated to initialize the new flag. 63/63 media_server tests green, 2148/2148 full suite green.
379 lines
15 KiB
Python
379 lines
15 KiB
Python
"""Tests for PlexClient's "All Libraries (combined)" mode.
|
|
|
|
When the user picks `ALL_LIBRARIES_SENTINEL` as their saved library
|
|
preference, every read method must dispatch through
|
|
`server.library.search(libtype=...)` instead of the single-section
|
|
`self.music_library.X` path. Pin both modes so future refactors can't
|
|
silently break either path.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from core.plex_client import PlexClient, ALL_LIBRARIES_SENTINEL
|
|
|
|
|
|
def _make_client(*, all_libraries_mode: bool = False, music_library=None, server=None):
|
|
client = PlexClient.__new__(PlexClient)
|
|
client.server = server
|
|
client.music_library = music_library
|
|
client._all_libraries_mode = all_libraries_mode
|
|
client._connection_attempted = server is not None
|
|
client._is_connecting = False
|
|
client._last_connection_check = 0
|
|
client._connection_check_interval = 30
|
|
return client
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Mode flag setup — sentinel handling
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_set_music_library_by_name_sentinel_enables_all_libraries_mode(monkeypatch):
|
|
"""Pin: passing the sentinel as library_name flips the client into
|
|
all-libraries mode and stores the sentinel as the saved preference."""
|
|
server = MagicMock()
|
|
client = _make_client(server=server, music_library=MagicMock())
|
|
|
|
saved_pref = {}
|
|
|
|
class _StubDB:
|
|
def set_preference(self, key, value):
|
|
saved_pref[key] = value
|
|
|
|
import database.music_database as db_mod
|
|
monkeypatch.setattr(db_mod, 'MusicDatabase', _StubDB)
|
|
|
|
result = client.set_music_library_by_name(ALL_LIBRARIES_SENTINEL)
|
|
|
|
assert result is True
|
|
assert client._all_libraries_mode is True
|
|
assert client.music_library is None
|
|
assert saved_pref == {'plex_music_library': ALL_LIBRARIES_SENTINEL}
|
|
|
|
|
|
def test_set_music_library_by_name_specific_library_disables_all_libraries_mode(monkeypatch):
|
|
"""Pin: when user switches FROM all-libraries to a specific section,
|
|
the mode flag clears."""
|
|
server = MagicMock()
|
|
section = MagicMock(type='artist', title='Music')
|
|
server.library.sections.return_value = [section]
|
|
client = _make_client(server=server, all_libraries_mode=True)
|
|
|
|
class _StubDB:
|
|
def set_preference(self, key, value):
|
|
pass
|
|
|
|
import database.music_database as db_mod
|
|
monkeypatch.setattr(db_mod, 'MusicDatabase', _StubDB)
|
|
|
|
result = client.set_music_library_by_name('Music')
|
|
|
|
assert result is True
|
|
assert client._all_libraries_mode is False
|
|
assert client.music_library is section
|
|
|
|
|
|
def test_is_fully_configured_true_in_all_libraries_mode():
|
|
"""Pin: all-libraries mode counts as configured even though
|
|
music_library is None — UI gating + dispatch site checks rely on
|
|
this for the new mode to be functional."""
|
|
server = MagicMock()
|
|
client = _make_client(server=server, all_libraries_mode=True, music_library=None)
|
|
|
|
assert client.is_fully_configured() is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Read methods — single-library mode (regression guard)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_get_all_artists_uses_section_search_in_single_library_mode():
|
|
"""Single-library mode: get_all_artists hits self.music_library.searchArtists()
|
|
and never calls server.library.search()."""
|
|
server = MagicMock()
|
|
section = MagicMock()
|
|
section.searchArtists.return_value = [MagicMock(ratingKey=1), MagicMock(ratingKey=2)]
|
|
client = _make_client(server=server, music_library=section, all_libraries_mode=False)
|
|
|
|
result = client.get_all_artists()
|
|
|
|
section.searchArtists.assert_called_once()
|
|
server.library.search.assert_not_called()
|
|
assert len(result) == 2
|
|
|
|
|
|
def test_get_all_album_ids_uses_section_albums_in_single_library_mode():
|
|
server = MagicMock()
|
|
section = MagicMock()
|
|
section.albums.return_value = [MagicMock(ratingKey=10), MagicMock(ratingKey=20)]
|
|
client = _make_client(server=server, music_library=section, all_libraries_mode=False)
|
|
|
|
ids = client.get_all_album_ids()
|
|
|
|
section.albums.assert_called_once()
|
|
server.library.search.assert_not_called()
|
|
assert ids == {'10', '20'}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Read methods — all-libraries mode (the new path)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_get_all_artists_uses_server_search_in_all_libraries_mode():
|
|
"""All-libraries mode: get_all_artists dispatches through
|
|
server.library.search(libtype='artist') — server-wide aggregation
|
|
via Plex's API, no per-section iteration."""
|
|
server = MagicMock()
|
|
server.library.search.return_value = [MagicMock(ratingKey=1), MagicMock(ratingKey=2), MagicMock(ratingKey=3)]
|
|
client = _make_client(server=server, all_libraries_mode=True, music_library=None)
|
|
|
|
result = client.get_all_artists()
|
|
|
|
server.library.search.assert_called_once_with(libtype='artist')
|
|
assert len(result) == 3
|
|
|
|
|
|
def test_get_all_album_ids_uses_server_search_in_all_libraries_mode():
|
|
server = MagicMock()
|
|
server.library.search.return_value = [
|
|
MagicMock(ratingKey=10), MagicMock(ratingKey=20), MagicMock(ratingKey=30),
|
|
]
|
|
client = _make_client(server=server, all_libraries_mode=True, music_library=None)
|
|
|
|
ids = client.get_all_album_ids()
|
|
|
|
server.library.search.assert_called_once_with(libtype='album')
|
|
assert ids == {'10', '20', '30'}
|
|
|
|
|
|
def test_get_library_stats_unions_across_sections_in_all_libraries_mode():
|
|
"""All-libraries stats sum totals across every music section via
|
|
server-wide search calls."""
|
|
server = MagicMock()
|
|
server.library.search.side_effect = [
|
|
[MagicMock()] * 5, # artists
|
|
[MagicMock()] * 12, # albums
|
|
[MagicMock()] * 87, # tracks
|
|
]
|
|
client = _make_client(server=server, all_libraries_mode=True, music_library=None)
|
|
|
|
stats = client.get_library_stats()
|
|
|
|
assert stats == {'artists': 5, 'albums': 12, 'tracks': 87}
|
|
assert server.library.search.call_count == 3
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Trigger / scanning — multi-section fan-out
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_trigger_library_scan_fans_out_in_all_libraries_mode():
|
|
"""Pin: in all-libraries mode, trigger_library_scan calls update()
|
|
on every music section, not just the named one."""
|
|
section_a = MagicMock(type='artist', title='Music A')
|
|
section_b = MagicMock(type='artist', title='Music B')
|
|
section_other = MagicMock(type='movie', title='Movies')
|
|
server = MagicMock()
|
|
server.library.sections.return_value = [section_a, section_b, section_other]
|
|
client = _make_client(server=server, all_libraries_mode=True, music_library=None)
|
|
# Bypass ensure_connection's internal logic.
|
|
client.ensure_connection = lambda: True
|
|
|
|
result = client.trigger_library_scan('ignored-name')
|
|
|
|
section_a.update.assert_called_once()
|
|
section_b.update.assert_called_once()
|
|
section_other.update.assert_not_called()
|
|
assert result is True
|
|
|
|
|
|
def test_is_library_scanning_returns_true_when_any_section_refreshing():
|
|
"""Pin: in all-libraries mode, is_library_scanning returns True
|
|
if any music section has refreshing=True."""
|
|
section_a = MagicMock(type='artist', title='A', refreshing=False)
|
|
section_b = MagicMock(type='artist', title='B', refreshing=True) # actively scanning
|
|
server = MagicMock()
|
|
server.library.sections.return_value = [section_a, section_b]
|
|
server.activities.return_value = []
|
|
client = _make_client(server=server, all_libraries_mode=True, music_library=None)
|
|
client.ensure_connection = lambda: True
|
|
|
|
assert client.is_library_scanning() is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# get_available_music_libraries — synthetic sentinel entry
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_available_music_libraries_prepends_sentinel_when_multiple_libraries():
|
|
"""Pin: when more than one music library exists, prepend "All
|
|
Libraries (combined)" with the sentinel as its key. Single-library
|
|
users don't get the extra option."""
|
|
server = MagicMock()
|
|
sec_a = MagicMock(type='artist', title='Music', key=1)
|
|
sec_b = MagicMock(type='artist', title='Audiobook Library', key=2)
|
|
server.library.sections.return_value = [sec_a, sec_b]
|
|
client = _make_client(server=server, music_library=sec_a, all_libraries_mode=False)
|
|
client.ensure_connection = lambda: True
|
|
|
|
libs = client.get_available_music_libraries()
|
|
|
|
# Synthetic entry first; ``value`` carries the sentinel so the
|
|
# frontend has a canonical identifier to POST back to the
|
|
# select-library endpoint.
|
|
assert libs[0]['title'] == 'All Libraries (combined)'
|
|
assert libs[0]['key'] == ALL_LIBRARIES_SENTINEL
|
|
assert libs[0]['value'] == ALL_LIBRARIES_SENTINEL
|
|
assert any(l['title'] == 'Music' and l['value'] == 'Music' for l in libs)
|
|
assert any(l['title'] == 'Audiobook Library' and l['value'] == 'Audiobook Library' for l in libs)
|
|
|
|
|
|
def test_available_music_libraries_omits_sentinel_when_only_one_library():
|
|
"""Single-library users don't need the option — keep the dropdown
|
|
clean."""
|
|
server = MagicMock()
|
|
sec_a = MagicMock(type='artist', title='Music', key=1)
|
|
server.library.sections.return_value = [sec_a]
|
|
client = _make_client(server=server, music_library=sec_a, all_libraries_mode=False)
|
|
client.ensure_connection = lambda: True
|
|
|
|
libs = client.get_available_music_libraries()
|
|
|
|
assert all(l['key'] != ALL_LIBRARIES_SENTINEL for l in libs)
|
|
assert len(libs) == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Defensive — _can_query / _all_artists / etc gracefully handle no-config
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_can_query_false_when_no_section_and_not_all_libraries():
|
|
server = MagicMock()
|
|
client = _make_client(server=server, music_library=None, all_libraries_mode=False)
|
|
assert client._can_query() is False
|
|
|
|
|
|
def test_can_query_false_when_no_server():
|
|
client = _make_client(server=None, music_library=None, all_libraries_mode=True)
|
|
assert client._can_query() is False
|
|
|
|
|
|
def test_all_artists_returns_empty_when_not_configured():
|
|
client = _make_client(server=None, all_libraries_mode=False)
|
|
assert client._all_artists() == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helper methods for downstream callers (DatabaseUpdateWorker, web_server)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_get_recently_added_albums_unions_across_sections_in_all_libraries_mode():
|
|
"""Pin: ``get_recently_added_albums`` iterates every music section
|
|
in all-libraries mode and concats the recentlyAdded list. Pre-fix
|
|
DatabaseUpdateWorker reached ``music_library.recentlyAdded()``
|
|
directly which crashed when music_library is None."""
|
|
section_a = MagicMock(type='artist', title='A')
|
|
section_a.recentlyAdded.return_value = [MagicMock(ratingKey=1), MagicMock(ratingKey=2)]
|
|
section_b = MagicMock(type='artist', title='B')
|
|
section_b.recentlyAdded.return_value = [MagicMock(ratingKey=3)]
|
|
server = MagicMock()
|
|
server.library.sections.return_value = [section_a, section_b]
|
|
client = _make_client(server=server, all_libraries_mode=True, music_library=None)
|
|
client.ensure_connection = lambda: True
|
|
|
|
result = client.get_recently_added_albums(maxresults=100)
|
|
|
|
section_a.recentlyAdded.assert_called_once_with(libtype='album', maxresults=100)
|
|
section_b.recentlyAdded.assert_called_once_with(libtype='album', maxresults=100)
|
|
assert len(result) == 3
|
|
|
|
|
|
def test_get_recently_added_albums_uses_section_in_single_library_mode():
|
|
section = MagicMock()
|
|
section.recentlyAdded.return_value = [MagicMock(ratingKey=1)]
|
|
server = MagicMock()
|
|
client = _make_client(server=server, music_library=section, all_libraries_mode=False)
|
|
client.ensure_connection = lambda: True
|
|
|
|
result = client.get_recently_added_albums(maxresults=50)
|
|
|
|
section.recentlyAdded.assert_called_once_with(libtype='album', maxresults=50)
|
|
assert len(result) == 1
|
|
|
|
|
|
def test_get_recently_added_albums_libtype_none_skips_filter():
|
|
"""Pin: ``libtype=None`` skips the libtype kwarg entirely so
|
|
callers can fetch mixed types when album-only returned nothing."""
|
|
section = MagicMock()
|
|
section.recentlyAdded.return_value = []
|
|
server = MagicMock()
|
|
client = _make_client(server=server, music_library=section, all_libraries_mode=False)
|
|
client.ensure_connection = lambda: True
|
|
|
|
client.get_recently_added_albums(maxresults=50, libtype=None)
|
|
|
|
section.recentlyAdded.assert_called_once_with(maxresults=50)
|
|
|
|
|
|
def test_get_recently_updated_albums_uses_search_dispatch():
|
|
"""Pin: routes through ``_search_general`` so single-section vs
|
|
all-libraries mode is handled by the helper."""
|
|
server = MagicMock()
|
|
server.library.search.return_value = [MagicMock(ratingKey=1), MagicMock(ratingKey=2)]
|
|
client = _make_client(server=server, all_libraries_mode=True, music_library=None)
|
|
client.ensure_connection = lambda: True
|
|
|
|
result = client.get_recently_updated_albums(limit=200)
|
|
|
|
server.library.search.assert_called_once_with(sort='updatedAt:desc', libtype='album', limit=200)
|
|
assert len(result) == 2
|
|
|
|
|
|
def test_get_music_library_locations_unions_across_sections_in_all_libraries_mode():
|
|
"""Pin: ``get_music_library_locations`` returns every folder root
|
|
configured across every music section. web_server.py uses this for
|
|
file-path resolution — pre-fix it reached ``music_library.locations``
|
|
which is None in all-libraries mode."""
|
|
section_a = MagicMock(type='artist', title='A')
|
|
section_a.locations = ['/data/userOne/Artists', '/data/userOne/More']
|
|
section_b = MagicMock(type='artist', title='B')
|
|
section_b.locations = ['/data/userTwo/Artists']
|
|
server = MagicMock()
|
|
server.library.sections.return_value = [section_a, section_b]
|
|
client = _make_client(server=server, all_libraries_mode=True, music_library=None)
|
|
client.ensure_connection = lambda: True
|
|
|
|
locations = client.get_music_library_locations()
|
|
|
|
assert '/data/userOne/Artists' in locations
|
|
assert '/data/userOne/More' in locations
|
|
assert '/data/userTwo/Artists' in locations
|
|
assert len(locations) == 3
|
|
|
|
|
|
def test_get_music_library_locations_dedupes_overlapping_paths():
|
|
"""Pin: same root listed in multiple sections returns once."""
|
|
section_a = MagicMock(type='artist', title='A')
|
|
section_a.locations = ['/data/shared']
|
|
section_b = MagicMock(type='artist', title='B')
|
|
section_b.locations = ['/data/shared', '/data/userTwo']
|
|
server = MagicMock()
|
|
server.library.sections.return_value = [section_a, section_b]
|
|
client = _make_client(server=server, all_libraries_mode=True, music_library=None)
|
|
client.ensure_connection = lambda: True
|
|
|
|
locations = client.get_music_library_locations()
|
|
|
|
assert locations.count('/data/shared') == 1
|
|
assert '/data/userTwo' in locations
|