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/navidrome_client.py

1125 lines
44 KiB

import requests
import hashlib
import secrets
from typing import List, Optional, Dict, Any
from dataclasses import dataclass
from datetime import datetime
import json
from utils.logging_config import get_logger
from config.settings import config_manager
logger = get_logger("navidrome_client")
@dataclass
class NavidromeTrackInfo:
id: str
title: str
artist: str
album: str
duration: int
track_number: Optional[int] = None
year: Optional[int] = None
rating: Optional[float] = None
@dataclass
class NavidromePlaylistInfo:
id: str
title: str
description: Optional[str]
duration: int
leaf_count: int
tracks: List[NavidromeTrackInfo]
class NavidromeArtist:
"""Wrapper class to mimic Plex artist object interface"""
def __init__(self, navidrome_data: Dict[str, Any], client: 'NavidromeClient'):
self._data = navidrome_data
self._client = client
self.ratingKey = navidrome_data.get('id', '')
self.title = navidrome_data.get('name', 'Unknown Artist')
self.addedAt = self._parse_date(navidrome_data.get('dateAdded'))
# Create genres property from Navidrome data
self.genres = []
# TODO: Map Navidrome genre data to match Plex format
# Create summary property (used for timestamp storage)
self.summary = navidrome_data.get('biography', '') or ''
# Create thumb property for artist images
self.thumb = self._get_artist_image_url()
def _parse_date(self, date_str: Optional[str]) -> Optional[datetime]:
"""Parse Navidrome date string to datetime"""
if not date_str:
return None
try:
# Navidrome uses ISO format
return datetime.fromisoformat(date_str.replace('Z', '+00:00'))
except:
return None
def _get_artist_image_url(self) -> Optional[str]:
"""Generate Navidrome artist image URL using Subsonic getCoverArt API"""
if not self.ratingKey:
return None
# Subsonic getCoverArt API for artist images
return f"/rest/getCoverArt?id={self.ratingKey}"
def albums(self) -> List['NavidromeAlbum']:
"""Get all albums for this artist"""
return self._client.get_albums_for_artist(self.ratingKey)
class NavidromeAlbum:
"""Wrapper class to mimic Plex album object interface"""
def __init__(self, navidrome_data: Dict[str, Any], client: 'NavidromeClient'):
self._data = navidrome_data
self._client = client
self.ratingKey = navidrome_data.get('id', '')
self.title = navidrome_data.get('name', 'Unknown Album')
self.year = navidrome_data.get('year')
self.addedAt = self._parse_date(navidrome_data.get('created'))
self._artist_id = navidrome_data.get('artistId', '')
def _parse_date(self, date_str: Optional[str]) -> Optional[datetime]:
if not date_str:
return None
try:
return datetime.fromisoformat(date_str.replace('Z', '+00:00'))
except:
return None
def artist(self) -> Optional[NavidromeArtist]:
"""Get the album artist"""
if self._artist_id:
return self._client.get_artist_by_id(self._artist_id)
return None
def tracks(self) -> List['NavidromeTrack']:
"""Get all tracks for this album"""
return self._client.get_tracks_for_album(self.ratingKey)
class NavidromeTrack:
"""Wrapper class to mimic Plex track object interface"""
def __init__(self, navidrome_data: Dict[str, Any], client: 'NavidromeClient'):
self._data = navidrome_data
self._client = client
self.ratingKey = navidrome_data.get('id', '')
self.title = navidrome_data.get('title', 'Unknown Track')
self.duration = navidrome_data.get('duration', 0) * 1000 # Convert to milliseconds
self.trackNumber = navidrome_data.get('track')
self.year = navidrome_data.get('year')
self.userRating = navidrome_data.get('userRating')
self.addedAt = self._parse_date(navidrome_data.get('created'))
# Subsonic API file/quality fields
self.suffix = navidrome_data.get('suffix') # e.g. "flac", "mp3"
self.bitRate = navidrome_data.get('bitRate') # e.g. 320
self.path = navidrome_data.get('path') # e.g. "/music/Artist/Album/track.flac"
self._album_id = navidrome_data.get('albumId', '')
self._artist_id = navidrome_data.get('artistId', '')
def _parse_date(self, date_str: Optional[str]) -> Optional[datetime]:
if not date_str:
return None
try:
return datetime.fromisoformat(date_str.replace('Z', '+00:00'))
except:
return None
def artist(self) -> Optional[NavidromeArtist]:
"""Get the track artist"""
if self._artist_id:
return self._client.get_artist_by_id(self._artist_id)
return None
def album(self) -> Optional[NavidromeAlbum]:
"""Get the track's album"""
if self._album_id:
return self._client.get_album_by_id(self._album_id)
return None
class NavidromeClient:
def __init__(self):
self.base_url: Optional[str] = None
self.username: Optional[str] = None
self.password: Optional[str] = None
self.music_folder_id: Optional[str] = None
self._connection_attempted = False
self._is_connecting = False
# Cache for performance
self._artist_cache = {}
self._album_cache = {}
self._track_cache = {}
# Progress callback for UI updates
self._progress_callback = None
def set_progress_callback(self, callback):
"""Set callback function for progress updates"""
self._progress_callback = callback
def reload_config(self):
"""Reset connection state so next ensure_connection() re-reads config."""
self.base_url = None
self.username = None
self.password = None
self.music_folder_id = None
self._connection_attempted = False
self._artist_cache.clear()
self._album_cache.clear()
self._track_cache.clear()
logger.info("🔄 Navidrome client config reset — will reconnect with new settings")
def get_music_folders(self) -> list:
"""Get available music folders from Navidrome."""
if not self.ensure_connection():
return []
try:
response = self._make_request('getMusicFolders')
if not response:
return []
folders_data = response.get('musicFolders', {})
folder_list = folders_data.get('musicFolder', [])
if isinstance(folder_list, dict):
folder_list = [folder_list]
return [{'title': f.get('name', 'Unknown'), 'key': str(f.get('id', ''))} for f in folder_list]
except Exception as e:
logger.error(f"Error getting music folders: {e}")
return []
def set_music_folder_by_name(self, folder_name: str) -> bool:
"""Set the active music folder by name."""
try:
folders = self.get_music_folders()
for folder in folders:
if folder['title'] == folder_name:
self.music_folder_id = folder['key']
logger.info(f"Set music folder to: {folder_name} (ID: {self.music_folder_id})")
from database.music_database import MusicDatabase
db = MusicDatabase()
db.set_preference('navidrome_music_folder', folder_name)
return True
# If folder_name is empty, clear the selection
if not folder_name:
self.music_folder_id = None
from database.music_database import MusicDatabase
db = MusicDatabase()
db.set_preference('navidrome_music_folder', '')
logger.info("Cleared music folder selection — will use all libraries")
return True
logger.warning(f"Music folder '{folder_name}' not found")
return False
except Exception as e:
logger.error(f"Error setting music folder: {e}")
return False
def ensure_connection(self) -> bool:
"""Ensure connection to Navidrome server with lazy initialization."""
if self._connection_attempted:
return self.base_url is not None and self.username is not None
if self._is_connecting:
return False
self._is_connecting = True
try:
self._setup_client()
return self.base_url is not None and self.username is not None
finally:
self._is_connecting = False
self._connection_attempted = True
def _setup_client(self):
"""Setup Navidrome client configuration"""
config = config_manager.get_navidrome_config()
if not config.get('base_url'):
logger.warning("Navidrome server URL not configured")
return
if not config.get('username') or not config.get('password'):
logger.warning("Navidrome username/password not configured")
return
self.base_url = config['base_url'].rstrip('/')
self.username = config['username']
self.password = config['password']
try:
# Test connection with ping
response = self._make_request('ping')
if response and response.get('status') == 'ok':
server_version = response.get('version', 'Unknown')
logger.info(f"Successfully connected to Navidrome server version: {server_version}")
# Restore saved music folder preference
try:
from database.music_database import MusicDatabase
db = MusicDatabase()
saved_folder = db.get_preference('navidrome_music_folder')
if saved_folder:
folders = self.get_music_folders()
for folder in folders:
if folder['title'] == saved_folder:
self.music_folder_id = folder['key']
logger.info(f"Restored music folder preference: {saved_folder} (ID: {self.music_folder_id})")
break
except Exception as e:
logger.warning(f"Could not restore music folder preference: {e}")
else:
logger.error(f"Failed to connect to Navidrome server at {self.base_url}/rest/ping — check URL and network connectivity")
self.base_url = None
self.username = None
self.password = None
except Exception as e:
logger.error(f"Failed to connect to Navidrome server at {self.base_url}/rest/ping: {e}")
self.base_url = None
self.username = None
self.password = None
def _generate_auth_params(self) -> Dict[str, str]:
"""Generate authentication parameters for Subsonic API"""
if not self.username or not self.password:
return {}
# Generate random salt (at least 6 characters)
salt = secrets.token_hex(8)
# Calculate token: md5(password + salt)
token = hashlib.md5((self.password + salt).encode()).hexdigest()
return {
'u': self.username,
't': token,
's': salt,
'v': '1.16.1', # API version
'c': 'SoulSync', # Client name
'f': 'json' # Response format
}
# Subsonic endpoints that modify data — use POST to avoid URL length limits
_WRITE_ENDPOINTS = frozenset({
'createPlaylist', 'updatePlaylist', 'deletePlaylist',
'star', 'unstar', 'scrobble', 'setRating',
'createShare', 'updateShare', 'deleteShare',
'createUser', 'updateUser', 'deleteUser',
'createBookmark', 'deleteBookmark',
'startScan',
})
def _make_request(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
"""Make authenticated request to Navidrome Subsonic API.
Uses POST for write operations (avoids URL length limits with large playlists)."""
if not self.base_url or not self.username:
return None
url = f"{self.base_url}/rest/{endpoint}"
# Add authentication parameters
auth_params = self._generate_auth_params()
if params:
auth_params.update(params)
try:
# Use POST for write operations to avoid URL length limits
# (e.g., createPlaylist with 161 songId params would exceed GET URL limits)
if endpoint in self._WRITE_ENDPOINTS:
response = requests.post(url, data=auth_params, timeout=30)
else:
response = requests.get(url, params=auth_params, timeout=60)
response.raise_for_status()
data = response.json()
# Check for Subsonic API errors
subsonic_response = data.get('subsonic-response', {})
if subsonic_response.get('status') == 'failed':
error = subsonic_response.get('error', {})
error_message = error.get('message', 'Unknown error')
logger.error(f"Navidrome API error: {error_message}")
return None
return subsonic_response
except requests.exceptions.RequestException as e:
logger.error(f"Navidrome API request failed for {url}: {e}")
return None
except json.JSONDecodeError as e:
logger.error(f"Failed to parse Navidrome response: {e}")
return None
def is_connected(self) -> bool:
"""Check if connected to Navidrome server"""
if not self._connection_attempted:
if not self._is_connecting:
self.ensure_connection()
return (self.base_url is not None and
self.username is not None and
self.password is not None)
def get_all_artists(self) -> List[NavidromeArtist]:
"""Get all artists from the music library"""
if not self.ensure_connection():
logger.error("Not connected to Navidrome server")
return []
try:
if self._progress_callback:
self._progress_callback("Fetching artists from Navidrome...")
params = {}
if self.music_folder_id:
params['musicFolderId'] = self.music_folder_id
response = self._make_request('getArtists', params if params else None)
if not response:
return []
if self._progress_callback:
self._progress_callback("Processing artist data...")
artists = []
indexes = response.get('artists', {}).get('index', [])
total_indexes = len(indexes)
for i, index in enumerate(indexes):
if self._progress_callback and total_indexes > 1:
progress_pct = int((i / total_indexes) * 100)
self._progress_callback(f"Processing artist index {i+1}/{total_indexes} ({progress_pct}%)")
for artist_data in index.get('artist', []):
artist = NavidromeArtist(artist_data, self)
# Cache the artist for quick lookup
self._artist_cache[artist.ratingKey] = artist
artists.append(artist)
if self._progress_callback:
self._progress_callback(f"Retrieved {len(artists)} artists from Navidrome")
logger.info(f"Retrieved {len(artists)} artists from Navidrome")
return artists
except Exception as e:
logger.error(f"Error getting artists from Navidrome: {e}")
return []
def get_all_artist_ids(self) -> set:
"""Get all artist IDs from Navidrome (lightweight, for removal detection)."""
if not self.ensure_connection():
return set()
try:
params = {}
if self.music_folder_id:
params['musicFolderId'] = self.music_folder_id
response = self._make_request('getArtists', params if params else None)
if not response:
return set()
ids = set()
for index in response.get('artists', {}).get('index', []):
for artist_data in index.get('artist', []):
aid = artist_data.get('id')
if aid:
ids.add(str(aid))
logger.info(f"Retrieved {len(ids)} artist IDs from Navidrome (lightweight)")
return ids
except Exception as e:
logger.error(f"Error getting artist IDs from Navidrome: {e}")
return set()
def get_all_album_ids(self) -> set:
"""Get all album IDs from Navidrome (lightweight, paginated, for removal detection)."""
if not self.ensure_connection():
return set()
try:
all_ids = set()
page_size = 500
offset = 0
while True:
params = {
'type': 'alphabeticalByName',
'size': page_size,
'offset': offset
}
if self.music_folder_id:
params['musicFolderId'] = self.music_folder_id
response = self._make_request('getAlbumList2', params)
if not response:
break
album_list = response.get('albumList2', {}).get('album', [])
if not album_list:
break
for album_data in album_list:
aid = album_data.get('id')
if aid:
all_ids.add(str(aid))
if len(album_list) < page_size:
break
offset += page_size
logger.info(f"Retrieved {len(all_ids)} album IDs from Navidrome (lightweight)")
return all_ids
except Exception as e:
logger.error(f"Error getting album IDs from Navidrome: {e}")
return set()
def get_albums_for_artist(self, artist_id: str) -> List[NavidromeAlbum]:
"""Get all albums for a specific artist"""
# Check cache first
if artist_id in self._album_cache:
return self._album_cache[artist_id]
if not self.ensure_connection():
return []
try:
# Get artist name for progress display
artist_name = "Unknown Artist"
if hasattr(self, '_artist_cache'):
for cached_artist in self._artist_cache.values():
if getattr(cached_artist, 'ratingKey', None) == artist_id:
artist_name = getattr(cached_artist, 'title', 'Unknown Artist')
break
if self._progress_callback:
self._progress_callback(f"Fetching albums for artist {artist_name}...")
response = self._make_request('getArtist', {'id': artist_id})
if not response:
return []
albums = []
artist_data = response.get('artist', {})
album_list = artist_data.get('album', [])
if self._progress_callback and album_list:
self._progress_callback(f"Processing {len(album_list)} albums...")
for album_data in album_list:
albums.append(NavidromeAlbum(album_data, self))
# Cache the result
self._album_cache[artist_id] = albums
return albums
except Exception as e:
logger.error(f"Error getting albums for artist {artist_id}: {e}")
return []
def get_recently_added_albums(self, limit: int = 400) -> List[NavidromeAlbum]:
"""Get recently added albums from Navidrome using getAlbumList2 sorted by newest"""
if not self.ensure_connection():
return []
try:
albums = []
page_size = min(limit, 500)
offset = 0
while len(albums) < limit:
params = {
'type': 'newest',
'size': page_size,
'offset': offset
}
if self.music_folder_id:
params['musicFolderId'] = self.music_folder_id
response = self._make_request('getAlbumList2', params)
if not response:
break
album_list = response.get('albumList2', {}).get('album', [])
if not album_list:
break
for album_data in album_list:
albums.append(NavidromeAlbum(album_data, self))
if len(album_list) < page_size:
break # No more pages
offset += page_size
logger.info(f"Retrieved {len(albums)} recently added albums from Navidrome")
return albums[:limit]
except Exception as e:
logger.error(f"Error getting recently added albums from Navidrome: {e}")
return []
def get_tracks_for_album(self, album_id: str) -> List[NavidromeTrack]:
"""Get all tracks for a specific album"""
# Check cache first
if album_id in self._track_cache:
return self._track_cache[album_id]
if not self.ensure_connection():
return []
try:
# Get album name for progress display
album_name = "Unknown Album"
if hasattr(self, '_album_cache'):
for artist_albums in self._album_cache.values():
for cached_album in artist_albums:
if getattr(cached_album, 'ratingKey', None) == album_id:
album_name = getattr(cached_album, 'title', 'Unknown Album')
break
if album_name != "Unknown Album":
break
if self._progress_callback:
self._progress_callback(f"Fetching tracks for album {album_name}...")
response = self._make_request('getAlbum', {'id': album_id})
if not response:
return []
tracks = []
album_data = response.get('album', {})
track_list = album_data.get('song', [])
if self._progress_callback and track_list:
self._progress_callback(f"Processing {len(track_list)} tracks...")
for track_data in track_list:
tracks.append(NavidromeTrack(track_data, self))
# Cache the result
self._track_cache[album_id] = tracks
return tracks
except Exception as e:
logger.error(f"Error getting tracks for album {album_id}: {e}")
return []
def get_artist_by_id(self, artist_id: str) -> Optional[NavidromeArtist]:
"""Get a specific artist by ID"""
# Check cache first
if artist_id in self._artist_cache:
return self._artist_cache[artist_id]
if not self.ensure_connection():
return None
try:
response = self._make_request('getArtist', {'id': artist_id})
if response and 'artist' in response:
artist = NavidromeArtist(response['artist'], self)
# Cache for future use
self._artist_cache[artist_id] = artist
return artist
return None
except Exception as e:
logger.error(f"Error getting artist {artist_id}: {e}")
return None
def get_album_by_id(self, album_id: str) -> Optional[NavidromeAlbum]:
"""Get a specific album by ID"""
if not self.ensure_connection():
return None
try:
response = self._make_request('getAlbum', {'id': album_id})
if response and 'album' in response:
return NavidromeAlbum(response['album'], self)
return None
except Exception as e:
logger.error(f"Error getting album {album_id}: {e}")
return None
def get_library_stats(self) -> Dict[str, int]:
"""Get library statistics"""
if not self.ensure_connection():
return {}
try:
# Get counts by making API calls
stats = {}
# Get artist count
artists = self.get_all_artists()
stats['artists'] = len(artists)
# For albums and tracks, we'd need to iterate through all artists
# This is expensive, so let's use reasonable estimates or make separate calls
# For now, return what we can efficiently get
stats['albums'] = 0
stats['tracks'] = 0
# TODO: Implement more efficient counting if Navidrome provides bulk stats
return stats
except Exception as e:
logger.error(f"Error getting library stats: {e}")
return {}
def get_play_history(self, limit=500):
"""Fetch recently played tracks via Subsonic API.
Uses getAlbumList2 with type=recent to get recently played albums,
then fetches tracks for each with their play dates.
Returns list of dicts with: track_title, artist, album, played_at,
duration_ms, track_id.
"""
if not self.ensure_connection():
return []
try:
response = self._make_request('getAlbumList2', {
'type': 'recent',
'size': min(limit, 500),
})
if not response:
return []
album_list = response.get('albumList2', {}).get('album', [])
if not isinstance(album_list, list):
album_list = [album_list] if album_list else []
results = []
for album in album_list[:50]: # Limit album fetches to avoid API spam
album_id = album.get('id')
if not album_id:
continue
album_resp = self._make_request('getAlbum', {'id': album_id})
if not album_resp:
continue
songs = album_resp.get('album', {}).get('song', [])
if not isinstance(songs, list):
songs = [songs] if songs else []
for song in songs:
played = song.get('played')
if not played:
continue
results.append({
'track_title': song.get('title', ''),
'artist': song.get('artist', ''),
'album': song.get('album', ''),
'played_at': played,
'duration_ms': int(song.get('duration', 0)) * 1000,
'track_id': song.get('id', ''),
})
logger.info(f"Retrieved {len(results)} play history entries from Navidrome")
return results
except Exception as e:
logger.error(f"Error getting Navidrome play history: {e}")
return []
def get_track_play_counts(self):
"""Get play counts for tracks via Subsonic API.
Uses getAlbumList2 type=frequent to find most-played albums,
then reads playCount from each track.
Returns dict of {track_id: play_count}.
"""
if not self.ensure_connection():
return {}
try:
response = self._make_request('getAlbumList2', {
'type': 'frequent',
'size': 500,
})
if not response:
return {}
album_list = response.get('albumList2', {}).get('album', [])
if not isinstance(album_list, list):
album_list = [album_list] if album_list else []
counts = {}
for album in album_list[:100]:
album_id = album.get('id')
if not album_id:
continue
album_resp = self._make_request('getAlbum', {'id': album_id})
if not album_resp:
continue
songs = album_resp.get('album', {}).get('song', [])
if not isinstance(songs, list):
songs = [songs] if songs else []
for song in songs:
pc = song.get('playCount', 0)
if pc and pc > 0:
counts[song.get('id', '')] = pc
logger.info(f"Retrieved play counts for {len(counts)} tracks from Navidrome")
return counts
except Exception as e:
logger.error(f"Error getting Navidrome track play counts: {e}")
return {}
return {}
def get_all_playlists(self) -> List[NavidromePlaylistInfo]:
"""Get all playlists from Navidrome server"""
if not self.ensure_connection():
return []
try:
response = self._make_request('getPlaylists')
if not response:
return []
playlists = []
playlists_data = response.get('playlists', {}).get('playlist', [])
for playlist_data in playlists_data:
playlist_info = NavidromePlaylistInfo(
id=playlist_data.get('id', ''),
title=playlist_data.get('name', 'Unknown Playlist'),
description=playlist_data.get('comment'),
duration=playlist_data.get('duration', 0) * 1000, # Convert to milliseconds
leaf_count=playlist_data.get('songCount', 0),
tracks=[] # Will be populated when needed
)
playlists.append(playlist_info)
logger.info(f"Retrieved {len(playlists)} playlists from Navidrome")
return playlists
except Exception as e:
logger.error(f"Error getting playlists from Navidrome: {e}")
return []
def get_playlist_by_name(self, name: str) -> Optional[NavidromePlaylistInfo]:
"""Get a specific playlist by name"""
playlists = self.get_all_playlists()
for playlist in playlists:
if playlist.title.lower() == name.lower():
return playlist
return None
def create_playlist(self, name: str, tracks, playlist_id: str = None) -> bool:
"""Create a new playlist or update existing one if playlist_id provided"""
if not self.ensure_connection():
return False
try:
# Convert tracks to Navidrome track IDs
track_ids = []
for track in tracks:
if hasattr(track, 'ratingKey'):
track_ids.append(str(track.ratingKey))
elif hasattr(track, 'id'):
track_ids.append(str(track.id))
if not track_ids:
logger.warning(f"No valid tracks provided for playlist '{name}'")
return False
logger.info(f"{'Updating' if playlist_id else 'Creating'} Navidrome playlist '{name}' with {len(track_ids)} tracks")
# Create/Update playlist params
params = {
'name': name,
'songId': track_ids # Subsonic API accepts multiple songId parameters
}
# If playlist_id is provided, it acts as an overwrite/update
if playlist_id:
params['playlistId'] = playlist_id
response = self._make_request('createPlaylist', params)
if response and response.get('status') == 'ok':
logger.info(f"{'Updated' if playlist_id else 'Created'} Navidrome playlist '{name}' with {len(track_ids)} tracks")
return True
else:
logger.error(f"Failed to {'update' if playlist_id else 'create'} Navidrome playlist '{name}'")
return False
except Exception as e:
logger.error(f"Error {'updating' if playlist_id else 'creating'} Navidrome 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.get_playlist_by_name(source_name)
if not source_playlist:
logger.error(f"Source playlist '{source_name}' not found")
return False
# Get tracks from source playlist
source_tracks = self.get_playlist_tracks(source_playlist.id)
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
# Delete target playlist if it exists (for overwriting backup)
try:
target_playlist = self.get_playlist_by_name(target_name)
if target_playlist:
self._make_request('deletePlaylist', {'id': target_playlist.id})
logger.info(f"Deleted existing backup playlist '{target_name}'")
except Exception:
pass # Target doesn't exist, which is fine
# Create new playlist with copied tracks
try:
success = self.create_playlist(target_name, source_tracks)
if success:
logger.info(f"✅ Created backup playlist '{target_name}' with {len(source_tracks)} tracks")
return True
else:
logger.error(f"Failed to create backup playlist '{target_name}'")
return False
except Exception as create_error:
logger.error(f"Failed to create backup playlist: {create_error}")
return False
except Exception as e:
logger.error(f"Error copying playlist '{source_name}' to '{target_name}': {e}")
return False
def get_playlist_tracks(self, playlist_id: str) -> List[NavidromeTrack]:
"""Get all tracks from a specific playlist"""
if not self.ensure_connection():
return []
try:
response = self._make_request('getPlaylist', {'id': playlist_id})
if not response:
return []
tracks = []
playlist_data = response.get('playlist', {})
for track_data in playlist_data.get('entry', []):
tracks.append(NavidromeTrack(track_data, self))
logger.debug(f"Retrieved {len(tracks)} tracks from playlist {playlist_id}")
return tracks
except Exception as e:
logger.error(f"Error getting tracks for playlist {playlist_id}: {e}")
return []
def get_playlists_by_name(self, name: str) -> List[NavidromePlaylistInfo]:
"""Get all playlists matching a specific name (case-insensitive)"""
matches = []
playlists = self.get_all_playlists()
for playlist in playlists:
if playlist.title.lower() == name.lower():
matches.append(playlist)
return matches
def update_playlist(self, playlist_name: str, tracks) -> bool:
"""Update an existing playlist or create it if it doesn't exist. Handles duplicates."""
if not self.ensure_connection():
return False
try:
# Find ALL existing playlists with this name to handle duplicates
existing_playlists = self.get_playlists_by_name(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 we have existing playlists and want to backup, use the first one found
if existing_playlists and create_backup:
backup_name = f"{playlist_name} Backup"
logger.info(f"🛡️ Creating backup playlist '{backup_name}' before sync")
# We only need to backup once, even if duplicates exist
if self.copy_playlist(playlist_name, backup_name):
logger.info(f"✅ Backup created successfully")
else:
logger.warning(f"⚠️ Failed to create backup, continuing with sync")
# STRATEGY: Update the first match, delete the rest
if existing_playlists:
primary_playlist = existing_playlists[0]
duplicates = existing_playlists[1:]
if duplicates:
logger.info(f"Found {len(duplicates)} duplicate playlists for '{playlist_name}'. Cleaning them up...")
for dup in duplicates:
try:
self._make_request('deletePlaylist', {'id': dup.id})
logger.info(f"Deleted duplicate playlist '{playlist_name}' (ID: {dup.id})")
except Exception as del_err:
logger.error(f"Error deleting duplicate playlist '{playlist_name}': {del_err}")
# Update the primary playlist using overwrite (passing playlistId)
logger.info(f"Updating existing playlist '{playlist_name}' (ID: {primary_playlist.id})")
return self.create_playlist(playlist_name, tracks, playlist_id=primary_playlist.id)
else:
# No existing playlist, create new
logger.info(f"Creating new playlist '{playlist_name}'")
return self.create_playlist(playlist_name, tracks)
except Exception as e:
logger.error(f"Error updating Navidrome playlist '{playlist_name}': {e}")
return False
def trigger_library_scan(self, library_name: str = "Music") -> bool:
"""Trigger Navidrome library scan via Subsonic startScan endpoint."""
try:
result = self._make_request('startScan')
if result is not None:
logger.info("Navidrome library scan triggered")
return True
logger.warning("Navidrome startScan returned no response")
return False
except Exception as e:
logger.error(f"Failed to trigger Navidrome scan: {e}")
return False
def is_library_scanning(self, library_name: str = "Music") -> bool:
"""Check if Navidrome library is currently scanning via Subsonic getScanStatus."""
try:
result = self._make_request('getScanStatus')
if result is not None:
scan_status = result.get('scanStatus', {})
return scan_status.get('scanning', False)
return False
except Exception:
return False
# Metadata update methods for compatibility with metadata updater
def update_artist_genres(self, artist, genres: List[str]):
"""Update artist genres - not implemented for Navidrome"""
try:
logger.debug(f"Genre update not implemented for Navidrome artist: {artist.title}")
return True
except Exception as e:
logger.error(f"Error updating genres for {artist.title}: {e}")
return False
def update_artist_poster(self, artist, image_data: bytes):
"""Update artist poster image - not implemented for Navidrome"""
try:
logger.debug(f"Poster update not implemented for Navidrome artist: {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 - not implemented for Navidrome"""
try:
logger.debug(f"Poster update not implemented for Navidrome album: {album.title}")
return True
except Exception as e:
logger.error(f"Error updating poster for album {album.title}: {e}")
return False
def update_artist_biography(self, artist) -> bool:
"""Update artist biography - not implemented for Navidrome"""
try:
logger.debug(f"Biography update not implemented for Navidrome artist: {artist.title}")
return True
except Exception as e:
logger.error(f"Error updating biography for {artist.title}: {e}")
return False
def needs_update_by_age(self, artist, refresh_interval_days: int) -> bool:
"""Check if artist needs updating based on age threshold - simplified for Navidrome"""
try:
# For now, just return True for all artists since we don't have timestamp tracking yet
return True
except Exception as e:
logger.debug(f"Error checking update age for {artist.title}: {e}")
return True
def is_artist_ignored(self, artist) -> bool:
"""Check if artist is manually marked to be ignored - simplified for Navidrome"""
try:
# For now, no artists are ignored
return False
except Exception as e:
logger.debug(f"Error checking ignore status for {artist.title}: {e}")
return False
def parse_update_timestamp(self, artist) -> Optional[datetime]:
"""Parse the last update timestamp from artist summary - not implemented for Navidrome"""
try:
return None
except Exception as e:
logger.debug(f"Error parsing timestamp for {artist.title}: {e}")
return None
def get_cache_stats(self):
"""Get cache statistics for debugging/logging"""
return {
'artists_cached': len(self._artist_cache),
'albums_cached': len(self._album_cache),
'tracks_cached': len(self._track_cache),
'bulk_albums_cached': len(self._album_cache), # For compatibility with Jellyfin interface
'bulk_tracks_cached': len(self._track_cache) # For compatibility with Jellyfin interface
}
def clear_cache(self):
"""Clear all caches to force fresh data on next request"""
self._artist_cache.clear()
self._album_cache.clear()
self._track_cache.clear()
logger.info("Navidrome client cache cleared")
def search_tracks(self, title: str, artist: str, limit: int = 15) -> List[NavidromeTrackInfo]:
"""Search for tracks using Navidrome search API"""
if not self.ensure_connection():
logger.warning("Navidrome not connected. Cannot perform search.")
return []
try:
# Use Subsonic search3 API for music search
query = f"{artist} {title}".strip()
params = {
'query': query,
'songCount': limit,
'artistCount': 0,
'albumCount': 0
}
if self.music_folder_id:
params['musicFolderId'] = self.music_folder_id
response = self._make_request('search3', params)
if not response:
return []
tracks = []
search_result = response.get('searchResult3', {})
for track_data in search_result.get('song', []):
track_info = NavidromeTrackInfo(
id=track_data.get('id', ''),
title=track_data.get('title', ''),
artist=track_data.get('artist', ''),
album=track_data.get('album', ''),
duration=track_data.get('duration', 0) * 1000, # Convert to milliseconds
track_number=track_data.get('track'),
year=track_data.get('year'),
rating=track_data.get('userRating')
)
# Store reference to original track for playlist creation
track_info._original_navidrome_track = NavidromeTrack(track_data, self)
tracks.append(track_info)
logger.info(f"Found {len(tracks)} tracks for '{title}' by '{artist}'")
return tracks
except Exception as e:
logger.error(f"Error searching for tracks: {e}")
return []