""" Unified import helper for importing music content across different services. This module provides consistent import functionality for tracks, albums, and playlists from various music streaming services like Spotify and Deezer. """ import json import logging import secrets import string from flask import current_app, flash from musicround.models import Song, Tag, db from musicround.helpers.metadata import get_song_metadata_by_isrc def generate_token(length=32): """ Generate a secure random token for authentication or validation purposes. Args: length (int): The length of the token to generate (default: 32) Returns: str: A secure random token string """ # Use secrets module for cryptographically strong random numbers alphabet = string.ascii_letters + string.digits token = ''.join(secrets.choice(alphabet) for _ in range(length)) return token class ImportHelper: """Unified helper for importing music content from different services.""" # Helper method to create tags from genres @staticmethod def create_tags_from_genre(song, genre_data): """ Create tags from genre data and associate them with a song Args: song (Song): Song object to associate tags with genre_data (str or list): Genre data that could be string, list, or comma-separated values """ if not genre_data: return genres = [] # Handle different types of genre data if isinstance(genre_data, str): # Handle comma-separated genre string genres = [g.strip() for g in genre_data.split(',')] elif isinstance(genre_data, list): # Handle genre list genres = [g.strip() if isinstance(g, str) else str(g).strip() for g in genre_data] # Add each genre as a tag for genre_name in genres: if not genre_name: continue # Convert to lowercase for consistency genre_name = genre_name.lower() # Find existing tag or create new one tag = Tag.query.filter(Tag.name.ilike(genre_name)).first() if not tag: tag = Tag(name=genre_name) db.session.add(tag) try: db.session.flush() # Flush to get ID but don't commit yet except Exception as e: current_app.logger.error(f"Error creating tag '{genre_name}': {e}") continue # Add tag to song if not already present if tag not in song.tags: song.tags.append(tag) current_app.logger.info(f"Added tag '{tag.name}' to song '{song.title}'") @staticmethod def import_item(service_name, item_type, item_id): """ Import a track, album, or playlist from a specific service. Args: service_name (str): Name of the service (e.g., 'spotify', 'deezer') item_type (str): Type of item ('track', 'album', 'playlist') item_id (str): ID of the item to import Returns: dict: Summary of import operation with counts of imported items """ current_app.logger.info(f"Importing {item_type} {item_id} from {service_name}") result = { 'success': False, 'imported_count': 0, 'skipped_count': 0, 'error_count': 0, 'errors': [], 'service': service_name, 'item_type': item_type, 'item_id': item_id } try: if service_name.lower() == 'spotify': # Get Spotify client sp = current_app.config.get('sp') if not sp: result['errors'].append("Spotify client not configured") return result # Handle based on item type if item_type.lower() == 'track': track_result = ImportHelper.import_spotify_track(sp, item_id) result.update(track_result) elif item_type.lower() == 'album': album_result = ImportHelper.import_spotify_album(sp, item_id) result.update(album_result) elif item_type.lower() == 'playlist': playlist_result = ImportHelper.import_spotify_playlist(sp, item_id) result.update(playlist_result) else: result['errors'].append(f"Unknown item type: {item_type}") return result elif service_name.lower() == 'deezer': # Get Deezer client deezer_client = current_app.config.get('deezer') if not deezer_client: result['errors'].append("Deezer client not configured") return result # Handle based on item type if item_type.lower() == 'track': track_result = ImportHelper.import_deezer_track(deezer_client, item_id) result.update(track_result) elif item_type.lower() == 'album': album_result = ImportHelper.import_deezer_album(deezer_client, item_id) result.update(album_result) elif item_type.lower() == 'playlist': playlist_result = ImportHelper.import_deezer_playlist(deezer_client, item_id) result.update(playlist_result) else: result['errors'].append(f"Unknown item type: {item_type}") return result else: result['errors'].append(f"Unsupported service: {service_name}") return result result['success'] = len(result['errors']) == 0 return result except Exception as e: current_app.logger.error(f"Error importing {item_type} {item_id} from {service_name}: {str(e)}") result['errors'].append(str(e)) result['success'] = False return result # ------------- SPOTIFY IMPORT METHODS ------------- @staticmethod def import_spotify_track(sp, track_id): """Import a single track from Spotify""" result = { 'imported_count': 0, 'skipped_count': 0, 'error_count': 0, 'errors': [] } try: # First check if this Spotify track is already in our database existing_song = Song.query.filter_by(spotify_id=track_id).first() if existing_song: current_app.logger.info(f'Song already exists: {existing_song.title} by {existing_song.artist}') result['skipped_count'] += 1 return result # Get track info from Spotify track_info = sp.track(track_id) if not track_info: result['errors'].append(f"Track with ID {track_id} not found on Spotify") result['error_count'] += 1 return result # Try to get ISRC if available isrc = track_info.get('external_ids', {}).get('isrc') song = None # If we have an ISRC, check if a song with this ISRC already exists if isrc: existing_by_isrc = Song.query.filter(Song.isrc == isrc).first() if existing_by_isrc: current_app.logger.info(f'Song already exists by ISRC: {existing_by_isrc.title} by {existing_by_isrc.artist}') result['skipped_count'] += 1 return result # Get comprehensive metadata using ISRC current_app.logger.info(f"Looking up metadata for ISRC: {isrc}") metadata = get_song_metadata_by_isrc(isrc, current_app) if metadata and metadata.get("title"): # Create song with enriched metadata song = Song( spotify_id=track_id, deezer_id=metadata.get("deezer_id"), title=metadata.get("title", track_info['name']), artist=metadata.get("artist_name", ", ".join([artist['name'] for artist in track_info['artists']])), genre=metadata.get("genre"), year=metadata.get("year"), preview_url=metadata.get("preview_url", track_info.get('preview_url')), cover_url=metadata.get("cover_url") or (track_info['album']['images'][0]['url'] if track_info.get('album', {}).get('images') else None), popularity=metadata.get("popularity", track_info.get('popularity')), isrc=isrc, album_name=track_info.get('album', {}).get('name'), metadata_sources=','.join(metadata.get("sources", [])), source='spotify', spotify_preview_url=metadata.get("spotify_preview_url"), deezer_preview_url=metadata.get("deezer_preview_url"), apple_preview_url=metadata.get("apple_preview_url"), youtube_preview_url=metadata.get("youtube_preview_url"), spotify_cover_url=metadata.get("spotify_cover_url"), deezer_cover_url=metadata.get("deezer_cover_url"), apple_cover_url=metadata.get("apple_cover_url"), additional_data=json.dumps({ k: v for k, v in metadata.items() if k not in ['artist_name', 'title', 'year', 'genre', 'popularity', 'preview_url', 'sources', 'isrc', 'spotify_id', 'deezer_id', 'cover_url', 'spotify_preview_url', 'deezer_preview_url', 'apple_preview_url', 'youtube_preview_url', 'spotify_cover_url', 'deezer_cover_url', 'apple_cover_url'] }) if metadata else None ) current_app.logger.info(f"Metadata found from sources: {metadata.get('sources', [])}") else: # Fallback to just Spotify data with the ISRC song = ImportHelper._create_song_from_spotify(track_info, isrc) else: # No ISRC available, just use Spotify data song = ImportHelper._create_song_from_spotify(track_info) # We already checked for duplicates above, so we can add the song directly if song: db.session.add(song) # Create tags from genre information if song.genre: ImportHelper.create_tags_from_genre(song, song.genre) # Also check additional data for genres if song.additional_data: try: additional_data = json.loads(song.additional_data) if 'genres' in additional_data: ImportHelper.create_tags_from_genre(song, additional_data['genres']) except Exception as e: current_app.logger.error(f"Error parsing additional data for genres: {e}") # Get audio features if this is a Spotify track - NEW ADDITION ImportHelper._fetch_audio_features_for_song(sp, song) db.session.commit() current_app.logger.info(f'Imported Spotify track {song.title} by {song.artist}') result['imported_count'] += 1 else: current_app.logger.warning(f'Could not create song from Spotify track {track_id}') result['skipped_count'] += 1 return result except Exception as e: current_app.logger.error(f"Error importing Spotify track {track_id}: {str(e)}") result['errors'].append(str(e)) result['error_count'] += 1 return result @staticmethod def import_spotify_album(sp, album_id): """Import all tracks from a Spotify album""" result = { 'imported_count': 0, 'skipped_count': 0, 'error_count': 0, 'errors': [] } try: # Get album tracks from Spotify album_tracks = sp.album_tracks(album_id) if not album_tracks or 'items' not in album_tracks: result['errors'].append(f"Album with ID {album_id} not found on Spotify") result['error_count'] += 1 return result # Import each track in the album for track in album_tracks['items']: if track and 'id' in track: track_result = ImportHelper.import_spotify_track(sp, track['id']) result['imported_count'] += track_result['imported_count'] result['skipped_count'] += track_result['skipped_count'] result['error_count'] += track_result['error_count'] result['errors'].extend(track_result['errors']) return result except Exception as e: current_app.logger.error(f"Error importing Spotify album {album_id}: {str(e)}") result['errors'].append(str(e)) result['error_count'] += 1 return result @staticmethod def import_spotify_playlist(sp, playlist_id): """Import all tracks from a Spotify playlist""" result = { 'imported_count': 0, 'skipped_count': 0, 'error_count': 0, 'errors': [] } try: # Get playlist tracks from Spotify tracks_info = sp.playlist_tracks(playlist_id) if not tracks_info or 'items' not in tracks_info: result['errors'].append(f"Playlist with ID {playlist_id} not found on Spotify") result['error_count'] += 1 return result # Import each track in the playlist items = tracks_info.get('items', []) for item in items: # 'track' can be None if it's a local or unavailable track track_obj = item.get('track') if track_obj and 'id' in track_obj: track_result = ImportHelper.import_spotify_track(sp, track_obj['id']) result['imported_count'] += track_result['imported_count'] result['skipped_count'] += track_result['skipped_count'] result['error_count'] += track_result['error_count'] result['errors'].extend(track_result['errors']) return result except Exception as e: current_app.logger.error(f"Error importing Spotify playlist {playlist_id}: {str(e)}") result['errors'].append(str(e)) result['error_count'] += 1 return result @staticmethod def _create_song_from_spotify(track_info, isrc=None): """Create a song object from Spotify track data""" # Basic song with just Spotify data preview_url = track_info.get('preview_url') cover_url = track_info['album']['images'][0]['url'] if track_info.get('album', {}).get('images') else None # Extract year from album if available year = None if track_info.get('album') and track_info['album'].get('release_date'): year = track_info['album']['release_date'][:4] # Try to get genre from album genre = None # Create song object return Song( spotify_id=track_info['id'], title=track_info['name'], artist=", ".join([artist['name'] for artist in track_info['artists']]), genre=genre, year=year, preview_url=preview_url, cover_url=cover_url, popularity=track_info.get('popularity'), isrc=isrc, album_name=track_info.get('album', {}).get('name'), metadata_sources='spotify', source='spotify' ) @staticmethod def _fetch_audio_features_for_song(sp, song): """Fetch audio features for a Spotify song and update the song object""" try: audio_features = sp.audio_features(song.spotify_id) if audio_features and len(audio_features) > 0: features = audio_features[0] song.danceability = features.get('danceability') song.energy = features.get('energy') song.key = features.get('key') song.loudness = features.get('loudness') song.mode = features.get('mode') song.speechiness = features.get('speechiness') song.acousticness = features.get('acousticness') song.instrumentalness = features.get('instrumentalness') song.liveness = features.get('liveness') song.valence = features.get('valence') song.tempo = features.get('tempo') current_app.logger.info(f"Audio features fetched for song '{song.title}'") except Exception as e: current_app.logger.error(f"Error fetching audio features for song '{song.title}': {str(e)}") # ------------- DEEZER IMPORT METHODS ------------- @staticmethod def import_deezer_track(deezer_client, track_id): """Import a single track from Deezer""" result = { 'imported_count': 0, 'skipped_count': 0, 'error_count': 0, 'errors': [] } try: # First check if this Deezer track is already in our database existing_song = Song.query.filter_by(deezer_id=track_id).first() if existing_song: current_app.logger.info(f'Song already exists: {existing_song.title} by {existing_song.artist}') result['skipped_count'] += 1 return result # Get track info from Deezer track = deezer_client.get_track(track_id) if not track: result['errors'].append(f"Track with ID {track_id} not found on Deezer") result['error_count'] += 1 return result # Check if ISRC is available isrc = track.get('isrc') song = None # If we have an ISRC, check if a song with this ISRC already exists if isrc: existing_by_isrc = Song.query.filter(Song.isrc == isrc).first() if existing_by_isrc: current_app.logger.info(f'Song already exists by ISRC: {existing_by_isrc.title} by {existing_by_isrc.artist}') result['skipped_count'] += 1 return result # Use metadata helper function to get comprehensive metadata current_app.logger.info(f"Looking up metadata for ISRC: {isrc}") metadata = get_song_metadata_by_isrc(isrc, current_app) # Use metadata if found, otherwise fall back to Deezer data only if metadata and metadata.get("title"): song = Song( deezer_id=track.get('id'), spotify_id=metadata.get("spotify_id"), title=metadata.get("title", track.get('title')), artist=metadata.get("artist_name", track.get('artist', {}).get('name') if track.get('artist') else 'Unknown Artist'), preview_url=metadata.get("preview_url", track.get('preview')), cover_url=metadata.get("cover_url") or track.get('album', {}).get('cover'), genre=metadata.get("genre"), year=metadata.get("year"), popularity=metadata.get("popularity"), isrc=isrc, album_name=track.get('album', {}).get('title'), metadata_sources=','.join(metadata.get("sources", [])), source='deezer', spotify_preview_url=metadata.get("spotify_preview_url"), deezer_preview_url=metadata.get("deezer_preview_url"), apple_preview_url=metadata.get("apple_preview_url"), youtube_preview_url=metadata.get("youtube_preview_url"), spotify_cover_url=metadata.get("spotify_cover_url"), deezer_cover_url=metadata.get("deezer_cover_url"), apple_cover_url=metadata.get("apple_cover_url"), additional_data=json.dumps( {k: v for k, v in metadata.items() if k not in ['artist_name', 'title', 'year', 'genre', 'popularity', 'preview_url', 'sources', 'isrc', 'spotify_id', 'deezer_id', 'cover_url', 'spotify_preview_url', 'deezer_preview_url', 'apple_preview_url', 'youtube_preview_url', 'spotify_cover_url', 'deezer_cover_url', 'apple_cover_url']} ) if metadata else None ) current_app.logger.info(f"Metadata found from sources: {metadata.get('sources', [])}") else: # Fallback to just Deezer data song = Song( deezer_id=track.get('id'), title=track.get('title'), artist=track.get('artist', {}).get('name') if track.get('artist') else 'Unknown Artist', preview_url=track.get('preview'), cover_url=track.get('album', {}).get('cover'), album_name=track.get('album', {}).get('title'), metadata_sources='deezer', source='deezer' ) else: # No ISRC available, just use Deezer data song = Song( deezer_id=track.get('id'), title=track.get('title'), artist=track.get('artist', {}).get('name') if track.get('artist') else 'Unknown Artist', preview_url=track.get('preview'), cover_url=track.get('album', {}).get('cover'), album_name=track.get('album', {}).get('title'), metadata_sources='deezer', source='deezer' ) # We already checked for duplicates above, so we can add the song directly if song: db.session.add(song) # Create tags from genre information if song.genre: ImportHelper.create_tags_from_genre(song, song.genre) # Also check additional data for genres if song.additional_data: try: additional_data = json.loads(song.additional_data) if 'genres' in additional_data: ImportHelper.create_tags_from_genre(song, additional_data['genres']) except Exception as e: current_app.logger.error(f"Error parsing additional data for genres: {e}") db.session.commit() current_app.logger.info(f'Imported Deezer track {song.title} by {song.artist}') result['imported_count'] += 1 else: current_app.logger.warning(f'Could not create song from Deezer track {track_id}') result['skipped_count'] += 1 return result except Exception as e: current_app.logger.error(f"Error importing Deezer track {track_id}: {str(e)}") result['errors'].append(str(e)) result['error_count'] += 1 return result @staticmethod def import_deezer_album(deezer_client, album_id): """Import all tracks from a Deezer album""" result = { 'imported_count': 0, 'skipped_count': 0, 'error_count': 0, 'errors': [] } try: # Get album info from Deezer album = deezer_client.get_album(album_id) if not album or not album.get('tracks') or not album['tracks'].get('data'): result['errors'].append(f"Album with ID {album_id} not found on Deezer") result['error_count'] += 1 return result # Import each track in the album tracks = album['tracks']['data'] for track in tracks: track_id = track.get('id') if track_id: track_result = ImportHelper.import_deezer_track(deezer_client, track_id) result['imported_count'] += track_result['imported_count'] result['skipped_count'] += track_result['skipped_count'] result['error_count'] += track_result['error_count'] result['errors'].extend(track_result['errors']) return result except Exception as e: current_app.logger.error(f"Error importing Deezer album {album_id}: {str(e)}") result['errors'].append(str(e)) result['error_count'] += 1 return result @staticmethod def import_deezer_playlist(deezer_client, playlist_id): """Import all tracks from a Deezer playlist""" result = { 'imported_count': 0, 'skipped_count': 0, 'error_count': 0, 'errors': [] } try: # Get playlist info from Deezer playlist = deezer_client.get_playlist(playlist_id) if not playlist or not playlist.get('tracks') or not playlist['tracks'].get('data'): result['errors'].append(f"Playlist with ID {playlist_id} not found on Deezer") result['error_count'] += 1 return result # Import each track in the playlist tracks = playlist['tracks']['data'] for track in tracks: track_id = track.get('id') if track_id: track_result = ImportHelper.import_deezer_track(deezer_client, track_id) result['imported_count'] += track_result['imported_count'] result['skipped_count'] += track_result['skipped_count'] result['error_count'] += track_result['error_count'] result['errors'].extend(track_result['errors']) return result except Exception as e: current_app.logger.error(f"Error importing Deezer playlist {playlist_id}: {str(e)}") result['errors'].append(str(e)) result['error_count'] += 1 return result