2f55f898ed
- 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.
323 lines
14 KiB
Python
323 lines
14 KiB
Python
import os
|
|
import requests
|
|
import logging
|
|
import random
|
|
import time
|
|
from flask import current_app
|
|
from musicround.models import Song, db
|
|
from musicround.helpers.metadata import get_song_metadata_by_isrc
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
class DeezerClient:
|
|
"""
|
|
Client for interacting with the Deezer API
|
|
Handles searching and importing songs, albums, and playlists
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.base_url = "https://api.deezer.com"
|
|
self.logger = logging.getLogger(__name__)
|
|
|
|
def _make_request(self, endpoint, params=None):
|
|
"""Make a GET request to the Deezer API"""
|
|
url = f"{self.base_url}/{endpoint}"
|
|
try:
|
|
response = requests.get(url, params=params)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
except requests.RequestException as e:
|
|
self.logger.error(f"Deezer API request error: {e}")
|
|
return None
|
|
|
|
def search_tracks(self, query, limit=50):
|
|
"""Search for tracks on Deezer"""
|
|
params = {
|
|
'q': query,
|
|
'limit': limit
|
|
}
|
|
result = self._make_request('search/track', params=params)
|
|
if result and 'data' in result:
|
|
return result['data']
|
|
return []
|
|
|
|
def search_albums(self, query, limit=25):
|
|
"""Search for albums on Deezer"""
|
|
params = {
|
|
'q': query,
|
|
'limit': limit
|
|
}
|
|
result = self._make_request('search/album', params=params)
|
|
if result and 'data' in result:
|
|
return result['data']
|
|
return []
|
|
|
|
def search_playlists(self, query, limit=25):
|
|
"""Search for playlists on Deezer"""
|
|
params = {
|
|
'q': query,
|
|
'limit': limit
|
|
}
|
|
result = self._make_request('search/playlist', params=params)
|
|
if result and 'data' in result:
|
|
return result['data']
|
|
return []
|
|
|
|
def get_track(self, track_id):
|
|
"""Get details for a specific track"""
|
|
return self._make_request(f'track/{track_id}')
|
|
|
|
def get_album(self, album_id):
|
|
"""Get details for a specific album"""
|
|
return self._make_request(f'album/{album_id}')
|
|
|
|
def get_album_tracks(self, album_id):
|
|
"""Get tracks from a specific album"""
|
|
result = self._make_request(f'album/{album_id}/tracks')
|
|
if result and 'data' in result:
|
|
return result['data']
|
|
return []
|
|
|
|
def get_playlist(self, playlist_id):
|
|
"""Get details for a specific playlist"""
|
|
return self._make_request(f'playlist/{playlist_id}')
|
|
|
|
def get_playlist_tracks(self, playlist_id):
|
|
"""Get tracks from a specific playlist"""
|
|
result = self._make_request(f'playlist/{playlist_id}/tracks')
|
|
if result and 'data' in result:
|
|
return result['data']
|
|
return []
|
|
|
|
def get_popular_playlists(self, limit=30):
|
|
"""
|
|
Get popular playlists from Deezer
|
|
This uses a set of predetermined searches to find popular playlists
|
|
"""
|
|
playlists = []
|
|
search_terms = ['hits', 'top', 'chart', 'popular', 'best', 'essential']
|
|
|
|
# Search for each term and combine results
|
|
for term in search_terms:
|
|
results = self.search_playlists(term, limit=10)
|
|
playlists.extend(results)
|
|
|
|
# Avoid rate limiting
|
|
time.sleep(0.1)
|
|
|
|
# Filter for playlists with reasonable track counts (avoid tiny playlists)
|
|
playlists = [p for p in playlists if p.get('nb_tracks', 0) >= 10]
|
|
|
|
# Sort by popularity or track count
|
|
playlists.sort(key=lambda x: x.get('nb_tracks', 0), reverse=True)
|
|
|
|
# Shuffle and limit results
|
|
random.shuffle(playlists)
|
|
return playlists[:limit]
|
|
|
|
def get_genre_from_lastfm(self, artist_name, track_name, lastfm_api_key):
|
|
"""
|
|
Fetch genre from Last.fm API
|
|
Returns the genre string, or empty string if not found
|
|
"""
|
|
if not lastfm_api_key:
|
|
return ""
|
|
|
|
url = 'http://ws.audioscrobbler.com/2.0/'
|
|
params = {
|
|
'method': 'track.getInfo',
|
|
'api_key': lastfm_api_key,
|
|
'artist': artist_name,
|
|
'track': track_name,
|
|
'format': 'json'
|
|
}
|
|
|
|
try:
|
|
response = requests.get(url=url, params=params).json()
|
|
|
|
# If present, use the first top-level tag as "genre"
|
|
if ('track' in response and
|
|
'toptags' in response['track'] and
|
|
'tag' in response['track']['toptags'] and
|
|
response['track']['toptags']['tag']):
|
|
return response['track']['toptags']['tag'][0]['name']
|
|
return ""
|
|
except Exception as e:
|
|
self.logger.error(f"Last.fm API error: {e}")
|
|
return ""
|
|
|
|
def import_track(self, track_id, lastfm_api_key=None):
|
|
"""
|
|
Import a track from Deezer into the database
|
|
Returns the Song object if successful, None otherwise
|
|
"""
|
|
track_info = self.get_track(track_id)
|
|
|
|
if not track_info:
|
|
self.logger.error(f"Could not fetch track with ID {track_id}")
|
|
return None
|
|
|
|
# Check if the track has a preview URL (required for our application)
|
|
preview_url = track_info.get('preview')
|
|
if not preview_url:
|
|
self.logger.warning(f"Track {track_info.get('title')} has no preview URL")
|
|
return None
|
|
|
|
# Extract ISRC
|
|
isrc = track_info.get('isrc')
|
|
|
|
# Check if this track is already in our database by Deezer ID or ISRC
|
|
existing_song = None
|
|
if isrc:
|
|
existing_song = Song.query.filter_by(isrc=isrc).first()
|
|
if not existing_song:
|
|
existing_song = Song.query.filter_by(deezer_id=str(track_info['id'])).first()
|
|
|
|
if existing_song:
|
|
self.logger.info(f"Track {track_info.get('title')} (Deezer ID: {track_info['id']}, ISRC: {isrc}) already exists in database with ID {existing_song.id}")
|
|
# If ISRC was missing and we found it now, update the existing record
|
|
if isrc and not existing_song.isrc:
|
|
existing_song.isrc = isrc
|
|
if existing_song.deezer_id is None: # If it was matched by ISRC but didn't have deezer_id
|
|
existing_song.deezer_id = str(track_info['id'])
|
|
try:
|
|
db.session.commit()
|
|
self.logger.info(f"Updated ISRC for existing song {existing_song.id} to {isrc}")
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
self.logger.error(f"Error updating ISRC for existing song {existing_song.id}: {e}")
|
|
|
|
# Optionally, trigger metadata refresh if ISRC is now available or if desired
|
|
if existing_song.isrc:
|
|
try:
|
|
app_context = current_app._get_current_object()
|
|
updated_metadata = get_song_metadata_by_isrc(existing_song.isrc, app=app_context)
|
|
if updated_metadata:
|
|
# Update song fields from aggregated metadata
|
|
existing_song.title = updated_metadata.get('title', existing_song.title)
|
|
existing_song.artist = updated_metadata.get('artist_name', existing_song.artist)
|
|
existing_song.year = updated_metadata.get('year', existing_song.year)
|
|
existing_song.genre = updated_metadata.get('genre', existing_song.genre)
|
|
# ... update other relevant fields ...
|
|
if updated_metadata.get('spotify_id') and not existing_song.spotify_id:
|
|
existing_song.spotify_id = updated_metadata.get('spotify_id')
|
|
if updated_metadata.get('cover_url') and not existing_song.cover_url: # Prioritize existing cover if any
|
|
existing_song.cover_url = updated_metadata.get('cover_url')
|
|
if updated_metadata.get('preview_url') and not existing_song.preview_url: # Prioritize existing preview if any
|
|
existing_song.preview_url = updated_metadata.get('preview_url')
|
|
|
|
db.session.commit()
|
|
self.logger.info(f"Refreshed metadata for existing song {existing_song.id} using ISRC {existing_song.isrc}")
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
self.logger.error(f"Error refreshing metadata for existing song {existing_song.id}: {e}")
|
|
return existing_song
|
|
|
|
# Get additional artist details if needed
|
|
artist_name = track_info.get('artist', {}).get('name', '')
|
|
|
|
# Get album release year
|
|
album_id = track_info.get('album', {}).get('id')
|
|
release_year = ''
|
|
cover_url = ''
|
|
|
|
if album_id:
|
|
album_info = self.get_album(album_id)
|
|
if album_info:
|
|
release_date = album_info.get('release_date', '')
|
|
release_year = release_date[:4] if release_date else ''
|
|
# Get highest quality cover image
|
|
cover_url = album_info.get('cover_xl') or album_info.get('cover_big') or album_info.get('cover_medium', '')
|
|
|
|
# Get genre from Last.fm (can be removed if metadata aggregation handles it)
|
|
# genre = self.get_genre_from_lastfm(artist_name, track_info.get('title', ''), lastfm_api_key)
|
|
genre = None # Will be populated by metadata aggregation if ISRC is present
|
|
|
|
# Create new Song object
|
|
new_song = Song(
|
|
deezer_id=str(track_info['id']),
|
|
spotify_id=None, # Will be populated by metadata aggregation if ISRC is present
|
|
title=track_info.get('title', ''),
|
|
artist=artist_name,
|
|
genre=genre,
|
|
year=release_year,
|
|
preview_url=preview_url,
|
|
cover_url=cover_url,
|
|
popularity=track_info.get('rank', 0), # Deezer 'rank' can be used as popularity
|
|
isrc=isrc, # Save the ISRC
|
|
used_count=0
|
|
)
|
|
|
|
try:
|
|
db.session.add(new_song)
|
|
db.session.commit()
|
|
self.logger.info(f"Imported track '{new_song.title}' by {new_song.artist} with Deezer ID {new_song.deezer_id} and ISRC {new_song.isrc}")
|
|
|
|
# If ISRC is present, fetch and update with aggregated metadata
|
|
if new_song.isrc:
|
|
try:
|
|
app_context = current_app._get_current_object()
|
|
aggregated_metadata = get_song_metadata_by_isrc(new_song.isrc, app=app_context)
|
|
if aggregated_metadata:
|
|
new_song.title = aggregated_metadata.get('title', new_song.title)
|
|
new_song.artist = aggregated_metadata.get('artist_name', new_song.artist)
|
|
new_song.year = aggregated_metadata.get('year', new_song.year)
|
|
new_song.genre = aggregated_metadata.get('genre', new_song.genre)
|
|
new_song.spotify_id = aggregated_metadata.get('spotify_id', new_song.spotify_id)
|
|
# Update cover and preview URLs if they are better or missing
|
|
if aggregated_metadata.get('cover_url'):
|
|
new_song.cover_url = aggregated_metadata.get('cover_url')
|
|
if aggregated_metadata.get('preview_url'):
|
|
new_song.preview_url = aggregated_metadata.get('preview_url')
|
|
# Potentially update popularity if a more universal score is available
|
|
# new_song.popularity = aggregated_metadata.get('popularity', new_song.popularity)
|
|
db.session.commit()
|
|
self.logger.info(f"Updated new song {new_song.id} with aggregated metadata using ISRC {new_song.isrc}")
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
self.logger.error(f"Error updating new song {new_song.id} with aggregated metadata: {e}")
|
|
return new_song
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
self.logger.error(f"Error saving track to database: {e}")
|
|
return None
|
|
|
|
def import_album(self, album_id, lastfm_api_key=None):
|
|
"""
|
|
Import all tracks from an album
|
|
Returns a list of successfully imported Song objects
|
|
"""
|
|
tracks = self.get_album_tracks(album_id)
|
|
imported_songs = []
|
|
|
|
for track in tracks:
|
|
track_id = track.get('id')
|
|
if track_id:
|
|
song = self.import_track(track_id, lastfm_api_key)
|
|
if song:
|
|
imported_songs.append(song)
|
|
|
|
# Add a small delay to avoid overwhelming the API
|
|
time.sleep(0.2)
|
|
|
|
return imported_songs
|
|
|
|
def import_playlist(self, playlist_id, lastfm_api_key=None):
|
|
"""
|
|
Import all tracks from a playlist
|
|
Returns a list of successfully imported Song objects
|
|
"""
|
|
tracks = self.get_playlist_tracks(playlist_id)
|
|
imported_songs = []
|
|
|
|
for track in tracks:
|
|
track_id = track.get('id')
|
|
if track_id:
|
|
song = self.import_track(track_id, lastfm_api_key)
|
|
if song:
|
|
imported_songs.append(song)
|
|
|
|
# Add a small delay to avoid overwhelming the API
|
|
time.sleep(0.2)
|
|
|
|
return imported_songs |