diff --git a/musicround/deezer_client.py b/musicround/deezer_client.py index 7562d3f..a9f4d53 100644 --- a/musicround/deezer_client.py +++ b/musicround/deezer_client.py @@ -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 diff --git a/musicround/helpers/import_helper.py b/musicround/helpers/import_helper.py index f9e5c73..fd6caf1 100644 --- a/musicround/helpers/import_helper.py +++ b/musicround/helpers/import_helper.py @@ -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: diff --git a/musicround/models.py b/musicround/models.py index 83616e3..363e3d6 100644 --- a/musicround/models.py +++ b/musicround/models.py @@ -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 + diff --git a/musicround/routes/generate.py b/musicround/routes/generate.py index ea50142..e775970 100644 --- a/musicround/routes/generate.py +++ b/musicround/routes/generate.py @@ -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 diff --git a/musicround/routes/import_routes.py b/musicround/routes/import_routes.py index 5fe598f..f0c98ef 100644 --- a/musicround/routes/import_routes.py +++ b/musicround/routes/import_routes.py @@ -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) \ No newline at end of file + 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 + ) \ No newline at end of file diff --git a/musicround/templates/base.html b/musicround/templates/base.html index 33d5284..e52c363 100644 --- a/musicround/templates/base.html +++ b/musicround/templates/base.html @@ -165,12 +165,16 @@ Backup Manager - -