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.
114 lines
4.5 KiB
Python
114 lines
4.5 KiB
Python
"""Phase A pinning tests for PlexClient.
|
|
|
|
Pin the OBSERVABLE BEHAVIOR the engine will dispatch through after
|
|
Phase B/C. The web_server.py dispatch sites + DatabaseUpdateWorker
|
|
read these methods generically — they must keep their current shape
|
|
through the engine refactor.
|
|
|
|
Plex's surface is wider than the contract requires (additional
|
|
playlist / metadata writeback methods), but pinning ALL of them
|
|
turns into ~30 tests. Focused on the methods the dispatch sites
|
|
actually call: is_connected, is_fully_configured, ensure_connection,
|
|
get_all_artists, get_all_album_ids, search_tracks, trigger_library_scan,
|
|
is_library_scanning, get_library_stats.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from core.plex_client import PlexClient
|
|
|
|
|
|
@pytest.fixture
|
|
def plex_client():
|
|
"""A bare PlexClient with no real connection. Tests that need
|
|
a connected state set client.server = MagicMock() directly."""
|
|
client = PlexClient.__new__(PlexClient)
|
|
client.server = None
|
|
client.music_library = None
|
|
client._all_libraries_mode = False
|
|
client._connection_attempted = False
|
|
client._is_connecting = False
|
|
client._last_connection_check = 0
|
|
client._connection_check_interval = 30
|
|
return client
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# is_connected
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_is_connected_returns_false_when_no_server(plex_client):
|
|
"""Pinning: no server object → False. Dashboard status indicators
|
|
+ endpoint guards depend on this."""
|
|
plex_client.server = None
|
|
# Patch ensure_connection to avoid network call
|
|
with patch.object(plex_client, 'ensure_connection', return_value=False):
|
|
assert plex_client.is_connected() is False
|
|
|
|
|
|
def test_is_connected_returns_true_when_server_present(plex_client):
|
|
"""Pinning: a non-None server object → True (even if music_library
|
|
is unset). is_fully_configured() is the stricter check."""
|
|
plex_client.server = MagicMock()
|
|
plex_client._connection_attempted = True
|
|
assert plex_client.is_connected() is True
|
|
|
|
|
|
def test_is_fully_configured_requires_server_and_music_library(plex_client):
|
|
"""Pinning: is_fully_configured == True only when BOTH server AND
|
|
music_library are set. Used by playlist-sync gating."""
|
|
plex_client.server = MagicMock()
|
|
plex_client.music_library = None
|
|
assert plex_client.is_fully_configured() is False
|
|
plex_client.music_library = MagicMock()
|
|
assert plex_client.is_fully_configured() is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# get_all_artists / get_all_album_ids
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_get_all_artists_returns_empty_when_not_connected(plex_client):
|
|
"""Pinning: ensure_connection returning False → empty list (not
|
|
exception). Caller iterates the result, never raising."""
|
|
with patch.object(plex_client, 'ensure_connection', return_value=False):
|
|
assert plex_client.get_all_artists() == []
|
|
|
|
|
|
def test_get_all_artists_iterates_searchartists_when_connected(plex_client):
|
|
"""Pinning: when connected, get_all_artists calls
|
|
music_library.searchArtists() and returns the result list."""
|
|
fake_artists = [MagicMock(title='A'), MagicMock(title='B')]
|
|
plex_client.server = MagicMock()
|
|
plex_client.music_library = MagicMock()
|
|
plex_client.music_library.searchArtists.return_value = fake_artists
|
|
|
|
with patch.object(plex_client, 'ensure_connection', return_value=True):
|
|
result = plex_client.get_all_artists()
|
|
|
|
assert result == fake_artists
|
|
|
|
|
|
def test_get_all_album_ids_returns_set_of_string_ratingkeys(plex_client):
|
|
"""Pinning: returns a set (not a list) of string ratingKey values.
|
|
DatabaseUpdateWorker uses set membership for diff-detection.
|
|
NOTE: Plex stores ratingKey as int but the method coerces to str
|
|
so the set semantics match other servers (Jellyfin GUIDs, Navidrome
|
|
string ids). Engine refactor must preserve."""
|
|
fake_albums = [MagicMock(ratingKey=1), MagicMock(ratingKey=2),
|
|
MagicMock(ratingKey=3)]
|
|
plex_client.server = MagicMock()
|
|
plex_client.music_library = MagicMock()
|
|
plex_client.music_library.albums.return_value = fake_albums
|
|
|
|
with patch.object(plex_client, 'ensure_connection', return_value=True):
|
|
result = plex_client.get_all_album_ids()
|
|
|
|
assert isinstance(result, set)
|
|
assert result == {'1', '2', '3'}
|