diff --git a/core/jellyfin_client.py b/core/jellyfin_client.py index 5de73c75..1b2cef01 100644 --- a/core/jellyfin_client.py +++ b/core/jellyfin_client.py @@ -43,6 +43,9 @@ class JellyfinArtist: # Create summary property from Jellyfin data (used for timestamp storage) self.summary = jellyfin_data.get('Overview', '') 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 Jellyfin date string to datetime""" @@ -53,6 +56,14 @@ class JellyfinArtist: return datetime.fromisoformat(date_str.replace('Z', '+00:00')) except: return None + + def _get_artist_image_url(self) -> Optional[str]: + """Generate Jellyfin artist image URL""" + if not self.ratingKey: + return None + + # Jellyfin primary image URL format + return f"/Items/{self.ratingKey}/Images/Primary" def albums(self) -> List['JellyfinAlbum']: """Get all albums for this artist""" diff --git a/core/navidrome_client.py b/core/navidrome_client.py index aa55a67b..85084ec2 100644 --- a/core/navidrome_client.py +++ b/core/navidrome_client.py @@ -46,6 +46,9 @@ class NavidromeArtist: # 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: @@ -56,6 +59,14 @@ class NavidromeArtist: 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) diff --git a/web_server.py b/web_server.py index c9bb096c..89bf55cb 100644 --- a/web_server.py +++ b/web_server.py @@ -1535,6 +1535,51 @@ def get_status(): except Exception as e: return jsonify({'error': str(e)}), 500 +@app.route('/api/fix-navidrome-urls', methods=['POST']) +def fix_navidrome_urls(): + """Fix Navidrome artist image URLs to use correct Subsonic format""" + try: + db = get_database() + with db._get_connection() as conn: + cursor = conn.cursor() + + # Get all Navidrome artists with old URL format + cursor.execute('SELECT id, name, thumb_url FROM artists WHERE server_source = "navidrome" AND thumb_url LIKE "/api/artist/%"') + artists = cursor.fetchall() + + if not artists: + return jsonify({"status": "success", "message": "No URLs needed fixing", "updated": 0}) + + # Update URLs to new Subsonic format + import re + updated = 0 + examples = [] + + for artist_id, name, old_url in artists: + # Extract artist ID from old URL: /api/artist/ARTIST_ID/image + match = re.search(r'/api/artist/([^/]+)/image', old_url) + if match: + artist_spotify_id = match.group(1) + new_url = f'/rest/getCoverArt?id={artist_spotify_id}' + + cursor.execute('UPDATE artists SET thumb_url = ? WHERE id = ? AND server_source = "navidrome"', (new_url, artist_id)) + updated += 1 + + if len(examples) < 3: # Show first 3 as examples + examples.append(f'{name}: {old_url} -> {new_url}') + + conn.commit() + + return jsonify({ + "status": "success", + "message": f"Updated {updated} Navidrome artist URLs to Subsonic format", + "updated": updated, + "examples": examples + }) + + except Exception as e: + return jsonify({"status": "error", "message": str(e)}), 500 + @app.route('/api/system/stats') def get_system_stats(): """Get system statistics for dashboard""" @@ -2602,7 +2647,8 @@ def fix_artist_image_url(thumb_url): thumb_url.startswith('https://localhost:') or thumb_url.startswith('/library/') or # Plex relative paths thumb_url.startswith('/Items/') or # Jellyfin relative paths - thumb_url.startswith('/api/') # Navidrome API paths + thumb_url.startswith('/api/') or # Old Navidrome API paths + thumb_url.startswith('/rest/') # Navidrome Subsonic API paths ) if needs_fixing: @@ -2660,12 +2706,14 @@ def fix_artist_image_url(thumb_url): elif active_server == 'navidrome': navidrome_config = config_manager.get_navidrome_config() navidrome_base_url = navidrome_config.get('base_url', '') - print(f"🔧 Navidrome config - base_url: {navidrome_base_url}") + navidrome_username = navidrome_config.get('username', '') + navidrome_password = navidrome_config.get('password', '') + print(f"🔧 Navidrome config - base_url: {navidrome_base_url}, username: {navidrome_username}") - if navidrome_base_url: + if navidrome_base_url and navidrome_username and navidrome_password: # Extract the path from URL - if thumb_url.startswith('/api/'): - # Already a path + if thumb_url.startswith('/rest/'): + # Already a Subsonic API path path = thumb_url else: # Full localhost URL, extract path @@ -2673,8 +2721,18 @@ def fix_artist_image_url(thumb_url): parsed = urlparse(thumb_url) path = parsed.path - # Construct proper Navidrome URL - fixed_url = f"{navidrome_base_url.rstrip('/')}{path}" + # Generate Subsonic API authentication + import hashlib + import secrets + salt = secrets.token_hex(6) + token = hashlib.md5((navidrome_password + salt).encode()).hexdigest() + + # Add authentication parameters to the URL + separator = '&' if '?' in path else '?' + auth_params = f"u={navidrome_username}&t={token}&s={salt}&v=1.16.1&c=SoulSync&f=json" + + # Construct proper Navidrome Subsonic URL + fixed_url = f"{navidrome_base_url.rstrip('/')}{path}{separator}{auth_params}" print(f"🔧 Fixed URL: {fixed_url}") return fixed_url @@ -12354,17 +12412,19 @@ def start_oauth_callback_servers(): # =============================================== def get_spotify_artist_discography(artist_name): - """Get complete artist discography from Spotify""" + """Get complete artist discography from Spotify using proper matching""" try: from core.spotify_client import SpotifyClient + from core.matching_engine import MusicMatchingEngine print(f"🎵 Searching Spotify for artist: {artist_name}") - # Initialize Spotify client + # Initialize clients spotify_client = SpotifyClient() + matching_engine = MusicMatchingEngine() - # Search for the artist - artists = spotify_client.search_artists(artist_name, limit=1) + # Search for multiple potential matches (not just 1) + artists = spotify_client.search_artists(artist_name, limit=5) if not artists: return { @@ -12372,10 +12432,43 @@ def get_spotify_artist_discography(artist_name): 'error': f'Artist "{artist_name}" not found on Spotify' } - artist = artists[0] + # Since database names are exact Spotify names, try exact match first + best_match = None + highest_score = 0.0 + + # Step 1: Try exact case-insensitive match + for spotify_artist in artists: + if artist_name.lower().strip() == spotify_artist.name.lower().strip(): + print(f"🎯 Exact match found: '{spotify_artist.name}'") + best_match = spotify_artist + highest_score = 1.0 + break + + # Step 2: If no exact match, use matching engine with higher threshold + if not best_match: + db_artist_normalized = matching_engine.normalize_string(artist_name) + + for spotify_artist in artists: + spotify_artist_normalized = matching_engine.normalize_string(spotify_artist.name) + score = matching_engine.similarity_score(db_artist_normalized, spotify_artist_normalized) + + print(f"🔍 Fuzzy match candidate: '{spotify_artist.name}' (score: {score:.3f})") + + if score > highest_score: + highest_score = score + best_match = spotify_artist + + # Require high confidence threshold since DB should have exact names + if not best_match or highest_score < 0.95: + return { + 'success': False, + 'error': f'No confident artist match found for "{artist_name}" (best: "{getattr(best_match, "name", "N/A")}", score: {highest_score:.3f})' + } + + artist = best_match spotify_artist_id = artist.id - print(f"🎵 Found Spotify artist: {artist.name} (ID: {spotify_artist_id})") + print(f"🎵 Found Spotify artist: {artist.name} (ID: {spotify_artist_id}, confidence: {highest_score:.3f})") # Get all albums (albums, singles, and compilations) all_albums = spotify_client.get_artist_albums(spotify_artist_id, album_type='album,single,compilation', limit=50) @@ -12505,9 +12598,9 @@ def merge_discography_data(owned_releases, spotify_discography): # Calculate track completion using Spotify track count spotify_track_count = spotify_release.get('track_count', 0) - owned_track_count = owned_release.get('track_count', 0) + owned_track_count = owned_release.get('track_count') or 0 - if spotify_track_count > 0: + if spotify_track_count > 0 and owned_track_count is not None: completion_percentage = (owned_track_count / spotify_track_count) * 100 owned_release['track_completion'] = { 'owned_tracks': owned_track_count,