""" 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, session from flask_login import current_user from authlib.integrations.base_client.errors import MissingTokenError # Corrected import path from httpx import HTTPStatusError from musicround.models import Song, Tag, db from musicround.helpers.metadata import get_song_metadata_by_isrc from musicround.helpers.auth_helpers import oauth, update_oauth_tokens # Ensure update_oauth_tokens is imported from datetime import datetime # Ensure datetime is imported 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.""" @staticmethod def _create_song_from_spotify(track_info): """Helper to create a Song object from Spotify track_info when ISRC/rich metadata is not found.""" if not track_info: return None artist_names = ", ".join([artist['name'] for artist in track_info.get('artists', [])]) cover_url = None if track_info.get('album', {}).get('images'): cover_url = track_info['album']['images'][0]['url'] song = Song( spotify_id=track_info.get('id'), title=track_info.get('name'), artist=artist_names, album_name=track_info.get('album', {}).get('name'), preview_url=track_info.get('preview_url'), cover_url=cover_url, popularity=track_info.get('popularity'), source='spotify' ) release_date = track_info.get('album', {}).get('release_date') if release_date: try: song.year = int(release_date.split('-')[0]) except (ValueError, IndexError, TypeError): current_app.logger.warning(f"Could not parse year from release_date: {release_date} for track {song.title}") return song @staticmethod def _fetch_audio_features_for_song(sp, song_obj, spotify_track_id, token=None): # Added token parameter """Fetches audio features for a given song and updates the song object.""" if not spotify_track_id: current_app.logger.warning(f"Cannot fetch audio features: Spotify track ID missing for song {song_obj.title if song_obj else 'Unknown'}.") return if not token and (not current_user or not current_user.is_authenticated or not current_user.spotify_token): current_app.logger.error(f"Cannot fetch audio features for {spotify_track_id}: User not authenticated or no Spotify token, and no explicit token passed.") return # Construct token if not passed explicitly but current_user is available # This provides a fallback if the calling context didn't pass it but expects this method to handle it. # However, for consistency, it's better if the caller (import_spotify_track) always passes it. token_to_use = token if not token_to_use: expires_at_timestamp = None if current_user.spotify_token_expiry: if isinstance(current_user.spotify_token_expiry, datetime): expires_at_timestamp = int(current_user.spotify_token_expiry.timestamp()) else: try: expires_at_timestamp = int(datetime.fromisoformat(str(current_user.spotify_token_expiry)).timestamp()) except ValueError: pass # Logged by caller token_to_use = { 'access_token': current_user.spotify_token, 'refresh_token': current_user.spotify_refresh_token, 'token_type': 'Bearer', 'expires_at': expires_at_timestamp } current_app.logger.info(f"Constructed token within _fetch_audio_features_for_song for {spotify_track_id}") original_access_token = token_to_use.get('access_token') if token_to_use else None try: current_app.logger.info(f"Fetching audio features for Spotify track ID: {spotify_track_id}") audio_features_resp = sp.get(f'audio-features/{spotify_track_id}', token=token_to_use) # Pass token audio_features_resp.raise_for_status() features = audio_features_resp.json() if sp.token and original_access_token and sp.token.get('access_token') != original_access_token: current_app.logger.info(f"Spotify token refreshed during _fetch_audio_features for {spotify_track_id}, user {current_user.id}.") if update_oauth_tokens(current_user, sp.token, 'spotify'): # token_to_use = sp.token # Update local token if it were to be used again in this function current_app.logger.info(f"Refreshed Spotify token saved (audio features) for user {current_user.id}.") else: current_app.logger.error(f"Failed to save refreshed Spotify token (audio features) for user {current_user.id}.") if features: song_obj.danceability = features.get('danceability') song_obj.energy = features.get('energy') song_obj.key = features.get('key') song_obj.loudness = features.get('loudness') song_obj.mode = features.get('mode') song_obj.speechiness = features.get('speechiness') song_obj.acousticness = features.get('acousticness') song_obj.instrumentalness = features.get('instrumentalness') song_obj.liveness = features.get('liveness') song_obj.valence = features.get('valence') song_obj.tempo = features.get('tempo') song_obj.duration_ms = features.get('duration_ms') song_obj.time_signature = features.get('time_signature') current_app.logger.info(f"Audio features updated for song: {song_obj.title}") else: current_app.logger.warning(f"No audio features returned for Spotify track ID: {spotify_track_id}") except MissingTokenError as mte: current_app.logger.error(f"Authlib MissingTokenError fetching audio features for {spotify_track_id}: {str(mte)}") except HTTPStatusError as hse: current_app.logger.error(f"HTTPStatusError ({hse.response.status_code}) fetching audio features for {spotify_track_id}: {hse.response.text}") except Exception as e: current_app.logger.error(f"Error fetching audio features for {spotify_track_id}: {str(e)}", exc_info=True) # 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() 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, oauth_spotify=None): # Added oauth_spotify parameter """ Generic import function to import items from various services. Args: service_name (str): The name of the service (e.g., 'spotify', 'deezer'). item_type (str): The type of item to import (e.g., 'track', 'album', 'playlist'). item_id (str): The ID of the item to import. oauth_spotify: Optional Authlib Spotify client instance. Returns: dict: A dictionary containing import statistics (imported_count, skipped_count, error_count, errors). """ current_app.logger.info(f"Import item called: service='{service_name}', type='{item_type}', id='{item_id}'") if service_name.lower() == 'spotify': # Use the provided oauth_spotify client, or fallback to the global one if not provided spotify_client = oauth_spotify if oauth_spotify else oauth.spotify if not spotify_client: current_app.logger.error("Spotify client not available for import.") return { 'imported_count': 0, 'skipped_count': 0, 'error_count': 1, 'errors': ["Spotify client not configured or passed correctly."] } if item_type.lower() == 'track': return ImportHelper.import_spotify_track(spotify_client, item_id) elif item_type.lower() == 'album': return ImportHelper.import_spotify_album(spotify_client, item_id) elif item_type.lower() == 'playlist': return ImportHelper.import_spotify_playlist(spotify_client, item_id) else: current_app.logger.error(f"Unsupported item_type '{item_type}' for Spotify import.") return { 'imported_count': 0, 'skipped_count': 0, 'error_count': 1, 'errors': [f"Unsupported item type '{item_type}' for Spotify."] } elif service_name.lower() == 'deezer': # Ensure we have the Deezer client available # The Deezer client is typically initialized in create_app and stored in app.config['deezer'] deezer_client = current_app.config.get('deezer') if not deezer_client: current_app.logger.error("Deezer client not found in app.config.") return { 'imported_count': 0, 'skipped_count': 0, 'error_count': 1, 'errors': ["Deezer client not configured."] } lastfm_api_key = current_app.config.get('LASTFM_API_KEY') if item_type.lower() == 'track': song = deezer_client.import_track(item_id, lastfm_api_key=lastfm_api_key) if song: return {'imported_count': 1, 'skipped_count': 0, 'error_count': 0, 'errors': []} else: return {'imported_count': 0, 'skipped_count': 0, 'error_count': 1, 'errors': [f"Failed to import Deezer track {item_id}."]} elif item_type.lower() == 'album': imported_songs = deezer_client.import_album(item_id, lastfm_api_key=lastfm_api_key) return { 'imported_count': len(imported_songs), 'skipped_count': 0, 'error_count': 0, 'errors': [] } elif item_type.lower() == 'playlist': imported_songs = deezer_client.import_playlist(item_id, lastfm_api_key=lastfm_api_key) return { 'imported_count': len(imported_songs), 'skipped_count': 0, 'error_count': 0, 'errors': [] } else: current_app.logger.error(f"Unsupported item_type '{item_type}' for Deezer import.") return { 'imported_count': 0, 'skipped_count': 0, 'error_count': 1, 'errors': [f"Unsupported item type '{item_type}' for Deezer."] } else: current_app.logger.error(f"Unsupported service_name '{service_name}'.") return { 'imported_count': 0, 'skipped_count': 0, 'error_count': 1, 'errors': [f"Unsupported service '{service_name}'."] } @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': [] } if not current_user or not current_user.is_authenticated or not current_user.spotify_token: current_app.logger.error(f"Import Spotify track: User not authenticated or no Spotify token for track {track_id}.") result['errors'].append("User not authenticated or no Spotify token.") result['error_count'] += 1 return result expires_at_timestamp = None if current_user.spotify_token_expiry: if isinstance(current_user.spotify_token_expiry, datetime): expires_at_timestamp = int(current_user.spotify_token_expiry.timestamp()) else: try: expires_at_timestamp = int(datetime.fromisoformat(str(current_user.spotify_token_expiry)).timestamp()) except ValueError: current_app.logger.warning(f"Could not parse spotify_token_expiry for user {current_user.id} in import_spotify_track.") authlib_token_for_request = { 'access_token': current_user.spotify_token, 'refresh_token': current_user.spotify_refresh_token, 'token_type': 'Bearer', 'expires_at': expires_at_timestamp } try: current_app.logger.info(f"Attempting to import Spotify track ID: {track_id} for user {current_user.id}") existing_song = Song.query.filter_by(spotify_id=track_id).first() if existing_song: current_app.logger.info(f'Spotify track ID {track_id} already exists as song: {existing_song.title} by {existing_song.artist}') result['skipped_count'] += 1 return result resp = sp.get(f'tracks/{track_id}', token=authlib_token_for_request) resp.raise_for_status() track_info = resp.json() if sp.token and sp.token.get('access_token') != authlib_token_for_request.get('access_token'): current_app.logger.info(f"Spotify token refreshed during import_spotify_track (track ID: {track_id}) for user {current_user.id}.") if update_oauth_tokens(current_user, sp.token, 'spotify'): authlib_token_for_request = sp.token # Update local token for any further use in this scope current_app.logger.info(f"Refreshed Spotify token saved for user {current_user.id} after track import.") else: current_app.logger.error(f"Failed to save refreshed Spotify token for user {current_user.id} after track import.") if not track_info: result['errors'].append(f"Track with ID {track_id} not found on Spotify or empty response.") result['error_count'] += 1 current_app.logger.warning(f"Track with ID {track_id} not found on Spotify or empty response.") return result isrc = track_info.get('external_ids', {}).get('isrc') song = None if isrc: current_app.logger.info(f"Track {track_id} has ISRC: {isrc}. Checking existing songs by 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 {isrc}: {existing_by_isrc.title} by {existing_by_isrc.artist}. Updating Spotify ID if needed.') if not existing_by_isrc.spotify_id: existing_by_isrc.spotify_id = track_id # Potentially update other Spotify-specific fields if they are missing or different db.session.commit() result['skipped_count'] += 1 return result current_app.logger.info(f"Looking up comprehensive metadata for ISRC: {isrc}") metadata = get_song_metadata_by_isrc(isrc, current_app) if metadata and metadata.get("title"): current_app.logger.info(f"Creating song for ISRC {isrc} using 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', # Indicate primary import source 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"Enriched metadata found from sources: {metadata.get('sources', [])} for track {track_id}") else: current_app.logger.info(f"No comprehensive metadata found for ISRC {isrc}, or metadata was incomplete. Falling back for track {track_id}.") if song is None: # If no ISRC, or ISRC lookup failed to produce a song object current_app.logger.info(f"Creating song for track {track_id} using basic Spotify data (no ISRC or failed ISRC enrichment).") song = ImportHelper._create_song_from_spotify(track_info) # Ensure this helper is robust if song: # Ensure spotify_id is set if created via ISRC path primarily if not song.spotify_id: song.spotify_id = track_id if not song.source: song.source = 'spotify' db.session.add(song) try: db.session.flush() # Flush to get song.id for relationships if needed, and catch early DB errors except Exception as e_flush: current_app.logger.error(f"Error flushing session for song {song.title} (Spotify ID: {track_id}): {str(e_flush)}", exc_info=True) db.session.rollback() result['errors'].append(f"DB flush error for {song.title}: {str(e_flush)}") result['error_count'] += 1 return result # Fetch and apply genres if not already set by metadata service if not song.genre and track_info.get('album'): album_id_for_genre = track_info['album'].get('id') if album_id_for_genre: try: # Use the potentially refreshed token for this new call album_details_resp = sp.get(f'albums/{album_id_for_genre}', token=authlib_token_for_request) album_details_resp.raise_for_status() album_details = album_details_resp.json() # Check for token refresh again after this call if sp.token and sp.token.get('access_token') != authlib_token_for_request.get('access_token'): current_app.logger.info(f"Spotify token refreshed during album genre fetch for track {track_id}, user {current_user.id}.") if update_oauth_tokens(current_user, sp.token, 'spotify'): authlib_token_for_request = sp.token current_app.logger.info(f"Refreshed Spotify token saved (album genre fetch) for user {current_user.id}.") else: current_app.logger.error(f"Failed to save refreshed Spotify token (album genre fetch) for user {current_user.id}.") if album_details.get('genres'): current_app.logger.info(f"Found genres from album {album_id_for_genre} for track {track_id}: {album_details['genres']}") ImportHelper.create_tags_from_genre(song, album_details['genres']) if not song.genre: # Set primary genre field if still empty song.genre = ", ".join(album_details['genres']) if isinstance(album_details['genres'], list) else str(album_details['genres']) except Exception as e_album_genre: current_app.logger.warning(f"Could not fetch/process album genres for track {track_id}: {str(e_album_genre)}") # Process genres from additional_data if present if song.additional_data: try: additional_data_json = json.loads(song.additional_data) if 'genres' in additional_data_json: ImportHelper.create_tags_from_genre(song, additional_data_json['genres']) elif 'tags' in additional_data_json: # Support 'tags' field as well ImportHelper.create_tags_from_genre(song, additional_data_json['tags']) except (json.JSONDecodeError, TypeError): current_app.logger.warning(f"Could not parse additional_data for genres for song {song.title} (Spotify ID: {track_id})") # Fetch audio features ImportHelper._fetch_audio_features_for_song(sp, song, track_id, token=authlib_token_for_request) # Pass token # Final commit db.session.commit() result['imported_count'] += 1 current_app.logger.info(f"Successfully imported Spotify track {track_id} as '{song.title}' with ID {song.id}") else: result['errors'].append(f"Failed to create song object for track {track_id}") result['error_count'] += 1 current_app.logger.error(f"Song object creation failed for Spotify track ID: {track_id}") return result except MissingTokenError as mte: # This specific error should ideally be prevented by the explicit token passing current_app.logger.error(f"Authlib MissingTokenError (should not happen with explicit token) for Spotify track {track_id}: {str(mte)}", exc_info=True) result['errors'].append(f"Spotify authentication error (missing token) for track {track_id}.") result['error_count'] += 1 db.session.rollback() return result except HTTPStatusError as hse: status_code = hse.response.status_code error_text = hse.response.text current_app.logger.error(f"HTTPStatusError ({status_code}) importing Spotify track {track_id}: {error_text}", exc_info=True) error_detail = f"Spotify API error ({status_code}) for track {track_id}." try: error_json = hse.response.json() if error_json.get('error', {}).get('message'): error_detail = f"Spotify error for track {track_id}: {error_json['error']['message']}" except ValueError: pass result['errors'].append(error_detail) result['error_count'] += 1 return result except Exception as e: current_app.logger.error(f"Generic error importing Spotify track {track_id}: {str(e)}", exc_info=True) result['errors'].append(f"Unexpected error for track {track_id}: {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': [] } if not current_user or not current_user.is_authenticated or not current_user.spotify_token: current_app.logger.error(f"Import Spotify album: User not authenticated or no Spotify token for album {album_id}.") result['errors'].append("User not authenticated or no Spotify token.") result['error_count'] += 1 return result expires_at_timestamp = None if current_user.spotify_token_expiry: if isinstance(current_user.spotify_token_expiry, datetime): expires_at_timestamp = int(current_user.spotify_token_expiry.timestamp()) else: try: expires_at_timestamp = int(datetime.fromisoformat(str(current_user.spotify_token_expiry)).timestamp()) except ValueError: current_app.logger.warning(f"Could not parse spotify_token_expiry for user {current_user.id} in import_spotify_album.") authlib_token_for_request = { 'access_token': current_user.spotify_token, 'refresh_token': current_user.spotify_refresh_token, 'token_type': 'Bearer', 'expires_at': expires_at_timestamp } try: current_app.logger.info(f"Starting import for Spotify album ID: {album_id} for user {current_user.id}") album_resp = sp.get(f'albums/{album_id}', token=authlib_token_for_request) album_resp.raise_for_status() album_data = album_resp.json() album_name = album_data.get('name', 'Unknown Album') current_app.logger.info(f"Importing tracks from album: '{album_name}' (ID: {album_id})") if sp.token and sp.token.get('access_token') != authlib_token_for_request.get('access_token'): current_app.logger.info(f"Spotify token refreshed during album metadata fetch (album ID: {album_id}) for user {current_user.id}.") if update_oauth_tokens(current_user, sp.token, 'spotify'): authlib_token_for_request = sp.token # Update local token current_app.logger.info(f"Refreshed Spotify token saved for user {current_user.id} after album metadata.") else: current_app.logger.error(f"Failed to save refreshed Spotify token for user {current_user.id} after album metadata.") tracks_url = f'albums/{album_id}/tracks' # Initial URL # Spotify API for album tracks might be relative, ensure sp.get handles it or construct full URL if needed. # Authlib's client.get usually handles relative URLs by joining with the base_url. page_count = 0 while tracks_url: page_count += 1 current_app.logger.info(f"Fetching page {page_count} of tracks for album '{album_name}' from URL: {tracks_url}") # For subsequent calls in pagination, use the potentially updated authlib_token_for_request tracks_resp = sp.get(tracks_url, token=authlib_token_for_request) tracks_resp.raise_for_status() tracks_data = tracks_resp.json() if sp.token and sp.token.get('access_token') != authlib_token_for_request.get('access_token'): current_app.logger.info(f"Spotify token refreshed during album tracks fetch (album ID: {album_id}, page: {page_count}) for user {current_user.id}.") if update_oauth_tokens(current_user, sp.token, 'spotify'): authlib_token_for_request = sp.token # Update local token for next iteration / track import current_app.logger.info(f"Refreshed Spotify token saved for user {current_user.id} (album tracks page {page_count}).") else: current_app.logger.error(f"Failed to save refreshed Spotify token for user {current_user.id} (album tracks page {page_count}).") track_items = tracks_data.get('items', []) if not track_items and page_count == 1: current_app.logger.warning(f"No tracks found in album '{album_name}' (ID: {album_id}) on the first page.") for track_item_simplified in track_items: track_id = track_item_simplified.get('id') if not track_id: current_app.logger.warning(f"Skipping track with no ID in album '{album_name}' (ID: {album_id})") result['errors'].append(f"Found a track with no ID in album {album_id}.") result['error_count'] +=1 continue # Call import_spotify_track. It will handle its own token now. track_import_result = ImportHelper.import_spotify_track(sp, track_id) result['imported_count'] += track_import_result.get('imported_count', 0) result['skipped_count'] += track_import_result.get('skipped_count', 0) result['error_count'] += track_import_result.get('error_count', 0) if track_import_result.get('errors'): result['errors'].extend(track_import_result['errors']) tracks_url = tracks_data.get('next') if tracks_url: current_app.logger.info(f"Next page of tracks for album '{album_name}' at: {tracks_url}") else: current_app.logger.info(f"No more track pages for album '{album_name}' (ID: {album_id}).") db.session.commit() # Commit any changes made by track imports except MissingTokenError as mte: current_app.logger.error(f"Authlib MissingTokenError while importing Spotify album {album_id}: {str(mte)}", exc_info=True) result['errors'].append(f"Spotify authentication error (missing token) for album {album_id}.") result['error_count'] += 1 except HTTPStatusError as hse: status_code = hse.response.status_code error_text = hse.response.text current_app.logger.error(f"HTTPStatusError ({status_code}) importing Spotify album {album_id}: {error_text}", exc_info=True) error_detail = f"Spotify API error ({status_code}) for album {album_id}." try: error_json = hse.response.json() if error_json.get('error', {}).get('message'): error_detail = f"Spotify error for album {album_id}: {error_json['error']['message']}" except ValueError: pass result['errors'].append(error_detail) result['error_count'] += 1 except Exception as e: current_app.logger.error(f"Unexpected error importing Spotify album {album_id}: {str(e)}", exc_info=True) result['errors'].append(f"Failed to import album {album_id} due to unexpected error: {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': [] } if not current_user or not current_user.is_authenticated or not current_user.spotify_token: current_app.logger.error(f"Import Spotify playlist: User not authenticated or no Spotify token for playlist {playlist_id}.") result['errors'].append("User not authenticated or no Spotify token.") result['error_count'] += 1 return result expires_at_timestamp = None if current_user.spotify_token_expiry: if isinstance(current_user.spotify_token_expiry, datetime): expires_at_timestamp = int(current_user.spotify_token_expiry.timestamp()) else: try: expires_at_timestamp = int(datetime.fromisoformat(str(current_user.spotify_token_expiry)).timestamp()) except ValueError: current_app.logger.warning(f"Could not parse spotify_token_expiry for user {current_user.id} in import_spotify_playlist.") authlib_token_for_request = { 'access_token': current_user.spotify_token, 'refresh_token': current_user.spotify_refresh_token, 'token_type': 'Bearer', 'expires_at': expires_at_timestamp } try: current_app.logger.info(f"Starting import for Spotify playlist ID: {playlist_id} for user {current_user.id}") # First, get playlist details to get the name (optional, but good for logging) playlist_details_resp = sp.get(f'playlists/{playlist_id}?fields=name,tracks.next', token=authlib_token_for_request) playlist_details_resp.raise_for_status() playlist_data = playlist_details_resp.json() playlist_name = playlist_data.get('name', 'Unknown Playlist') current_app.logger.info(f"Importing tracks from playlist: '{playlist_name}' (ID: {playlist_id})") if sp.token and sp.token.get('access_token') != authlib_token_for_request.get('access_token'): current_app.logger.info(f"Spotify token refreshed during playlist metadata fetch (playlist ID: {playlist_id}) for user {current_user.id}.") if update_oauth_tokens(current_user, sp.token, 'spotify'): authlib_token_for_request = sp.token # Update local token current_app.logger.info(f"Refreshed Spotify token saved for user {current_user.id} after playlist metadata.") else: current_app.logger.error(f"Failed to save refreshed Spotify token for user {current_user.id} after playlist metadata.") tracks_url = f'playlists/{playlist_id}/tracks' # Initial URL page_count = 0 while tracks_url: page_count += 1 current_app.logger.info(f"Fetching page {page_count} of tracks for playlist '{playlist_name}' from URL: {tracks_url}") tracks_resp = sp.get(tracks_url, token=authlib_token_for_request) tracks_resp.raise_for_status() tracks_data = tracks_resp.json() if sp.token and sp.token.get('access_token') != authlib_token_for_request.get('access_token'): current_app.logger.info(f"Spotify token refreshed during playlist tracks fetch (playlist ID: {playlist_id}, page: {page_count}) for user {current_user.id}.") if update_oauth_tokens(current_user, sp.token, 'spotify'): authlib_token_for_request = sp.token # Update local token current_app.logger.info(f"Refreshed Spotify token saved for user {current_user.id} (playlist tracks page {page_count}).") else: current_app.logger.error(f"Failed to save refreshed Spotify token for user {current_user.id} (playlist tracks page {page_count}).") track_items = tracks_data.get('items', []) if not track_items and page_count == 1 and not tracks_data.get('next'): # Check if playlist is actually empty current_app.logger.warning(f"No tracks found in playlist '{playlist_name}' (ID: {playlist_id}). It might be empty.") for item_wrapper in track_items: track_info_obj = item_wrapper.get('track') if not track_info_obj or not isinstance(track_info_obj, dict): # Skip if track is None (e.g., local file) or not a dict current_app.logger.warning(f"Skipping item in playlist '{playlist_name}' (ID: {playlist_id}) as it's not a valid track object or is unavailable: {track_info_obj}") result['errors'].append(f"Skipped an invalid/unavailable item in playlist {playlist_id}.") # Not necessarily an error_count increment unless we want to be strict continue track_id = track_info_obj.get('id') if not track_id: # Should not happen if track_info_obj is valid current_app.logger.warning(f"Skipping track with no ID in playlist '{playlist_name}' (ID: {playlist_id})") result['errors'].append(f"Found a track with no ID in playlist {playlist_id}.") result['error_count'] +=1 continue # Call import_spotify_track. It will handle its own token. track_import_result = ImportHelper.import_spotify_track(sp, track_id) result['imported_count'] += track_import_result.get('imported_count', 0) result['skipped_count'] += track_import_result.get('skipped_count', 0) result['error_count'] += track_import_result.get('error_count', 0) if track_import_result.get('errors'): result['errors'].extend(track_import_result['errors']) tracks_url = tracks_data.get('next') if tracks_url: current_app.logger.info(f"Next page of tracks for playlist '{playlist_name}' at: {tracks_url}") else: current_app.logger.info(f"No more track pages for playlist '{playlist_name}' (ID: {playlist_id}).") db.session.commit() # Commit any changes made by track imports 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