95
README.md
95
README.md
@@ -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"
|
||||
|
||||

|
||||
|
||||
Expand Cookies section and select the "https://www.deezer.com". Find the "arl" cookie and double-click the "Cookie Value" tab's text.
|
||||
|
||||

|
||||
|
||||
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"
|
||||
|
||||

|
||||
|
||||
Click the cookies host "https://www.deezer.com" and find the "arl" cookie.
|
||||
|
||||

|
||||
|
||||
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"
|
||||
|
||||

|
||||
|
||||
Expand Cookies section and select the "https://www.deezer.com". Find the "arl" cookie and double-click the "Cookie Value" tab's text.
|
||||
|
||||

|
||||
|
||||
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"
|
||||
|
||||

|
||||
|
||||
Click the cookies host "https://www.deezer.com" and find the "arl" cookie.
|
||||
|
||||

|
||||
|
||||
Copy that value and paste it into the correspondant setting in Spotizerr
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Operations
|
||||
|
||||
@@ -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'
|
||||
|
||||
144
routes/prgs.py
144
routes/prgs.py
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"""
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
1
static/css/queue.css
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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 */
|
||||
/* ------------------------------- */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
1193
static/js/queue.js
1193
static/js/queue.js
File diff suppressed because it is too large
Load Diff
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user