You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
SoulSync/core/plex_client.py

1485 lines
66 KiB

from plexapi.server import PlexServer
from plexapi.library import LibrarySection, MusicSection
from plexapi.audio import Track as PlexTrack, Album as PlexAlbum, Artist as PlexArtist
from plexapi.playlist import Playlist as PlexPlaylist
from plexapi.exceptions import PlexApiException, NotFound
from typing import List, Optional, Dict, Any
import requests
from datetime import datetime, timedelta
import re
from utils.logging_config import get_logger
from config.settings import config_manager
import threading
# Shared dataclasses live in the neutral media_server package now —
# every server client used to define a near-identical XTrackInfo /
# XPlaylistInfo. Lifted to one canonical type so consumers (matching
# engine, sync service, etc.) get a single import.
from core.media_server.types import TrackInfo, PlaylistInfo
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
self._connection_check_interval = 30 # Check every 30 seconds max
def ensure_connection(self) -> bool:
"""Ensure connection to Plex server with lazy initialization."""
# If we've successfully connected before and server object exists, return immediately
if self._connection_attempted and self.server is not None:
return True
# Prevent concurrent connection attempts
if self._is_connecting:
return False
self._is_connecting = True
try:
self._setup_client()
connection_successful = self.server is not None
# Only mark as attempted if connection succeeded
# This allows retries if connection fails
if connection_successful:
self._connection_attempted = True
else:
# Reset flag to allow retry on next call
self._connection_attempted = False
return connection_successful
finally:
self._is_connecting = False
def reset_connection(self):
"""Reset connection state to force reconnection on next ensure_connection() call.
Useful when config changes or connection needs to be refreshed."""
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
def _setup_client(self):
config = config_manager.get_plex_config()
if not config.get('base_url'):
logger.warning("Plex server URL not configured")
return
try:
if config.get('token'):
# Use a longer timeout (15 seconds) to prevent read timeouts on slow servers
self.server = PlexServer(config['base_url'], config['token'], timeout=15)
else:
logger.error("Plex token not configured")
return
self._find_music_library()
logger.debug(f"Successfully connected to Plex server: {self.server.friendlyName}")
except Exception as e:
logger.error(f"Failed to connect to Plex server: {e}")
self.server = None
def get_available_music_libraries(self) -> List[Dict[str, str]]:
"""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 []
try:
music_libraries = []
for section in self.server.library.sections():
if section.type == 'artist':
music_libraries.append({
'title': section.title,
'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,
})
# 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.
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
from database.music_database import MusicDatabase
db = MusicDatabase()
db.set_preference('plex_music_library', library_name)
return True
logger.warning(f"Music library '{library_name}' not found")
return False
except Exception as e:
logger.error(f"Error setting music library: {e}")
return False
def _find_music_library(self):
if not self.server:
return
try:
music_sections = []
# Collect all music libraries
for section in self.server.library.sections():
if section.type == 'artist':
music_sections.append(section)
if not music_sections:
logger.warning("No music library found on Plex server")
return
# Check if user has a saved preference
try:
from database.music_database import MusicDatabase
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:
logger.debug(f"Could not check library preference: {e}")
# Priority order for common library names
priority_names = ['Music', 'music', 'Audio', 'audio', 'Songs', 'songs']
# First, try to find a library with a priority name
for priority_name in priority_names:
for section in music_sections:
if section.title == priority_name:
self.music_library = section
logger.debug(f"Found preferred music library: {section.title}")
return
# If no priority match found, use the first one
self.music_library = music_sections[0]
logger.debug(f"Found music library (first available): {self.music_library.title}")
# Log other available libraries if multiple exist
if len(music_sections) > 1:
other_libraries = [s.title for s in music_sections[1:]]
logger.info(f"Other music libraries available: {', '.join(other_libraries)}")
except Exception as e:
logger.error(f"Error finding music library: {e}")
def is_connected(self) -> bool:
"""Check if connected to Plex server with cached connection checks."""
import time
current_time = time.time()
# Always attempt connection if not connected OR cache expired
# This ensures we retry failed connections and detect disconnections
should_check = (
self.server is None or # Not connected - always try
current_time - self._last_connection_check > self._connection_check_interval # Cache expired
)
if should_check:
self._last_connection_check = current_time
# Try to connect, but don't block if already connecting
if not self._is_connecting:
self.ensure_connection()
# For status checks, only verify server connection, not music library
# Music library might be None if user hasn't selected one yet
return self.server is not None
def is_fully_configured(self) -> bool:
"""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 []
# ------------------------------------------------------------------
# Cross-section dedup. Applied ONLY in all-libraries mode and ONLY
# at the listing/stats layer. Never apply to ratingKey enumeration
# (``get_all_artist_ids`` / ``get_all_album_ids``) — removal
# detection compares those sets against DB-linked ratingKeys and
# deduping would falsely prune library tracks pointing at the
# non-canonical ratingKey. Single-library mode is a no-op fast
# path so the dedup code path never touches it.
# ------------------------------------------------------------------
def _dedupe_artists(self, artists: List[PlexArtist]) -> List[PlexArtist]:
"""Collapse same-name artists across sections to a single
canonical entry (the one with the higher track count). Returns
the input unchanged in single-library mode and when there's
nothing to dedup."""
if not self._all_libraries_mode or not artists:
return artists
by_name: Dict[str, PlexArtist] = {}
for a in artists:
name_key = (getattr(a, 'title', '') or '').strip().lower()
if not name_key:
# Untitled artist — keep as-is, can't dedup blindly.
by_name[f'__nokey_{getattr(a, "ratingKey", id(a))}'] = a
continue
existing = by_name.get(name_key)
if existing is None:
by_name[name_key] = a
continue
try:
existing_count = getattr(existing, 'leafCount', 0) or 0
new_count = getattr(a, 'leafCount', 0) or 0
if new_count > existing_count:
by_name[name_key] = a
except Exception as e:
logger.debug("artist leafCount compare failed: %s", e)
return list(by_name.values())
def _dedupe_albums(self, albums: List[PlexAlbum]) -> List[PlexAlbum]:
"""Collapse same-album-by-same-artist across sections to a
single canonical entry. Group key is (artist_lower, title_lower);
canonical = higher track count. No-op in single-library mode."""
if not self._all_libraries_mode or not albums:
return albums
by_key: Dict[tuple, PlexAlbum] = {}
for alb in albums:
title = (getattr(alb, 'title', '') or '').strip().lower()
if not title:
by_key[('__nokey__', getattr(alb, 'ratingKey', id(alb)))] = alb
continue
artist = ''
try:
artist = (getattr(alb, 'parentTitle', '') or '').strip().lower()
except Exception as e:
logger.debug("album parentTitle read failed: %s", e)
key = (artist, title)
existing = by_key.get(key)
if existing is None:
by_key[key] = alb
continue
try:
existing_count = getattr(existing, 'leafCount', 0) or 0
new_count = getattr(alb, 'leafCount', 0) or 0
if new_count > existing_count:
by_key[key] = alb
except Exception as e:
logger.debug("album leafCount compare failed: %s", e)
return list(by_key.values())
def get_all_playlists(self) -> List[PlaylistInfo]:
if not self.ensure_connection():
logger.error("Not connected to Plex server")
return []
playlists = []
try:
for playlist in self.server.playlists():
if getattr(playlist, 'playlistType', None) == 'audio':
playlist_info = PlaylistInfo.from_plex_playlist(playlist)
playlists.append(playlist_info)
logger.info(f"Retrieved {len(playlists)} audio playlists")
return playlists
except Exception as e:
logger.error(f"Error fetching playlists: {e}")
return []
def get_playlist_by_name(self, name: str) -> Optional[PlaylistInfo]:
if not self.ensure_connection():
return None
try:
playlist = self.server.playlist(name)
if getattr(playlist, 'playlistType', None) == 'audio':
return PlaylistInfo.from_plex_playlist(playlist)
return None
except NotFound:
logger.info(f"Playlist '{name}' not found")
return None
except Exception as e:
logger.error(f"Error fetching playlist '{name}': {e}")
return None
def create_playlist(self, name: str, tracks) -> bool:
if not self.ensure_connection():
logger.error("Not connected to Plex server")
return False
try:
# Handle both TrackInfo objects and actual Plex track objects
plex_tracks = []
for track in tracks:
if hasattr(track, 'ratingKey'):
# This is already a Plex track object
plex_tracks.append(track)
elif hasattr(track, '_original_plex_track'):
# This is a TrackInfo object with stored original track reference
original_track = track._original_plex_track
if original_track is not None:
plex_tracks.append(original_track)
logger.debug(f"Using stored track reference for: {track.title} by {track.artist} (ratingKey: {original_track.ratingKey})")
else:
logger.warning(f"Stored track reference is None for: {track.title} by {track.artist}")
elif hasattr(track, 'title'):
# Fallback: This is a TrackInfo object, need to find the actual track
plex_track = self._find_track(track.title, track.artist, track.album)
if plex_track:
plex_tracks.append(plex_track)
else:
logger.warning(f"Track not found in Plex: {track.title} by {track.artist}")
logger.info(f"Processed {len(tracks)} input tracks, resulting in {len(plex_tracks)} valid Plex tracks for playlist '{name}'")
if plex_tracks:
# Additional validation
valid_tracks = [t for t in plex_tracks if t is not None and hasattr(t, 'ratingKey')]
logger.info(f"Final validation: {len(valid_tracks)} valid tracks with ratingKeys")
if valid_tracks:
# Debug the track objects before creating playlist
logger.debug("About to create playlist with tracks:")
for i, track in enumerate(valid_tracks):
logger.debug(f" Track {i+1}: {track.title} (type: {type(track)}, ratingKey: {track.ratingKey})")
try:
playlist = self.server.createPlaylist(name, valid_tracks)
logger.info(f"Created playlist '{name}' with {len(valid_tracks)} tracks")
return True
except Exception as create_error:
logger.error(f"CreatePlaylist failed: {create_error}")
# Try alternative approach - pass items as list
try:
playlist = self.server.createPlaylist(name, items=valid_tracks)
logger.info(f"Created playlist '{name}' with {len(valid_tracks)} tracks (using items parameter)")
return True
except Exception as alt_error:
logger.error(f"Alternative createPlaylist also failed: {alt_error}")
# Try creating empty playlist first, then adding tracks
try:
logger.debug("Trying to create empty playlist first, then add tracks...")
playlist = self.server.createPlaylist(name, [])
playlist.addItems(valid_tracks)
logger.info(f"Created empty playlist and added {len(valid_tracks)} tracks")
return True
except Exception as empty_error:
logger.error(f"Empty playlist approach also failed: {empty_error}")
# Final attempt: Create with first item, then add the rest
try:
logger.debug("Trying to create playlist with first track, then add remaining...")
playlist = self.server.createPlaylist(name, valid_tracks[0])
if len(valid_tracks) > 1:
playlist.addItems(valid_tracks[1:])
logger.info(f"Created playlist with first track and added {len(valid_tracks)-1} more tracks")
return True
except Exception as final_error:
logger.error(f"Final playlist creation attempt failed: {final_error}")
raise create_error from final_error
else:
logger.error(f"No valid tracks with ratingKeys for playlist '{name}'")
return False
else:
logger.error(f"No tracks found for playlist '{name}'")
return False
except Exception as e:
logger.error(f"Error creating playlist '{name}': {e}")
return False
def copy_playlist(self, source_name: str, target_name: str) -> bool:
"""Copy a playlist to create a backup"""
if not self.ensure_connection():
return False
try:
# Get the source playlist
source_playlist = self.server.playlist(source_name)
# Get all tracks from source playlist
source_tracks = source_playlist.items()
logger.debug(f"Retrieved {len(source_tracks) if source_tracks else 0} tracks from source playlist")
# Validate tracks
if not source_tracks:
logger.warning(f"Source playlist '{source_name}' has no tracks to copy")
return False
# Filter for valid track objects
valid_tracks = [track for track in source_tracks if hasattr(track, 'ratingKey')]
logger.debug(f"Found {len(valid_tracks)} valid tracks with ratingKeys")
if not valid_tracks:
logger.error(f"No valid tracks found in source playlist '{source_name}'")
return False
# Delete target playlist if it exists (for overwriting backup)
try:
target_playlist = self.server.playlist(target_name)
target_playlist.delete()
logger.info(f"Deleted existing backup playlist '{target_name}'")
except NotFound:
pass # Target doesn't exist, which is fine
# Create new playlist with copied tracks
try:
self.server.createPlaylist(target_name, items=valid_tracks)
logger.info(f"Created backup playlist '{target_name}' with {len(valid_tracks)} tracks")
return True
except Exception as create_error:
logger.error(f"Failed to create backup playlist: {create_error}")
# Try alternative method
try:
new_playlist = self.server.createPlaylist(target_name)
new_playlist.addItems(valid_tracks)
logger.info(f"Created backup playlist '{target_name}' with {len(valid_tracks)} tracks (alternative method)")
return True
except Exception as alt_error:
logger.error(f"Alternative backup creation also failed: {alt_error}")
return False
except NotFound:
logger.error(f"Source playlist '{source_name}' not found")
return False
except Exception as e:
logger.error(f"Error copying playlist '{source_name}' to '{target_name}': {e}")
return False
def append_to_playlist(self, playlist_name: str, tracks: List[TrackInfo]) -> bool:
"""Append tracks to an existing playlist (creates it if missing).
Differs from `update_playlist`: never deletes existing tracks,
never recreates the playlist, no backup. Used by sync mode
'append' so user-added tracks on the server playlist survive
re-syncing the source. Dedupe-by-ratingKey ensures we don't
re-add tracks the playlist already contains."""
if not self.ensure_connection():
return False
try:
try:
existing_playlist = self.server.playlist(playlist_name)
except NotFound:
logger.info(
f"Plex append: playlist '{playlist_name}' doesn't exist yet — "
f"creating with {len(tracks)} tracks"
)
return self.create_playlist(playlist_name, tracks)
existing_keys = {
str(t.ratingKey) for t in existing_playlist.items()
if hasattr(t, 'ratingKey')
}
new_tracks = [
t for t in tracks
if hasattr(t, 'ratingKey') and str(t.ratingKey) not in existing_keys
]
if not new_tracks:
logger.info(
f"Plex append: no new tracks to add to '{playlist_name}' "
f"(all {len(tracks)} matched-tracks already present)"
)
return True
existing_playlist.addItems(new_tracks)
logger.info(
f"Plex append: added {len(new_tracks)} new tracks to '{playlist_name}' "
f"(skipped {len(tracks) - len(new_tracks)} already present)"
)
return True
except Exception as e:
logger.error(f"Error appending to Plex playlist '{playlist_name}': {e}")
return False
def update_playlist(self, playlist_name: str, tracks: List[TrackInfo]) -> bool:
if not self.ensure_connection():
return False
try:
existing_playlist = self.server.playlist(playlist_name)
# Check if backup is enabled in config
from config.settings import config_manager
create_backup = config_manager.get('playlist_sync.create_backup', True)
if create_backup:
backup_name = f"{playlist_name} Backup"
logger.info(f"Creating backup playlist '{backup_name}' before sync")
if self.copy_playlist(playlist_name, backup_name):
logger.info("Backup created successfully")
else:
logger.warning("Failed to create backup, continuing with sync")
# Delete original and recreate
existing_playlist.delete()
return self.create_playlist(playlist_name, tracks)
except NotFound:
logger.info(f"Playlist '{playlist_name}' not found, creating new one")
return self.create_playlist(playlist_name, tracks)
except Exception as e:
logger.error(f"Error updating playlist '{playlist_name}': {e}")
return False
def set_playlist_image(self, playlist_name: str, image_url: str) -> bool:
"""Set the poster image for a playlist from a URL."""
if not self.ensure_connection() or not image_url:
return False
try:
playlist = self.server.playlist(playlist_name)
playlist.uploadPoster(url=image_url)
logger.info(f"Set playlist poster for '{playlist_name}'")
return True
except Exception as e:
logger.warning(f"Could not set playlist poster for '{playlist_name}': {e}")
return False
def _find_track(self, title: str, artist: str, album: str) -> Optional[PlexTrack]:
if not self._can_query():
return None
try:
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
result.artist().title.lower() == artist.lower() and
result.album().title.lower() == album.lower()):
return result
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
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
def search_tracks(self, title: str, artist: str, limit: int = 15) -> List[TrackInfo]:
"""
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._can_query():
logger.warning("Plex music library not found. Cannot perform search.")
return []
try:
candidate_tracks = []
found_track_keys = set()
def add_candidates(tracks):
"""Helper function to add unique tracks to the main candidate list."""
for track in tracks:
if track.ratingKey not in found_track_keys:
candidate_tracks.append(track)
found_track_keys.add(track.ratingKey)
# --- Stage 1: High-Precision Search (Artist -> then filter by Title) ---
if artist:
logger.debug(f"Stage 1: Searching for artist '{artist}'")
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()
lower_title = title.lower()
stage1_results = [track for track in all_artist_tracks if lower_title in track.title.lower()]
add_candidates(stage1_results)
logger.debug(f"Stage 1 found {len(stage1_results)} candidates.")
# --- Early Exit: If Stage 1 found results, stop here ---
if candidate_tracks:
logger.info(f"Found {len(candidate_tracks)} candidates in Stage 1. Exiting early.")
tracks = [TrackInfo.from_plex_track(track) for track in candidate_tracks[:limit]]
# Store references to original tracks for playlist creation
for i, track_info in enumerate(tracks):
if i < len(candidate_tracks):
track_info._original_plex_track = candidate_tracks[i]
logger.debug(f"Stored original track reference for '{track_info.title}' (ratingKey: {candidate_tracks[i].ratingKey})")
else:
logger.warning(f"Index mismatch: cannot store original track for '{track_info.title}'")
return tracks
# --- 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._search_general(title=search_query, libtype='track', limit=limit)
add_candidates(stage2_results)
# --- Early Exit: If Stage 2 found results, stop here ---
if candidate_tracks:
logger.info(f"Found {len(candidate_tracks)} candidates in Stage 2. Exiting early.")
tracks = [TrackInfo.from_plex_track(track) for track in candidate_tracks[:limit]]
# Store references to original tracks for playlist creation
for i, track_info in enumerate(tracks):
if i < len(candidate_tracks):
track_info._original_plex_track = candidate_tracks[i]
logger.debug(f"Stored original track reference for '{track_info.title}' (ratingKey: {candidate_tracks[i].ratingKey})")
else:
logger.warning(f"Index mismatch: cannot store original track for '{track_info.title}'")
return tracks
# --- Stage 3: Title-Only Fallback REMOVED ---
# Removed to prevent false positives where tracks with same title
# but different artists are incorrectly matched
tracks = [TrackInfo.from_plex_track(track) for track in candidate_tracks[:limit]]
# Store references to original tracks for playlist creation
for i, track_info in enumerate(tracks):
if i < len(candidate_tracks):
track_info._original_plex_track = candidate_tracks[i]
logger.debug(f"Stored original track reference for '{track_info.title}' (ratingKey: {candidate_tracks[i].ratingKey})")
else:
logger.warning(f"Index mismatch: cannot store original track for '{track_info.title}'")
if tracks:
logger.info(f"Found {len(tracks)} total potential matches for '{title}' by '{artist}' after all stages.")
return tracks
except Exception as e:
logger.error(f"Error during multi-stage search for title='{title}', artist='{artist}': {e}")
import traceback
traceback.print_exc()
return []
def get_library_stats(self) -> Dict[str, int]:
if not self._can_query():
return {}
try:
# Apply dedup to artist + album counts so stats match what
# the user actually sees in the library list (deduped). Tracks
# stay raw — same track in two sections means two distinct
# files / Plex entries, not a logical duplicate.
return {
'artists': len(self._dedupe_artists(self._all_artists())),
'albums': len(self._dedupe_albums(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.
Returns list of dicts with: track_title, artist, album, played_at,
duration_ms, track_id (ratingKey).
"""
if not self.ensure_connection() or not self.server:
return []
try:
history = self.server.history(librarySectionID=self.music_library.key if self.music_library else None,
maxresults=limit)
results = []
for item in history:
if item.type != 'track':
continue
try:
results.append({
'track_title': item.title or '',
'artist': item.grandparentTitle or '',
'album': item.parentTitle or '',
'played_at': item.viewedAt.isoformat() if hasattr(item, 'viewedAt') and item.viewedAt else None,
'duration_ms': (item.duration or 0),
'track_id': str(item.ratingKey),
})
except Exception:
continue
logger.info(f"Retrieved {len(results)} play history entries from Plex")
return results
except Exception as e:
logger.error(f"Error getting Plex play history: {e}")
return []
def get_track_play_counts(self):
"""Get viewCount for all tracks in the music library.
Returns dict of {ratingKey: play_count}.
"""
if not self.ensure_connection() or not self._can_query():
return {}
try:
tracks = self._all_tracks()
counts = {}
for track in tracks:
view_count = getattr(track, 'viewCount', 0) or 0
if view_count > 0:
counts[str(track.ratingKey)] = view_count
logger.info(f"Retrieved play counts for {len(counts)} tracks from Plex")
return counts
except Exception as e:
logger.error(f"Error getting Plex track play counts: {e}")
return {}
def get_all_artists(self) -> List[PlexArtist]:
"""Get all artists from the music library.
In all-libraries mode, dedupes same-name artists across
sections (canonical = higher track count) so the library list
doesn't show "Drake" twice when Drake is in two sections.
Single-library mode is unaffected — dedup helper is a no-op.
"""
if not self.ensure_connection() or not self._can_query():
logger.error("Not connected to Plex server or no music library")
return []
try:
raw = self._all_artists()
artists = self._dedupe_artists(raw)
if len(raw) != len(artists):
logger.info(
f"Found {len(raw)} artists across all music sections "
f"({len(artists)} unique after cross-section dedup)"
)
else:
logger.info(f"Found {len(artists)} artists in Plex library")
return artists
except Exception as e:
logger.error(f"Error getting all artists: {e}")
return []
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._can_query():
return set()
try:
artists = self._all_artists()
ids = {str(a.ratingKey) for a in artists}
logger.info(f"Retrieved {len(ids)} artist IDs from Plex")
return ids
except Exception as e:
logger.error(f"Error getting artist IDs from Plex: {e}")
return set()
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._can_query():
return set()
try:
albums = self._all_albums()
ids = {str(a.ratingKey) for a in albums}
logger.info(f"Retrieved {len(ids)} album IDs from Plex")
return ids
except Exception as e:
logger.error(f"Error getting album IDs from Plex: {e}")
return set()
def update_artist_genres(self, artist: PlexArtist, genres: List[str]):
"""Update artist genres"""
try:
# Clear existing genres first
for genre in artist.genres:
artist.removeGenre(genre)
# Add new genres
for genre in genres:
artist.addGenre(genre)
# Use safe logging to avoid Unicode encoding errors
try:
logger.info(f"Updated genres for {artist.title}: {len(genres)} genres")
except UnicodeEncodeError:
logger.info(f"Updated genres for artist (ID: {artist.ratingKey}): {len(genres)} genres")
return True
except Exception as e:
logger.error(f"Error updating genres for {artist.title}: {e}")
return False
def update_artist_poster(self, artist: PlexArtist, image_data: bytes):
"""Update artist poster image"""
try:
# Upload poster using Plex API
upload_url = f"{self.server._baseurl}/library/metadata/{artist.ratingKey}/posters"
headers = {
'X-Plex-Token': self.server._token,
'Content-Type': 'image/jpeg'
}
response = requests.post(upload_url, data=image_data, headers=headers)
response.raise_for_status()
# Refresh artist to see changes
artist.refresh()
logger.info(f"Updated poster for {artist.title}")
return True
except Exception as e:
logger.error(f"Error updating poster for {artist.title}: {e}")
return False
def update_album_poster(self, album, image_data: bytes):
"""Update album poster image"""
try:
# Upload poster using Plex API
upload_url = f"{self.server._baseurl}/library/metadata/{album.ratingKey}/posters"
headers = {
'X-Plex-Token': self.server._token,
'Content-Type': 'image/jpeg'
}
response = requests.post(upload_url, data=image_data, headers=headers)
response.raise_for_status()
# Refresh album to see changes
album.refresh()
logger.info(f"Updated poster for album '{album.title}' by '{album.parentTitle}'")
return True
except Exception as e:
logger.error(f"Error updating poster for album '{album.title}': {e}")
return False
def parse_update_timestamp(self, artist: PlexArtist) -> Optional[datetime]:
"""Parse the last update timestamp from artist summary"""
try:
# Get artist summary which stores our timestamp
summary = getattr(artist, 'summary', '') or ''
# Look for timestamp pattern: -updatedAtYYYY-MM-DD
pattern = r'-updatedAt(\d{4}-\d{2}-\d{2})'
match = re.search(pattern, summary)
if match:
date_str = match.group(1)
parsed_date = datetime.strptime(date_str, '%Y-%m-%d')
return parsed_date
return None
except Exception as e:
logger.debug(f"Error parsing timestamp for {artist.title}: {e}")
return None
def is_artist_ignored(self, artist: PlexArtist) -> bool:
"""Check if artist is manually marked to be ignored"""
try:
# Check summary field where we store timestamps and ignore flags
summary = getattr(artist, 'summary', '') or ''
return '-IgnoreUpdate' in summary
except Exception as e:
logger.debug(f"Error checking ignore status for {artist.title}: {e}")
return False
def needs_update_by_age(self, artist: PlexArtist, refresh_interval_days: int) -> bool:
"""Check if artist needs updating based on age threshold"""
try:
# First check if artist is manually ignored
if self.is_artist_ignored(artist):
logger.debug(f"Artist {artist.title} is manually ignored")
return False
# If refresh_interval_days is 0, always update (full refresh)
if refresh_interval_days == 0:
return True
last_update = self.parse_update_timestamp(artist)
# If no timestamp found, needs update
if last_update is None:
return True
# Check if last update is older than threshold
threshold_date = datetime.now() - timedelta(days=refresh_interval_days)
return last_update < threshold_date
except Exception as e:
logger.debug(f"Error checking update age for {artist.title}: {e}")
return True # Default to needing update if error
def update_artist_biography(self, artist: PlexArtist) -> bool:
"""Update artist summary with current timestamp"""
try:
# Get current summary/biography
current_summary = getattr(artist, 'summary', '') or ''
# Preserve any IgnoreUpdate flag
ignore_flag = ''
if '-IgnoreUpdate' in current_summary:
ignore_flag = '-IgnoreUpdate'
# Remove IgnoreUpdate flag temporarily for processing
current_summary = current_summary.replace('-IgnoreUpdate', '').strip()
# Remove existing timestamp if present (ensures only one timestamp)
pattern = r'\s*-updatedAt\d{4}-\d{2}-\d{2}\s*'
clean_summary = re.sub(pattern, '', current_summary).strip()
# Build new summary with timestamp
today = datetime.now().strftime('%Y-%m-%d')
# Add timestamp to summary field
new_summary = clean_summary
if ignore_flag:
new_summary = f"{new_summary}\n\n{ignore_flag}".strip()
new_summary = f"{new_summary}\n\n-updatedAt{today}".strip()
# Use the correct Plex API syntax with .value
artist.edit(**{
'summary.value': new_summary
})
# Add a small delay to let the edit process
import time
time.sleep(0.5)
# Reload to see the changes
artist.reload()
# Check if edit worked
updated_summary = getattr(artist, 'summary', '') or ''
if updated_summary and '-updatedAt' in updated_summary:
logger.info(f"Updated summary timestamp for {artist.title}")
return True
else:
return False
except Exception as e:
logger.error(f"Error updating summary for {artist.title}: {e}")
return False
def update_track_metadata(self, track_id: str, metadata: Dict[str, Any]) -> bool:
if not self.ensure_connection():
return False
try:
track = self.server.fetchItem(int(track_id))
if isinstance(track, PlexTrack):
edits = {}
if 'title' in metadata:
edits['title'] = metadata['title']
if 'artist' in metadata:
edits['artist'] = metadata['artist']
if 'album' in metadata:
edits['album'] = metadata['album']
if 'year' in metadata:
edits['year'] = metadata['year']
if edits:
track.edit(**edits)
logger.info(f"Updated metadata for track: {track.title}")
return True
return False
except Exception as e:
logger.error(f"Error updating track metadata: {e}")
return False
def trigger_library_scan(self, library_name: str = "Music") -> bool:
"""Trigger Plex library scan.
In all-libraries mode, fans the scan trigger across every music
section. Otherwise targets the auto-detected music section
(``self.music_library`` — populated by ``_find_music_library``
which iterates by ``section.type == 'artist'`` and works
regardless of the section's display name). Falls back to
looking up by ``library_name`` only when auto-detection
hasn't run / failed (test fixtures, edge cases).
Pre-fix this method ignored ``self.music_library`` and always
called ``self.server.library.section(library_name)`` with the
hardcoded "Music" default — broke for any non-English section
name (Música, Musique, Musik, etc. — issue #535). Read
methods like ``get_artists`` already routed through
``_get_music_sections`` so they didn't have the bug; this
aligns the scan-trigger path with the same resolution.
Returns True if at least one section was successfully
triggered.
"""
if not self.ensure_connection():
return False
section_label = library_name
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
# Prefer the auto-detected music section. Falls back to a
# literal section lookup by library_name only when
# auto-detection hasn't populated the cached reference.
if self.music_library is not None:
library = self.music_library
section_label = library.title
else:
library = self.server.library.section(library_name)
section_label = library_name
library.update() # Non-blocking scan request
logger.info(f"Triggered Plex library scan for '{section_label}'")
return True
except Exception as e:
logger.error(f"Failed to trigger library scan for '{section_label}': {e}")
return False
def is_library_scanning(self, library_name: str = "Music") -> bool:
"""Check if Plex library is currently scanning.
In all-libraries mode, returns True if ANY music section is
scanning. Otherwise checks the auto-detected music section
first (same fix as ``trigger_library_scan`` — see #535) and
falls back to literal ``library_name`` lookup only when
auto-detection hasn't populated the cached reference.
"""
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
# Same resolution as trigger_library_scan — prefer the
# auto-detected section so non-English Plex section names
# (Música, Musique, Musik) don't break scan-status checks.
if self.music_library is not None:
library = self.music_library
else:
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.
# Match on the actual resolved section's title — NOT the
# `library_name` arg, which defaults to "Music" and would
# never match activities for non-English sections like
# "Música" / "Musique" / "Musik" (#535).
section_title = getattr(library, 'title', library_name)
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
section_title.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._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._search_artists_by_name(title=artist_name, limit=3)
for artist in artist_results:
try:
artist_albums = artist.albums()
for album in artist_albums:
if album_name.lower() in album.title.lower():
search_results.append(album)
except Exception as e:
logger.debug(f"Error getting albums for artist {artist.title}: {e}")
except Exception as e:
logger.debug(f"Artist search failed, trying general search: {e}")
# Fallback to general album search
try:
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._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._search_artists_by_name(title=artist_name, limit=1)
if artist_results:
search_results = artist_results[0].albums()
except Exception as e:
logger.debug(f"Artist album search failed: {e}")
else:
# Get all albums if no search terms
try:
search_results = self._all_albums()
except Exception as e:
logger.debug(f"Get all albums failed: {e}")
# Process results and convert to standardized format
if search_results:
for result in search_results:
if isinstance(result, PlexAlbum):
try:
# Get album info
album_info = {
'id': str(result.ratingKey),
'title': result.title,
'artist': result.artist().title if result.artist() else "Unknown Artist",
'year': result.year,
'track_count': len(result.tracks()) if hasattr(result, 'tracks') else 0,
'plex_album': result # Keep reference to original object
}
albums.append(album_info)
if len(albums) >= limit:
break
except Exception as e:
logger.debug(f"Error processing album {result.title}: {e}")
continue
logger.debug(f"Found {len(albums)} albums matching query: album='{album_name}', artist='{artist_name}'")
return albums
except Exception as e:
logger.error(f"Error searching albums: {e}")
return []
def get_album_by_name_and_artist(self, album_name: str, artist_name: str) -> Optional[Dict[str, Any]]:
"""Get a specific album by name and artist"""
albums = self.search_albums(album_name, artist_name, limit=5)
# Look for exact matches first
for album in albums:
if (album['title'].lower() == album_name.lower() and
album['artist'].lower() == artist_name.lower()):
return album
# Return first result if no exact match
return albums[0] if albums else None