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:
self.logger.error(f"Last.fm API error: {e}")
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
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()
self.logger.error(f"Error saving track to database: {e}")
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
Returns a dictionary with import statistics
@@ -307,7 +307,7 @@ class DeezerClient:
'imported_count': len(imported_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
Returns a dictionary with import statistics
+24 -9
View File
@@ -1,7 +1,17 @@
"""
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.
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 logging
@@ -240,7 +250,7 @@ class ImportHelper:
elif item_type.lower() == 'playlist':
try:
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}")
return {
'imported_count': 0,
@@ -278,8 +288,9 @@ class ImportHelper:
'error_count': 1,
'errors': ["Deezer client not configured."]
}
lastfm_api_key = current_app.config.get('LASTFM_API_KEY') if item_type.lower() == 'track':
lastfm_api_key = current_app.config.get('LASTFM_API_KEY')
if item_type.lower() == 'track':
song, was_new = deezer_client.import_track(item_id, lastfm_api_key=lastfm_api_key)
if song:
if was_new:
@@ -535,9 +546,9 @@ class ImportHelper:
# 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()
# Final commit db.session.commit()
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}")
else:
result['errors'].append(f"Failed to create song object for track {track_id}")
@@ -703,7 +714,8 @@ class ImportHelper:
'imported_count': 0,
'skipped_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:
@@ -783,8 +795,7 @@ class ImportHelper:
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.
# 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)
@@ -792,6 +803,10 @@ class ImportHelper:
result['error_count'] += track_import_result.get('error_count', 0)
if track_import_result.get('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')
if tracks_url:
+52 -3
View File
@@ -323,9 +323,58 @@ class SystemSetting(db.Model):
db.session.add(setting)
else:
setting.value = value
db.session.commit()
@staticmethod
db.session.commit() @staticmethod
def all_settings():
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
+42 -8
View File
@@ -353,22 +353,56 @@ def get_songs_from_deezer_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
Always returns the songs in the playlist, even if all already exist in the DB.
"""
try:
songs_per_round = current_app.config.get('SONGS_PER_ROUND', 10)
imported_songs = ImportHelper.import_item(
import_result = ImportHelper.import_item(
item_id=playlist_id,
item_type='playlist',
source='spotify',
service_name='spotify',
oauth_spotify=oauth.spotify
)
if not imported_songs:
current_app.logger.warning(f"No songs returned from ImportHelper.import_item for Spotify playlist {playlist_id}")
from musicround.models import Song
# 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 []
return imported_songs[:songs_per_round]
# 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:
current_app.logger.error(f"Error fetching or importing Spotify playlist {playlist_id}: {e}")
import traceback
+144 -26
View File
@@ -6,7 +6,7 @@ import time
import random
from datetime import datetime # Add datetime import
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.routes.import_songs import import_pl
from musicround.helpers.import_helper import ImportHelper
@@ -140,20 +140,34 @@ def import_official_playlists():
elif not auth_token:
flash("No active Spotify session. Please connect your Spotify account.", "warning")
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':
playlist_id = request.form['playlist_id']
result = ImportHelper.import_item('spotify', 'playlist', playlist_id)
if result['imported_count'] > 0:
flash(f'Successfully imported {result["imported_count"]} songs from official Spotify playlist!', 'success')
elif result['skipped_count'] > 0 and result['error_count'] == 0:
flash(f'All {result["skipped_count"]} songs were already in the database.', 'info')
elif result['error_count'] > 0:
flash(f'Encountered {result["error_count"]} errors during import.', 'warning')
else:
flash(f'Error importing playlist: {", ".join(result["errors"])}', 'danger')
# Check if user is authenticated for queue system
if not current_user.is_authenticated:
flash("Please log in to import playlists.", "warning")
return redirect(url_for('users.login'))
# 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'))
# 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'))
@@ -280,22 +294,35 @@ def direct_official_playlists():
# Initialize direct Spotify client with bearer token
from musicround.helpers.spotify_direct import SpotifyDirectClient
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':
playlist_id = request.form['playlist_id']
# Use the new unified ImportHelper
result = ImportHelper.import_item('spotify', 'playlist', playlist_id)
if result['imported_count'] > 0:
flash(f'Successfully imported {result["imported_count"]} songs from official Spotify playlist!', 'success')
elif result['skipped_count'] > 0 and result['error_count'] == 0:
flash(f'All {result["skipped_count"]} songs were already in the database.', 'info')
elif result['error_count'] > 0:
flash(f'Encountered {result["error_count"]} errors during import.', 'warning')
else:
flash(f'Error importing playlist: {", ".join(result["errors"])}', 'danger')
# Check if user is authenticated for queue system
if not current_user.is_authenticated:
flash("Please log in to import playlists.", "warning")
return redirect(url_for('users.login'))
# 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'))
# 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'))
# Get filter keywords from the query string (default to empty list)
@@ -713,4 +740,95 @@ def update_direct_token():
current_app.logger.error(f"Error validating bearer token: {e}")
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">
<i class="fas fa-download mr-2"></i> Backup Manager
</span>
</a></li>
<li><a class="block px-4 py-2 hover:bg-navy-50" href="{{ url_for('users.system_health') }}">
</a></li> <li><a class="block px-4 py-2 hover:bg-navy-50" href="{{ url_for('users.system_health') }}">
<span class="flex items-center">
<i class="fas fa-heartbeat mr-2"></i> System Health
</span>
</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>
</li>
{% endif %}