Merge pull request #85 from Xoconoch/dev

Dev
This commit is contained in:
Xoconoch
2025-03-24 16:22:02 -06:00
committed by GitHub
19 changed files with 2297 additions and 948 deletions

View File

@@ -69,54 +69,6 @@ Access at: `http://localhost:7171`
_Note: If you want Spotify-only mode, just keep "Download fallback" setting disabled and don't bother adding Deezer credentials. Deezer-only mode is not, and will not be supported since there already is a much better tool for that called "Deemix"_
### Spotify Developer Setup
Due to Spotify's ToS changes, anonymous API calls are now more limited. You need to set up your own Spotify Developer application:
1. Visit the [Spotify Developer Dashboard](https://developer.spotify.com/dashboard/)
2. Log in with your Spotify account
3. Click "Create app"
4. Fill in:
- App name (e.g., "My Spotizerr App")
- App description
- Redirect URI: `http://localhost:7171/callback` (or your custom domain if exposed)
- Check the Developer Terms agreement box
5. Click "Create"
6. On your app page, note your "Client ID"
7. Click "Show client secret" to reveal your "Client Secret"
8. Add these credentials in Spotizerr's settings page under the Spotify service section
### Deezer ARL Setup
#### Chrome-based browsers
Open the [web player](https://www.deezer.com/)
There, press F12 and select "Application"
![image](https://github.com/user-attachments/assets/22e61d91-50b4-48f2-bba7-28ef45b45ee5)
Expand Cookies section and select the "https://www.deezer.com". Find the "arl" cookie and double-click the "Cookie Value" tab's text.
![image](https://github.com/user-attachments/assets/75a67906-596e-42a0-beb0-540f2748b16e)
Copy that value and paste it into the correspondant setting in Spotizerr
#### Firefox-based browsers
Open the [web player](https://www.deezer.com/)
There, press F12 and select "Storage"
![image](https://github.com/user-attachments/assets/601be3fb-1ec9-44d9-be4f-28b1d853df2f)
Click the cookies host "https://www.deezer.com" and find the "arl" cookie.
![image](https://github.com/user-attachments/assets/ef8ea256-2c13-4780-ae9f-71527466df56)
Copy that value and paste it into the correspondant setting in Spotizerr
### Spotify Credentials Setup
First create a Spotify credentials file using the 3rd-party `librespot-auth` tool, this step has to be done in a PC/Laptop that has the Spotify desktop app installed.
@@ -201,6 +153,53 @@ In the terminal, you can directly print these parameters using jq:
jq -r '.username, .auth_data' credentials.json
```
### Spotify Developer Setup
In order for searching to work, you need to set up your own Spotify Developer application:
1. Visit the [Spotify Developer Dashboard](https://developer.spotify.com/dashboard/)
2. Log in with your Spotify account
3. Click "Create app"
4. Fill in:
- App name (e.g., "My Spotizerr App")
- App description
- Redirect URI: `http://localhost:7171/callback` (or your custom domain if exposed)
- Check the Developer Terms agreement box
5. Click "Create"
6. On your app page, note your "Client ID"
7. Click "Show client secret" to reveal your "Client Secret"
8. Add these credentials in Spotizerr's settings page under the Spotify service section
### Deezer ARL Setup
#### Chrome-based browsers
Open the [web player](https://www.deezer.com/)
There, press F12 and select "Application"
![image](https://github.com/user-attachments/assets/22e61d91-50b4-48f2-bba7-28ef45b45ee5)
Expand Cookies section and select the "https://www.deezer.com". Find the "arl" cookie and double-click the "Cookie Value" tab's text.
![image](https://github.com/user-attachments/assets/75a67906-596e-42a0-beb0-540f2748b16e)
Copy that value and paste it into the correspondant setting in Spotizerr
#### Firefox-based browsers
Open the [web player](https://www.deezer.com/)
There, press F12 and select "Storage"
![image](https://github.com/user-attachments/assets/601be3fb-1ec9-44d9-be4f-28b1d853df2f)
Click the cookies host "https://www.deezer.com" and find the "arl" cookie.
![image](https://github.com/user-attachments/assets/ef8ea256-2c13-4780-ae9f-71527466df56)
Copy that value and paste it into the correspondant setting in Spotizerr
## Usage
### Basic Operations

View File

@@ -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 discography queued {len(task_ids)} album tasks have been queued."
}),
status=202,
mimetype='application/json'

View File

@@ -1,6 +1,8 @@
from flask import Blueprint, abort, jsonify
from flask import Blueprint, abort, jsonify, Response, stream_with_context
import os
import json
import logging
import time
from routes.utils.celery_tasks import (
get_task_info,
@@ -8,9 +10,14 @@ from routes.utils.celery_tasks import (
get_last_task_status,
get_all_tasks,
cancel_task,
retry_task
retry_task,
ProgressState,
redis_client
)
# Configure logging
logger = logging.getLogger(__name__)
prgs_bp = Blueprint('prgs', __name__, url_prefix='/api/prgs')
# The old path for PRG files (keeping for backward compatibility during transition)
@@ -35,9 +42,18 @@ def get_prg_file(task_id):
if task_info:
# This is a task ID in the new system
original_request = task_info.get("original_request", {})
last_status = get_last_task_status(task_id)
return jsonify({
# Get the latest status update for this task
last_status = get_last_task_status(task_id)
logger.debug(f"API: Got last_status for {task_id}: {json.dumps(last_status) if last_status else None}")
# Get all status updates for debugging
all_statuses = get_task_status(task_id)
status_count = len(all_statuses)
logger.debug(f"API: Task {task_id} has {status_count} status updates")
# Prepare the response with basic info
response = {
"type": task_info.get("type", ""),
"name": task_info.get("name", ""),
"artist": task_info.get("artist", ""),
@@ -45,8 +61,115 @@ def get_prg_file(task_id):
"original_request": original_request,
"display_title": original_request.get("display_title", task_info.get("name", "")),
"display_type": original_request.get("display_type", task_info.get("type", "")),
"display_artist": original_request.get("display_artist", task_info.get("artist", ""))
})
"display_artist": original_request.get("display_artist", task_info.get("artist", "")),
"status_count": status_count,
"task_id": task_id,
"timestamp": time.time()
}
# Handle different status types
if last_status:
status_type = last_status.get("status", "unknown")
# Set event type based on status (like in the previous SSE implementation)
event_type = "update"
if status_type in [ProgressState.COMPLETE, ProgressState.DONE]:
event_type = "complete"
elif status_type == ProgressState.TRACK_COMPLETE:
event_type = "track_complete"
elif status_type == ProgressState.ERROR:
event_type = "error"
elif status_type in [ProgressState.TRACK_PROGRESS, ProgressState.REAL_TIME]:
event_type = "progress"
response["event"] = event_type
# For terminal statuses (complete, error, cancelled)
if status_type in [ProgressState.COMPLETE, ProgressState.ERROR, ProgressState.CANCELLED]:
response["progress_message"] = last_status.get("message", f"Download {status_type}")
# For progress status with track information
elif status_type == "progress" and last_status.get("track"):
# Add explicit track progress fields to the top level for easy access
response["current_track"] = last_status.get("track", "")
response["track_number"] = last_status.get("parsed_current_track", 0)
response["total_tracks"] = last_status.get("parsed_total_tracks", 0)
response["progress_percent"] = last_status.get("overall_progress", 0)
response["album"] = last_status.get("album", "")
# Format a nice progress message for display
track_info = last_status.get("track", "")
current = last_status.get("parsed_current_track", 0)
total = last_status.get("parsed_total_tracks", 0)
progress = last_status.get("overall_progress", 0)
if current and total:
response["progress_message"] = f"Downloading track {current}/{total} ({progress}%): {track_info}"
elif track_info:
response["progress_message"] = f"Downloading: {track_info}"
# For real-time status messages
elif status_type == "real_time":
# Add real-time specific fields
response["current_song"] = last_status.get("song", "")
response["percent"] = last_status.get("percent", 0)
response["percentage"] = last_status.get("percentage", 0)
response["time_elapsed"] = last_status.get("time_elapsed", 0)
# Format a nice progress message for display
song = last_status.get("song", "")
percent = last_status.get("percent", 0)
if song:
response["progress_message"] = f"Downloading {song} ({percent}%)"
else:
response["progress_message"] = f"Downloading ({percent}%)"
# For initializing status
elif status_type == "initializing":
album = last_status.get("album", "")
if album:
response["progress_message"] = f"Initializing download for {album}"
else:
response["progress_message"] = "Initializing download..."
# For processing status (default)
elif status_type == "processing":
# Search for the most recent track progress in all statuses
has_progress = False
for status in reversed(all_statuses):
if status.get("status") == "progress" and status.get("track"):
# Use this track progress information
track_info = status.get("track", "")
current_raw = status.get("current_track", "")
response["current_track"] = track_info
# Try to parse track numbers if available
if isinstance(current_raw, str) and "/" in current_raw:
try:
parts = current_raw.split("/")
current = int(parts[0])
total = int(parts[1])
response["track_number"] = current
response["total_tracks"] = total
response["progress_percent"] = min(int((current / total) * 100), 100)
response["progress_message"] = f"Processing track {current}/{total}: {track_info}"
except (ValueError, IndexError):
response["progress_message"] = f"Processing: {track_info}"
else:
response["progress_message"] = f"Processing: {track_info}"
has_progress = True
break
if not has_progress:
# Just use the processing message
response["progress_message"] = last_status.get("message", "Processing download...")
# For other status types
else:
response["progress_message"] = last_status.get("message", f"Status: {status_type}")
return jsonify(response)
# If not found in new system, try the old PRG file system
# Security check to prevent path traversal attacks.
@@ -69,7 +192,9 @@ def get_prg_file(task_id):
"original_request": None,
"display_title": "",
"display_type": "",
"display_artist": ""
"display_artist": "",
"task_id": task_id,
"event": "unknown"
})
# Attempt to extract the original request from the first line.
@@ -131,7 +256,10 @@ def get_prg_file(task_id):
"original_request": original_request,
"display_title": display_title,
"display_type": display_type,
"display_artist": display_artist
"display_artist": display_artist,
"task_id": task_id,
"event": "unknown", # Old files don't have event types
"timestamp": time.time()
})
except FileNotFoundError:
abort(404, "Task or file not found")

View File

@@ -22,6 +22,9 @@ def download_album(
progress_callback=None
):
try:
# DEBUG: Print parameters
print(f"DEBUG: album.py received - service={service}, main={main}, fallback={fallback}")
# Load Spotify client credentials if available
spotify_client_id = None
spotify_client_secret = None
@@ -30,9 +33,11 @@ def download_album(
if service == 'spotify' and fallback:
# If fallback is enabled, use the fallback account for Spotify search credentials
search_creds_path = Path(f'./creds/spotify/{fallback}/search.json')
print(f"DEBUG: Using Spotify search credentials from fallback: {search_creds_path}")
else:
# Otherwise use the main account for Spotify search credentials
search_creds_path = Path(f'./creds/spotify/{main}/search.json')
print(f"DEBUG: Using Spotify search credentials from main: {search_creds_path}")
if search_creds_path.exists():
try:
@@ -40,6 +45,7 @@ def download_album(
search_creds = json.load(f)
spotify_client_id = search_creds.get('client_id')
spotify_client_secret = search_creds.get('client_secret')
print(f"DEBUG: Loaded Spotify client credentials successfully")
except Exception as e:
print(f"Error loading Spotify search credentials: {e}")
@@ -56,6 +62,19 @@ def download_album(
# Load Deezer credentials from 'main' under deezer directory
deezer_creds_dir = os.path.join('./creds/deezer', main)
deezer_creds_path = os.path.abspath(os.path.join(deezer_creds_dir, 'credentials.json'))
# DEBUG: Print Deezer credential paths being used
print(f"DEBUG: Looking for Deezer credentials at:")
print(f"DEBUG: deezer_creds_dir = {deezer_creds_dir}")
print(f"DEBUG: deezer_creds_path = {deezer_creds_path}")
print(f"DEBUG: Directory exists = {os.path.exists(deezer_creds_dir)}")
print(f"DEBUG: Credentials file exists = {os.path.exists(deezer_creds_path)}")
# List available directories to compare
print(f"DEBUG: Available Deezer credential directories:")
for dir_name in os.listdir('./creds/deezer'):
print(f"DEBUG: ./creds/deezer/{dir_name}")
with open(deezer_creds_path, 'r') as f:
deezer_creds = json.load(f)
# Initialize DeeLogin with Deezer credentials and Spotify client credentials
@@ -65,6 +84,7 @@ def download_album(
spotify_client_secret=spotify_client_secret,
progress_callback=progress_callback
)
print(f"DEBUG: Starting album download using Deezer credentials (download_albumspo)")
# Download using download_albumspo; pass real_time_dl accordingly and the custom formatting
dl.download_albumspo(
link_album=url,
@@ -82,6 +102,7 @@ def download_album(
retry_delay_increase=retry_delay_increase,
max_retries=max_retries
)
print(f"DEBUG: Album download completed successfully using Deezer credentials")
except Exception as e:
deezer_error = e
# Immediately report the Deezer error
@@ -94,6 +115,9 @@ def download_album(
spo_creds_dir = os.path.join('./creds/spotify', fallback)
spo_creds_path = os.path.abspath(os.path.join(spo_creds_dir, 'credentials.json'))
print(f"DEBUG: Using Spotify fallback credentials from: {spo_creds_path}")
print(f"DEBUG: Fallback credentials exist: {os.path.exists(spo_creds_path)}")
# We've already loaded the Spotify client credentials above based on fallback
spo = SpoLogin(
@@ -102,6 +126,7 @@ def download_album(
spotify_client_secret=spotify_client_secret,
progress_callback=progress_callback
)
print(f"DEBUG: Starting album download using Spotify fallback credentials")
spo.download_album(
link_album=url,
output_dir="./downloads",
@@ -119,8 +144,10 @@ def download_album(
retry_delay_increase=retry_delay_increase,
max_retries=max_retries
)
print(f"DEBUG: Album download completed successfully using Spotify fallback")
except Exception as e2:
# If fallback also fails, raise an error indicating both attempts failed
print(f"ERROR: Spotify fallback also failed: {e2}")
raise RuntimeError(
f"Both main (Deezer) and fallback (Spotify) attempts failed. "
f"Deezer error: {deezer_error}, Spotify error: {e2}"
@@ -131,12 +158,16 @@ def download_album(
quality = 'HIGH'
creds_dir = os.path.join('./creds/spotify', main)
credentials_path = os.path.abspath(os.path.join(creds_dir, 'credentials.json'))
print(f"DEBUG: Using Spotify main credentials from: {credentials_path}")
print(f"DEBUG: Credentials exist: {os.path.exists(credentials_path)}")
spo = SpoLogin(
credentials_path=credentials_path,
spotify_client_id=spotify_client_id,
spotify_client_secret=spotify_client_secret,
progress_callback=progress_callback
)
print(f"DEBUG: Starting album download using Spotify main credentials")
spo.download_album(
link_album=url,
output_dir="./downloads",
@@ -154,12 +185,16 @@ def download_album(
retry_delay_increase=retry_delay_increase,
max_retries=max_retries
)
print(f"DEBUG: Album download completed successfully using Spotify main")
elif service == 'deezer':
if quality is None:
quality = 'FLAC'
# Existing code remains the same, ignoring fallback
creds_dir = os.path.join('./creds/deezer', main)
creds_path = os.path.abspath(os.path.join(creds_dir, 'credentials.json'))
print(f"DEBUG: Using Deezer credentials from: {creds_path}")
print(f"DEBUG: Credentials exist: {os.path.exists(creds_path)}")
with open(creds_path, 'r') as f:
creds = json.load(f)
dl = DeeLogin(
@@ -168,6 +203,7 @@ def download_album(
spotify_client_secret=spotify_client_secret,
progress_callback=progress_callback
)
print(f"DEBUG: Starting album download using Deezer credentials (download_albumdee)")
dl.download_albumdee(
link_album=url,
output_dir="./downloads",
@@ -183,8 +219,10 @@ def download_album(
retry_delay_increase=retry_delay_increase,
max_retries=max_retries
)
print(f"DEBUG: Album download completed successfully using Deezer direct")
else:
raise ValueError(f"Unsupported service: {service}")
except Exception as e:
print(f"ERROR: Album download failed with exception: {e}")
traceback.print_exc()
raise # Re-raise the exception after logging

View File

@@ -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,119 @@ 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
})
# Debug logging to inspect the structure of artist_data
logger.debug(f"Artist data structure has keys: {list(artist_data.keys() if isinstance(artist_data, dict) else [])}")
else:
raise ValueError(f"Unsupported service: {service}")
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}")
# Queue the album downloads
# 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}")
# 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:
# Add detailed logging to inspect each album's structure and URLs
logger.debug(f"Processing album: {album.get('name', 'Unknown')}")
logger.debug(f"Album structure has keys: {list(album.keys())}")
external_urls = album.get('external_urls', {})
logger.debug(f"Album external_urls: {external_urls}")
album_url = 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'
logger.debug(f"Extracted album URL: {album_url}")
if not album_url:
logger.warning(f"Skipping album without URL: {album_name}")
continue
# Create album-specific request args instead of using original artist request
album_request_args = {
"url": album_url,
"name": album_name,
"artist": album_artist,
"type": "album",
"service": "spotify",
# Add reference to parent artist request if needed
"parent_artist_url": url,
"parent_request_type": "artist"
}
# 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": album_request_args # Store album-specific request params
}
# Debug log the task data being sent to the queue
logger.debug(f"Album task data: url={task_data['url']}, retry_url={task_data['retry_url']}")
# 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

View File

@@ -148,4 +148,16 @@ result_expires = 60 * 60 * 24 * 7 # 7 days
# Configure visibility timeout for task messages
broker_transport_options = {
'visibility_timeout': 3600, # 1 hour
}
'fanout_prefix': True,
'fanout_patterns': True,
'priority_steps': [0, 3, 6, 9],
}
# Important broker connection settings
broker_connection_retry = True
broker_connection_retry_on_startup = True
broker_connection_max_retries = 10
broker_pool_limit = 10
worker_prefetch_multiplier = 1 # Process one task at a time per worker
worker_max_tasks_per_child = 100 # Restart worker after 100 tasks
worker_disable_rate_limits = False

View File

@@ -9,6 +9,7 @@ from pathlib import Path
import threading
import queue
import sys
import uuid
# Configure logging
logger = logging.getLogger(__name__)
@@ -102,16 +103,36 @@ class CeleryManager:
# Stop existing workers if running
if self.celery_process:
try:
logger.info("Stopping existing Celery workers...")
os.killpg(os.getpgid(self.celery_process.pid), signal.SIGTERM)
self.celery_process.wait(timeout=5)
except (subprocess.TimeoutExpired, ProcessLookupError):
try:
logger.warning("Forcibly killing Celery workers with SIGKILL")
os.killpg(os.getpgid(self.celery_process.pid), signal.SIGKILL)
except ProcessLookupError:
pass
# Clear output threads list
self.output_threads = []
# Wait a moment to ensure processes are terminated
time.sleep(2)
# Additional cleanup - find and kill any stray Celery processes
try:
# This runs a shell command to find and kill all celery processes
subprocess.run(
"ps aux | grep 'celery -A routes.utils.celery_tasks.celery_app worker' | grep -v grep | awk '{print $2}' | xargs -r kill -9",
shell=True,
stderr=subprocess.PIPE
)
logger.info("Killed any stray Celery processes")
# Wait a moment to ensure processes are terminated
time.sleep(1)
except Exception as e:
logger.error(f"Error during stray process cleanup: {e}")
# Start new workers with updated concurrency
try:
@@ -127,13 +148,16 @@ class CeleryManager:
'--loglevel=info',
f'--concurrency={new_worker_count}',
'-Q', 'downloads',
# Add timestamp to Celery logs
'--logfile=-', # Output logs to stdout
'--without-heartbeat', # Reduce log noise
'--without-gossip', # Reduce log noise
'--without-mingle' # Reduce log noise
'--without-mingle', # Reduce log noise
# Add unique worker name to prevent conflicts
f'--hostname=worker@%h-{uuid.uuid4()}'
]
logger.info(f"Starting new Celery workers with command: {' '.join(cmd)}")
self.celery_process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
@@ -145,7 +169,23 @@ class CeleryManager:
)
self.current_worker_count = new_worker_count
logger.info(f"Started Celery workers with concurrency {new_worker_count}")
logger.info(f"Started Celery workers with concurrency {new_worker_count}, PID: {self.celery_process.pid}")
# Verify the process started correctly
time.sleep(2)
if self.celery_process.poll() is not None:
# Process exited prematurely
stdout, stderr = "", ""
try:
stdout, stderr = self.celery_process.communicate(timeout=1)
except subprocess.TimeoutExpired:
pass
logger.error(f"Celery workers failed to start. Exit code: {self.celery_process.poll()}")
logger.error(f"Stdout: {stdout}")
logger.error(f"Stderr: {stderr}")
self.celery_process = None
raise RuntimeError("Celery workers failed to start")
# Start non-blocking output reader threads for both stdout and stderr
stdout_thread = threading.Thread(
@@ -166,6 +206,13 @@ class CeleryManager:
except Exception as e:
logger.error(f"Error starting Celery workers: {e}")
# In case of failure, make sure we don't leave orphaned processes
if self.celery_process and self.celery_process.poll() is None:
try:
os.killpg(os.getpgid(self.celery_process.pid), signal.SIGKILL)
except (ProcessLookupError, OSError):
pass
self.celery_process = None
def _process_output_reader(self, pipe, stream_name):
"""Read and log output from the process"""

View File

@@ -96,50 +96,36 @@ class CeleryDownloadQueueManager:
def add_task(self, task):
"""
Adds a new download task to the queue.
Add a new download task to the Celery queue
Args:
task (dict): Dictionary containing task parameters
task (dict): Task parameters including download_type, url, etc.
Returns:
str: The task ID for status tracking
str: Task ID
"""
try:
# Extract essential parameters
download_type = task.get("download_type", "unknown")
service = task.get("service", "")
# Get common parameters from config
config_params = get_config_params()
# Debug existing task data
logger.debug(f"Adding {download_type} task with data: {json.dumps({k: v for k, v in task.items() if k != 'orig_request'})}")
# Use service from config instead of task
service = config_params.get('service')
# Generate a unique task ID
# Create a unique task ID
task_id = str(uuid.uuid4())
# Store the original request in task info
original_request = task.get("orig_request", {}).copy()
# Get config parameters and process original request
config_params = get_config_params()
# Add essential metadata for retry operations
original_request["download_type"] = download_type
# Extract original request or use empty dict
original_request = task.get("orig_request", task.get("original_request", {}))
# Add type from download_type if not provided
if "type" not in task:
task["type"] = download_type
# Determine service (spotify or deezer) from config or request
service = original_request.get("service", config_params.get("service", "spotify"))
# Ensure key information is included
for key in ["type", "name", "artist", "service", "url"]:
if key in task and key not in original_request:
original_request[key] = task[key]
# Add API endpoint information
if "endpoint" not in original_request:
original_request["endpoint"] = f"/api/{download_type}/download"
# Add explicit display information for the frontend
original_request["display_title"] = task.get("name", original_request.get("name", "Unknown"))
original_request["display_type"] = task.get("type", original_request.get("type", download_type))
original_request["display_artist"] = task.get("artist", original_request.get("artist", ""))
# Debug retry_url if present
if "retry_url" in task:
logger.debug(f"Task has retry_url: {task['retry_url']}")
# Build the complete task with config parameters
complete_task = {
@@ -150,6 +136,9 @@ class CeleryDownloadQueueManager:
"service": service,
"url": task.get("url", ""),
# Preserve retry_url if present
"retry_url": task.get("retry_url", ""),
# Use config values but allow override from request
"main": original_request.get("main",
config_params['spotify'] if service == 'spotify' else config_params['deezer']),

File diff suppressed because it is too large Load Diff

View File

@@ -3,17 +3,9 @@
from deezspot.easy_spoty import Spo
import json
from pathlib import Path
from routes.utils.celery_queue_manager import get_config_params
# Load configuration from ./config/main.json
CONFIG_PATH = './config/main.json'
try:
with open(CONFIG_PATH, 'r') as f:
config_data = json.load(f)
# Get the main Spotify account from config
DEFAULT_SPOTIFY_ACCOUNT = config_data.get("spotify", "")
except Exception as e:
print(f"Error loading configuration: {e}")
DEFAULT_SPOTIFY_ACCOUNT = ""
# We'll rely on get_config_params() instead of directly loading the config file
def get_spotify_info(spotify_id, spotify_type):
"""
@@ -29,8 +21,9 @@ def get_spotify_info(spotify_id, spotify_type):
client_id = None
client_secret = None
# Use the default account from config
main = DEFAULT_SPOTIFY_ACCOUNT
# Get config parameters including Spotify account
config_params = get_config_params()
main = config_params.get('spotify', '')
if not main:
raise ValueError("No Spotify account configured in settings")

View File

@@ -19,9 +19,13 @@ def download_playlist(
initial_retry_delay=5,
retry_delay_increase=5,
max_retries=3,
progress_callback=None
progress_callback=None,
spotify_quality=None
):
try:
# DEBUG: Print parameters
print(f"DEBUG: playlist.py received - service={service}, main={main}, fallback={fallback}")
# Load Spotify client credentials if available
spotify_client_id = None
spotify_client_secret = None
@@ -30,9 +34,11 @@ def download_playlist(
if service == 'spotify' and fallback:
# If fallback is enabled, use the fallback account for Spotify search credentials
search_creds_path = Path(f'./creds/spotify/{fallback}/search.json')
print(f"DEBUG: Using Spotify search credentials from fallback: {search_creds_path}")
else:
# Otherwise use the main account for Spotify search credentials
search_creds_path = Path(f'./creds/spotify/{main}/search.json')
print(f"DEBUG: Using Spotify search credentials from main: {search_creds_path}")
if search_creds_path.exists():
try:
@@ -40,6 +46,7 @@ def download_playlist(
search_creds = json.load(f)
spotify_client_id = search_creds.get('client_id')
spotify_client_secret = search_creds.get('client_secret')
print(f"DEBUG: Loaded Spotify client credentials successfully")
except Exception as e:
print(f"Error loading Spotify search credentials: {e}")
@@ -56,6 +63,14 @@ def download_playlist(
# Load Deezer credentials from 'main' under deezer directory
deezer_creds_dir = os.path.join('./creds/deezer', main)
deezer_creds_path = os.path.abspath(os.path.join(deezer_creds_dir, 'credentials.json'))
# DEBUG: Print Deezer credential paths being used
print(f"DEBUG: Looking for Deezer credentials at:")
print(f"DEBUG: deezer_creds_dir = {deezer_creds_dir}")
print(f"DEBUG: deezer_creds_path = {deezer_creds_path}")
print(f"DEBUG: Directory exists = {os.path.exists(deezer_creds_dir)}")
print(f"DEBUG: Credentials file exists = {os.path.exists(deezer_creds_path)}")
with open(deezer_creds_path, 'r') as f:
deezer_creds = json.load(f)
# Initialize DeeLogin with Deezer credentials
@@ -65,6 +80,7 @@ def download_playlist(
spotify_client_secret=spotify_client_secret,
progress_callback=progress_callback
)
print(f"DEBUG: Starting playlist download using Deezer credentials (download_playlistspo)")
# Download using download_playlistspo; pass the custom formatting parameters.
dl.download_playlistspo(
link_playlist=url,
@@ -80,8 +96,10 @@ def download_playlist(
pad_tracks=pad_tracks,
initial_retry_delay=initial_retry_delay,
retry_delay_increase=retry_delay_increase,
max_retries=max_retries
max_retries=max_retries,
spotify_quality=spotify_quality or fall_quality
)
print(f"DEBUG: Playlist download completed successfully using Deezer credentials")
except Exception as e:
deezer_error = e
# Immediately report the Deezer error
@@ -94,6 +112,9 @@ def download_playlist(
spo_creds_dir = os.path.join('./creds/spotify', fallback)
spo_creds_path = os.path.abspath(os.path.join(spo_creds_dir, 'credentials.json'))
print(f"DEBUG: Using Spotify fallback credentials from: {spo_creds_path}")
print(f"DEBUG: Fallback credentials exist: {os.path.exists(spo_creds_path)}")
# We've already loaded the Spotify client credentials above based on fallback
spo = SpoLogin(
@@ -102,10 +123,11 @@ def download_playlist(
spotify_client_secret=spotify_client_secret,
progress_callback=progress_callback
)
print(f"DEBUG: Starting playlist download using Spotify fallback credentials")
spo.download_playlist(
link_playlist=url,
output_dir="./downloads",
quality_download=fall_quality,
quality_download=spotify_quality or fall_quality,
recursive_quality=True,
recursive_download=False,
not_interface=False,
@@ -119,8 +141,10 @@ def download_playlist(
retry_delay_increase=retry_delay_increase,
max_retries=max_retries
)
print(f"DEBUG: Playlist download completed successfully using Spotify fallback")
except Exception as e2:
# If fallback also fails, raise an error indicating both attempts failed
print(f"ERROR: Spotify fallback also failed: {e2}")
raise RuntimeError(
f"Both main (Deezer) and fallback (Spotify) attempts failed. "
f"Deezer error: {deezer_error}, Spotify error: {e2}"
@@ -131,16 +155,20 @@ def download_playlist(
quality = 'HIGH'
creds_dir = os.path.join('./creds/spotify', main)
credentials_path = os.path.abspath(os.path.join(creds_dir, 'credentials.json'))
print(f"DEBUG: Using Spotify main credentials from: {credentials_path}")
print(f"DEBUG: Credentials exist: {os.path.exists(credentials_path)}")
spo = SpoLogin(
credentials_path=credentials_path,
spotify_client_id=spotify_client_id,
spotify_client_secret=spotify_client_secret,
progress_callback=progress_callback
)
print(f"DEBUG: Starting playlist download using Spotify main credentials")
spo.download_playlist(
link_playlist=url,
output_dir="./downloads",
quality_download=quality,
quality_download=spotify_quality or quality,
recursive_quality=True,
recursive_download=False,
not_interface=False,
@@ -154,12 +182,16 @@ def download_playlist(
retry_delay_increase=retry_delay_increase,
max_retries=max_retries
)
print(f"DEBUG: Playlist download completed successfully using Spotify main")
elif service == 'deezer':
if quality is None:
quality = 'FLAC'
# Existing code for Deezer, using main as Deezer account.
creds_dir = os.path.join('./creds/deezer', main)
creds_path = os.path.abspath(os.path.join(creds_dir, 'credentials.json'))
print(f"DEBUG: Using Deezer credentials from: {creds_path}")
print(f"DEBUG: Credentials exist: {os.path.exists(creds_path)}")
with open(creds_path, 'r') as f:
creds = json.load(f)
dl = DeeLogin(
@@ -168,6 +200,7 @@ def download_playlist(
spotify_client_secret=spotify_client_secret,
progress_callback=progress_callback
)
print(f"DEBUG: Starting playlist download using Deezer direct")
dl.download_playlistdee(
link_playlist=url,
output_dir="./downloads",
@@ -183,8 +216,10 @@ def download_playlist(
retry_delay_increase=retry_delay_increase,
max_retries=max_retries
)
print(f"DEBUG: Playlist download completed successfully using Deezer direct")
else:
raise ValueError(f"Unsupported service: {service}")
except Exception as e:
print(f"ERROR: Playlist download failed with exception: {e}")
traceback.print_exc()
raise # Re-raise the exception after logging

View File

@@ -1,6 +1,10 @@
from deezspot.easy_spoty import Spo
import json
from pathlib import Path
import logging
# Configure logger
logger = logging.getLogger(__name__)
def search(
query: str,
@@ -8,35 +12,48 @@ def search(
limit: int = 3,
main: str = None
) -> dict:
logger.info(f"Search requested: query='{query}', type={search_type}, limit={limit}, main={main}")
# If main account is specified, load client ID and secret from the account's search.json
client_id = None
client_secret = None
if main:
search_creds_path = Path(f'./creds/spotify/{main}/search.json')
logger.debug(f"Looking for credentials at: {search_creds_path}")
if search_creds_path.exists():
try:
with open(search_creds_path, 'r') as f:
search_creds = json.load(f)
client_id = search_creds.get('client_id')
client_secret = search_creds.get('client_secret')
logger.debug(f"Credentials loaded successfully for account: {main}")
except Exception as e:
logger.error(f"Error loading search credentials: {e}")
print(f"Error loading search credentials: {e}")
else:
logger.warning(f"Credentials file not found at: {search_creds_path}")
# Initialize the Spotify client with credentials (if available)
if client_id and client_secret:
logger.debug("Initializing Spotify client with account credentials")
Spo.__init__(client_id, client_secret)
else:
logger.debug("Using default Spotify client credentials")
# Perform the Spotify search
# Note: We don't need to pass client_id and client_secret again in the search method
# as they've already been set during initialization
spotify_response = Spo.search(
query=query,
search_type=search_type,
limit=limit,
client_id=client_id,
client_secret=client_secret
)
return spotify_response
logger.debug(f"Executing Spotify search with query='{query}', type={search_type}")
try:
spotify_response = Spo.search(
query=query,
search_type=search_type,
limit=limit,
client_id=client_id,
client_secret=client_secret
)
logger.info(f"Search completed successfully")
return spotify_response
except Exception as e:
logger.error(f"Error during Spotify search: {e}")
raise

View File

@@ -868,3 +868,63 @@ input:checked + .slider:before {
line-height: 20px;
}
}
/* Format help styles */
.format-help {
display: flex;
margin-top: 8px;
align-items: center;
}
.format-selector {
width: 100%;
padding: 6px;
border-radius: 4px;
border: 1px solid #404040;
background-color: #2a2a2a;
color: #ffffff;
}
.format-selector:focus {
outline: none;
border-color: #1db954;
box-shadow: 0 0 0 2px rgba(29, 185, 84, 0.2);
}
.setting-description {
margin-top: 8px;
font-size: 0.9em;
color: #b3b3b3;
}
/* Copy notification styles */
#copyNotificationContainer {
position: fixed;
bottom: 20px;
left: 0;
right: 0;
display: flex;
flex-direction: column;
align-items: center;
z-index: 1000;
pointer-events: none;
}
.copy-notification {
background-color: rgba(0, 0, 0, 0.8);
color: white;
padding: 10px 20px;
border-radius: 4px;
margin-top: 5px;
opacity: 0;
transform: translateY(20px);
transition: opacity 0.3s, transform 0.3s;
max-width: 90%;
text-align: center;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
}
.copy-notification.show {
opacity: 1;
transform: translateY(0);
}

1
static/css/queue.css Normal file
View File

@@ -0,0 +1 @@

View File

@@ -43,12 +43,88 @@
margin: 0;
}
/* Queue subtitle with statistics */
.queue-subtitle {
display: flex;
gap: 10px;
margin-top: 5px;
font-size: 0.8rem;
color: #b3b3b3;
}
.queue-stat {
padding: 2px 6px;
border-radius: 4px;
font-weight: 500;
}
.queue-stat-active {
color: #4a90e2;
background-color: rgba(74, 144, 226, 0.1);
}
.queue-stat-completed {
color: #1DB954;
background-color: rgba(29, 185, 84, 0.1);
}
.queue-stat-error {
color: #ff5555;
background-color: rgba(255, 85, 85, 0.1);
}
.header-actions {
display: flex;
gap: 10px;
align-items: center;
}
/* Refresh queue button */
#refreshQueueBtn {
background: #2a2a2a;
border: none;
color: #fff;
padding: 8px;
border-radius: 4px;
cursor: pointer;
transition: background 0.3s ease, transform 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
#refreshQueueBtn:hover {
background: #333;
transform: translateY(-1px);
}
#refreshQueueBtn:active {
transform: scale(0.95);
}
#refreshQueueBtn.refreshing {
animation: spin 1s linear infinite;
}
/* Artist queue message */
.queue-artist-message {
background: #2a2a2a;
padding: 15px;
border-radius: 8px;
margin-bottom: 15px;
color: #fff;
text-align: center;
border-left: 4px solid #4a90e2;
animation: pulse 1.5s infinite;
font-weight: 500;
}
@keyframes pulse {
0% { opacity: 0.8; }
50% { opacity: 1; }
100% { opacity: 0.8; }
}
/* Cancel all button styling */
#cancelAllBtn {
background: #8b0000; /* Dark blood red */
@@ -153,7 +229,7 @@
.queue-item:hover {
background-color: #333;
transform: translateY(-2px);
transform: translateY(-5px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
}
@@ -286,6 +362,13 @@
vertical-align: middle;
}
.loading-spinner.small {
width: 10px;
height: 10px;
border-width: 1px;
margin-right: 4px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@@ -395,60 +478,50 @@
/* Container for error action buttons */
.error-buttons {
display: flex;
gap: 10px;
justify-content: flex-end;
gap: 8px;
margin-top: 8px;
}
/* Base styles for error buttons */
.error-buttons button {
background: #2a2a2a;
border: none;
padding: 8px 12px;
border-radius: 4px;
color: #fff;
padding: 6px 12px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background 0.3s ease, transform 0.2s ease;
font-size: 14px;
transition: all 0.2s ease;
}
/* Hover state for all error buttons */
.error-buttons button:hover {
background: #333;
transform: translateY(-1px);
transform: translateY(-2px);
}
.error-buttons button:active {
transform: scale(0.98);
transform: translateY(0);
}
/* Specific styles for the Close (X) error button */
.close-error-btn {
background: #ff5555 !important;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50% !important;
font-size: 18px !important;
padding: 0 !important;
background-color: #333;
color: #fff;
}
.close-error-btn:hover {
background: #ff7777 !important;
background-color: #444;
}
/* Specific styles for the Retry button */
.retry-btn {
background: #1DB954 !important;
padding: 8px 16px !important;
border-radius: 20px !important;
font-weight: 500 !important;
flex: 1;
background-color: #ff5555;
color: #fff;
padding: 6px 15px !important;
}
.retry-btn:hover {
background: #17a448 !important;
background-color: #ff6b6b;
}
/* Empty queue state */
@@ -487,6 +560,35 @@
animation: fadeIn 0.3s ease;
}
/* Error state styling */
.queue-item.error {
border-left: 4px solid #ff5555;
background-color: rgba(255, 85, 85, 0.05);
transition: none !important; /* Remove all transitions */
transform: none !important; /* Prevent any transform */
position: relative !important; /* Keep normal positioning */
left: 0 !important; /* Prevent any left movement */
right: 0 !important; /* Prevent any right movement */
top: 0 !important; /* Prevent any top movement */
}
.queue-item.error:hover {
background-color: rgba(255, 85, 85, 0.1);
transform: none !important; /* Force disable any transform */
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2) !important; /* Keep original shadow */
position: relative !important; /* Force normal positioning */
left: 0 !important; /* Prevent any left movement */
right: 0 !important; /* Prevent any right movement */
top: 0 !important; /* Prevent any top movement */
}
.error-message {
color: #ff5555;
margin-bottom: 10px;
font-size: 13px;
line-height: 1.4;
}
/* ------------------------------- */
/* MOBILE RESPONSIVE ADJUSTMENTS */
/* ------------------------------- */

View File

@@ -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 ?
`<div class="album-group-header">
<h3>Featuring</h3>
<div class="download-note">Visit album pages to download content</div>
</div>` :
`<div class="album-group-header">
<h3>Featuring</h3>
<button class="download-btn download-btn--main group-download-btn"
data-group-type="appears_on">
Download All Featuring Albums
</button>
</div>`;
featuringSection.innerHTML = `
${featuringHeaderHTML}
<div class="albums-list"></div>
`;
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 = `
<a href="/album/${album.id || ''}" class="album-link">
<img src="${album.images?.[1]?.url || album.images?.[0]?.url || '/static/images/placeholder.jpg'}"
alt="Album cover"
class="album-cover">
</a>
<div class="album-info">
<div class="album-title">${album.name || 'Unknown Album'}</div>
<div class="album-artist">${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}</div>
</div>
`;
} else {
albumElement.innerHTML = `
<a href="/album/${album.id || ''}" class="album-link">
<img src="${album.images?.[1]?.url || album.images?.[0]?.url || '/static/images/placeholder.jpg'}"
alt="Album cover"
class="album-cover">
</a>
<div class="album-info">
<div class="album-title">${album.name || 'Unknown Album'}</div>
<div class="album-artist">${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}</div>
</div>
<button class="download-btn download-btn--circle"
data-url="${album.external_urls?.spotify || ''}"
data-type="${album.album_type || 'album'}"
data-name="${album.name || 'Unknown Album'}"
title="Download">
<img src="/static/images/download.svg" alt="Download">
</button>
`;
}
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,11 +352,14 @@ async function startDownload(url, type, item, albumType) {
}
try {
// Use the centralized downloadQueue.download method
await downloadQueue.download(url, type, item, albumType);
// Use the centralized downloadQueue.download method for all downloads including artist downloads
const result = await downloadQueue.download(url, type, item, albumType);
// Make the queue visible after queueing
downloadQueue.toggleVisibility(true);
// Return the result for tracking
return result;
} catch (error) {
showError('Download failed: ' + (error?.message || 'Unknown error'));
throw error;

View File

@@ -104,6 +104,14 @@ function setupEventListeners() {
// Formatting settings
document.getElementById('customDirFormat').addEventListener('change', saveConfig);
document.getElementById('customTrackFormat').addEventListener('change', saveConfig);
// Copy to clipboard when selecting placeholders
document.getElementById('dirFormatHelp').addEventListener('change', function() {
copyPlaceholderToClipboard(this);
});
document.getElementById('trackFormatHelp').addEventListener('change', function() {
copyPlaceholderToClipboard(this);
});
// Max concurrent downloads change listener
document.getElementById('maxConcurrentDownloads').addEventListener('change', saveConfig);
@@ -699,3 +707,57 @@ function showConfigSuccess(message) {
successDiv.textContent = message;
setTimeout(() => (successDiv.textContent = ''), 5000);
}
// Function to copy the selected placeholder to clipboard
function copyPlaceholderToClipboard(select) {
const placeholder = select.value;
if (!placeholder) return; // If nothing selected
// Copy to clipboard
navigator.clipboard.writeText(placeholder)
.then(() => {
// Show success notification
showCopyNotification(`Copied ${placeholder} to clipboard`);
// Reset select to default after a short delay
setTimeout(() => {
select.selectedIndex = 0;
}, 500);
})
.catch(err => {
console.error('Failed to copy: ', err);
});
}
// Function to show a notification when copying
function showCopyNotification(message) {
// Check if notification container exists, create if not
let notificationContainer = document.getElementById('copyNotificationContainer');
if (!notificationContainer) {
notificationContainer = document.createElement('div');
notificationContainer.id = 'copyNotificationContainer';
document.body.appendChild(notificationContainer);
}
// Create notification element
const notification = document.createElement('div');
notification.className = 'copy-notification';
notification.textContent = message;
// Add to container
notificationContainer.appendChild(notification);
// Trigger animation
setTimeout(() => {
notification.classList.add('show');
}, 10);
// Remove after animation completes
setTimeout(() => {
notification.classList.remove('show');
setTimeout(() => {
notificationContainer.removeChild(notification);
}, 300);
}, 2000);
}

File diff suppressed because it is too large Load Diff

View File

@@ -109,6 +109,50 @@
placeholder="e.g. %artist%/%album%"
class="form-input"
/>
<div class="format-help">
<select id="dirFormatHelp" class="format-selector">
<option value="">-- Select placeholder --</option>
<optgroup label="Common">
<option value="%music%">%music% - Track title</option>
<option value="%artist%">%artist% - Track artist</option>
<option value="%album%">%album% - Album name</option>
<option value="%album_artist%">%album_artist% - Album artist</option>
<option value="%tracknum%">%tracknum% - Track number</option>
<option value="%year%">%year% - Year of release</option>
</optgroup>
<optgroup label="Additional">
<option value="%discnum%">%discnum% - Disc number</option>
<option value="%date%">%date% - Release date</option>
<option value="%genre%">%genre% - Music genre</option>
<option value="%isrc%">%isrc% - International Standard Recording Code</option>
<option value="%explicit%">%explicit% - Explicit content flag</option>
<option value="%duration%">%duration% - Track duration (seconds)</option>
</optgroup>
<optgroup label="Metadata">
<option value="%publisher%">%publisher% - Publisher information</option>
<option value="%composer%">%composer% - Track composer</option>
<option value="%copyright%">%copyright% - Copyright information</option>
<option value="%author%">%author% - Author information</option>
<option value="%lyricist%">%lyricist% - Lyricist information</option>
<option value="%version%">%version% - Version information</option>
<option value="%comment%">%comment% - Comment field</option>
</optgroup>
<optgroup label="Other">
<option value="%encodedby%">%encodedby% - Encoded by information</option>
<option value="%language%">%language% - Language information</option>
<option value="%lyrics%">%lyrics% - Track lyrics</option>
<option value="%mood%">%mood% - Mood information</option>
<option value="%rating%">%rating% - Track rating</option>
<option value="%website%">%website% - Website information</option>
</optgroup>
<optgroup label="ReplayGain">
<option value="%replaygain_album_gain%">%replaygain_album_gain% - Album gain</option>
<option value="%replaygain_album_peak%">%replaygain_album_peak% - Album peak</option>
<option value="%replaygain_track_gain%">%replaygain_track_gain% - Track gain</option>
<option value="%replaygain_track_peak%">%replaygain_track_peak% - Track peak</option>
</optgroup>
</select>
</div>
</div>
<div class="config-item">
<label>Custom Track Format:</label>
@@ -118,6 +162,53 @@
placeholder="e.g. %tracknum% - %music%"
class="form-input"
/>
<div class="format-help">
<select id="trackFormatHelp" class="format-selector">
<option value="">-- Select placeholder --</option>
<optgroup label="Common">
<option value="%music%">%music% - Track title</option>
<option value="%artist%">%artist% - Track artist</option>
<option value="%album%">%album% - Album name</option>
<option value="%album_artist%">%album_artist% - Album artist</option>
<option value="%tracknum%">%tracknum% - Track number</option>
<option value="%year%">%year% - Year of release</option>
</optgroup>
<optgroup label="Additional">
<option value="%discnum%">%discnum% - Disc number</option>
<option value="%date%">%date% - Release date</option>
<option value="%genre%">%genre% - Music genre</option>
<option value="%isrc%">%isrc% - International Standard Recording Code</option>
<option value="%explicit%">%explicit% - Explicit content flag</option>
<option value="%duration%">%duration% - Track duration (seconds)</option>
</optgroup>
<optgroup label="Metadata">
<option value="%publisher%">%publisher% - Publisher information</option>
<option value="%composer%">%composer% - Track composer</option>
<option value="%copyright%">%copyright% - Copyright information</option>
<option value="%author%">%author% - Author information</option>
<option value="%lyricist%">%lyricist% - Lyricist information</option>
<option value="%version%">%version% - Version information</option>
<option value="%comment%">%comment% - Comment field</option>
</optgroup>
<optgroup label="Other">
<option value="%encodedby%">%encodedby% - Encoded by information</option>
<option value="%language%">%language% - Language information</option>
<option value="%lyrics%">%lyrics% - Track lyrics</option>
<option value="%mood%">%mood% - Mood information</option>
<option value="%rating%">%rating% - Track rating</option>
<option value="%website%">%website% - Website information</option>
</optgroup>
<optgroup label="ReplayGain">
<option value="%replaygain_album_gain%">%replaygain_album_gain% - Album gain</option>
<option value="%replaygain_album_peak%">%replaygain_album_peak% - Album peak</option>
<option value="%replaygain_track_gain%">%replaygain_track_gain% - Track gain</option>
<option value="%replaygain_track_peak%">%replaygain_track_peak% - Track peak</option>
</optgroup>
</select>
</div>
<div class="setting-description">
Note that these placeholder depend on the metadata of the track, if one entry is not available in a track, the placeholder will be replaced with an empty string.
</div>
</div>
<!-- New Track Number Padding Toggle -->
<div class="config-item">