diff --git a/core/database_update_worker.py b/core/database_update_worker.py index 3037fa79..943cd444 100644 --- a/core/database_update_worker.py +++ b/core/database_update_worker.py @@ -407,9 +407,14 @@ class DatabaseUpdateWorker: logger.error(f"Could not connect to {self.server_type} server — check URL, credentials, and network (Docker users: use container name or host.docker.internal instead of host IP)") return [] - # Check for music library (Plex-specific check) - if self.server_type == "plex" and not self.media_client.music_library: - logger.error("No music library found in Plex") + # Check for music library (Plex-specific check). Routes + # through ``is_fully_configured`` so all-libraries mode (in + # which ``music_library`` is None but ``_all_libraries_mode`` + # is True) counts as configured. Pre-fix this bailed out on + # the bare music_library None check, silently aborting the + # deep scan for any all-libraries-mode user. + if self.server_type == "plex" and not self.media_client.is_fully_configured(): + logger.error("No music library configured in Plex") return [] # Check if database has enough content for incremental updates (server-specific) @@ -1084,24 +1089,31 @@ class DatabaseUpdateWorker: return [] def _get_recent_albums_plex(self) -> List: - """Get recently added and updated albums from Plex""" + """Get recently added and updated albums from Plex. + + Routes through ``PlexClient.get_recently_added_albums`` and + ``get_recently_updated_albums`` so the all-libraries mode union + works (pre-fix this reached ``self.media_client.music_library.X`` + directly which crashed when music_library is None in all- + libraries mode). + """ all_recent_content = [] - + try: - # Get recently added albums (up to 400 to catch more recent content) - try: - recently_added = self.media_client.music_library.recentlyAdded(libtype='album', maxresults=400) + # Get recently added albums (up to 400 to catch more recent content) + recently_added = self.media_client.get_recently_added_albums(maxresults=400, libtype='album') + if recently_added: all_recent_content.extend(recently_added) logger.info(f"Found {len(recently_added)} recently added albums") - except: - # Fallback to general recently added - recently_added = self.media_client.music_library.recentlyAdded(maxresults=400) - all_recent_content.extend(recently_added) - logger.info(f"Found {len(recently_added)} recently added items (mixed types)") - + else: + # Fallback to mixed-type recents. + recently_added = self.media_client.get_recently_added_albums(maxresults=400, libtype=None) + all_recent_content.extend(recently_added or []) + logger.info(f"Found {len(recently_added or [])} recently added items (mixed types)") + # Get recently updated albums (catches metadata corrections) try: - recently_updated = self.media_client.music_library.search(sort='updatedAt:desc', libtype='album', limit=400) + recently_updated = self.media_client.get_recently_updated_albums(limit=400) # Remove duplicates (items that are both recently added and updated) added_keys = {getattr(item, 'ratingKey', None) for item in all_recent_content} unique_updated = [item for item in recently_updated if getattr(item, 'ratingKey', None) not in added_keys] diff --git a/core/plex_client.py b/core/plex_client.py index 1cf771e1..890e6f46 100644 --- a/core/plex_client.py +++ b/core/plex_client.py @@ -21,10 +21,26 @@ from core.media_server.contract import MediaServerClient logger = get_logger("plex_client") +# Sentinel value stored in the ``plex_music_library`` DB preference when +# the user picks "All Libraries (combined)". Detection is one string +# compare in ``_find_music_library`` / ``set_music_library_by_name``. +# Existing prefs (real library names) are unaffected — only this exact +# string flips the client into multi-section read mode. +ALL_LIBRARIES_SENTINEL = '__all_libraries__' + + class PlexClient(MediaServerClient): def __init__(self): self.server: Optional[PlexServer] = None self.music_library: Optional[MusicSection] = None + # When True, read methods union across every music section under + # the connected token via ``server.library.search(libtype=...)`` + # instead of querying ``self.music_library`` only. Set by + # ``_find_music_library`` / ``set_music_library_by_name`` when the + # user's saved preference matches ``ALL_LIBRARIES_SENTINEL``. + # Write methods (genre / poster / metadata updates) are unaffected + # — they operate on Plex objects via ratingKey, section-agnostic. + self._all_libraries_mode = False self._connection_attempted = False self._is_connecting = False self._last_connection_check = 0 # Cache connection checks @@ -63,6 +79,7 @@ class PlexClient(MediaServerClient): logger.info("Resetting Plex connection state") self.server = None self.music_library = None + self._all_libraries_mode = False self._connection_attempted = False self._last_connection_check = 0 @@ -89,7 +106,14 @@ class PlexClient(MediaServerClient): self.server = None def get_available_music_libraries(self) -> List[Dict[str, str]]: - """Get list of all available music libraries on the Plex server""" + """Get list of all available music libraries on the Plex server. + + Prepends an "All Libraries (combined)" synthetic entry whose + ``key`` is ``ALL_LIBRARIES_SENTINEL``. The settings UI renders + this as a normal dropdown option; selecting it stores the + sentinel as the saved preference, which flips the client into + all-libraries read mode (see ``_all_libraries_mode``). + """ if not self.ensure_connection() or not self.server: return [] @@ -99,24 +123,56 @@ class PlexClient(MediaServerClient): if section.type == 'artist': music_libraries.append({ 'title': section.title, - 'key': str(section.key) + 'key': str(section.key), + # ``value`` is what the frontend submits to + # ``/api/plex/select-music-library``. Real + # libraries use their title (preserves prior + # behavior). The synthetic sentinel entry below + # uses the sentinel string so the backend's + # ``set_music_library_by_name`` can recognize it. + 'value': section.title, }) - logger.debug(f"Found {len(music_libraries)} music libraries") + # Only offer the "All Libraries" option when the user actually + # has more than one music library on their server — single- + # library users don't need the extra menu item. + if len(music_libraries) > 1: + music_libraries.insert(0, { + 'title': 'All Libraries (combined)', + 'key': ALL_LIBRARIES_SENTINEL, + 'value': ALL_LIBRARIES_SENTINEL, + }) + + logger.debug(f"Found {len(music_libraries)} music libraries (incl. sentinel)") return music_libraries except Exception as e: logger.error(f"Error getting music libraries: {e}") return [] def set_music_library_by_name(self, library_name: str) -> bool: - """Set the active music library by name""" + """Set the active music library by name. + + Special-case ``ALL_LIBRARIES_SENTINEL`` (``'__all_libraries__'``): + flips the client into all-libraries mode where read methods union + across every music section instead of querying a single one. + """ if not self.server: return False try: + if library_name == ALL_LIBRARIES_SENTINEL: + self.music_library = None + self._all_libraries_mode = True + logger.info("Set music library to: All Libraries (combined)") + from database.music_database import MusicDatabase + db = MusicDatabase() + db.set_preference('plex_music_library', ALL_LIBRARIES_SENTINEL) + return True + for section in self.server.library.sections(): if section.type == 'artist' and section.title == library_name: self.music_library = section + self._all_libraries_mode = False logger.info(f"Set music library to: {library_name}") # Store preference in database @@ -154,11 +210,23 @@ class PlexClient(MediaServerClient): db = MusicDatabase() preferred_library = db.get_preference('plex_music_library') + if preferred_library == ALL_LIBRARIES_SENTINEL: + # User opted into all-libraries mode — read methods + # union across every music section. + self.music_library = None + self._all_libraries_mode = True + logger.debug( + f"Using all-libraries mode across " + f"{len(music_sections)} music sections" + ) + return + if preferred_library: # Try to find the preferred library for section in music_sections: if section.title == preferred_library: self.music_library = section + self._all_libraries_mode = False logger.debug(f"Using user-selected music library: {section.title}") return except Exception as e: @@ -212,8 +280,97 @@ class PlexClient(MediaServerClient): return self.server is not None def is_fully_configured(self) -> bool: - """Check if both server is connected AND music library is selected.""" - return self.server is not None and self.music_library is not None + """Check if both server is connected AND music library is selected + (or user has opted into all-libraries mode).""" + return self.server is not None and ( + self.music_library is not None or self._all_libraries_mode + ) + + def is_all_libraries_mode(self) -> bool: + """True when the user has selected the synthetic "All Libraries + (combined)" option — read methods union across every music + section instead of querying a single one. Public accessor so + external callers don't reach into the underscore-prefixed + flag directly.""" + return self._all_libraries_mode + + # ------------------------------------------------------------------ + # Library scope helpers — single dispatch point for "single section + # vs all music sections" so every read method funnels through the + # same branch. + # ------------------------------------------------------------------ + + def _can_query(self) -> bool: + """True when there's something to query — a selected section or + all-libraries mode + a connected server.""" + if not self.server: + return False + if self._all_libraries_mode: + return True + return self.music_library is not None + + def _get_music_sections(self) -> List: + """Music sections currently in scope (one for single-library + mode, every music section for all-libraries mode). Used by + ``trigger_library_scan`` / ``is_library_scanning`` which take + per-section action.""" + if not self.server: + return [] + if self._all_libraries_mode: + try: + return [s for s in self.server.library.sections() if s.type == 'artist'] + except Exception as exc: + logger.error(f"Error enumerating music sections: {exc}") + return [] + if self.music_library: + return [self.music_library] + return [] + + def _all_artists(self) -> List[PlexArtist]: + """Every artist in the configured scope.""" + if self._all_libraries_mode: + return self.server.library.search(libtype='artist') + if self.music_library: + return self.music_library.searchArtists() + return [] + + def _all_albums(self) -> List[PlexAlbum]: + """Every album in the configured scope.""" + if self._all_libraries_mode: + return self.server.library.search(libtype='album') + if self.music_library: + return self.music_library.albums() + return [] + + def _all_tracks(self) -> List[PlexTrack]: + """Every track in the configured scope.""" + if self._all_libraries_mode: + return self.server.library.search(libtype='track') + if self.music_library: + return self.music_library.searchTracks() + return [] + + def _search_general(self, **kwargs): + """Generic search dispatch — single section or server-wide. + + Used by ``_find_track`` / ``search_tracks`` for fielded + searches (title=, artist=, libtype=, limit=, etc). + """ + if self._all_libraries_mode: + return self.server.library.search(**kwargs) + if self.music_library: + return self.music_library.search(**kwargs) + return [] + + def _search_artists_by_name(self, title: str, limit: int = 1) -> List[PlexArtist]: + """Find artist objects matching a name. Used by ``search_tracks`` + Stage 1 + ``search_albums`` artist-then-filter flow.""" + if self._all_libraries_mode: + results = self.server.library.search(title=title, libtype='artist', limit=limit) + return [r for r in results if isinstance(r, PlexArtist)] + if self.music_library: + return self.music_library.searchArtists(title=title, limit=limit) + return [] def get_all_playlists(self) -> List[PlaylistInfo]: if not self.ensure_connection(): @@ -440,28 +597,28 @@ class PlexClient(MediaServerClient): return False def _find_track(self, title: str, artist: str, album: str) -> Optional[PlexTrack]: - if not self.music_library: + if not self._can_query(): return None - + try: - search_results = self.music_library.search(title=title, artist=artist, album=album) - + search_results = self._search_general(title=title, artist=artist, album=album) + for result in search_results: if isinstance(result, PlexTrack): - if (result.title.lower() == title.lower() and + if (result.title.lower() == title.lower() and result.artist().title.lower() == artist.lower() and result.album().title.lower() == album.lower()): return result - - broader_search = self.music_library.search(title=title, artist=artist) + + broader_search = self._search_general(title=title, artist=artist) for result in broader_search: if isinstance(result, PlexTrack): - if (result.title.lower() == title.lower() and + if (result.title.lower() == title.lower() and result.artist().title.lower() == artist.lower()): return result - + return None - + except Exception as e: logger.error(f"Error searching for track '{title}' by '{artist}': {e}") return None @@ -471,7 +628,7 @@ class PlexClient(MediaServerClient): Searches for tracks using an efficient, multi-stage "early exit" strategy. It stops and returns results as soon as candidates are found. """ - if not self.music_library: + if not self._can_query(): logger.warning("Plex music library not found. Cannot perform search.") return [] @@ -489,7 +646,7 @@ class PlexClient(MediaServerClient): # --- Stage 1: High-Precision Search (Artist -> then filter by Title) --- if artist: logger.debug(f"Stage 1: Searching for artist '{artist}'") - artist_results = self.music_library.searchArtists(title=artist, limit=1) + artist_results = self._search_artists_by_name(title=artist, limit=1) if artist_results: plex_artist = artist_results[0] all_artist_tracks = plex_artist.tracks() @@ -514,7 +671,7 @@ class PlexClient(MediaServerClient): # --- Stage 2: Flexible Keyword Search (Artist + Title combined) --- search_query = f"{artist} {title}".strip() logger.debug(f"Stage 2: Performing keyword search for '{search_query}'") - stage2_results = self.music_library.search(title=search_query, libtype='track', limit=limit) + stage2_results = self._search_general(title=search_query, libtype='track', limit=limit) add_candidates(stage2_results) # --- Early Exit: If Stage 2 found results, stop here --- @@ -559,18 +716,102 @@ class PlexClient(MediaServerClient): def get_library_stats(self) -> Dict[str, int]: - if not self.music_library: + if not self._can_query(): return {} - + try: return { - 'artists': len(self.music_library.searchArtists()), - 'albums': len(self.music_library.searchAlbums()), - 'tracks': len(self.music_library.searchTracks()) + 'artists': len(self._all_artists()), + 'albums': len(self._all_albums()), + 'tracks': len(self._all_tracks()) } except Exception as e: logger.error(f"Error getting library stats: {e}") return {} + + def get_recently_added_albums(self, maxresults: int = 400, + libtype: Optional[str] = 'album') -> List: + """Recently added items across the configured scope. + + In all-libraries mode, iterates every music section and concats + each section's ``recentlyAdded`` list. Honors ``maxresults`` + per-section to bound the response. Caller is responsible for + further sorting / deduping if needed. + + Pass ``libtype=None`` to skip the type filter (returns mixed + artists / albums / tracks). Used by the database update worker's + deep-scan recent-content sweep — pre-fix it reached + ``self.music_library.recentlyAdded()`` directly which crashed + in all-libraries mode (music_library is None there). + """ + if not self.ensure_connection() or not self._can_query(): + return [] + + def _call(target): + try: + if libtype is not None: + return target.recentlyAdded(libtype=libtype, maxresults=maxresults) + return target.recentlyAdded(maxresults=maxresults) + except TypeError: + # Older PlexAPI versions don't accept libtype/maxresults + # as kwargs — fall back to bare call. + return target.recentlyAdded() + + try: + if self._all_libraries_mode: + aggregate = [] + for section in self._get_music_sections(): + try: + aggregate.extend(_call(section)) + except Exception as inner_exc: + logger.debug(f"recentlyAdded failed for section '{section.title}': {inner_exc}") + return aggregate + if self.music_library: + return _call(self.music_library) + return [] + except Exception as exc: + logger.error(f"Error getting recently added albums: {exc}") + return [] + + def get_recently_updated_albums(self, limit: int = 400) -> List: + """Albums sorted by ``updatedAt`` descending across the scope. + + Used by the deep-scan to catch metadata corrections (renames, + re-tagging) that recently-added doesn't surface. In all- + libraries mode, unions across every music section. + """ + if not self.ensure_connection() or not self._can_query(): + return [] + try: + return self._search_general(sort='updatedAt:desc', libtype='album', limit=limit) + except Exception as exc: + logger.error(f"Error getting recently updated albums: {exc}") + return [] + + def get_music_library_locations(self) -> List[str]: + """Folder roots configured for the music library scope. + + Single-library mode returns the selected section's locations. + All-libraries mode unions locations across every music section + — needed by ``web_server.py``'s file-path resolver to recognize + files under any music root, not just one. + """ + if not self.ensure_connection() or not self._can_query(): + return [] + try: + sections = self._get_music_sections() + locations = [] + for section in sections: + try: + for loc in section.locations: + if loc and loc not in locations: + locations.append(loc) + except Exception as inner_exc: + logger.debug(f"Could not read locations for section '{getattr(section, 'title', '?')}': {inner_exc}") + return locations + except Exception as exc: + logger.error(f"Error getting music library locations: {exc}") + return [] def get_play_history(self, limit=500): """Fetch recently played tracks from Plex. @@ -610,11 +851,11 @@ class PlexClient(MediaServerClient): Returns dict of {ratingKey: play_count}. """ - if not self.ensure_connection() or not self.music_library: + if not self.ensure_connection() or not self._can_query(): return {} try: - tracks = self.music_library.searchTracks() + tracks = self._all_tracks() counts = {} for track in tracks: view_count = getattr(track, 'viewCount', 0) or 0 @@ -628,12 +869,12 @@ class PlexClient(MediaServerClient): def get_all_artists(self) -> List[PlexArtist]: """Get all artists from the music library""" - if not self.ensure_connection() or not self.music_library: + if not self.ensure_connection() or not self._can_query(): logger.error("Not connected to Plex server or no music library") return [] try: - artists = self.music_library.searchArtists() + artists = self._all_artists() logger.info(f"Found {len(artists)} artists in Plex library") return artists except Exception as e: @@ -642,10 +883,10 @@ class PlexClient(MediaServerClient): def get_all_artist_ids(self) -> set: """Get all artist IDs from Plex library (lightweight, for removal detection).""" - if not self.ensure_connection() or not self.music_library: + if not self.ensure_connection() or not self._can_query(): return set() try: - artists = self.music_library.searchArtists() + artists = self._all_artists() ids = {str(a.ratingKey) for a in artists} logger.info(f"Retrieved {len(ids)} artist IDs from Plex") return ids @@ -655,10 +896,10 @@ class PlexClient(MediaServerClient): def get_all_album_ids(self) -> set: """Get all album IDs from Plex library (lightweight, for removal detection).""" - if not self.ensure_connection() or not self.music_library: + if not self.ensure_connection() or not self._can_query(): return set() try: - albums = self.music_library.albums() + albums = self._all_albums() ids = {str(a.ratingKey) for a in albums} logger.info(f"Retrieved {len(ids)} album IDs from Plex") return ids @@ -868,11 +1109,31 @@ class PlexClient(MediaServerClient): return False def trigger_library_scan(self, library_name: str = "Music") -> bool: - """Trigger Plex library scan for the specified library""" + """Trigger Plex library scan. + + In all-libraries mode, fans the scan trigger across every music + section. Otherwise targets the named section (default ``Music``). + Returns True if at least one section was successfully triggered. + """ if not self.ensure_connection(): return False - + try: + if self._all_libraries_mode: + sections = self._get_music_sections() + if not sections: + logger.warning("All-libraries mode active but no music sections found") + return False + triggered = 0 + for section in sections: + try: + section.update() + triggered += 1 + except Exception as inner_exc: + logger.error(f"Failed to trigger scan for '{section.title}': {inner_exc}") + logger.info(f"Triggered Plex library scan across {triggered}/{len(sections)} music sections") + return triggered > 0 + library = self.server.library.section(library_name) library.update() # Non-blocking scan request logger.info(f"Triggered Plex library scan for '{library_name}'") @@ -880,66 +1141,93 @@ class PlexClient(MediaServerClient): except Exception as e: logger.error(f"Failed to trigger library scan for '{library_name}': {e}") return False - + def is_library_scanning(self, library_name: str = "Music") -> bool: - """Check if Plex library is currently scanning""" + """Check if Plex library is currently scanning. + + In all-libraries mode, returns True if ANY music section is + scanning. Otherwise checks the named section. + """ if not self.ensure_connection(): logger.debug("Not connected to Plex, cannot check scan status") return False - + try: + if self._all_libraries_mode: + sections = self._get_music_sections() + # Per-section refreshing flag check. + for section in sections: + if hasattr(section, 'refreshing') and section.refreshing: + logger.debug(f"Section '{section.title}' is refreshing") + return True + # Activity feed check — match any music-section title. + try: + activities = self.server.activities() + section_titles = {s.title.lower() for s in sections} + for activity in activities: + activity_type = getattr(activity, 'type', 'unknown') + activity_title = getattr(activity, 'title', '') or '' + if activity_type not in ['library.scan', 'library.refresh']: + continue + if any(title in activity_title.lower() for title in section_titles): + logger.debug(f"Matching scan activity: {activity_title}") + return True + except Exception as activities_error: + logger.debug(f"Could not check server activities: {activities_error}") + return False + library = self.server.library.section(library_name) - + # Check if library has a scanning attribute or is refreshing # The Plex API exposes this through the library's refreshing property refreshing = hasattr(library, 'refreshing') and library.refreshing logger.debug("Library.refreshing = %s", refreshing) - + if refreshing: logger.debug("Library is refreshing") return True - + # Alternative method: Check server activities for scanning try: activities = self.server.activities() logger.debug("Found %s server activities", len(activities)) - + for activity in activities: # Look for library scan activities activity_type = getattr(activity, 'type', 'unknown') activity_title = getattr(activity, 'title', 'unknown') logger.debug("Activity - type=%s title=%s", activity_type, activity_title) - + if (activity_type in ['library.scan', 'library.refresh'] and library_name.lower() in activity_title.lower()): logger.debug("Found matching scan activity: %s", activity_title) return True except Exception as activities_error: logger.debug(f"Could not check server activities: {activities_error}") - + logger.debug("No scan activity detected") return False - + except Exception as e: logger.debug(f"Error checking if library is scanning: {e}") return False def search_albums(self, album_name: str = "", artist_name: str = "", limit: int = 20) -> List[Dict[str, Any]]: """Search for albums in Plex library""" - if not self.ensure_connection() or not self.music_library: + if not self.ensure_connection() or not self._can_query(): return [] - + try: albums = [] - + # Perform search - different approaches based on what we're searching for search_results = [] - + if album_name and artist_name: # Search for albums by specific artist and title try: # First try searching for the artist, then filter their albums - artist_results = self.music_library.searchArtists(title=artist_name, limit=3) + artist_results = self._search_artists_by_name(title=artist_name, limit=3) for artist in artist_results: try: artist_albums = artist.albums() @@ -952,25 +1240,25 @@ class PlexClient(MediaServerClient): logger.debug(f"Artist search failed, trying general search: {e}") # Fallback to general album search try: - search_results = self.music_library.search(title=album_name) + search_results = self._search_general(title=album_name) # Filter to only albums search_results = [r for r in search_results if isinstance(r, PlexAlbum)] except Exception as e2: logger.debug(f"General search also failed: {e2}") - + elif album_name: # Search for albums by title only try: - search_results = self.music_library.search(title=album_name) - # Filter to only albums + search_results = self._search_general(title=album_name) + # Filter to only albums search_results = [r for r in search_results if isinstance(r, PlexAlbum)] except Exception as e: logger.debug(f"Album title search failed: {e}") - + elif artist_name: # Search for all albums by artist try: - artist_results = self.music_library.searchArtists(title=artist_name, limit=1) + artist_results = self._search_artists_by_name(title=artist_name, limit=1) if artist_results: search_results = artist_results[0].albums() except Exception as e: @@ -978,7 +1266,7 @@ class PlexClient(MediaServerClient): else: # Get all albums if no search terms try: - search_results = self.music_library.albums() + search_results = self._all_albums() except Exception as e: logger.debug(f"Get all albums failed: {e}") diff --git a/tests/media_server/test_plex_all_libraries.py b/tests/media_server/test_plex_all_libraries.py new file mode 100644 index 00000000..fc84883e --- /dev/null +++ b/tests/media_server/test_plex_all_libraries.py @@ -0,0 +1,379 @@ +"""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 diff --git a/tests/media_server/test_plex_pinning.py b/tests/media_server/test_plex_pinning.py index 7f314f73..9e3a3ba7 100644 --- a/tests/media_server/test_plex_pinning.py +++ b/tests/media_server/test_plex_pinning.py @@ -29,6 +29,7 @@ def plex_client(): 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 diff --git a/web_server.py b/web_server.py index 9a07cc5b..1f8a093d 100644 --- a/web_server.py +++ b/web_server.py @@ -5129,8 +5129,12 @@ def clear_plex_library_preference(): from database.music_database import MusicDatabase db = MusicDatabase() db.set_preference('plex_music_library', '') - if media_server_engine.client('plex'): - media_server_engine.client('plex').music_library = None + plex = media_server_engine.client('plex') + if plex: + plex.music_library = None + # Also clear all-libraries mode so a fresh "select library" + # flow doesn't inherit stale state. + plex._all_libraries_mode = False return jsonify({"success": True, "message": "Plex library preference cleared."}) except Exception as e: logger.error(f"Error clearing Plex library preference: {e}") @@ -5148,10 +5152,15 @@ def get_plex_music_libraries(): db = MusicDatabase() selected_library = db.get_preference('plex_music_library') - # Get the currently active library name + # Get the currently active library name. In all-libraries mode + # ``music_library`` is None — surface a friendly label so the + # settings UI displays the active selection correctly. current_library = None - if media_server_engine.client('plex').music_library: - current_library = media_server_engine.client('plex').music_library.title + plex = media_server_engine.client('plex') + if plex.music_library: + current_library = plex.music_library.title + elif plex.is_all_libraries_mode(): + current_library = 'All Libraries (combined)' return jsonify({ "success": True, @@ -10940,11 +10949,14 @@ def _resolve_library_file_path(file_path): transfer_dir = docker_resolve_path(config_manager.get('soulseek.transfer_path', './Transfer')) download_dir = docker_resolve_path(config_manager.get('soulseek.download_path', './downloads')) - # Also check the media server's music library path (handles Docker↔host path mismatch) + # Also check the media server's music library path (handles Docker↔host path mismatch). + # ``get_music_library_locations`` handles both single-library mode and + # all-libraries mode (unions location paths across every music section). library_dirs = set() try: - if media_server_engine.client('plex') and media_server_engine.client('plex').server and media_server_engine.client('plex').music_library: - for loc in media_server_engine.client('plex').music_library.locations: + plex = media_server_engine.client('plex') + if plex and plex.server: + for loc in plex.get_music_library_locations(): library_dirs.add(loc) except Exception: pass diff --git a/webui/static/beatport-ui.js b/webui/static/beatport-ui.js index e2c5a774..b692de9e 100644 --- a/webui/static/beatport-ui.js +++ b/webui/static/beatport-ui.js @@ -3638,14 +3638,24 @@ async function loadPlexMusicLibraries() { // Clear existing options selector.innerHTML = ''; - // Add options for each library + // Add options for each library. ``value`` is the canonical + // identifier the backend expects (real libraries: title; + // synthetic "All Libraries" entry: the sentinel string). + // ``title`` stays the human-readable label. data.libraries.forEach(library => { const option = document.createElement('option'); - option.value = library.title; + const optionValue = library.value || library.title; + option.value = optionValue; option.textContent = library.title; - // Mark the currently selected library - if (library.title === data.current || library.title === data.selected) { + // Pre-select match: compare ``value`` against the saved + // DB pref (``data.selected``) AND ``title`` against the + // live-active library name (``data.current``). Covers + // both the sentinel case and the legacy single-library + // case. + if (optionValue === data.selected + || library.title === data.current + || library.title === data.selected) { option.selected = true; } diff --git a/webui/static/helper.js b/webui/static/helper.js index 9d2669f0..ad83c591 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3432,6 +3432,7 @@ const WHATS_NEW = { '2.4.2': [ // --- post-2.4.1 dev work — entries hidden by _getLatestWhatsNewVersion until the build version bumps --- { date: 'Unreleased — 2.4.2 dev cycle' }, + { title: 'Plex: "All Libraries (Combined)" Mode', desc: 'github issue #505 (popebruhlxix): users with multiple plex music libraries (e.g. one per plex home user) only saw one library inside soulsync because the connection settings forced you to pick a single library section. now there\'s a new "all libraries (combined)" option in settings → connections → plex → music library dropdown. picking it flips the plex 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 api call, plex handles the aggregation. write methods (genre / poster / metadata updates) are unaffected — they operate on plex objects via ratingKey, section-agnostic. trigger_library_scan + is_library_scanning fan out across every music section in the new mode. backward compatible — existing users with a real library name saved see no behavior change. the "all libraries" option only appears in the dropdown when more than one music library exists on the server. honest tradeoffs: same artist appearing in multiple sections shows as separate entries (no dedup), and write-back updates only one section\'s copy when an artist exists in multiple. document and revisit if it bites users. 15 new tests pin both modes (single-section preserved, all-libraries dispatches through server-wide search).', page: 'settings' }, { title: 'Fix: Download Discography Showed Wrong Artist\'s Albums', desc: 'clicked download discography on 50 cent → modal showed young hot rod\'s albums. clicked weird al → modal showed the beatles. cause: the endpoint received whichever single artist id the frontend happened to pick (spotify or itunes or deezer or library db id) and dispatched it as-is to whichever source it queried. when the picked id didn\'t match the queried source\'s id format, lookup either returned wrong-artist results (numeric collisions — db id 194687 was a real deezer artist for someone else) or fell back to a fuzzy name search that picked a wrong artist. the per-source id dispatch mechanism (`MetadataLookupOptions.artist_source_ids`) already existed and the watchlist scanner already used it; the on-demand discography endpoint just wasn\'t wired to it. fixed: when the url artist_id matches a library row by ANY stored id (db id, spotify_artist_id, itunes_artist_id, deezer_id, musicbrainz_id), backend pulls every stored provider id and dispatches the right id to each source. each source gets its OWN stored id regardless of what the url carries. when the url id is a non-library source-native id and the row lookup misses entirely, behavior is identical to before (single-id fallback). also fixed two log-namespace bugs: enhance quality and multi-source search were writing through `getLogger(__name__)` which resolves to `core.artists.quality` / `core.metadata.multi_source_search` — neither under the soulsync handler — so every diagnostic line was silently dropped. switched both to `get_logger()` from utils.logging_config so they actually land in app.log.', page: 'library' }, { title: 'Enhance Quality: Direct ID Lookup Like Download Discography', desc: 'two related fixes. (1) discord report: enhance quality on an artist with neither spotify nor deezer connected added tracks as "unknown artist - unknown album - unknown track". enhance was running a single-source itunes fallback chain that returned junk matches with empty fields, while track redownload had been doing parallel multi-source search the whole time. extracted that search into `core/metadata/multi_source_search.py` and pointed both flows at it. (2) followup: enhance was still using fuzzy text search even when the library track had a stored source ID (spotify_track_id / deezer_id / itunes_track_id / soul_id) on the row, which meant tracks with messy tags ("Title (Live)", featured artists in the artist field, etc.) failed to match even though a perfect ID was sitting right there. download discography never had this problem because it resolves albums by stable ID, not by name. enhance now does the same: for every source you have configured, if the track has a stored ID for that source, it calls `get_track_details(id)` directly — no fuzzy matching. preferred source (your configured primary) is tried first so a deezer-primary user gets deezer payloads on the wishlist entry. text search is only the fallback now (kicks in for tracks with no stored IDs). also fixed the modal toast that lied "matching tracks to spotify..." regardless of which sources were actually being queried.', page: 'library' }, { title: 'Internal: Media Server Engine Cin/JohnBaumb Pass', desc: 'internal — applied the same architectural cleanups the download engine PR went through to the media server engine PR before review. (1) every server client (Plex / Jellyfin / Navidrome / SoulSync) now explicitly inherits `MediaServerClient` instead of relying on structural typing — drift in any class fails at the conformance test boundary. (2) generic accessors on the engine: `configured_clients()` (replaces per-server `if X and X.is_connected(): clients[name] = X` chains in web_server.py) and `reload_config(name=None)` (generic dispatch instead of per-client reload calls). (3) singleton factory: `get_media_server_engine()` / `set_media_server_engine()` matching the metadata + download engine shape. web_server.py boots via `set_media_server_engine(...)` so factory + global handle share state. (4) ~70 direct `plex_client.X` / `jellyfin_client.X` / `navidrome_client.X` / `soulsync_library_client.X` attribute reaches in web_server.py migrated to `media_server_engine.client(\'\').X`. ~60 standalone refs (truthy checks, media_client assignments, source-name tuples) also routed through the engine. (5) the per-server `plex_client` / `jellyfin_client` / `navidrome_client` / `soulsync_library_client` globals in web_server.py are gone entirely — engine owns the client instances now, every caller reaches via `media_server_engine.client(\'\')`. four multi-client consumers (`PlaylistSyncService`, `ListeningStatsWorker`, `WebScanManager`, discovery `SyncDeps`) refactored to take the engine instead of separate per-server kwargs. (6) `TrackInfo` and `PlaylistInfo` lifted out of `core/plex_client.py` / `jellyfin_client.py` / `navidrome_client.py` (each was defining a near-identical copy) into the neutral `core/media_server/types.py` module — same lift Cin caught on the download `TrackResult`/`AlbumResult`/`DownloadStatus` situation. consumers (matching engine, sync service) get one import. zero behavior change.' }, @@ -3776,6 +3777,20 @@ const WHATS_NEW = { // Section shape: { title, description, features: [bullet strings], // usage_note?: 'optional hint shown at the bottom' } const VERSION_MODAL_SECTIONS = [ + { + title: "Plex: Combine All Music Libraries Into One", + description: "github issue #505 (popebruhlxix): users with multiple plex music libraries (e.g. one per plex home user) only saw one library inside soulsync because settings forced you to pick a single library section.", + features: [ + "• new \"all libraries (combined)\" option in settings → connections → plex → music library dropdown — only shows up when your server has more than one music library", + "• picking it flips the plex client into server-wide read mode — every scan / search / library-stat call dispatches through `server.library.search()` instead of querying a single section", + "• one api call, plex handles the aggregation — no per-section iteration code on our side", + "• write methods (genre / poster / metadata updates) and playlists are unaffected — section-agnostic", + "• trigger_library_scan + is_library_scanning fan out across every music section in the new mode", + "• backward compatible — existing users with a single library saved see no behavior change", + "• honest tradeoffs: same artist appearing in multiple sections shows as separate entries (no dedup yet), and write-back updates only one section\'s copy if an artist exists in multiple — revisit if it bites users", + ], + usage_note: "settings → connections → plex → music library → pick \"all libraries (combined)\"", + }, { title: "Download Discography No Longer Shows Wrong Artist", description: "clicked download discog on 50 cent → modal showed young hot rod\'s albums. clicked weird al → modal showed beatles albums. real bug, not just data weirdness.",