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 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 = 10 class SpotifyService: def __init__(self, config): self.config = config 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) def perform_spotify_search(self, search_req): 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"]: parsed_results["tracks"].append({ "id": item["id"], "type": "track", "name": item["name"], "artist": item["artists"][0]["name"], "album": item["album"]["name"], "url": item["external_urls"]["spotify"], "image": item["album"]["images"][0]["url"] if item["album"]["images"] else None, "duration": item["duration_ms"], }) # Parsing albums if "albums" in results: for item in results["albums"]["items"]: parsed_results["albums"].append({ "id": item["id"], "type": "album", "name": item["name"], "artist": item["artists"][0]["name"], "url": item["external_urls"]["spotify"], "image": item["images"][0]["url"] if item["images"] else None, "total_tracks": item["total_tracks"], "release_date": item["release_date"], }) # Parsing artists if "artists" in results: for item in results["artists"]["items"]: parsed_results["artists"].append({ "id": item["id"], "type": "artist", "name": item["name"], "url": item["external_urls"]["spotify"], "image": item["images"][0]["url"] if item["images"] else None, "followers": item["followers"]["total"], }) # Parsing playlists if "playlists" in results: for item in results["playlists"]["items"]: parsed_results["playlists"].append({ "id": item["id"], "type": "playlist", "name": item["name"], "owner": item["owner"]["display_name"], "url": item["external_urls"]["spotify"], "image": item["images"][0]["url"] if item["images"] else None, "tracks_total": item["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 get_recommendations(self, seed_tracks): """Get song recommendations from Spotify based on seed tracks.""" if not seed_tracks: 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'): spotify_track_id = search_result['tracks'][0]['id'] seed_track_ids.append(spotify_track_id) logging.info(f"Found Spotify seed track ID: {spotify_track_id} for {track['artist']} - {track['track']}") 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=20) # 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}") return [] def get_for_you_section(self, recently_played): """Get personalized recommendations based on listening history.""" try: if recently_played: personalized = self.get_recommendations(recently_played) 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): """Get new releases section.""" try: # New releases (using featured playlists as proxy) featured = self.sp.featured_playlists(limit=1) if featured and featured.get('playlists', {}).get('items'): playlist_id = featured['playlists']['items'][0]['id'] playlist_tracks = self.sp.playlist_tracks(playlist_id, limit=50) # More tracks for "See More" page if playlist_tracks and playlist_tracks.get('items'): new_releases = [] for item in playlist_tracks['items']: if item['track']: track = item['track'] new_releases.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'] }) return { 'title': 'New Releases', 'description': 'Fresh music to discover', 'tracks': self._transform_to_media_items(new_releases) } return { 'title': 'New Releases', 'description': 'Fresh music to discover', 'tracks': [] } 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): """Get trending tracks section.""" try: # Search for popular tracks popular_search = self.sp.search(q='year:2024', type='track', limit=50) # More tracks for "See More" page if popular_search and popular_search.get('tracks', {}).get('items'): trending_tracks = [] for track in popular_search['tracks']['items']: trending_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'] }) return { 'title': 'Trending Now', 'description': 'Popular tracks everyone is listening to', 'tracks': self._transform_to_media_items(trending_tracks) } return { 'title': 'Trending Now', 'description': 'Popular tracks everyone is listening to', 'tracks': [] } 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.""" try: # Search for popular albums using a more effective query # First try with recent years, then fall back to general popularity popular_albums = None for year in range(2024, 2019, -1): popular_albums = self.sp.search(q=f'year:{year}', type='album', limit=50) # More albums for "See More" page if popular_albums and popular_albums.get('albums', {}).get('items'): break # If still no results, try a general search for popular albums if not (popular_albums and popular_albums.get('albums', {}).get('items')): popular_albums = self.sp.search(q='tag:popular', type='album', limit=50) if popular_albums and popular_albums.get('albums', {}).get('items'): trending_albums = [] for album in popular_albums['albums']['items']: trending_albums.append({ 'id': album['id'], 'name': album['name'], 'artist': album['artists'][0]['name'] if album['artists'] else 'Unknown Artist', 'url': album['external_urls']['spotify'], 'image': album['images'][0]['url'] if album['images'] else None, 'total_tracks': album.get('total_tracks', 0), 'release_date': album.get('release_date', '') }) return { 'title': 'Popular Albums', 'description': 'Popular albums everyone is listening to', 'albums': self._transform_to_media_items(trending_albums, item_type='album') } return { 'title': 'Popular Albums', 'description': 'Popular albums everyone is listening to', 'albums': [] } 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']: new_releases.append({ 'id': album['id'], 'name': album['name'], 'artist': album['artists'][0]['name'] if album['artists'] else 'Unknown Artist', 'url': album['external_urls']['spotify'], 'image': album['images'][0]['url'] if album['images'] else None, '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): """Get tracks for a specific genre.""" 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' } spotify_genre = genre_map.get(genre_name, genre_name.lower()) # Get recommendations for the specific genre genre_recs = self.sp.recommendations(seed_genres=[spotify_genre], limit=50) # 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'] }) 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 if recently_played: 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]) } # 2. Genre-based recommendations genres = ['hip-hop', 'pop', 'rock', 'r-n-b', 'electronic', 'indie'] discovery_sections['genres'] = [] for genre in genres: try: # Get recommendations for each genre genre_recs = self.sp.recommendations(seed_genres=[genre], limit=8) 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'] }) discovery_sections['genres'].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 # 3. New releases (using featured playlists as proxy) try: featured = self.sp.featured_playlists(limit=1) if featured and featured.get('playlists', {}).get('items'): playlist_id = featured['playlists']['items'][0]['id'] playlist_tracks = self.sp.playlist_tracks(playlist_id, limit=10) if playlist_tracks and playlist_tracks.get('items'): new_releases = [] for item in playlist_tracks['items']: if item['track']: track = item['track'] new_releases.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'] }) discovery_sections['new_releases'] = { 'title': 'New Releases', 'description': 'Fresh music to discover', 'tracks': self._transform_to_media_items(new_releases) } except Exception as e: logging.warning(f"Failed to get new releases: {str(e)}") # 4. Trending/Popular (using top tracks from popular artists) try: # Search for popular tracks popular_search = self.sp.search(q='year:2024', type='track', limit=10) if popular_search and popular_search.get('tracks', {}).get('items'): trending_tracks = [] for track in popular_search['tracks']['items']: trending_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'] }) discovery_sections['trending'] = { 'title': 'Trending Now', 'description': 'Popular tracks everyone is listening to', 'tracks': self._transform_to_media_items(trending_tracks) } except Exception as e: logging.warning(f"Failed to get trending tracks: {str(e)}") # 5. Popular albums try: # Search for popular albums using a more effective query # First try with recent years, then fall back to general popularity popular_albums = None for year in range(2024, 2019, -1): popular_albums = self.sp.search(q=f'year:{year}', type='album', limit=10) if popular_albums and popular_albums.get('albums', {}).get('items'): break # If still no results, try a general search for popular albums if not (popular_albums and popular_albums.get('albums', {}).get('items')): popular_albums = self.sp.search(q='tag:popular', type='album', limit=10) if popular_albums and popular_albums.get('albums', {}).get('items'): trending_albums = [] for album in popular_albums['albums']['items']: trending_albums.append({ 'id': album['id'], 'name': album['name'], 'artist': album['artists'][0]['name'] if album['artists'] else 'Unknown Artist', 'url': album['external_urls']['spotify'], 'image': album['images'][0]['url'] if album['images'] else None, 'total_tracks': album.get('total_tracks', 0), 'release_date': album.get('release_date', '') }) discovery_sections['trending_albums'] = { 'title': 'Popular Albums', 'description': 'Popular albums everyone is listening to', 'albums': self._transform_to_media_items(trending_albums, item_type='album') } else: # If still no results, create a fallback section discovery_sections['trending_albums'] = { 'title': 'Popular Albums', 'description': 'Popular albums everyone is listening to', 'albums': [] } 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': [] } # 6. New album releases 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']: new_releases.append({ 'id': album['id'], 'name': album['name'], 'artist': album['artists'][0]['name'] if album['artists'] else 'Unknown Artist', 'url': album['external_urls']['spotify'], 'image': album['images'][0]['url'] if album['images'] else None, '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') } 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': [] } 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="*", async_mode='eventlet') # 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] or (item.get('ArtistItems', [{}])[0].get('Name') if item.get('ArtistItems') 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: # 1. 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.") # 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 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)}") return jsonify({"error": "Internal server error during discovery."}), 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 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 section_data = spotify_service.get_for_you_section(recently_played) 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("Successfully generated for you section data.") 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 new releases section data section_data = spotify_service.get_new_releases_section() 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("Successfully generated new releases section data.") 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 trending section data section_data = spotify_service.get_trending_section() 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("Successfully generated trending section data.") 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 specific genre section data section_data = spotify_service.get_genre_section(genre_name) 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.") 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())}) @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('/status') def status(): """Health check endpoint for Docker.""" return "OK", 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)