soulsync/tests/media_server/test_plex_pinning.py
Broque Thomas 620c41f1ac Add "All Libraries (combined)" mode to PlexClient
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.
2026-05-06 15:39:19 -07:00

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'}