Files
gh-christianlouis-quizzical…/musicround/helpers/import_helper.py
T
Christian Krakau-Louis 2f55f898ed Add Spotify integration with improved token management and user interface
- Implemented Spotify OAuth handling in spotify_client_manager.py to ensure valid access tokens for users.
- Created helper functions in spotify_helper.py for refreshing tokens and retrieving user information.
- Developed manage_spotify.html template for connecting and managing Spotify accounts, displaying connection status and token information.
- Added logging for token management processes to enhance debugging and monitoring.
- Introduced a debug client for Spotify interactions to facilitate easier testing and development.
2025-05-27 21:40:37 +02:00

728 lines
41 KiB
Python

"""
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