Fixed Spotify stuff

This commit is contained in:
Christian Krakau-Louis
2025-06-09 13:13:30 +02:00
parent 213f53593c
commit bc4055ded5
6 changed files with 271 additions and 51 deletions
+3 -3
View File
@@ -145,7 +145,7 @@ class DeezerClient:
except Exception as e: except Exception as e:
self.logger.error(f"Last.fm API error: {e}") self.logger.error(f"Last.fm API error: {e}")
return "" return ""
def import_track(self, track_id, lastfm_api_key=None): def import_track(self, track_id, lastfm_api_key=None):
""" """
Import a track from Deezer into the database Import a track from Deezer into the database
Returns a tuple (Song object, was_new) where was_new indicates if this was a new import Returns a tuple (Song object, was_new) where was_new indicates if this was a new import
@@ -279,7 +279,7 @@ class DeezerClient:
db.session.rollback() db.session.rollback()
self.logger.error(f"Error saving track to database: {e}") self.logger.error(f"Error saving track to database: {e}")
return None, False return None, False
def import_album(self, album_id, lastfm_api_key=None): def import_album(self, album_id, lastfm_api_key=None):
""" """
Import all tracks from an album Import all tracks from an album
Returns a dictionary with import statistics Returns a dictionary with import statistics
@@ -307,7 +307,7 @@ class DeezerClient:
'imported_count': len(imported_songs), 'imported_count': len(imported_songs),
'skipped_count': len(skipped_songs) 'skipped_count': len(skipped_songs)
} }
def import_playlist(self, playlist_id, lastfm_api_key=None): def import_playlist(self, playlist_id, lastfm_api_key=None):
""" """
Import all tracks from a playlist Import all tracks from a playlist
Returns a dictionary with import statistics Returns a dictionary with import statistics
+23 -8
View File
@@ -1,7 +1,17 @@
""" """
Unified import helper for importing music content across different services. Unified import helper for importing music content across different services.
This module provides consistent import functionality for tracks, albums, and playlists This module provides consistent import functionality for tracks, albums, and playlists
from various music streaming services like Spotify and Deezer. from various music streaming services like Spoti try:
imported_songs = ImportHelper.import_spotify_playlist(spotify_client, item_id)
if not imported_songs or imported_songs.get('imported_count', 0) == 0:
current_app.logger.warning(f"Spotify playlist import returned empty result for playlist ID: {item_id}")
return {
'imported_count': 0,
'skipped_count': 0,
'error_count': 1,
'errors': [f"No songs found in Spotify playlist {item_id} or playlist import failed."]
}
return imported_songser.
""" """
import json import json
import logging import logging
@@ -240,7 +250,7 @@ class ImportHelper:
elif item_type.lower() == 'playlist': elif item_type.lower() == 'playlist':
try: try:
imported_songs = ImportHelper.import_spotify_playlist(spotify_client, item_id) imported_songs = ImportHelper.import_spotify_playlist(spotify_client, item_id)
if not imported_songs or len(imported_songs) == 0: if not imported_songs or imported_songs.get('imported_count', 0) == 0:
current_app.logger.warning(f"Spotify playlist import returned empty result for playlist ID: {item_id}") current_app.logger.warning(f"Spotify playlist import returned empty result for playlist ID: {item_id}")
return { return {
'imported_count': 0, 'imported_count': 0,
@@ -278,8 +288,9 @@ class ImportHelper:
'error_count': 1, 'error_count': 1,
'errors': ["Deezer client not configured."] 'errors': ["Deezer client not configured."]
} }
lastfm_api_key = current_app.config.get('LASTFM_API_KEY')
lastfm_api_key = current_app.config.get('LASTFM_API_KEY') if item_type.lower() == 'track': if item_type.lower() == 'track':
song, was_new = deezer_client.import_track(item_id, lastfm_api_key=lastfm_api_key) song, was_new = deezer_client.import_track(item_id, lastfm_api_key=lastfm_api_key)
if song: if song:
if was_new: if was_new:
@@ -535,9 +546,9 @@ class ImportHelper:
# Fetch audio features # Fetch audio features
ImportHelper._fetch_audio_features_for_song(sp, song, track_id, token=authlib_token_for_request) # Pass token ImportHelper._fetch_audio_features_for_song(sp, song, track_id, token=authlib_token_for_request) # Pass token
# Final commit # Final commit db.session.commit()
db.session.commit()
result['imported_count'] += 1 result['imported_count'] += 1
result['song_id'] = song.id # Add the song ID to the result
current_app.logger.info(f"Successfully imported Spotify track {track_id} as '{song.title}' with ID {song.id}") current_app.logger.info(f"Successfully imported Spotify track {track_id} as '{song.title}' with ID {song.id}")
else: else:
result['errors'].append(f"Failed to create song object for track {track_id}") result['errors'].append(f"Failed to create song object for track {track_id}")
@@ -703,7 +714,8 @@ class ImportHelper:
'imported_count': 0, 'imported_count': 0,
'skipped_count': 0, 'skipped_count': 0,
'error_count': 0, 'error_count': 0,
'errors': [] 'errors': [],
'imported_song_ids': [] # Track IDs of successfully imported songs
} }
if not current_user or not current_user.is_authenticated or not current_user.spotify_token: if not current_user or not current_user.is_authenticated or not current_user.spotify_token:
@@ -783,8 +795,7 @@ class ImportHelper:
result['errors'].append(f"Found a track with no ID in playlist {playlist_id}.") result['errors'].append(f"Found a track with no ID in playlist {playlist_id}.")
result['error_count'] +=1 result['error_count'] +=1
continue continue
# Call import_spotify_track. It will handle its own token.
# Call import_spotify_track. It will handle its own token.
track_import_result = ImportHelper.import_spotify_track(sp, track_id) track_import_result = ImportHelper.import_spotify_track(sp, track_id)
result['imported_count'] += track_import_result.get('imported_count', 0) result['imported_count'] += track_import_result.get('imported_count', 0)
@@ -793,6 +804,10 @@ class ImportHelper:
if track_import_result.get('errors'): if track_import_result.get('errors'):
result['errors'].extend(track_import_result['errors']) result['errors'].extend(track_import_result['errors'])
# Track the song ID if it was successfully imported
if track_import_result.get('imported_count', 0) > 0 and track_import_result.get('song_id'):
result['imported_song_ids'].append(track_import_result['song_id'])
tracks_url = tracks_data.get('next') tracks_url = tracks_data.get('next')
if tracks_url: if tracks_url:
current_app.logger.info(f"Next page of tracks for playlist '{playlist_name}' at: {tracks_url}") current_app.logger.info(f"Next page of tracks for playlist '{playlist_name}' at: {tracks_url}")
+52 -3
View File
@@ -323,9 +323,58 @@ class SystemSetting(db.Model):
db.session.add(setting) db.session.add(setting)
else: else:
setting.value = value setting.value = value
db.session.commit() db.session.commit() @staticmethod
@staticmethod
def all_settings(): def all_settings():
return {s.key: s.value for s in SystemSetting.query.all()} return {s.key: s.value for s in SystemSetting.query.all()}
class ImportJobRecord(db.Model):
"""
Database model for tracking import jobs
"""
id = db.Column(db.Integer, primary_key=True)
service_name = db.Column(db.String(50), nullable=False)
item_type = db.Column(db.String(20), nullable=False)
item_id = db.Column(db.String(255), nullable=False)
priority = db.Column(db.Integer, nullable=False, default=10)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
status = db.Column(db.String(20), default='pending') # pending, processing, completed, failed
created_at = db.Column(db.DateTime, default=datetime.utcnow)
started_at = db.Column(db.DateTime)
completed_at = db.Column(db.DateTime)
error_message = db.Column(db.Text)
imported_count = db.Column(db.Integer, default=0)
skipped_count = db.Column(db.Integer, default=0)
# Relationships
user = db.relationship('User', backref=db.backref('import_jobs', lazy=True))
def __repr__(self):
return f"ImportJobRecord(id={self.id}, service={self.service_name}, type={self.item_type}, item_id={self.item_id}, status={self.status})"
@property
def duration(self):
"""Calculate the job duration in seconds."""
if self.started_at and self.completed_at:
return (self.completed_at - self.started_at).total_seconds()
return None
@property
def item_url(self):
"""Generate a URL to the imported item based on service and type."""
if self.service_name == 'spotify':
if self.item_type == 'playlist':
return f"https://open.spotify.com/playlist/{self.item_id}"
elif self.item_type == 'album':
return f"https://open.spotify.com/album/{self.item_id}"
elif self.item_type == 'track':
return f"https://open.spotify.com/track/{self.item_id}"
elif self.service_name == 'deezer':
if self.item_type == 'playlist':
return f"https://www.deezer.com/playlist/{self.item_id}"
elif self.item_type == 'album':
return f"https://www.deezer.com/album/{self.item_id}"
elif self.item_type == 'track':
return f"https://www.deezer.com/track/{self.item_id}"
return None
+41 -7
View File
@@ -353,22 +353,56 @@ def get_songs_from_deezer_playlist(playlist_id):
def get_songs_from_spotify_playlist(playlist_id): def get_songs_from_spotify_playlist(playlist_id):
""" """
Fetch songs from a Spotify playlist, properly import them with metadata, and return them Fetch songs from a Spotify playlist, properly import them with metadata, and return them
Always returns the songs in the playlist, even if all already exist in the DB.
""" """
try: try:
songs_per_round = current_app.config.get('SONGS_PER_ROUND', 10) songs_per_round = current_app.config.get('SONGS_PER_ROUND', 10)
import_result = ImportHelper.import_item(
imported_songs = ImportHelper.import_item(
item_id=playlist_id, item_id=playlist_id,
item_type='playlist', item_type='playlist',
source='spotify', service_name='spotify',
oauth_spotify=oauth.spotify oauth_spotify=oauth.spotify
) )
if not imported_songs: from musicround.models import Song
current_app.logger.warning(f"No songs returned from ImportHelper.import_item for Spotify playlist {playlist_id}")
return []
return imported_songs[:songs_per_round] # If we have imported_song_ids, use them (these are DB IDs)
if import_result.get('imported_song_ids'):
song_db_ids = import_result['imported_song_ids']
imported_songs = Song.query.filter(Song.id.in_(song_db_ids)).all()
return imported_songs[:songs_per_round]
# If no imported_song_ids, fetch all Spotify IDs from the playlist and get those songs from DB
# Use the Spotify API directly to get the playlist track IDs
sp = oauth.spotify
# Get playlist tracks (paginated)
all_spotify_ids = []
next_url = f'playlists/{playlist_id}/tracks'
authlib_token = {
'access_token': current_user.spotify_token,
'refresh_token': current_user.spotify_refresh_token,
'token_type': 'Bearer',
'expires_at': int(current_user.spotify_token_expiry.timestamp()) if current_user.spotify_token_expiry else None
}
while next_url:
resp = sp.get(next_url, token=authlib_token)
resp.raise_for_status()
data = resp.json()
for item in data.get('items', []):
track = item.get('track')
if track and track.get('id'):
all_spotify_ids.append(track['id'])
next_url = data.get('next')
# If next_url is a full URL, convert to relative for sp.get
if next_url and next_url.startswith('https://api.spotify.com/v1/'):
next_url = next_url.replace('https://api.spotify.com/v1/', '')
if not all_spotify_ids:
current_app.logger.warning(f"No valid tracks found in Spotify playlist {playlist_id}")
return []
# Query all songs in DB with those Spotify IDs, preserving playlist order
songs_by_spotify_id = {s.spotify_id: s for s in Song.query.filter(Song.spotify_id.in_(all_spotify_ids)).all()}
ordered_songs = [songs_by_spotify_id[sid] for sid in all_spotify_ids if sid in songs_by_spotify_id]
return ordered_songs[:songs_per_round]
except Exception as e: except Exception as e:
current_app.logger.error(f"Error fetching or importing Spotify playlist {playlist_id}: {e}") current_app.logger.error(f"Error fetching or importing Spotify playlist {playlist_id}: {e}")
import traceback import traceback
+142 -24
View File
@@ -6,7 +6,7 @@ import time
import random import random
from datetime import datetime # Add datetime import from datetime import datetime # Add datetime import
from flask import Blueprint, render_template, redirect, url_for, request, current_app, flash, session, jsonify from flask import Blueprint, render_template, redirect, url_for, request, current_app, flash, session, jsonify
from flask_login import current_user from flask_login import current_user, login_required
from musicround.models import Song, db from musicround.models import Song, db
from musicround.routes.import_songs import import_pl from musicround.routes.import_songs import import_pl
from musicround.helpers.import_helper import ImportHelper from musicround.helpers.import_helper import ImportHelper
@@ -140,20 +140,34 @@ def import_official_playlists():
elif not auth_token: elif not auth_token:
flash("No active Spotify session. Please connect your Spotify account.", "warning") flash("No active Spotify session. Please connect your Spotify account.", "warning")
return redirect(url_for('users.spotify_link')) return redirect(url_for('users.spotify_link'))
# Handle POST request for importing a playlist
# Handle POST request for importing a playlist
if request.method == 'POST': if request.method == 'POST':
playlist_id = request.form['playlist_id'] playlist_id = request.form['playlist_id']
result = ImportHelper.import_item('spotify', 'playlist', playlist_id)
if result['imported_count'] > 0: # Check if user is authenticated for queue system
flash(f'Successfully imported {result["imported_count"]} songs from official Spotify playlist!', 'success') if not current_user.is_authenticated:
elif result['skipped_count'] > 0 and result['error_count'] == 0: flash("Please log in to import playlists.", "warning")
flash(f'All {result["skipped_count"]} songs were already in the database.', 'info') return redirect(url_for('users.login'))
elif result['error_count'] > 0:
flash(f'Encountered {result["error_count"]} errors during import.', 'warning') # Get the import queue from app config
else: queue = current_app.config.get('import_queue')
flash(f'Error importing playlist: {", ".join(result["errors"])}', 'danger') if not queue:
flash("Import queue not initialized.", "danger")
return redirect(url_for('core.view_songs'))
# Create import job and add to queue
from musicround.helpers.import_queue import ImportJob
priority = int(request.form.get('priority', 10))
job = ImportJob(
priority=priority,
service_name='spotify',
item_type='playlist',
item_id=playlist_id,
user_id=current_user.id,
)
queue.add_job(job)
flash('Official Spotify playlist import queued successfully. You will be notified when it completes.', 'info')
return redirect(url_for('core.view_songs')) return redirect(url_for('core.view_songs'))
@@ -280,21 +294,34 @@ def direct_official_playlists():
# Initialize direct Spotify client with bearer token # Initialize direct Spotify client with bearer token
from musicround.helpers.spotify_direct import SpotifyDirectClient from musicround.helpers.spotify_direct import SpotifyDirectClient
direct_client = SpotifyDirectClient(bearer_token=bearer_token) direct_client = SpotifyDirectClient(bearer_token=bearer_token)
# Handle POST request for importing a playlist
# Handle POST request for importing a playlist
if request.method == 'POST': if request.method == 'POST':
playlist_id = request.form['playlist_id'] playlist_id = request.form['playlist_id']
# Use the new unified ImportHelper
result = ImportHelper.import_item('spotify', 'playlist', playlist_id)
if result['imported_count'] > 0: # Check if user is authenticated for queue system
flash(f'Successfully imported {result["imported_count"]} songs from official Spotify playlist!', 'success') if not current_user.is_authenticated:
elif result['skipped_count'] > 0 and result['error_count'] == 0: flash("Please log in to import playlists.", "warning")
flash(f'All {result["skipped_count"]} songs were already in the database.', 'info') return redirect(url_for('users.login'))
elif result['error_count'] > 0:
flash(f'Encountered {result["error_count"]} errors during import.', 'warning') # Get the import queue from app config
else: queue = current_app.config.get('import_queue')
flash(f'Error importing playlist: {", ".join(result["errors"])}', 'danger') if not queue:
flash("Import queue not initialized.", "danger")
return redirect(url_for('core.view_songs'))
# Create import job and add to queue
from musicround.helpers.import_queue import ImportJob
priority = int(request.form.get('priority', 10))
job = ImportJob(
priority=priority,
service_name='spotify',
item_type='playlist',
item_id=playlist_id,
user_id=current_user.id,
)
queue.add_job(job)
flash('Direct Spotify playlist import queued successfully. You will be notified when it completes.', 'info')
return redirect(url_for('core.view_songs')) return redirect(url_for('core.view_songs'))
@@ -714,3 +741,94 @@ def update_direct_token():
flash(f'Error validating token: {str(e)}', 'error') flash(f'Error validating token: {str(e)}', 'error')
return redirect(return_url) return redirect(return_url)
@import_bp.route('/queue-status')
@login_required
def queue_status():
"""
Display real-time status of the import queue for administrators
"""
# Check if user is an admin
if not current_user.is_admin():
flash('Admin access required for Import Queue view.', 'danger')
return redirect(url_for('core.index'))
# Helper function to get current time
from datetime import datetime
def now():
return datetime.utcnow()
# Get the import queue from app config
queue = current_app.config.get('import_queue')
if not queue:
flash("Import queue not initialized.", "danger")
return redirect(url_for('core.view_songs'))
# Access queue internals for display - this won't modify the queue
queue_size = queue._queue.qsize()
# Extract information about jobs in the queue (without removing them)
# This is a bit of a hack but necessary to see what's in the PriorityQueue
# without removing items
queue_snapshot = []
if hasattr(queue._queue, 'queue'):
# Make a copy of the internal queue list
with queue._lock: # Ensure thread safety while accessing the queue
queue_items = list(queue._queue.queue)
for priority, counter, job in queue_items:
queue_snapshot.append({
'priority': priority,
'counter': counter,
'service': job.service_name,
'type': job.item_type,
'item_id': job.item_id,
'user_id': job.user_id
})
# Get active and recent jobs from database if available
active_jobs = []
recent_jobs = []
# Check if ImportJobRecord is defined
try:
from musicround.models import ImportJobRecord
# Get last 50 jobs from the database, sorted by most recent first
recent_jobs = ImportJobRecord.query.order_by(ImportJobRecord.created_at.desc()).limit(50).all()
# Get the active jobs (status='processing')
active_jobs = ImportJobRecord.query.filter_by(status='processing').all()
except (ImportError, AttributeError):
# ImportJobRecord might not be defined yet, handle this case
pass
# Get some basic stats
stats = {
'queue_size': queue_size,
'active_jobs': len(active_jobs),
'completed_today': 0,
'failed_today': 0
}
# If we have ImportJobRecord, get some stats
if recent_jobs:
import datetime
today = datetime.datetime.utcnow().date()
for job in recent_jobs:
if job.completed_at and job.completed_at.date() == today:
if job.status == 'completed':
stats['completed_today'] += 1
elif job.status == 'failed':
stats['failed_today'] += 1
return render_template(
'import_queue_status.html',
stats=stats,
active_jobs=active_jobs,
recent_jobs=recent_jobs,
queue_snapshot=queue_snapshot,
queue=queue,
now=now
)
+6 -2
View File
@@ -165,12 +165,16 @@
<span class="flex items-center"> <span class="flex items-center">
<i class="fas fa-download mr-2"></i> Backup Manager <i class="fas fa-download mr-2"></i> Backup Manager
</span> </span>
</a></li> </a></li> <li><a class="block px-4 py-2 hover:bg-navy-50" href="{{ url_for('users.system_health') }}">
<li><a class="block px-4 py-2 hover:bg-navy-50" href="{{ url_for('users.system_health') }}">
<span class="flex items-center"> <span class="flex items-center">
<i class="fas fa-heartbeat mr-2"></i> System Health <i class="fas fa-heartbeat mr-2"></i> System Health
</span> </span>
</a></li> </a></li>
<li><a class="block px-4 py-2 hover:bg-navy-50" href="{{ url_for('import.queue_status') }}">
<span class="flex items-center">
<i class="fas fa-tasks mr-2"></i> Import Queue
</span>
</a></li>
</ul> </ul>
</li> </li>
{% endif %} {% endif %}