From 1d1a42b7d64e0a20b32c82f0669408cbcfcc7941 Mon Sep 17 00:00:00 2001 From: "architect.in.git" Date: Sun, 23 Mar 2025 18:37:00 -0600 Subject: [PATCH] fixed artist downloading --- routes/artist.py | 10 +- routes/utils/artist.py | 208 +++++++++++++++-------------------------- static/js/artist.js | 166 ++++++++++++++++++++++++++++---- static/js/queue.js | 42 ++++++++- 4 files changed, 264 insertions(+), 162 deletions(-) diff --git a/routes/artist.py b/routes/artist.py index dae16f9..795cbec 100644 --- a/routes/artist.py +++ b/routes/artist.py @@ -18,7 +18,7 @@ def log_json(message_dict): @artist_bp.route('/download', methods=['GET']) def handle_artist_download(): """ - Enqueues album download tasks for the given artist using the new artist module. + Enqueues album download tasks for the given artist. Expected query parameters: - url: string (a Spotify artist URL) - album_type: string(s); comma-separated values such as "album,single,appears_on,compilation" @@ -39,8 +39,8 @@ def handle_artist_download(): # Import and call the updated download_artist_albums() function. from routes.utils.artist import download_artist_albums - # Delegate to the download_artist_albums function which will handle config itself - album_prg_files = download_artist_albums( + # Delegate to the download_artist_albums function which will handle album filtering + task_ids = download_artist_albums( url=url, album_type=album_type, request_args=request.args.to_dict() @@ -50,8 +50,8 @@ def handle_artist_download(): return Response( json.dumps({ "status": "complete", - "album_prg_files": album_prg_files, - "message": "Artist download completed – album tasks have been queued." + "task_ids": task_ids, + "message": f"Artist download completed – {len(task_ids)} album tasks have been queued." }), status=202, mimetype='application/json' diff --git a/routes/utils/artist.py b/routes/utils/artist.py index cc8a6d8..65f92fd 100644 --- a/routes/utils/artist.py +++ b/routes/utils/artist.py @@ -4,6 +4,7 @@ from pathlib import Path import os import logging from routes.utils.celery_queue_manager import download_queue_manager, get_config_params +from routes.utils.get_info import get_spotify_info from deezspot.easy_spoty import Spo from deezspot.libutils.utils import get_ids, link_is_valid @@ -63,155 +64,92 @@ def get_artist_discography(url, main, album_type='album,single,compilation,appea raise -def download_artist_albums(service, url, album_type="album,single,compilation", request_args=None, progress_callback=None): +def download_artist_albums(url, album_type="album,single,compilation", request_args=None): """ - Download albums from an artist. + Download albums by an artist, filtered by album types. Args: - service (str): 'spotify' or 'deezer' - url (str): URL of the artist - album_type (str): Comma-separated list of album types to download (album,single,compilation,appears_on) - request_args (dict): Original request arguments for additional parameters - progress_callback (callable): Optional callback function for progress reporting - + url (str): Spotify artist URL + album_type (str): Comma-separated list of album types to download + (album, single, compilation, appears_on) + request_args (dict): Original request arguments for tracking + Returns: - list: List of task IDs for the enqueued album downloads + list: List of task IDs for the queued album downloads """ - logger.info(f"Starting artist albums download: {url} (service: {service}, album_types: {album_type})") + if not url: + raise ValueError("Missing required parameter: url") - if request_args is None: - request_args = {} + # Extract artist ID from URL + artist_id = url.split('/')[-1] + if '?' in artist_id: + artist_id = artist_id.split('?')[0] - # Get config parameters - config_params = get_config_params() + logger.info(f"Fetching artist info for ID: {artist_id}") - # Get the artist information first - if service == 'spotify': - from deezspot.spotloader import SpoLogin - - # Get credentials - spotify_profile = request_args.get('main', config_params['spotify']) - credentials_path = os.path.abspath(os.path.join('./creds/spotify', spotify_profile, 'credentials.json')) - - # Validate credentials - if not os.path.isfile(credentials_path): - raise ValueError(f"Invalid Spotify credentials path: {credentials_path}") - - # Load Spotify client credentials if available - spotify_client_id = None - spotify_client_secret = None - search_creds_path = Path(f'./creds/spotify/{spotify_profile}/search.json') - if search_creds_path.exists(): - try: - with open(search_creds_path, 'r') as f: - search_creds = json.load(f) - spotify_client_id = search_creds.get('client_id') - spotify_client_secret = search_creds.get('client_secret') - except Exception as e: - logger.error(f"Error loading Spotify search credentials: {e}") - - # Initialize the Spotify client - spo = SpoLogin( - credentials_path=credentials_path, - spotify_client_id=spotify_client_id, - spotify_client_secret=spotify_client_secret, - progress_callback=progress_callback - ) - - # Get artist information - artist_info = spo.get_artist_info(url) - artist_name = artist_info['name'] - artist_id = artist_info['id'] - - # Get the list of albums - album_types = album_type.split(',') - albums = [] - - for album_type_item in album_types: - # Fetch albums of the specified type - albums_of_type = spo.get_albums_by_artist(artist_id, album_type_item.strip()) - for album in albums_of_type: - albums.append({ - 'name': album['name'], - 'url': album['external_urls']['spotify'], - 'type': 'album', - 'artist': artist_name - }) + # Get artist info with albums + artist_data = get_spotify_info(artist_id, "artist") - elif service == 'deezer': - from deezspot.deezloader import DeeLogin - - # Get credentials - deezer_profile = request_args.get('main', config_params['deezer']) - credentials_path = os.path.abspath(os.path.join('./creds/deezer', deezer_profile, 'credentials.json')) - - # Validate credentials - if not os.path.isfile(credentials_path): - raise ValueError(f"Invalid Deezer credentials path: {credentials_path}") - - # For Deezer, we need to extract the ARL - with open(credentials_path, 'r') as f: - credentials = json.load(f) - arl = credentials.get('arl') - - if not arl: - raise ValueError("No ARL found in Deezer credentials") - - # Load Spotify client credentials if available for search purposes - spotify_client_id = None - spotify_client_secret = None - search_creds_path = Path(f'./creds/spotify/{deezer_profile}/search.json') - if search_creds_path.exists(): - try: - with open(search_creds_path, 'r') as f: - search_creds = json.load(f) - spotify_client_id = search_creds.get('client_id') - spotify_client_secret = search_creds.get('client_secret') - except Exception as e: - logger.error(f"Error loading Spotify search credentials: {e}") - - # Initialize the Deezer client - dee = DeeLogin( - arl=arl, - spotify_client_id=spotify_client_id, - spotify_client_secret=spotify_client_secret, - progress_callback=progress_callback - ) - - # Get artist information - artist_info = dee.get_artist_info(url) - artist_name = artist_info['name'] - - # Get the list of albums (Deezer doesn't distinguish types like Spotify) - albums_result = dee.get_artist_albums(url) - albums = [] - - for album in albums_result: - albums.append({ - 'name': album['title'], - 'url': f"https://www.deezer.com/album/{album['id']}", - 'type': 'album', - 'artist': artist_name - }) + if not artist_data or 'items' not in artist_data: + raise ValueError(f"Failed to retrieve artist data or no albums found for artist ID {artist_id}") - else: - raise ValueError(f"Unsupported service: {service}") + # Parse the album types to filter by + allowed_types = [t.strip().lower() for t in album_type.split(",")] + logger.info(f"Filtering albums by types: {allowed_types}") - # Queue the album downloads + # Get artist name from the first album + artist_name = "" + if artist_data.get('items') and len(artist_data['items']) > 0: + first_album = artist_data['items'][0] + if first_album.get('artists') and len(first_album['artists']) > 0: + artist_name = first_album['artists'][0].get('name', '') + + # Filter albums by the specified types + filtered_albums = [] + for album in artist_data.get('items', []): + album_type_value = album.get('album_type', '').lower() + album_group_value = album.get('album_group', '').lower() + + # Apply filtering logic based on album_type and album_group + if (('album' in allowed_types and album_type_value == 'album' and album_group_value == 'album') or + ('single' in allowed_types and album_type_value == 'single' and album_group_value == 'single') or + ('compilation' in allowed_types and album_type_value == 'compilation') or + ('appears_on' in allowed_types and album_group_value == 'appears_on')): + filtered_albums.append(album) + + if not filtered_albums: + logger.warning(f"No albums match the specified types: {album_type}") + return [] + + # Queue each album as a separate download task album_task_ids = [] - for album in albums: - # Create a task for each album - task_id = download_queue_manager.add_task({ - "download_type": "album", - "service": service, - "url": album['url'], - "name": album['name'], - "artist": album['artist'], - "orig_request": request_args.copy() # Pass along original request args - }) + for album in filtered_albums: + album_url = album.get('external_urls', {}).get('spotify', '') + album_name = album.get('name', 'Unknown Album') + album_artists = album.get('artists', []) + album_artist = album_artists[0].get('name', 'Unknown Artist') if album_artists else 'Unknown Artist' + if not album_url: + logger.warning(f"Skipping album without URL: {album_name}") + continue + + # Create task for this album + task_data = { + "download_type": "album", + "type": "album", # Type for the download task + "service": "spotify", # Default to Spotify since we're using Spotify API + "url": album_url, # Important: use the album URL, not artist URL + "retry_url": album_url, # Use album URL for retry logic, not artist URL + "name": album_name, + "artist": album_artist, + "orig_request": request_args or {} # Store original request params + } + + # Add the task to the queue manager + task_id = download_queue_manager.add_task(task_data) album_task_ids.append(task_id) - logger.info(f"Queued album: {album['name']} by {album['artist']} (task ID: {task_id})") + logger.info(f"Queued album download: {album_name} ({task_id})") + logger.info(f"Queued {len(album_task_ids)} album downloads for artist: {artist_name}") return album_task_ids diff --git a/static/js/artist.js b/static/js/artist.js index 34c2d4e..4e0cacb 100644 --- a/static/js/artist.js +++ b/static/js/artist.js @@ -88,12 +88,17 @@ function renderArtist(artistData, artistId) { artistUrl, 'artist', { name: artistName, artist: artistName }, - 'album,single,compilation' + 'album,single,compilation,appears_on' ) - .then(() => { + .then((taskIds) => { downloadArtistBtn.textContent = 'Artist queued'; // Make the queue visible after queueing downloadQueue.toggleVisibility(true); + + // Optionally show number of albums queued + if (Array.isArray(taskIds)) { + downloadArtistBtn.title = `${taskIds.length} albums queued for download`; + } }) .catch(err => { downloadArtistBtn.textContent = 'Download All Discography'; @@ -103,25 +108,34 @@ function renderArtist(artistData, artistId) { }); } - // Group albums by type (album, single, compilation, etc.) - const albumGroups = (artistData.items || []).reduce((groups, album) => { - if (!album) return groups; + // Group albums by type (album, single, compilation, etc.) and separate "appears_on" albums + const albumGroups = {}; + const appearingAlbums = []; + + (artistData.items || []).forEach(album => { + if (!album) return; // Skip explicit albums if filter is enabled if (isExplicitFilterEnabled && album.explicit) { - return groups; + return; } - const type = (album.album_type || 'unknown').toLowerCase(); - if (!groups[type]) groups[type] = []; - groups[type].push(album); - return groups; - }, {}); + // Check if this is an "appears_on" album + if (album.album_group === 'appears_on') { + appearingAlbums.push(album); + } else { + // Group by album_type for the artist's own releases + const type = (album.album_type || 'unknown').toLowerCase(); + if (!albumGroups[type]) albumGroups[type] = []; + albumGroups[type].push(album); + } + }); // Render album groups const groupsContainer = document.getElementById('album-groups'); groupsContainer.innerHTML = ''; + // Render regular album groups first for (const [groupType, albums] of Object.entries(albumGroups)) { const groupSection = document.createElement('section'); groupSection.className = 'album-group'; @@ -192,6 +206,77 @@ function renderArtist(artistData, artistId) { groupsContainer.appendChild(groupSection); } + // Render "Featuring" section if there are any appearing albums + if (appearingAlbums.length > 0) { + const featuringSection = document.createElement('section'); + featuringSection.className = 'album-group'; + + const featuringHeaderHTML = isExplicitFilterEnabled ? + `
+

Featuring

+
Visit album pages to download content
+
` : + `
+

Featuring

+ +
`; + + featuringSection.innerHTML = ` + ${featuringHeaderHTML} +
+ `; + + const albumsContainer = featuringSection.querySelector('.albums-list'); + appearingAlbums.forEach(album => { + if (!album) return; + + const albumElement = document.createElement('div'); + albumElement.className = 'album-card'; + + // Create album card with or without download button based on explicit filter setting + if (isExplicitFilterEnabled) { + albumElement.innerHTML = ` + + Album cover + +
+
${album.name || 'Unknown Album'}
+
${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}
+
+ `; + } else { + albumElement.innerHTML = ` + + Album cover + +
+
${album.name || 'Unknown Album'}
+
${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}
+
+ + `; + } + + albumsContainer.appendChild(albumElement); + }); + + // Add to the end so it appears at the bottom + groupsContainer.appendChild(featuringSection); + } + document.getElementById('artist-header').classList.remove('hidden'); document.getElementById('albums-container').classList.remove('hidden'); @@ -207,25 +292,33 @@ function renderArtist(artistData, artistId) { function attachGroupDownloadListeners(artistUrl, artistName) { document.querySelectorAll('.group-download-btn').forEach(btn => { btn.addEventListener('click', async (e) => { - const groupType = e.target.dataset.groupType || 'album'; // e.g. "album", "single", "compilation" + const groupType = e.target.dataset.groupType || 'album'; // e.g. "album", "single", "compilation", "appears_on" e.target.disabled = true; - e.target.textContent = `Queueing all ${capitalize(groupType)}s...`; + + // Custom text for the 'appears_on' group + const displayType = groupType === 'appears_on' ? 'Featuring Albums' : `${capitalize(groupType)}s`; + e.target.textContent = `Queueing all ${displayType}...`; try { // Use our local startDownload function with the group type filter - await startDownload( + const taskIds = await startDownload( artistUrl, 'artist', { name: artistName || 'Unknown Artist', artist: artistName || 'Unknown Artist' }, groupType // Only queue releases of this specific type. ); - e.target.textContent = `Queued all ${capitalize(groupType)}s`; + + // Optionally show number of albums queued + const totalQueued = Array.isArray(taskIds) ? taskIds.length : 0; + e.target.textContent = `Queued all ${displayType}`; + e.target.title = `${totalQueued} albums queued for download`; + // Make the queue visible after queueing downloadQueue.toggleVisibility(true); } catch (error) { - e.target.textContent = `Download All ${capitalize(groupType)}s`; + e.target.textContent = `Download All ${displayType}`; e.target.disabled = false; - showError(`Failed to queue download for all ${groupType}s: ${error?.message || 'Unknown error'}`); + showError(`Failed to queue download for all ${groupType}: ${error?.message || 'Unknown error'}`); } }); }); @@ -259,7 +352,44 @@ async function startDownload(url, type, item, albumType) { } try { - // Use the centralized downloadQueue.download method + // For artist downloads, use the album_type query parameter + if (type === 'artist') { + const params = new URLSearchParams({ + url: url, + album_type: albumType || 'album,single,compilation' + }); + + // Add any additional parameters from item + if (item) { + Object.entries(item).forEach(([key, value]) => { + if (key !== 'name' && key !== 'artist') { // These are already in the URL + params.append(key, value); + } + }); + } + + const response = await fetch(`/api/artist/download?${params.toString()}`); + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.message || 'Unknown error'); + } + + const data = await response.json(); + if (data.status === 'error') { + throw new Error(data.message || 'Unknown error'); + } + + if (data.status === 'warning') { + console.warn(data.message); + } + + // Make the queue visible after queueing + downloadQueue.toggleVisibility(true); + + // Return the task_ids for tracking + return data.task_ids || []; + } + // Use the centralized downloadQueue.download method for non-artist downloads await downloadQueue.download(url, type, item, albumType); // Make the queue visible after queueing diff --git a/static/js/queue.js b/static/js/queue.js index bf2fa70..93a2d1a 100644 --- a/static/js/queue.js +++ b/static/js/queue.js @@ -762,7 +762,7 @@ class DownloadQueue { case 'done': if (data.type === 'track') { - return `Finished track "${data.song}" by ${data.artist}`; + return `Finished track "${data.song || data.name}" by ${data.artist}`; } else if (data.type === 'playlist') { return `Finished playlist "${data.name}" with ${data.total_tracks} tracks`; } else if (data.type === 'album') { @@ -772,6 +772,18 @@ class DownloadQueue { } return `Finished ${data.type}`; + case 'complete': + if (data.type === 'track') { + return `Finished track "${data.name || data.song}" by ${data.artist}`; + } else if (data.type === 'playlist') { + return `Finished playlist "${data.name}" with ${data.total_tracks || ''} tracks`; + } else if (data.type === 'album') { + return `Finished album "${data.album || data.name}" by ${data.artist}`; + } else if (data.type === 'artist') { + return `Finished artist "${data.artist}" (${data.album_type || ''})`; + } + return `Download completed successfully`; + case 'retrying': if (data.retry_count !== undefined) { return `Retrying download (attempt ${data.retry_count}/${this.MAX_RETRIES})`; @@ -789,9 +801,6 @@ class DownloadQueue { } return errorMsg; - case 'complete': - return 'Download completed successfully'; - case 'skipped': return `Track "${data.song}" skipped, it already exists!`; @@ -1426,6 +1435,17 @@ class DownloadQueue { return; } + // Make sure the status is set to 'complete' for UI purposes + if (!data.status || data.status === '') { + data.status = 'complete'; + } + + // For track downloads, make sure we have a proper name + if (entry.type === 'track' && !data.name && entry.lastStatus) { + data.name = entry.lastStatus.name || ''; + data.artist = entry.lastStatus.artist || ''; + } + this.handleSSEUpdate(queueId, data); // Always mark as terminal state for 'complete' events (except individual track completions in albums) @@ -1455,6 +1475,20 @@ class DownloadQueue { const data = JSON.parse(event.data); console.log('SSE end event:', data); + // For track downloads, ensure we have the proper fields for UI display + if (entry.type === 'track') { + // If the end event doesn't have a name/artist, copy from lastStatus + if ((!data.name || !data.artist) && entry.lastStatus) { + data.name = data.name || entry.lastStatus.name || ''; + data.artist = data.artist || entry.lastStatus.artist || ''; + } + + // Force status to 'complete' if not provided + if (!data.status || data.status === '') { + data.status = 'complete'; + } + } + // Update with final status this.handleSSEUpdate(queueId, data);