import logging import os import queue import subprocess import threading import requests from datetime import datetime from flask import Flask, jsonify, request from flask_socketio import SocketIO from flask_cors import CORS from spotipy import Spotify from spotipy.oauth2 import SpotifyClientCredentials from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') # Configuration class Config: def __init__(self): # Obtain credentials from environment variables for flexibility self.client_id = os.getenv("SPOTIFY_CLIENT_ID", "") self.client_secret = os.getenv("SPOTIFY_CLIENT_SECRET", "") self.jellyfin_url = os.getenv("JELLYFIN_URL", "") self.jellyfin_api_key = os.getenv("JELLYFIN_API_KEY", "") if not self.client_id or not self.client_secret: logging.error("Spotify credentials missing: set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET") if not self.jellyfin_url or not self.jellyfin_api_key: logging.error("Jellyfin credentials missing: set JELLYFIN_URL and JELLYFIN_API_KEY") self.search_limit = 25 class SpotifyService: def __init__(self, config): self.config = config self.sp = None if self.config.client_id and self.config.client_secret: try: client_credentials_manager = SpotifyClientCredentials( client_id=self.config.client_id, client_secret=self.config.client_secret ) self.sp = Spotify(client_credentials_manager=client_credentials_manager) logging.info("Spotify service initialized successfully") except Exception as e: logging.error(f"Failed to initialize Spotify service: {e}") self.sp = None else: logging.warning("Spotify credentials not provided - Spotify features will be disabled") def perform_spotify_search(self, search_req): if not self.sp: logging.warning("Spotify service not available - search disabled") return {} try: query_type = search_req.get("type", "track") query = search_req.get("query") search_type = "album,artist,playlist,track" if query_type == "all" else query_type logging.info(f"Search query: {query}, Type: {search_type}") results = self.sp.search(q=query, limit=self.config.search_limit, type=search_type) return self.parse_spotify_data(results) except Exception as e: logging.error(f"Spotify Search Error: {str(e)}") return {} def _extract_year(self, release_date): """Extract year from release date string.""" if not release_date: return None try: # Handle different date formats (YYYY, YYYY-MM, YYYY-MM-DD) return int(release_date.split('-')[0]) except (ValueError, IndexError): return None def parse_spotify_data(self, results): parsed_results = {"tracks": [], "albums": [], "artists": [], "playlists": []} try: # Parsing tracks if "tracks" in results: for item in results["tracks"]["items"]: # Safely extract artist name artist_name = "Unknown Artist" if item.get("artists") and len(item["artists"]) > 0 and item["artists"][0].get("name"): artist_name = item["artists"][0]["name"] # Safely extract album image album_image = None if item.get("album") and item["album"].get("images") and len(item["album"]["images"]) > 0: album_image = item["album"]["images"][0]["url"] parsed_results["tracks"].append({ "id": item["id"], "type": "track", "name": item["name"], "artist": artist_name, "album": item["album"]["name"], "url": item["external_urls"]["spotify"], "image": album_image, "duration": item["duration_ms"], }) # Parsing albums if "albums" in results: for item in results["albums"]["items"]: # Safely extract artist name album_artist_name = "Unknown Artist" if item.get("artists") and len(item["artists"]) > 0 and item["artists"][0].get("name"): album_artist_name = item["artists"][0]["name"] # Safely extract album image album_image_url = None if item.get("images") and len(item["images"]) > 0: album_image_url = item["images"][0]["url"] parsed_results["albums"].append({ "id": item["id"], "type": "album", "name": item["name"], "artist": album_artist_name, "url": item["external_urls"]["spotify"], "image": album_image_url, "total_tracks": item["total_tracks"], "release_date": item["release_date"], }) # Parsing artists if "artists" in results: for item in results["artists"]["items"]: # Safely extract artist image artist_image_url = None if item.get("images") and len(item["images"]) > 0: artist_image_url = item["images"][0]["url"] # Safely extract followers count followers_count = 0 if item.get("followers") and item["followers"].get("total"): followers_count = item["followers"]["total"] parsed_results["artists"].append({ "id": item["id"], "type": "artist", "name": item["name"], "url": item["external_urls"]["spotify"], "image": artist_image_url, "followers": followers_count, }) # Parsing playlists if "playlists" in results: for item in results["playlists"]["items"]: # Safely extract playlist owner name playlist_owner = "Unknown Owner" if item.get("owner") and item["owner"].get("display_name"): playlist_owner = item["owner"]["display_name"] # Safely extract playlist image playlist_image_url = None if item.get("images") and len(item["images"]) > 0: playlist_image_url = item["images"][0]["url"] # Safely extract tracks total tracks_total = 0 if item.get("tracks") and item["tracks"].get("total"): tracks_total = item["tracks"]["total"] parsed_results["playlists"].append({ "id": item["id"], "type": "playlist", "name": item["name"], "owner": playlist_owner, "url": item["external_urls"]["spotify"], "image": playlist_image_url, "tracks_total": tracks_total, }) return {k: v for k, v in parsed_results.items() if v} # Only return non-empty results except Exception as e: logging.error(f"Error parsing Spotify data: {str(e)}") return {} def _format_track_data(self, track): """Format track data consistently with popularity scoring.""" # Safely extract artist name artist_name = "Unknown Artist" if track.get("artists") and len(track["artists"]) > 0 and track["artists"][0].get("name"): artist_name = track["artists"][0]["name"] # Safely extract album image album_image = None if track.get("album") and track["album"].get("images") and len(track["album"]["images"]) > 0: album_image = track["album"]["images"][0]["url"] # Safely extract album name album_name = "Unknown Album" if track.get("album") and track["album"].get("name"): album_name = track["album"]["name"] return { 'id': track['id'], 'name': track['name'], 'artist': artist_name, 'album': album_name, 'url': track['external_urls']['spotify'], 'image': album_image, 'duration': track['duration_ms'], 'popularity': track.get('popularity', 0), 'artist_popularity': track.get('artists', [{}])[0].get('popularity', 0) if track.get('artists') else 0 } def _sort_by_popularity(self, tracks): """Sort tracks by combined popularity score.""" def popularity_score(track): track_pop = track.get('popularity', 0) artist_pop = track.get('artist_popularity', 0) # Weight track popularity more heavily than artist popularity return (track_pop * 0.7) + (artist_pop * 0.3) return sorted(tracks, key=popularity_score, reverse=True) def _sort_by_recency_and_popularity(self, tracks): """Sort tracks by release recency combined with popularity.""" from datetime import datetime def recency_popularity_score(track): # Get popularity scores track_pop = track.get('popularity', 0) artist_pop = track.get('artist_popularity', 0) popularity_score = (track_pop * 0.6) + (artist_pop * 0.2) # Add recency bonus based on album release date recency_bonus = 0 if track.get('album') and isinstance(track['album'], dict): release_date = track['album'].get('release_date', '') if release_date: try: # Parse release date (handle different formats) if len(release_date) == 4: # Year only release_year = int(release_date) current_year = datetime.now().year if release_year == current_year: recency_bonus = 20 elif release_year == current_year - 1: recency_bonus = 10 elif len(release_date) >= 7: # YYYY-MM or YYYY-MM-DD release_dt = datetime.strptime(release_date[:7], "%Y-%m") current_dt = datetime.now() months_diff = (current_dt.year - release_dt.year) * 12 + (current_dt.month - release_dt.month) if months_diff <= 1: recency_bonus = 30 elif months_diff <= 3: recency_bonus = 20 elif months_diff <= 6: recency_bonus = 10 except (ValueError, TypeError): pass return popularity_score + recency_bonus return sorted(tracks, key=recency_popularity_score, reverse=True) def _format_album_data(self, album): """Format album data consistently with popularity scoring.""" # Safely extract artist name artist_name = "Unknown Artist" if album.get("artists") and len(album["artists"]) > 0 and album["artists"][0].get("name"): artist_name = album["artists"][0]["name"] # Safely extract album image album_image = None if album.get("images") and len(album["images"]) > 0: album_image = album["images"][0]["url"] return { 'id': album['id'], 'name': album['name'], 'artist': artist_name, 'url': album['external_urls']['spotify'], 'image': album_image, 'total_tracks': album.get('total_tracks', 0), 'release_date': album.get('release_date', ''), 'popularity': album.get('popularity', 0), 'artist_popularity': album.get('artists', [{}])[0].get('popularity', 0) if album.get('artists') else 0 } def _sort_albums_by_popularity(self, albums): """Sort albums by combined popularity and recency score.""" from datetime import datetime def album_score(album): # Get popularity scores album_pop = album.get('popularity', 0) artist_pop = album.get('artist_popularity', 0) popularity_score = (album_pop * 0.6) + (artist_pop * 0.3) # Add recency bonus recency_bonus = 0 release_date = album.get('release_date', '') if release_date: try: if len(release_date) == 4: # Year only release_year = int(release_date) current_year = datetime.now().year if release_year == current_year: recency_bonus = 15 elif release_year == current_year - 1: recency_bonus = 8 elif len(release_date) >= 7: # YYYY-MM or YYYY-MM-DD release_dt = datetime.strptime(release_date[:7], "%Y-%m") current_dt = datetime.now() months_diff = (current_dt.year - release_dt.year) * 12 + (current_dt.month - release_dt.month) if months_diff <= 1: recency_bonus = 25 elif months_diff <= 3: recency_bonus = 15 elif months_diff <= 6: recency_bonus = 8 except (ValueError, TypeError): pass return popularity_score + recency_bonus return sorted(albums, key=album_score, reverse=True) def get_recommendations(self, seed_tracks, limit=20): """Get song recommendations from Spotify based on seed tracks with configurable limit.""" if not seed_tracks: return [] # If Spotify service is not available, return empty recommendations if not self.sp: logging.warning("Spotify service not available - recommendations disabled") return [] seed_track_ids = [] for track in seed_tracks: if len(seed_track_ids) >= 5: # Spotify API has a limit of 5 seeds break # Find the Spotify ID for each seed track query = f"artist:{track['artist']} track:{track['track']}" search_result = self.perform_spotify_search({'query': query, 'type': 'track'}) if search_result and search_result.get('tracks') and search_result['tracks']: first_track = search_result['tracks'][0] if first_track and first_track.get('id'): spotify_track_id = first_track['id'] seed_track_ids.append(spotify_track_id) # Safely extract artist and track name for logging artist_name = track.get('artist', 'Unknown Artist') track_name = track.get('track', 'Unknown Track') logging.info(f"Found Spotify seed track ID: {spotify_track_id} for {artist_name} - {track_name}") if not seed_track_ids: logging.warning("Could not find any Spotify track IDs for the seed tracks.") return [] try: logging.info(f"Getting Spotify recommendations with seed IDs: {seed_track_ids}") recommendations = self.sp.recommendations( seed_tracks=seed_track_ids, limit=min(limit, 100), # Spotify max is 100 market='US' # Required parameter for recommendations API ) # The recommendations response is a list of full track objects, just like search results # We can wrap it to match the structure our parser expects return self.parse_spotify_data({'tracks': {'items': recommendations['tracks']}}) except Exception as e: logging.error(f"Error getting Spotify recommendations: {e}") # Fallback: Use similar artists search instead of recommendations API return self._get_fallback_recommendations(seed_tracks, limit) def _get_fallback_recommendations(self, seed_tracks, limit=20): """Fallback method when recommendations API fails - use search with similar artists.""" try: fallback_tracks = [] artists_used = set() for track in seed_tracks[:3]: # Use first 3 tracks as seeds artist = track.get('artist', '') if artist and artist not in artists_used: artists_used.add(artist) # Search for tracks by the same artist query = f"artist:{artist}" search_result = self.perform_spotify_search({'query': query, 'type': 'track'}) if search_result and search_result.get('tracks'): # Take a few tracks from this artist for track_item in search_result['tracks'][:3]: if len(fallback_tracks) < limit: fallback_tracks.append(track_item) logging.info(f"Generated {len(fallback_tracks)} fallback recommendations") return {'tracks': fallback_tracks} except Exception as e: logging.error(f"Error in fallback recommendations: {e}") return [] def get_for_you_section(self, recently_played, limit=20): """Get personalized recommendations based on listening history with configurable limit.""" try: if recently_played: personalized = self.get_recommendations(recently_played, limit=limit) if personalized and personalized.get('tracks'): return { 'title': 'Made For You', 'description': 'Based on your listening history', 'tracks': self._transform_to_media_items(personalized['tracks']) } return { 'title': 'Made For You', 'description': 'Based on your listening history', 'tracks': [] } except Exception as e: logging.error(f"Error getting for you section: {e}") return { 'title': 'Made For You', 'description': 'Based on your listening history', 'tracks': [] } def get_new_releases_section(self, limit=100): """Get new releases section with improved algorithm for truly fresh content.""" try: from datetime import datetime, timedelta new_releases = [] current_date = datetime.now() # Strategy 1: Get tracks from "New Music Friday" and similar playlists try: new_music_queries = [ 'New Music Friday', 'Fresh Finds', 'New Releases', 'Latest Hits' ] for playlist_query in new_music_queries: if len(new_releases) >= limit: break playlist_search = self.sp.search(q=playlist_query, type='playlist', limit=1) if playlist_search and playlist_search.get('playlists', {}).get('items'): playlist_id = playlist_search['playlists']['items'][0]['id'] tracks = self.sp.playlist_tracks(playlist_id, limit=min(25, limit - len(new_releases))) for item in tracks.get('items', []): if len(new_releases) >= limit: break if item.get('track') and item['track'].get('id'): track = item['track'] if track['id'] not in [t.get('id') for t in new_releases]: # Avoid duplicates new_releases.append(self._format_track_data(track)) if len(new_releases) >= limit // 2: # Got enough from new music playlists break except Exception as e: logging.warning(f"Failed to get new music playlists: {e}") # Strategy 2: Search for tracks from the last 14 days if len(new_releases) < limit: try: # Search for very recent releases for days_back in [7, 14, 21]: if len(new_releases) >= limit: break date_from = (current_date - timedelta(days=days_back)).strftime("%Y-%m-%d") date_to = current_date.strftime("%Y-%m-%d") recent_search = self.sp.search( q=f'tag:new year:{current_date.year}', type='track', limit=min(20, limit - len(new_releases)) ) if recent_search and recent_search.get('tracks', {}).get('items'): for track in recent_search['tracks']['items']: if len(new_releases) >= limit: break if track['id'] not in [t.get('id') for t in new_releases]: # Avoid duplicates new_releases.append(self._format_track_data(track)) if len(new_releases) >= limit * 0.8: # Got most of what we need break except Exception as e: logging.warning(f"Failed to get recent releases: {e}") # Strategy 3: Fill remaining with featured playlists (fallback) if len(new_releases) < limit: try: featured = self.sp.featured_playlists(limit=2) if featured and featured.get('playlists', {}).get('items'): for playlist in featured['playlists']['items']: if len(new_releases) >= limit: break playlist_tracks = self.sp.playlist_tracks(playlist['id'], limit=min(15, limit - len(new_releases))) if playlist_tracks and playlist_tracks.get('items'): for item in playlist_tracks['items']: if len(new_releases) >= limit: break if item.get('track') and item['track'].get('id'): track = item['track'] if track['id'] not in [t.get('id') for t in new_releases]: # Avoid duplicates new_releases.append(self._format_track_data(track)) except Exception as e: logging.warning(f"Failed to get featured playlists: {e}") # Sort by release recency and popularity new_releases = self._sort_by_recency_and_popularity(new_releases) return { 'title': 'New Releases', 'description': 'Fresh music to discover', 'tracks': self._transform_to_media_items(new_releases[:limit]) } except Exception as e: logging.error(f"Error getting new releases section: {e}") return { 'title': 'New Releases', 'description': 'Fresh music to discover', 'tracks': [] } def get_trending_section(self, limit=100): """Get trending section with improved algorithms for current popular content.""" try: from datetime import datetime current_year = datetime.now().year current_month = datetime.now().month trending_tracks = [] # Strategy 1: Get tracks from Spotify's top charts playlists try: # Search for official Spotify charts playlists chart_queries = [ 'Top 50 Global', 'Today\'s Top Hits', 'Global Top 50', 'Hot Hits' ] for chart_query in chart_queries: if len(trending_tracks) >= limit: break playlist_search = self.sp.search(q=chart_query, type='playlist', limit=1) if playlist_search and playlist_search.get('playlists', {}).get('items'): playlist_id = playlist_search['playlists']['items'][0]['id'] tracks = self.sp.playlist_tracks(playlist_id, limit=min(20, limit - len(trending_tracks))) for item in tracks.get('items', []): if len(trending_tracks) >= limit: break if item.get('track') and item['track'].get('id'): track = item['track'] if track['id'] not in [t.get('id') for t in trending_tracks]: # Avoid duplicates trending_tracks.append(self._format_track_data(track)) if len(trending_tracks) >= limit // 2: # Got enough from charts break except Exception as e: logging.warning(f"Failed to get chart playlists: {e}") # Strategy 2: Fill remaining with current year popular tracks with popularity scoring if len(trending_tracks) < limit: try: # Use multiple search strategies for diverse results search_queries = [ f'year:{current_year}', f'year:{current_year-1}', 'tag:popular', 'tag:trending' ] for query in search_queries: if len(trending_tracks) >= limit: break popular_search = self.sp.search(q=query, type='track', limit=min(25, limit - len(trending_tracks))) if popular_search and popular_search.get('tracks', {}).get('items'): for track in popular_search['tracks']['items']: if len(trending_tracks) >= limit: break if track['id'] not in [t.get('id') for t in trending_tracks]: # Avoid duplicates trending_tracks.append(self._format_track_data(track)) except Exception as e: logging.warning(f"Failed to get popular tracks: {e}") # Sort by popularity score (track popularity + artist popularity) trending_tracks = self._sort_by_popularity(trending_tracks) return { 'title': 'Trending Now', 'description': 'Popular tracks everyone is listening to', 'tracks': self._transform_to_media_items(trending_tracks[:limit]) } except Exception as e: logging.error(f"Error getting trending section: {e}") return { 'title': 'Trending Now', 'description': 'Popular tracks everyone is listening to', 'tracks': [] } def get_trending_albums_section(self): """Get trending albums section with improved algorithms for current popular albums.""" try: from datetime import datetime current_year = datetime.now().year trending_albums = [] # Strategy 1: Get albums from popular playlists and charts try: album_playlist_queries = [ 'New Albums', 'Hot Albums', 'Popular Albums', 'Album Chart' ] for playlist_query in album_playlist_queries: if len(trending_albums) >= 30: # Get more for diversity break playlist_search = self.sp.search(q=playlist_query, type='playlist', limit=1) if playlist_search and playlist_search.get('playlists', {}).get('items'): playlist_id = playlist_search['playlists']['items'][0]['id'] tracks = self.sp.playlist_tracks(playlist_id, limit=20) # Extract unique albums from playlist tracks album_ids_seen = set() for item in tracks.get('items', []): if len(trending_albums) >= 30: break if item.get('track') and item['track'].get('album'): album = item['track']['album'] if album.get('id') and album['id'] not in album_ids_seen: album_ids_seen.add(album['id']) trending_albums.append(self._format_album_data(album)) except Exception as e: logging.warning(f"Failed to get album playlists: {e}") # Strategy 2: Search for popular albums by year and popularity if len(trending_albums) < 30: try: # Use multiple search strategies for diverse album results search_queries = [ f'year:{current_year}', f'year:{current_year-1}', 'tag:popular', 'tag:new' ] for query in search_queries: if len(trending_albums) >= 50: break album_search = self.sp.search(q=query, type='album', limit=min(20, 50 - len(trending_albums))) if album_search and album_search.get('albums', {}).get('items'): for album in album_search['albums']['items']: if len(trending_albums) >= 50: break if album['id'] not in [a.get('id') for a in trending_albums]: # Avoid duplicates trending_albums.append(self._format_album_data(album)) except Exception as e: logging.warning(f"Failed to get popular albums: {e}") # Sort by popularity and recency trending_albums = self._sort_albums_by_popularity(trending_albums) return { 'title': 'Popular Albums', 'description': 'Popular albums everyone is listening to', 'albums': self._transform_to_media_items(trending_albums[:50], item_type='album') } except Exception as e: logging.error(f"Error getting trending albums section: {e}") return { 'title': 'Popular Albums', 'description': 'Popular albums everyone is listening to', 'albums': [] } def get_new_albums_section(self): """Get new album releases section.""" try: # Search for new album releases (last 30 days) from datetime import datetime, timedelta thirty_days_ago = datetime.now() - timedelta(days=30) new_albums = self.sp.search(q=f'date:{thirty_days_ago.strftime("%Y-%m-%d")} TO {datetime.now().strftime("%Y-%m-%d")}', type='album', limit=50) # More albums for "See More" page if new_albums and new_albums.get('albums', {}).get('items'): new_releases = [] for album in new_albums['albums']['items']: # Safely extract artist name artist_name = "Unknown Artist" if album.get("artists") and len(album["artists"]) > 0 and album["artists"][0].get("name"): artist_name = album["artists"][0]["name"] # Safely extract album image album_image = None if album.get("images") and len(album["images"]) > 0: album_image = album["images"][0]["url"] new_releases.append({ 'id': album['id'], 'name': album['name'], 'artist': artist_name, 'url': album['external_urls']['spotify'], 'image': album_image, 'total_tracks': album.get('total_tracks', 0), 'release_date': album.get('release_date', '') }) return { 'title': 'New Album Releases', 'description': 'Fresh albums released in the last 30 days', 'albums': self._transform_to_media_items(new_releases, item_type='album') } return { 'title': 'New Album Releases', 'description': 'Fresh albums released in the last 30 days', 'albums': [] } except Exception as e: logging.error(f"Error getting new albums section: {e}") return { 'title': 'New Album Releases', 'description': 'Fresh albums released in the last 30 days', 'albums': [] } def get_genres_section(self): """Get genre-based recommendations section.""" try: genres = ['hip-hop', 'pop', 'rock', 'r-n-b', 'electronic', 'indie'] genre_sections = [] for genre in genres: try: # Get recommendations for each genre genre_recs = self.sp.recommendations(seed_genres=[genre], limit=20) # More tracks for "See More" page if genre_recs and genre_recs.get('tracks'): genre_tracks = [] for track in genre_recs['tracks']: genre_tracks.append({ 'id': track['id'], 'name': track['name'], 'artist': track['artists'][0]['name'], 'album': track['album']['name'], 'url': track['external_urls']['spotify'], 'image': track['album']['images'][0]['url'] if track['album']['images'] else None, 'duration': track['duration_ms'] }) genre_sections.append({ 'genre': genre.title(), 'title': f'{genre.title()} Picks', 'tracks': self._transform_to_media_items(genre_tracks) }) except Exception as e: logging.warning(f"Failed to get {genre} recommendations: {str(e)}") continue return genre_sections except Exception as e: logging.error(f"Error getting genres section: {e}") return [] def get_genre_section(self, genre_name, limit=40): """Get tracks for a specific genre with configurable limit.""" try: # Convert genre name back to the format used by Spotify genre_map = { 'Hip-Hop': 'hip-hop', 'Pop': 'pop', 'Rock': 'rock', 'R-N-B': 'r-n-b', 'Electronic': 'electronic', 'Indie': 'indie', 'Afrobeats': 'afrobeats', 'Amapiano': 'amapiano', 'Dancehall': 'dancehall' } spotify_genre = genre_map.get(genre_name, genre_name.lower()) # Handle special genres that might not be in Spotify's genre seeds if spotify_genre in ['afrobeats', 'amapiano']: # Use search instead of recommendations for these genres search_results = self.sp.search(q=f'genre:{spotify_genre}', type='track', limit=limit) if search_results and search_results.get('tracks', {}).get('items'): tracks_data = search_results['tracks']['items'] else: # Fallback: search by genre name in track/artist names search_results = self.sp.search(q=spotify_genre, type='track', limit=limit) tracks_data = search_results.get('tracks', {}).get('items', []) if search_results else [] else: # Get recommendations for standard genres genre_recs = self.sp.recommendations(seed_genres=[spotify_genre], limit=limit) tracks_data = genre_recs.get('tracks', []) if genre_recs else [] if tracks_data: genre_tracks = [] for track in tracks_data: # Safely extract artist name artist_name = "Unknown Artist" if track.get("artists") and len(track["artists"]) > 0 and track["artists"][0].get("name"): artist_name = track["artists"][0]["name"] # Safely extract album image album_image = None if track.get("album") and track["album"].get("images") and len(track["album"]["images"]) > 0: album_image = track["album"]["images"][0]["url"] genre_tracks.append({ 'id': track['id'], 'name': track['name'], 'artist': artist_name, 'album': track['album']['name'], 'url': track['external_urls']['spotify'], 'image': album_image, 'duration': track['duration_ms'] }) return { 'title': f'{genre_name} Picks', 'tracks': self._transform_to_media_items(genre_tracks) } return { 'title': f'{genre_name} Picks', 'tracks': [] } except Exception as e: logging.error(f"Error getting genre section for {genre_name}: {e}") return { 'title': f'{genre_name} Picks', 'tracks': [] } def get_comprehensive_discovery(self, recently_played): """Get comprehensive discovery data with multiple sections.""" try: discovery_sections = {} # 1. Personalized recommendations based on listening history logging.info("Starting personalized recommendations section") if recently_played and len(recently_played) > 0: logging.info(f"Using {len(recently_played)} tracks for personalized recommendations") personalized = self.get_recommendations(recently_played) if personalized and personalized.get('tracks'): discovery_sections['for_you'] = { 'title': 'Made For You', 'description': 'Based on your listening history', 'tracks': self._transform_to_media_items(personalized['tracks'][:10]) } logging.info(f"Generated {len(discovery_sections['for_you']['tracks'])} personalized tracks") else: logging.warning("Failed to get personalized recommendations from Spotify") else: logging.info("No listening history available, creating fallback 'Made For You' section") # Create fallback recommendations using improved trending algorithm try: from datetime import datetime current_year = datetime.now().year fallback_search = self.sp.search(q=f'year:{current_year}', type='track', limit=10) if fallback_search and fallback_search.get('tracks', {}).get('items'): fallback_tracks = [] for track in fallback_search['tracks']['items']: fallback_tracks.append({ 'id': track['id'], 'name': track['name'], 'artist': track['artists'][0]['name'] if track['artists'] else 'Unknown Artist', 'album': track['album']['name'], 'url': track['external_urls']['spotify'], 'image': track['album']['images'][0]['url'] if track['album']['images'] else None, 'duration': track['duration_ms'] }) discovery_sections['for_you'] = { 'title': 'Made For You', 'description': 'Popular tracks to get you started', 'tracks': self._transform_to_media_items(fallback_tracks) } logging.info(f"Generated {len(discovery_sections['for_you']['tracks'])} fallback tracks") except Exception as e: logging.error(f"Failed to create fallback recommendations: {e}") logging.info("Completed personalized recommendations section") # 2. Genre-based recommendations with popular genres including Caribbean and African logging.info("Starting genre-based recommendations section") genres = ['afrobeats', 'amapiano', 'dancehall', 'electronic', 'hip-hop', 'pop', 'rock'] discovery_sections['genres'] = [] for genre in genres: try: logging.info(f"Getting recommendations for genre: {genre}") genre_tracks = [] # Handle special genres that might not be in Spotify's genre seeds if genre in ['afrobeats', 'amapiano']: # Use search instead of recommendations for these genres search_results = self.sp.search(q=f'genre:{genre}', type='track', limit=10) if search_results and search_results.get('tracks', {}).get('items'): tracks_data = search_results['tracks']['items'][:10] else: # Fallback: search by genre name in track/artist names search_results = self.sp.search(q=genre, type='track', limit=10) tracks_data = search_results.get('tracks', {}).get('items', [])[:10] if search_results else [] else: # Use recommendations for standard genres genre_recs = self.sp.recommendations(seed_genres=[genre], limit=10) tracks_data = genre_recs.get('tracks', [])[:10] if genre_recs else [] if tracks_data: for track in tracks_data: # Safely extract artist name artist_name = "Unknown Artist" if track.get("artists") and len(track["artists"]) > 0 and track["artists"][0].get("name"): artist_name = track["artists"][0]["name"] # Safely extract album image album_image = None if track.get("album") and track["album"].get("images") and len(track["album"]["images"]) > 0: album_image = track["album"]["images"][0]["url"] # Safely extract album name album_name = "Unknown Album" if track.get("album") and track["album"].get("name"): album_name = track["album"]["name"] genre_tracks.append({ 'id': track['id'], 'name': track['name'], 'artist': artist_name, 'album': album_name, 'url': track['external_urls']['spotify'], 'image': album_image, 'duration': track['duration_ms'] }) discovery_sections['genres'].append({ 'genre': genre.title(), 'title': f'{genre.title()} Picks', 'tracks': self._transform_to_media_items(genre_tracks) }) logging.info(f"Successfully added {len(genre_tracks)} tracks for genre: {genre}") except Exception as e: logging.warning(f"Failed to get {genre} recommendations: {str(e)}") continue logging.info("Completed genre-based recommendations section") # 3. New releases (using improved new releases algorithm) logging.info("Starting new releases section") try: new_releases_section = self.get_new_releases_section(limit=10) if new_releases_section and new_releases_section.get('tracks'): discovery_sections['new_releases'] = new_releases_section logging.info(f"Successfully added {len(new_releases_section['tracks'])} tracks for new releases") else: logging.warning("Failed to get new releases from improved algorithm") except Exception as e: logging.warning(f"Failed to get new releases: {str(e)}") logging.info("Completed new releases section") # 4. Trending/Popular (using improved trending algorithm) logging.info("Starting trending tracks section") try: trending_section = self.get_trending_section(limit=10) if trending_section and trending_section.get('tracks'): discovery_sections['trending'] = trending_section logging.info(f"Successfully added {len(trending_section['tracks'])} tracks for trending section") else: logging.warning("Failed to get trending tracks from improved algorithm") except Exception as e: logging.warning(f"Failed to get trending tracks: {str(e)}") logging.info("Completed trending tracks section") # 5. Popular albums (using improved trending albums algorithm) logging.info("Starting popular albums section") try: trending_albums_section = self.get_trending_albums_section() if trending_albums_section and trending_albums_section.get('albums'): # Limit to 10 items for the main discovery view discovery_sections['trending_albums'] = { 'title': trending_albums_section['title'], 'description': trending_albums_section['description'], 'albums': trending_albums_section['albums'][:10] } logging.info(f"Successfully added {len(discovery_sections['trending_albums']['albums'])} albums for popular albums section") else: # Create a fallback section discovery_sections['trending_albums'] = { 'title': 'Popular Albums', 'description': 'Popular albums everyone is listening to', 'albums': [] } logging.warning("Failed to get trending albums from improved algorithm") except Exception as e: logging.warning(f"Failed to get trending albums: {str(e)}") # Create a fallback section in case of error discovery_sections['trending_albums'] = { 'title': 'Popular Albums', 'description': 'Popular albums everyone is listening to', 'albums': [] } logging.info("Completed popular albums section") # 6. New album releases - 10 items logging.info("Starting new album releases section") try: # Search for new album releases (last 30 days) from datetime import datetime, timedelta thirty_days_ago = datetime.now() - timedelta(days=30) new_albums = self.sp.search(q=f'date:{thirty_days_ago.strftime("%Y-%m-%d")} TO {datetime.now().strftime("%Y-%m-%d")}', type='album', limit=10) if new_albums and new_albums.get('albums', {}).get('items'): new_releases = [] for album in new_albums['albums']['items'][:10]: # 10 albums # Safely extract artist name artist_name = "Unknown Artist" if album.get("artists") and len(album["artists"]) > 0 and album["artists"][0].get("name"): artist_name = album["artists"][0]["name"] # Safely extract album image album_image = None if album.get("images") and len(album["images"]) > 0: album_image = album["images"][0]["url"] new_releases.append({ 'id': album['id'], 'name': album['name'], 'artist': artist_name, 'url': album['external_urls']['spotify'], 'image': album_image, 'total_tracks': album.get('total_tracks', 0), 'release_date': album.get('release_date', '') }) discovery_sections['new_albums'] = { 'title': 'New Album Releases', 'description': 'Fresh albums released in the last 30 days', 'albums': self._transform_to_media_items(new_releases, item_type='album') } logging.info(f"Successfully added {len(new_releases)} albums for new album releases section") else: # If no results, create a fallback section discovery_sections['new_albums'] = { 'title': 'New Album Releases', 'description': 'Fresh albums released in the last 30 days', 'albums': [] } except Exception as e: logging.warning(f"Failed to get new album releases: {str(e)}") # Create a fallback section in case of error discovery_sections['new_albums'] = { 'title': 'New Album Releases', 'description': 'Fresh albums released in the last 30 days', 'albums': [] } logging.info("Completed new album releases section") logging.info(f"Successfully generated comprehensive discovery data with {len(discovery_sections)} sections.") return discovery_sections except Exception as e: logging.error(f"Error in comprehensive discovery: {str(e)}") return {} def _transform_to_media_items(self, spotify_items, item_type='track'): """Transform Spotify data to MediaItem format.""" transformed_items = [] for item in spotify_items: if item_type == 'album': media_item = { 'Id': item.get('id', ''), 'Name': item.get('name', 'Unknown Album'), 'Type': 'MusicAlbum', 'MediaType': 'Audio', 'Artists': [item.get('artist', 'Unknown Artist')], 'AlbumArtist': item.get('artist', 'Unknown Artist'), 'ImageTags': {'Primary': 'spotify_image'}, 'UserData': {'IsFavorite': False, 'PlayCount': 0}, 'SpotifyUrl': item.get('url', ''), 'SpotifyImage': item.get('image', ''), 'SpotifyId': item.get('id', ''), 'ChildCount': item.get('total_tracks', 0), 'ProductionYear': self._extract_year(item.get('release_date', '')), 'IsFolder': True } else: # track media_item = { 'Id': item.get('id', ''), 'Name': item.get('name', 'Unknown Track'), 'Type': 'Audio', 'MediaType': 'Audio', 'Artists': [item.get('artist', 'Unknown Artist')], 'Album': item.get('album', 'Unknown Album'), 'AlbumArtist': item.get('artist', 'Unknown Artist'), 'RunTimeTicks': item.get('duration', 0) * 10000, 'ImageTags': {'Primary': 'spotify_image'}, 'UserData': {'IsFavorite': False, 'PlayCount': 0}, 'SpotifyUrl': item.get('url', ''), 'SpotifyImage': item.get('image', ''), 'SpotifyId': item.get('id', '') } transformed_items.append(media_item) return transformed_items # Initialize Flask & SocketIO app = Flask(__name__) app.config['SECRET_KEY'] = 'secret!' # Enable CORS for all routes CORS(app, resources={ r"/*": { "origins": ["http://localhost:5143", "http://127.0.0.1:5143", "https://music.samcloud.ca", "https://samcloud.ca"], "methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"], "allow_headers": ["Content-Type", "Authorization"], "supports_credentials": True } }) socketio = SocketIO(app, cors_allowed_origins="*") # Initialize global variables download_queue = queue.Queue() download_history = {} active_downloads = {} class JellyfinService: def __init__(self, config): self.config = config self.base_url = config.jellyfin_url self.api_key = config.jellyfin_api_key self.user_id = None # We'll fetch this dynamically self.headers = { 'Authorization': f'MediaBrowser Token="{self.api_key}"', 'Content-Type': 'application/json' } def _get_user_id(self): """Fetch the Jellyfin User ID for the admin user.""" if self.user_id: return self.user_id try: response = requests.get(f"{self.base_url}/Users", headers=self.headers) response.raise_for_status() users = response.json() # Assuming the first admin user is the target for user in users: if user.get('Policy', {}).get('IsAdministrator'): self.user_id = user['Id'] logging.info(f"Found Jellyfin admin user ID: {self.user_id}") return self.user_id logging.error("Could not find an admin user in Jellyfin.") return None except requests.exceptions.RequestException as e: logging.error(f"Error fetching Jellyfin users: {e}") return None def get_recently_played(self, limit=20): """Fetch listening history for music discovery recommendations.""" user_id = self._get_user_id() if not user_id: return [] try: # Try multiple approaches to get listening history played_tracks = [] # Approach 1: Get items sorted by last played date url = f"{self.base_url}/Users/{user_id}/Items?SortBy=DatePlayed&SortOrder=Descending&IncludeItemTypes=Audio&Limit={limit}&Recursive=true&Fields=DatePlayed" response = requests.get(url, headers=self.headers) response.raise_for_status() data = response.json() items = data.get('Items', []) logging.info(f"Found {len(items)} items from Jellyfin.") # If no items with DatePlayed, fall back to recently added items as seed data if not items: logging.info("No played items found, falling back to recently added items.") url = f"{self.base_url}/Users/{user_id}/Items/Latest?IncludeItemTypes=Audio&Limit={limit}" response = requests.get(url, headers=self.headers) response.raise_for_status() items = response.json() # Extract artist/track info for Spotify recommendations for item in items: # Check for audio items if item.get('Type') == 'Audio' or item.get('MediaType') == 'Audio': # Try multiple ways to get artist name artist = ( item.get('AlbumArtist') or (item.get('Artists', [None])[0] if item.get('Artists') and len(item.get('Artists', [])) > 0 else None) or (item.get('ArtistItems', [{}])[0].get('Name') if item.get('ArtistItems') and len(item.get('ArtistItems', [])) > 0 else None) or 'Unknown Artist' ) track = item.get('Name', 'Unknown Track') played_tracks.append({ 'artist': artist, 'track': track }) logging.info(f"Extracted {len(played_tracks)} tracks for recommendation seeds.") return played_tracks except requests.exceptions.RequestException as e: logging.error(f"Error fetching listening history from Jellyfin: {e}") return [] # Initialize Services config = Config() spotify_service = SpotifyService(config) jellyfin_service = JellyfinService(config) # Routes @app.route('/api/discover', methods=['GET']) def discover(): """ Comprehensive music discovery endpoint with multiple sections: - Personalized recommendations based on listening history - Genre-separated recommendations - New releases - Trending music """ logging.info("Received request for /api/discover") try: # Debug: Test Jellyfin connection user_id = jellyfin_service._get_user_id() logging.info(f"Jellyfin user ID: {user_id}") # 1. Get your music listening patterns from Jellyfin as seed data recently_played = jellyfin_service.get_recently_played(limit=50) logging.info(f"Recently played tracks from Jellyfin: {len(recently_played) if recently_played else 0}") if recently_played: logging.info(f"Sample tracks: {recently_played[:3]}") # Log first 3 tracks else: logging.warning("No listening history found in Jellyfin. Using fallback genres.") recently_played = [] # Will use default genres logging.info(f"Using {len(recently_played)} tracks from your listening history as seed data.") # 2. Get comprehensive discovery data discovery_data = spotify_service.get_comprehensive_discovery(recently_played) if not discovery_data: logging.warning("Failed to get discovery data from Spotify.") return jsonify({"error": "Could not generate discovery content."}), 500 # Debug: Log what sections were generated sections = list(discovery_data.keys()) logging.info(f"Generated sections: {sections}") if 'for_you' in discovery_data: for_you_count = len(discovery_data['for_you'].get('tracks', [])) logging.info(f"For You section has {for_you_count} tracks") else: logging.warning("No 'for_you' section generated!") logging.info(f"Successfully generated comprehensive discovery data with {len(discovery_data)} sections.") return jsonify(discovery_data) except Exception as e: logging.error(f"Error in discover endpoint: {str(e)}") import traceback logging.error(f"Traceback: {traceback.format_exc()}") return jsonify({"error": "Internal server error during discovery."}), 500 @app.route('/api/test/jellyfin', methods=['GET']) def test_jellyfin(): """Test Jellyfin connection and listening history.""" try: user_id = jellyfin_service._get_user_id() if not user_id: return jsonify({"error": "Could not get Jellyfin user ID", "user_id": None}), 400 recently_played = jellyfin_service.get_recently_played(limit=10) return jsonify({ "jellyfin_connected": True, "user_id": user_id, "recently_played_count": len(recently_played) if recently_played else 0, "sample_tracks": recently_played[:3] if recently_played else [], "jellyfin_url": jellyfin_service.base_url }) except Exception as e: logging.error(f"Jellyfin test error: {str(e)}") return jsonify({"error": str(e), "jellyfin_connected": False}), 500 @app.route('/api/discover/for-you', methods=['GET']) def discover_for_you(): """Get personalized recommendations based on listening history.""" logging.info("Received request for /api/discover/for-you") try: # Get limit parameter for See More functionality (default 20, max 100) try: limit = min(int(request.args.get('limit', 20)), 100) except (ValueError, TypeError): limit = 20 # Get your music listening patterns from Jellyfin as seed data recently_played = jellyfin_service.get_recently_played(limit=50) if not recently_played: logging.warning("No listening history found in Jellyfin. Using fallback genres.") recently_played = [] # Will use default genres logging.info(f"Using {len(recently_played)} tracks from your listening history as seed data.") # Get for you section data with specified limit section_data = spotify_service.get_for_you_section(recently_played, limit=limit) if not section_data: logging.warning("Failed to get for you section data from Spotify.") return jsonify({"error": "Could not generate for you section content."}), 500 logging.info(f"Successfully generated for you section data with {limit} items.") return jsonify(section_data) except Exception as e: logging.error(f"Error in discover for you endpoint: {str(e)}") return jsonify({"error": "Internal server error during for you section generation."}), 500 @app.route('/api/discover/new-releases', methods=['GET']) def discover_new_releases(): """Get new releases section.""" logging.info("Received request for /api/discover/new-releases") try: # Get limit parameter for See More functionality (default 20, max 100) try: limit = min(int(request.args.get('limit', 20)), 100) except (ValueError, TypeError): limit = 20 data = spotify_service.get_new_releases_section(limit) section_data = spotify_service.get_new_releases_section(limit=limit) if not section_data: logging.warning("Failed to get new releases section data from Spotify.") return jsonify({"error": "Could not generate new releases section content."}), 500 logging.info(f"Successfully generated new releases section data with {limit} items.") return jsonify(section_data) except Exception as e: logging.error(f"Error in discover new releases endpoint: {str(e)}") return jsonify({"error": "Internal server error during new releases section generation."}), 500 @app.route('/api/discover/trending', methods=['GET']) def discover_trending(): """Get trending tracks section.""" logging.info("Received request for /api/discover/trending") try: # Get limit parameter for See More functionality (default 20, max 100) try: limit = min(int(request.args.get('limit', 20)), 100) except (ValueError, TypeError): limit = 20 data = spotify_service.get_trending_section(limit) section_data = spotify_service.get_trending_section(limit=limit) if not section_data: logging.warning("Failed to get trending section data from Spotify.") return jsonify({"error": "Could not generate trending section content."}), 500 logging.info(f"Successfully generated trending section data with {limit} items.") return jsonify(section_data) except Exception as e: logging.error(f"Error in discover trending endpoint: {str(e)}") return jsonify({"error": "Internal server error during trending section generation."}), 500 @app.route('/api/discover/trending-albums', methods=['GET']) def discover_trending_albums(): """Get trending albums section.""" logging.info("Received request for /api/discover/trending-albums") try: # Get trending albums section data section_data = spotify_service.get_trending_albums_section() if not section_data: logging.warning("Failed to get trending albums section data from Spotify.") return jsonify({"error": "Could not generate trending albums section content."}), 500 logging.info("Successfully generated trending albums section data.") return jsonify(section_data) except Exception as e: logging.error(f"Error in discover trending albums endpoint: {str(e)}") return jsonify({"error": "Internal server error during trending albums section generation."}), 500 @app.route('/api/discover/new-albums', methods=['GET']) def discover_new_albums(): """Get new album releases section.""" logging.info("Received request for /api/discover/new-albums") try: # Get new albums section data section_data = spotify_service.get_new_albums_section() if not section_data: logging.warning("Failed to get new albums section data from Spotify.") return jsonify({"error": "Could not generate new albums section content."}), 500 logging.info("Successfully generated new albums section data.") return jsonify(section_data) except Exception as e: logging.error(f"Error in discover new albums endpoint: {str(e)}") return jsonify({"error": "Internal server error during new albums section generation."}), 500 @app.route('/api/discover/genres', methods=['GET']) def discover_genres(): """Get genre-based recommendations section.""" logging.info("Received request for /api/discover/genres") try: # Get genres section data section_data = spotify_service.get_genres_section() if not section_data: logging.warning("Failed to get genres section data from Spotify.") return jsonify({"error": "Could not generate genres section content."}), 500 logging.info("Successfully generated genres section data.") return jsonify(section_data) except Exception as e: logging.error(f"Error in discover genres endpoint: {str(e)}") return jsonify({"error": "Internal server error during genres section generation."}), 500 @app.route('/api/discover/genre/', methods=['GET']) def discover_genre(genre_name): """Get tracks for a specific genre.""" logging.info(f"Received request for /api/discover/genre/{genre_name}") try: # Get limit parameter for See More functionality (default 20, max 100) try: limit = min(int(request.args.get('limit', 20)), 100) except (ValueError, TypeError): limit = 20 # Get specific genre section data with specified limit section_data = spotify_service.get_genre_section(genre_name, limit=limit) if not section_data: logging.warning(f"Failed to get {genre_name} section data from Spotify.") return jsonify({"error": f"Could not generate {genre_name} section content."}), 500 logging.info(f"Successfully generated {genre_name} section data with {limit} items.") return jsonify(section_data) except Exception as e: logging.error(f"Error in discover genre endpoint: {str(e)}") return jsonify({"error": "Internal server error during genre section generation."}), 500 def trigger_jellyfin_scan(): jellyfin_url = os.getenv("JELLYFIN_URL") jellyfin_api_key = os.getenv("JELLYFIN_API_KEY") if not jellyfin_url or not jellyfin_api_key: logging.warning("Jellyfin URL or API key not configured. Skipping library scan.") return headers = { "Authorization": f'MediaBrowser Token="{jellyfin_api_key}"', } scan_url = f"{jellyfin_url.rstrip('/')}/Library/Refresh" try: logging.info(f"Triggering Jellyfin library scan at {scan_url}...") response = requests.post(scan_url, headers=headers) if response.status_code == 204: logging.info("Successfully triggered Jellyfin library scan.") else: logging.error(f"Failed to trigger Jellyfin library scan. Status: {response.status_code}, Response: {response.text}") except requests.exceptions.RequestException as e: logging.error(f"Error connecting to Jellyfin to trigger library scan: {e}") def process_downloads(): while True: try: url, download_info = download_queue.get() if download_info.get('status') == 'Cancelled': download_queue.task_done() continue # Use the proper download path from environment variables download_path = os.getenv('ABSOLUTE_SERVER_PATH', '/data') temp_path = os.getenv('TEMP_PATH', '/temp') output_template = os.path.join(download_path, '{artist}/{album} - ({year})/{artist} - {title}.{output-ext}') # Update status to 'Downloading' and notify frontend download_info['status'] = 'Downloading' download_info['progress'] = 0 # Reset progress download_key = download_info.get('id') or url download_history[download_key] = download_info logging.info(f"Status updated to Downloading for: {download_info.get('name')} (key: {download_key})") socketio.emit('update_status', {'history': list(download_history.values())}) providers = ['youtube-music', 'youtube'] download_successful = False last_stdout, last_stderr = "", "" for provider in providers: if download_info.get('status') == 'Cancelled': break command = [ 'spotdl', 'download', url, '--output', output_template, '--format', 'mp3', '--bitrate', '320k', '--lyrics', 'genius', '--generate-lrc', '--audio', provider, '--log-level', 'INFO' # Get more detailed output for progress tracking ] logging.info(f"Attempting download with provider: {provider} for '{download_info.get('name')}'") logging.debug(f'Executing SpotDL command: {" ".join(command)}') try: process = subprocess.Popen( command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding='utf-8', bufsize=1, universal_newlines=True ) # Capture real-time output and send progress updates download_info['status'] = 'Downloading...' download_info['progress'] = 0 download_history[download_key] = download_info socketio.emit('update_status', {'history': list(download_history.values())}) # Process stdout line by line for progress updates if process.stdout: for line in process.stdout: line = line.strip() if line: logging.info(f"SpotDL output: {line}") # Parse progress from spotdl output # Look for patterns like "Progress: 45%" or similar progress indicators import re progress_match = re.search(r'(?:Progress|progress):\s*(\d+)%', line, re.IGNORECASE) if progress_match: progress = int(progress_match.group(1)) # Only update if progress has changed significantly to avoid spam if abs(download_info['progress'] - progress) >= 5 or progress == 100: download_info['progress'] = progress download_history[download_key] = download_info socketio.emit('update_status', {'history': list(download_history.values())}) logging.info(f"Progress update: {progress}% for {download_info.get('name')}") # Check for completion if 'downloaded' in line.lower() and 'successfully' in line.lower(): download_info['progress'] = 100 download_history[download_key] = download_info socketio.emit('update_status', {'history': list(download_history.values())}) stdout, stderr = process.communicate() last_stdout, last_stderr = stdout, stderr if process.returncode == 0 and 'error' not in stdout.lower() and 'error' not in stderr.lower(): download_successful = True logging.info(f"Successfully downloaded '{download_info.get('name')}' with provider: {provider}") break else: logging.warning(f"Download failed with provider {provider} for '{download_info.get('name')}'. Trying next provider.") except Exception as e: logging.error(f"An exception occurred while running spotdl with provider {provider}: {e}") continue # Final status update based on download attempts if download_info.get('status') == 'Cancelled': logging.info(f"Download cancelled for {download_info.get('name')}") elif download_successful: download_info['status'] = 'Complete' download_info['progress'] = 100 logging.info(f"Download completed successfully: {download_info.get('name')}") if os.getenv('TRIGGER_JELLYFIN_SCAN') == 'True': trigger_jellyfin_scan() else: download_info['status'] = 'Failed' download_info['progress'] = 0 logging.error(f"Download failed for '{download_info.get('name')}' after trying all providers.") if last_stderr: logging.error(f"Final SpotDL error: {last_stderr}") download_history[download_key] = download_info socketio.emit('update_status', {'history': list(download_history.values())}) logging.info(f"Final status for '{download_info.get('name')}': {download_info.get('status')}") download_queue.task_done() except queue.Empty: continue except Exception as e: logging.error(f"An unexpected error occurred in download processor: {e}", exc_info=True) # Ensure task is marked as done to prevent blocking in case of unexpected error if 'download_queue' in locals() and not download_queue.empty(): download_queue.task_done() @socketio.on('connect') def handle_connect(): logging.info('Client connected') @socketio.on('disconnect') def handle_disconnect(): logging.info('Client disconnected') @socketio.on('search') def handle_search(data): print(f"🔍 [DEBUG] Search request received: {data}") query = data.get('query') search_type = data.get('type', 'track') if not query: print("❌ [DEBUG] Empty query received") socketio.emit('toast', { 'title': 'Empty Search', 'body': 'Please enter a search query' }) return try: print(f"🎵 [DEBUG] Calling spotify_service.perform_spotify_search with query='{query}', type='{search_type}'") results = spotify_service.perform_spotify_search({ 'query': query, 'type': search_type }) print(f"📊 [DEBUG] Search results received: {results}") print(f"📊 [DEBUG] Results type: {type(results)}") # Convert nested results to flat array for frontend flat_results = [] if isinstance(results, dict): for key, value in results.items(): print(f"📊 [DEBUG] {key}: {len(value) if isinstance(value, list) else value}") if isinstance(value, list): flat_results.extend(value) print(f"📊 [DEBUG] Flattened results: {len(flat_results)} items") socketio.emit('search_results', {'results': flat_results}) print(f"✅ [DEBUG] Emitted search_results to frontend") except Exception as e: print(f"❌ [DEBUG] Search error: {str(e)}") logging.error(f"Search error: {str(e)}") socketio.emit('search_error', {'message': str(e)}) @socketio.on('download') def handle_download(data): print(f"🔽 [DEBUG] Download request received: {data}") url = data.get('url') search_result = data.get('searchResult', {}) # Extract data from searchResult if provided, otherwise fall back to individual fields item_name = search_result.get('name') or data.get('name') item_type = search_result.get('type') or data.get('type') item_artist = search_result.get('artist') or data.get('artist') item_id = search_result.get('id') or data.get('id') print(f"🔽 [DEBUG] Extracted: name={item_name}, type={item_type}, artist={item_artist}, id={item_id}, url={url}") if not url: socketio.emit('toast', { 'title': 'Download Error', 'body': 'No URL provided' }) return download_info = { 'id': item_id, 'name': item_name, 'type': item_type, 'artist': item_artist, 'url': url, 'status': 'Pending...', 'progress': 0 } print(f"🔽 [DEBUG] Adding to download queue: {download_info}") download_queue.put((url, download_info)) # Use ID as key to match frontend expectations download_key = item_id or url download_history[download_key] = download_info socketio.emit('update_status', {'history': list(download_history.values())}) print(f"🔽 [DEBUG] Emitted update_status with {len(download_history)} items (key: {download_key})") @socketio.on('cancel_all') def handle_cancel_all(): for url in active_downloads: if active_downloads[url]: active_downloads[url].terminate() if url in download_history: download_history[url]['status'] = 'Cancelled' active_downloads.clear() with download_queue.mutex: download_queue.queue.clear() socketio.emit('update_status', {'history': list(download_history.values())}) @socketio.on('cancel_active') def handle_cancel_active(): for url in active_downloads: if active_downloads[url]: active_downloads[url].terminate() if url in download_history: download_history[url]['status'] = 'Cancelled' break socketio.emit('update_status', {'history': list(download_history.values())}) @socketio.on('download_spotify') def handle_download_spotify(data): """Handle Spotify download requests via socket - BULLETPROOF VERSION""" print(f"🔽 [DEBUG] Spotify download request received via socket: {data}") try: # Robust data extraction with multiple fallbacks spotify_id = data.get('spotifyId') or data.get('id') or data.get('spotify_id') track_name = data.get('trackName') or data.get('name') or data.get('track_name') or 'Unknown Track' artist_name = data.get('artistName') or data.get('artist') or data.get('artist_name') or 'Unknown Artist' # Validate required data if not spotify_id: error_msg = "❌ [ERROR] No Spotify ID provided in download request" print(error_msg) socketio.emit('download_error', {'message': 'Missing Spotify ID', 'data': data}) return # Clean and validate Spotify ID (should be 22 characters) spotify_id = str(spotify_id).strip() if len(spotify_id) != 22: error_msg = f"❌ [ERROR] Invalid Spotify ID format: {spotify_id} (length: {len(spotify_id)})" print(error_msg) socketio.emit('download_error', {'message': 'Invalid Spotify ID format', 'spotify_id': spotify_id}) return # Create download entry download_key = f"spotify_{spotify_id}" # Check if already downloading/downloaded if download_key in download_history: existing_status = download_history[download_key].get('status') if existing_status in ['Downloading', 'Complete', 'Queued']: print(f"⚠️ [WARNING] Download already exists with status: {existing_status}") socketio.emit('update_status', {'history': list(download_history.values())}) return download_history[download_key] = { 'id': download_key, 'name': f"{track_name} by {artist_name}", 'status': 'Queued', 'progress': 0, 'timestamp': datetime.now().isoformat(), 'type': 'Spotify Track', 'spotify_id': spotify_id, 'track_name': track_name, 'artist_name': artist_name } # Create Spotify URL for SpotDL spotify_url = f"https://open.spotify.com/track/{spotify_id}" # Add to download queue in the expected format (url, download_info) download_queue.put((spotify_url, download_history[download_key])) print(f"✅ [DEBUG] Added Spotify download to queue: {download_key} - {track_name} by {artist_name}") # Emit immediate status update socketio.emit('update_status', {'history': list(download_history.values())}) # Also emit specific download started event socketio.emit('download_started', { 'id': download_key, 'name': f"{track_name} by {artist_name}", 'status': 'Queued' }) except Exception as e: error_msg = f"❌ [ERROR] Error handling Spotify download: {str(e)}" print(error_msg) logging.error(error_msg) socketio.emit('download_error', {'message': str(e), 'data': data}) @socketio.on('download_spotify_album') def handle_download_spotify_album(data): """Handle Spotify album download requests - downloads tracks in reverse order for proper sorting""" print(f"🔽 [DEBUG] Spotify album download request received: {data}") try: album_id = data.get('albumId') or data.get('id') or data.get('album_id') if not album_id: error_msg = "❌ [ERROR] No album ID provided in download request" print(error_msg) socketio.emit('download_error', {'message': 'Missing album ID', 'data': data}) return # Clean and validate album ID album_id = str(album_id).strip() if len(album_id) != 22: error_msg = f"❌ [ERROR] Invalid album ID format: {album_id}" print(error_msg) socketio.emit('download_error', {'message': 'Invalid album ID format', 'album_id': album_id}) return # Get album tracks from Spotify print(f"🎵 [DEBUG] Fetching album tracks for: {album_id}") album_tracks = spotify_service.sp.album_tracks(album_id, limit=50) album_info = spotify_service.sp.album(album_id) if not album_tracks or not album_tracks.get('items'): error_msg = f"❌ [ERROR] No tracks found for album: {album_id}" print(error_msg) socketio.emit('download_error', {'message': 'No tracks found for this album', 'album_id': album_id}) return # Parse tracks and sort in REVERSE order (highest track number first) # This ensures when sorted by date added (newest first), track 1 appears at top tracks = [] for track in album_tracks['items']: if track and track.get('id'): artist_names = [] if track.get('artists'): for artist in track['artists']: if artist.get('name'): artist_names.append(artist['name']) tracks.append({ 'spotify_id': track['id'], 'name': track['name'], 'artist': ', '.join(artist_names) if artist_names else 'Unknown Artist', 'album': album_info.get('name', 'Unknown Album'), 'track_number': track.get('track_number', 1), 'url': f"https://open.spotify.com/track/{track['id']}" }) # Sort tracks in REVERSE order by track number (highest first) tracks.sort(key=lambda x: x['track_number'], reverse=True) album_name = album_info.get('name', 'Unknown Album') print(f"🎵 [DEBUG] Album '{album_name}' has {len(tracks)} tracks, downloading in reverse order") # Add each track to download queue queued_count = 0 skipped_count = 0 for i, track in enumerate(tracks): download_key = f"spotify_{track['spotify_id']}" # Check if track already exists if download_key in download_history: existing_status = download_history[download_key].get('status') if existing_status in ['Downloading', 'Complete', 'Queued']: print(f"⚠️ [DEBUG] Skipping track {track['name']} - already {existing_status}") skipped_count += 1 continue # Create download info download_info = { 'id': download_key, 'name': f"{track['name']} by {track['artist']}", 'status': 'Queued', 'progress': 0, 'timestamp': datetime.now().isoformat(), 'type': 'Album Track', 'spotify_id': track['spotify_id'], 'track_name': track['name'], 'artist_name': track['artist'], 'album_name': track['album'], 'track_number': track['track_number'], 'album_id': album_id, 'queue_position': i # Track position in album queue } # Add to queue and history download_queue.put((track['url'], download_info)) download_history[download_key] = download_info queued_count += 1 print(f"✅ [DEBUG] Queued track {track['track_number']}: {track['name']}") # Emit status updates success_msg = f"Started downloading {queued_count} tracks from '{album_name}'" if skipped_count > 0: success_msg += f" ({skipped_count} already downloaded)" socketio.emit('album_download_started', { 'album_id': album_id, 'album_name': album_name, 'total_tracks': len(tracks), 'queued_tracks': queued_count, 'skipped_tracks': skipped_count, 'message': success_msg }) # Emit general status update socketio.emit('update_status', { 'history': list(download_history.values()) }) print(f"✅ [DEBUG] Album download initiated: {queued_count} tracks queued, {skipped_count} skipped for '{album_name}'") except Exception as e: error_msg = f"❌ [ERROR] Error handling Spotify album download: {str(e)}" print(error_msg) logging.error(error_msg) socketio.emit('download_error', {'message': str(e), 'data': data}) @app.route('/api/download/spotify', methods=['POST']) def download_spotify(): """Download a Spotify track using SpotDL and save it to the Jellyfin music directory.""" try: data = request.get_json() spotify_id = data.get('spotifyId') track_name = data.get('trackName') artist_name = data.get('artistName') if not spotify_id: return jsonify({'error': 'Missing spotifyId'}), 400 # Create a unique download key download_key = f"spotify_{spotify_id}" download_info = { 'id': download_key, 'name': f"{track_name} by {artist_name}", 'type': 'Spotify Track', 'status': 'Queued...', 'progress': 0 } # Add to download queue download_queue.put((f"https://open.spotify.com/track/{spotify_id}", download_info)) download_history[download_key] = download_info # Emit status update socketio.emit('update_status', {'history': list(download_history.values())}) return jsonify({'message': 'Download queued successfully', 'downloadId': download_key}), 200 except Exception as e: logging.error(f"Error in download_spotify endpoint: {str(e)}") return jsonify({'error': 'Internal server error'}), 500 @app.route('/api/discover/see-more/
', methods=['GET']) def discover_see_more(section): """Get more results for a specific discovery section.""" logging.info(f"Received request for /api/discover/see-more/{section}") # Get limit parameter from query string, default to 100, max 100 limit = min(int(request.args.get('limit', 100)), 100) try: # Get your music listening patterns from Jellyfin as seed data recently_played = jellyfin_service.get_recently_played(limit=50) if not recently_played: recently_played = [] # Will use default genres section_data = None if section == 'for-you': # Get personalized recommendations with higher limit section_data = spotify_service.get_for_you_section(recently_played, limit=limit) elif section == 'new-releases': # Get new releases with higher limit section_data = spotify_service.get_new_releases_section(limit=limit) elif section == 'trending': # Get trending tracks with higher limit section_data = spotify_service.get_trending_section(limit=limit) elif section == 'trending-albums': # Get trending albums with higher limit section_data = spotify_service.get_trending_albums_section() # Note: This section doesn't currently support limit parameter elif section == 'new-albums': # Get new albums with higher limit section_data = spotify_service.get_new_albums_section() # Note: This section doesn't currently support limit parameter elif section == 'genres': # Get all genres with higher limit per genre section_data = spotify_service.get_genres_section() # Note: This section doesn't currently support limit parameter else: # Try to get a specific genre section section_data = spotify_service.get_genre_section(section.replace('-', ' ').title()) if not section_data: logging.warning(f"Failed to get {section} section data from Spotify.") return jsonify({"error": f"Could not generate {section} section content."}), 500 logging.info(f"Successfully generated {section} section data with up to {limit} items.") return jsonify(section_data) except Exception as e: logging.error(f"Error in discover see more endpoint for {section}: {str(e)}") return jsonify({"error": "Internal server error during section generation."}), 500 @app.route('/api/spotify/album//tracks', methods=['GET']) def get_spotify_album_tracks(album_id): """Get tracks for a specific Spotify album.""" logging.info(f"Received request for Spotify album tracks: {album_id}") try: # Get album tracks from Spotify album_tracks = spotify_service.sp.album_tracks(album_id, limit=50) album_info = spotify_service.sp.album(album_id) if not album_tracks or not album_tracks.get('items'): return jsonify({ 'success': False, 'error': 'No tracks found for this album' }), 404 # Parse tracks data tracks = [] for index, track in enumerate(album_tracks['items']): if track and track.get('id'): # Get artist names artist_names = [] if track.get('artists'): for artist in track['artists']: if artist.get('name'): artist_names.append(artist['name']) tracks.append({ 'spotify_id': track['id'], 'name': track['name'], 'artists': artist_names, 'artist': ', '.join(artist_names) if artist_names else 'Unknown Artist', 'album': album_info.get('name', 'Unknown Album'), 'track_number': track.get('track_number', index + 1), 'duration_ms': track.get('duration_ms', 0), 'duration': track.get('duration_ms', 0), 'explicit': track.get('explicit', False), 'popularity': track.get('popularity', 0), 'url': track.get('external_urls', {}).get('spotify', ''), 'image': album_info.get('images', [{}])[0].get('url', '') if album_info.get('images') else '' }) return jsonify({ 'success': True, 'tracks': tracks, 'album': { 'name': album_info.get('name', 'Unknown Album'), 'artist': ', '.join([artist['name'] for artist in album_info.get('artists', [])]), 'image': album_info.get('images', [{}])[0].get('url', '') if album_info.get('images') else '', 'release_date': album_info.get('release_date', ''), 'total_tracks': album_info.get('total_tracks', len(tracks)) } }) except Exception as e: logging.error(f"Error fetching Spotify album tracks: {e}") return jsonify({ 'success': False, 'error': f'Failed to fetch album tracks: {str(e)}' }), 500 @app.route('/api/spotify/artist/', methods=['GET']) def get_spotify_artist_tracks(artist_id): """Get top tracks for a specific Spotify artist.""" logging.info(f"Received request for Spotify artist tracks: {artist_id}") try: # Get artist info and top tracks artist_info = spotify_service.sp.artist(artist_id) top_tracks = spotify_service.sp.artist_top_tracks(artist_id, country='US') if not top_tracks or not top_tracks.get('tracks'): return jsonify({ 'success': False, 'error': 'No tracks found for this artist' }), 404 # Parse tracks data tracks = [] for index, track in enumerate(top_tracks['tracks']): if track and track.get('id'): # Get artist names artist_names = [] if track.get('artists'): for artist in track['artists']: if artist.get('name'): artist_names.append(artist['name']) tracks.append({ 'spotify_id': track['id'], 'name': track['name'], 'artists': artist_names, 'artist': ', '.join(artist_names) if artist_names else 'Unknown Artist', 'album': track.get('album', {}).get('name', 'Unknown Album'), 'track_number': index + 1, 'duration_ms': track.get('duration_ms', 0), 'duration': track.get('duration_ms', 0), 'explicit': track.get('explicit', False), 'popularity': track.get('popularity', 0), 'is_top_track': True, 'url': track.get('external_urls', {}).get('spotify', ''), 'image': track.get('album', {}).get('images', [{}])[0].get('url', '') if track.get('album', {}).get('images') else '' }) return jsonify({ 'success': True, 'tracks': tracks, 'artist': { 'name': artist_info.get('name', 'Unknown Artist'), 'image': artist_info.get('images', [{}])[0].get('url', '') if artist_info.get('images') else '', 'followers': artist_info.get('followers', {}).get('total', 0), 'genres': artist_info.get('genres', []), 'popularity': artist_info.get('popularity', 0) } }) except Exception as e: logging.error(f"Error fetching Spotify artist tracks: {e}") return jsonify({ 'success': False, 'error': f'Failed to fetch artist tracks: {str(e)}' }), 500 @app.route('/api/spotify/playlist//tracks', methods=['GET']) def get_spotify_playlist_tracks(playlist_id): """Get tracks for a specific Spotify playlist.""" logging.info(f"Received request for Spotify playlist tracks: {playlist_id}") try: # Get playlist info and tracks playlist_info = spotify_service.sp.playlist(playlist_id) playlist_tracks = spotify_service.sp.playlist_tracks(playlist_id, limit=50) if not playlist_tracks or not playlist_tracks.get('items'): return jsonify({ 'success': False, 'error': 'No tracks found for this playlist' }), 404 # Parse tracks data tracks = [] for index, item in enumerate(playlist_tracks['items']): if item and item.get('track') and item['track'].get('id'): track = item['track'] # Get artist names artist_names = [] if track.get('artists'): for artist in track['artists']: if artist.get('name'): artist_names.append(artist['name']) tracks.append({ 'spotify_id': track['id'], 'name': track['name'], 'artists': artist_names, 'artist': ', '.join(artist_names) if artist_names else 'Unknown Artist', 'album': track.get('album', {}).get('name', 'Unknown Album'), 'track_number': index + 1, 'duration_ms': track.get('duration_ms', 0), 'duration': track.get('duration_ms', 0), 'explicit': track.get('explicit', False), 'popularity': track.get('popularity', 0), 'url': track.get('external_urls', {}).get('spotify', ''), 'image': track.get('album', {}).get('images', [{}])[0].get('url', '') if track.get('album', {}).get('images') else '' }) return jsonify({ 'success': True, 'tracks': tracks, 'playlist': { 'name': playlist_info.get('name', 'Unknown Playlist'), 'description': playlist_info.get('description', ''), 'image': playlist_info.get('images', [{}])[0].get('url', '') if playlist_info.get('images') else '', 'owner': playlist_info.get('owner', {}).get('display_name', 'Unknown'), 'total_tracks': playlist_info.get('tracks', {}).get('total', len(tracks)) } }) except Exception as e: logging.error(f"Error fetching Spotify playlist tracks: {e}") return jsonify({ 'success': False, 'error': f'Failed to fetch playlist tracks: {str(e)}' }), 500 @app.route('/api/search', methods=['GET']) def search_spotify(): """Search Spotify for tracks, albums, artists, or playlists.""" query = request.args.get('query', '') search_type = request.args.get('type', 'track') limit = int(request.args.get('limit', 20)) if not query: return jsonify({ 'success': False, 'error': 'Query parameter is required' }), 400 try: # Perform search results = spotify_service.sp.search(q=query, type=search_type, limit=limit) # Parse results based on type parsed_results = [] if search_type == 'track' and results.get('tracks', {}).get('items'): for track in results['tracks']['items']: artist_names = [artist['name'] for artist in track.get('artists', [])] parsed_results.append({ 'id': track['id'], 'name': track['name'], 'type': 'track', 'artist': ', '.join(artist_names) if artist_names else 'Unknown Artist', 'album': track.get('album', {}).get('name', 'Unknown Album'), 'url': track.get('external_urls', {}).get('spotify', ''), 'image': track.get('album', {}).get('images', [{}])[0].get('url', '') if track.get('album', {}).get('images') else '', 'duration': track.get('duration_ms', 0), 'explicit': track.get('explicit', False) }) elif search_type == 'album' and results.get('albums', {}).get('items'): for album in results['albums']['items']: artist_names = [artist['name'] for artist in album.get('artists', [])] parsed_results.append({ 'id': album['id'], 'name': album['name'], 'type': 'album', 'artist': ', '.join(artist_names) if artist_names else 'Unknown Artist', 'url': album.get('external_urls', {}).get('spotify', ''), 'image': album.get('images', [{}])[0].get('url', '') if album.get('images') else '', 'release_date': album.get('release_date', ''), 'total_tracks': album.get('total_tracks', 0) }) elif search_type == 'artist' and results.get('artists', {}).get('items'): for artist in results['artists']['items']: parsed_results.append({ 'id': artist['id'], 'name': artist['name'], 'type': 'artist', 'url': artist.get('external_urls', {}).get('spotify', ''), 'image': artist.get('images', [{}])[0].get('url', '') if artist.get('images') else '', 'followers': artist.get('followers', {}).get('total', 0), 'genres': artist.get('genres', []), 'popularity': artist.get('popularity', 0) }) elif search_type == 'playlist' and results.get('playlists', {}).get('items'): for playlist in results['playlists']['items']: parsed_results.append({ 'id': playlist['id'], 'name': playlist['name'], 'type': 'playlist', 'description': playlist.get('description', ''), 'url': playlist.get('external_urls', {}).get('spotify', ''), 'image': playlist.get('images', [{}])[0].get('url', '') if playlist.get('images') else '', 'owner': playlist.get('owner', {}).get('display_name', 'Unknown'), 'total_tracks': playlist.get('tracks', {}).get('total', 0) }) return jsonify({ 'success': True, 'results': parsed_results, 'total': len(parsed_results) }) except Exception as e: logging.error(f"Error searching Spotify: {e}") return jsonify({ 'success': False, 'error': f'Search failed: {str(e)}' }), 500 @app.route('/status') def status(): """Health check endpoint for Docker.""" return "OK", 200 @app.route('/api/trigger-jellyfin-scan', methods=['POST']) def trigger_jellyfin_scan_endpoint(): """API endpoint to trigger Jellyfin library scan.""" trigger_jellyfin_scan() return jsonify({"status": "Scan triggered"}), 200 if __name__ == '__main__': # Start download processing thread download_thread = threading.Thread(target=process_downloads, daemon=True) download_thread.start() # Start the server socketio.run(app, host='0.0.0.0', port=5140, debug=True)