soulsync/tests/media_server/test_plex_all_libraries.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

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