This commit is contained in:
cool.gitter.choco
2025-03-15 14:44:43 -06:00
parent c6204ada00
commit a4932ae36e
31 changed files with 2183 additions and 807 deletions

4
.gitignore vendored
View File

@@ -27,3 +27,7 @@ routes/utils/__pycache__/search.cpython-312.pyc
search_test.py
config/main.json
.cache
config/state/queue_state.json
output.log
queue_state.json
search_demo.py

23
app.py
View File

@@ -106,19 +106,12 @@ def create_app():
return app
app = create_app()
if __name__ == '__main__':
import os
DEBUG = os.getenv("FLASK_DEBUG", "0") == "1"
if DEBUG:
logging.info("Starting Flask in DEBUG mode on port 7171")
app.run(debug=True, host='0.0.0.0', port=7171) # Use Flask's built-in server
else:
logger = logging.getLogger('waitress')
logger.setLevel(logging.INFO)
logging.info("Starting Flask server with Waitress on port 7171")
serve(app, host='0.0.0.0', port=7171) # Use Waitress for production
# Configure waitress logger
logger = logging.getLogger('waitress')
logger.setLevel(logging.INFO)
app = create_app()
logging.info("Starting Flask server on port 7171")
from waitress import serve
serve(app, host='0.0.0.0', port=7171)

View File

@@ -2,27 +2,60 @@ from flask import Blueprint, Response, request
import json
import os
import traceback
from routes.utils.queue import download_queue_manager
from routes.utils.queue import download_queue_manager, get_config_params
album_bp = Blueprint('album', __name__)
@album_bp.route('/download', methods=['GET'])
def handle_download():
# Retrieve parameters from the request.
# Retrieve essential parameters from the request.
service = request.args.get('service')
url = request.args.get('url')
# Get common parameters from config
config_params = get_config_params()
# Allow request parameters to override config values
main = request.args.get('main')
fallback = request.args.get('fallback')
quality = request.args.get('quality')
fall_quality = request.args.get('fall_quality')
real_time_arg = request.args.get('real_time')
custom_dir_format = request.args.get('custom_dir_format')
custom_track_format = request.args.get('custom_track_format')
pad_tracks_arg = request.args.get('tracknum_padding')
# Normalize the real_time parameter; default to False.
real_time_arg = request.args.get('real_time', 'false')
real_time = real_time_arg.lower() in ['true', '1', 'yes']
# New custom formatting parameters (with defaults)
custom_dir_format = request.args.get('custom_dir_format', "%ar_album%/%album%")
custom_track_format = request.args.get('custom_track_format', "%tracknum%. %music% - %artist%")
# Use config values as defaults when parameters are not provided
if not main:
main = config_params['spotify'] if service == 'spotify' else config_params['deezer']
if not fallback and config_params['fallback'] and service == 'spotify':
fallback = config_params['spotify']
if not quality:
quality = config_params['spotifyQuality'] if service == 'spotify' else config_params['deezerQuality']
if not fall_quality and fallback:
fall_quality = config_params['spotifyQuality']
# Parse boolean parameters
real_time = real_time_arg.lower() in ['true', '1', 'yes'] if real_time_arg is not None else config_params['realTime']
pad_tracks = pad_tracks_arg.lower() in ['true', '1', 'yes'] if pad_tracks_arg is not None else config_params['tracknum_padding']
# Use config values for formatting if not provided
if not custom_dir_format:
custom_dir_format = config_params['customDirFormat']
if not custom_track_format:
custom_track_format = config_params['customTrackFormat']
# Validate required parameters
if not all([service, url, main]):
return Response(
json.dumps({"error": "Missing parameters: service, url, or main account"}),
status=400,
mimetype='application/json'
)
# Sanitize main and fallback to prevent directory traversal.
if main:
@@ -30,13 +63,6 @@ def handle_download():
if fallback:
fallback = os.path.basename(fallback)
if not all([service, url, main]):
return Response(
json.dumps({"error": "Missing parameters"}),
status=400,
mimetype='application/json'
)
# Validate credentials based on service and fallback.
try:
if service == 'spotify':
@@ -101,6 +127,7 @@ def handle_download():
"real_time": real_time,
"custom_dir_format": custom_dir_format,
"custom_track_format": custom_track_format,
"pad_tracks": pad_tracks,
"orig_request": request.args.to_dict(),
# New additional parameters:
"type": "album",
@@ -147,7 +174,6 @@ def get_album_info():
Expects a query parameter 'id' that contains the Spotify album ID.
"""
spotify_id = request.args.get('id')
main = request.args.get('main', '')
if not spotify_id:
return Response(
@@ -156,26 +182,10 @@ def get_album_info():
mimetype='application/json'
)
# If main parameter is not provided in the request, get it from config
if not main:
from routes.config import get_config
config = get_config()
if config and 'spotify' in config:
main = config['spotify']
print(f"Using main from config for album info: {main}")
# Validate main parameter
if not main:
return Response(
json.dumps({"error": "Missing parameter: main (Spotify account)"}),
status=400,
mimetype='application/json'
)
try:
# Import and use the get_spotify_info function from the utility module.
from routes.utils.get_info import get_spotify_info
album_info = get_spotify_info(spotify_id, "album", main=main)
album_info = get_spotify_info(spotify_id, "album")
return Response(
json.dumps(album_info),
status=200,

View File

@@ -9,6 +9,7 @@ import os
import random
import string
import traceback
from routes.utils.queue import download_queue_manager, get_config_params
artist_bp = Blueprint('artist', __name__)
@@ -23,32 +24,61 @@ def handle_artist_download():
Expected query parameters:
- url: string (a Spotify artist URL)
- service: string ("spotify" or "deezer")
- main: string (e.g., a credentials directory name)
- fallback: string (optional)
- quality: string (e.g., "MP3_128")
- fall_quality: string (optional, e.g., "HIGH")
- real_time: bool (e.g., "true" or "false")
- album_type: string(s); comma-separated values such as "album,single,appears_on,compilation"
- custom_dir_format: string (optional, default: "%ar_album%/%album%/%copyright%")
- custom_track_format: string (optional, default: "%tracknum%. %music% - %artist%")
Since the new download_artist_albums() function simply enqueues album tasks via
the global queue manager, it returns a list of album PRG filenames. These are sent
back immediately in the JSON response.
"""
# Retrieve essential parameters from the request.
service = request.args.get('service')
url = request.args.get('url')
album_type = request.args.get('album_type')
# Get common parameters from config
config_params = get_config_params()
# Allow request parameters to override config values
main = request.args.get('main')
fallback = request.args.get('fallback')
quality = request.args.get('quality')
fall_quality = request.args.get('fall_quality')
album_type = request.args.get('album_type')
real_time_arg = request.args.get('real_time', 'false')
real_time = real_time_arg.lower() in ['true', '1', 'yes']
# New query parameters for custom formatting.
custom_dir_format = request.args.get('custom_dir_format', "%ar_album%/%album%/%copyright%")
custom_track_format = request.args.get('custom_track_format', "%tracknum%. %music% - %artist%")
real_time_arg = request.args.get('real_time')
custom_dir_format = request.args.get('custom_dir_format')
custom_track_format = request.args.get('custom_track_format')
pad_tracks_arg = request.args.get('tracknum_padding')
# Use config values as defaults when parameters are not provided
if not main:
main = config_params['spotify'] if service == 'spotify' else config_params['deezer']
if not fallback and config_params['fallback'] and service == 'spotify':
fallback = config_params['spotify']
if not quality:
quality = config_params['spotifyQuality'] if service == 'spotify' else config_params['deezerQuality']
if not fall_quality and fallback:
fall_quality = config_params['spotifyQuality']
# Parse boolean parameters
real_time = real_time_arg.lower() in ['true', '1', 'yes'] if real_time_arg is not None else config_params['realTime']
pad_tracks = pad_tracks_arg.lower() in ['true', '1', 'yes'] if pad_tracks_arg is not None else config_params['tracknum_padding']
# Use config values for formatting if not provided
if not custom_dir_format:
custom_dir_format = config_params['customDirFormat']
if not custom_track_format:
custom_track_format = config_params['customTrackFormat']
# Use default album_type if not specified
if not album_type:
album_type = "album,single,compilation"
# Validate required parameters
if not all([service, url, main, quality]):
return Response(
json.dumps({"error": "Missing parameters: service, url, main, or quality"}),
status=400,
mimetype='application/json'
)
# Sanitize main and fallback to prevent directory traversal.
if main:
@@ -56,14 +86,6 @@ def handle_artist_download():
if fallback:
fallback = os.path.basename(fallback)
# Check for required parameters.
if not all([service, url, main, quality, album_type]):
return Response(
json.dumps({"error": "Missing parameters"}),
status=400,
mimetype='application/json'
)
# Validate credentials based on the selected service.
try:
if service == 'spotify':
@@ -125,7 +147,8 @@ def handle_artist_download():
real_time=real_time,
album_type=album_type,
custom_dir_format=custom_dir_format,
custom_track_format=custom_track_format
custom_track_format=custom_track_format,
pad_tracks=pad_tracks
)
# Return the list of album PRG filenames.
return Response(
@@ -169,7 +192,6 @@ def get_artist_info():
Expects a query parameter 'id' with the Spotify artist ID.
"""
spotify_id = request.args.get('id')
main = request.args.get('main', '')
if not spotify_id:
return Response(
@@ -178,25 +200,9 @@ def get_artist_info():
mimetype='application/json'
)
# If main parameter is not provided in the request, get it from config
if not main:
from routes.config import get_config
config = get_config()
if config and 'spotify' in config:
main = config['spotify']
print(f"Using main from config for artist info: {main}")
# Validate main parameter
if not main:
return Response(
json.dumps({"error": "Missing parameter: main (Spotify account)"}),
status=400,
mimetype='application/json'
)
try:
from routes.utils.get_info import get_spotify_info
artist_info = get_spotify_info(spotify_id, "artist", main=main)
artist_info = get_spotify_info(spotify_id, "artist")
return Response(
json.dumps(artist_info),
status=200,

View File

@@ -24,6 +24,30 @@ def handle_config():
config = get_config()
if config is None:
return jsonify({"error": "Could not read config file"}), 500
# Create config/state directory
Path('./config/state').mkdir(parents=True, exist_ok=True)
# Set default values for any missing config options
defaults = {
'fallback': False,
'spotifyQuality': 'NORMAL',
'deezerQuality': 'MP3_128',
'realTime': False,
'customDirFormat': '%ar_album%/%album%',
'customTrackFormat': '%tracknum%. %music%',
'maxConcurrentDownloads': 3,
'maxRetries': 3,
'retryDelaySeconds': 5,
'retry_delay_increase': 5,
'tracknum_padding': True
}
# Populate defaults for any missing keys
for key, default_value in defaults.items():
if key not in config:
config[key] = default_value
return jsonify(config)
@config_bp.route('/config', methods=['POST', 'PUT'])

View File

@@ -2,27 +2,60 @@ from flask import Blueprint, Response, request
import os
import json
import traceback
from routes.utils.queue import download_queue_manager
from routes.utils.queue import download_queue_manager, get_config_params
playlist_bp = Blueprint('playlist', __name__)
@playlist_bp.route('/download', methods=['GET'])
def handle_download():
# Retrieve parameters from the request.
# Retrieve essential parameters from the request.
service = request.args.get('service')
url = request.args.get('url')
# Get common parameters from config
config_params = get_config_params()
# Allow request parameters to override config values
main = request.args.get('main')
fallback = request.args.get('fallback')
quality = request.args.get('quality')
fall_quality = request.args.get('fall_quality')
real_time_arg = request.args.get('real_time')
custom_dir_format = request.args.get('custom_dir_format')
custom_track_format = request.args.get('custom_track_format')
pad_tracks_arg = request.args.get('tracknum_padding')
# Normalize the real_time parameter; default to False.
real_time_arg = request.args.get('real_time', 'false')
real_time = real_time_arg.lower() in ['true', '1', 'yes']
# Use config values as defaults when parameters are not provided
if not main:
main = config_params['spotify'] if service == 'spotify' else config_params['deezer']
# New custom formatting parameters (with defaults)
custom_dir_format = request.args.get('custom_dir_format', "%ar_album%/%album%/%copyright%")
custom_track_format = request.args.get('custom_track_format', "%tracknum%. %music% - %artist%")
if not fallback and config_params['fallback'] and service == 'spotify':
fallback = config_params['spotify']
if not quality:
quality = config_params['spotifyQuality'] if service == 'spotify' else config_params['deezerQuality']
if not fall_quality and fallback:
fall_quality = config_params['spotifyQuality']
# Parse boolean parameters
real_time = real_time_arg.lower() in ['true', '1', 'yes'] if real_time_arg is not None else config_params['realTime']
pad_tracks = pad_tracks_arg.lower() in ['true', '1', 'yes'] if pad_tracks_arg is not None else config_params['tracknum_padding']
# Use config values for formatting if not provided
if not custom_dir_format:
custom_dir_format = config_params['customDirFormat']
if not custom_track_format:
custom_track_format = config_params['customTrackFormat']
# Validate required parameters
if not all([service, url, main]):
return Response(
json.dumps({"error": "Missing parameters: service, url, or main account"}),
status=400,
mimetype='application/json'
)
# Sanitize main and fallback to prevent directory traversal.
if main:
@@ -30,13 +63,6 @@ def handle_download():
if fallback:
fallback = os.path.basename(fallback)
if not all([service, url, main]):
return Response(
json.dumps({"error": "Missing parameters"}),
status=400,
mimetype='application/json'
)
# Build the task dictionary.
# Note: the key "download_type" tells the queue handler which download function to call.
task = {
@@ -50,6 +76,7 @@ def handle_download():
"real_time": real_time,
"custom_dir_format": custom_dir_format,
"custom_track_format": custom_track_format,
"pad_tracks": pad_tracks,
"orig_request": request.args.to_dict(),
# If provided, these additional parameters can be used by your download function.
"type": "playlist",
@@ -96,7 +123,6 @@ def get_playlist_info():
Expects a query parameter 'id' that contains the Spotify playlist ID.
"""
spotify_id = request.args.get('id')
main = request.args.get('main', '')
if not spotify_id:
return Response(
@@ -105,26 +131,10 @@ def get_playlist_info():
mimetype='application/json'
)
# If main parameter is not provided in the request, get it from config
if not main:
from routes.config import get_config
config = get_config()
if config and 'spotify' in config:
main = config['spotify']
print(f"Using main from config for playlist info: {main}")
# Validate main parameter
if not main:
return Response(
json.dumps({"error": "Missing parameter: main (Spotify account)"}),
status=400,
mimetype='application/json'
)
try:
# Import and use the get_spotify_info function from the utility module.
from routes.utils.get_info import get_spotify_info
playlist_info = get_spotify_info(spotify_id, "playlist", main=main)
playlist_info = get_spotify_info(spotify_id, "playlist")
return Response(
json.dumps(playlist_info),
status=200,

View File

@@ -32,32 +32,57 @@ def get_prg_file(filename):
return jsonify({
"type": "",
"name": "",
"artist": "",
"last_line": None,
"original_request": None
"original_request": None,
"display_title": "",
"display_type": "",
"display_artist": ""
})
# Attempt to extract the original request from the first line.
original_request = None
display_title = ""
display_type = ""
display_artist = ""
try:
first_line = json.loads(lines[0])
if "original_request" in first_line:
original_request = first_line["original_request"]
except Exception:
if isinstance(first_line, dict):
if "original_request" in first_line:
original_request = first_line["original_request"]
else:
# The first line might be the original request itself
original_request = first_line
# Extract display information from the original request
if original_request:
display_title = original_request.get("display_title", original_request.get("name", ""))
display_type = original_request.get("display_type", original_request.get("type", ""))
display_artist = original_request.get("display_artist", original_request.get("artist", ""))
except Exception as e:
print(f"Error parsing first line of PRG file: {e}")
original_request = None
# For resource type and name, use the second line if available.
resource_type = ""
resource_name = ""
resource_artist = ""
if len(lines) > 1:
try:
second_line = json.loads(lines[1])
# Directly extract 'type' and 'name' from the JSON
resource_type = second_line.get("type", "")
resource_name = second_line.get("name", "")
resource_artist = second_line.get("artist", "")
except Exception:
resource_type = ""
resource_name = ""
resource_artist = ""
else:
resource_type = ""
resource_name = ""
resource_artist = ""
# Get the last line from the file.
last_line_raw = lines[-1]
@@ -69,8 +94,12 @@ def get_prg_file(filename):
return jsonify({
"type": resource_type,
"name": resource_name,
"artist": resource_artist,
"last_line": last_line_parsed,
"original_request": original_request
"original_request": original_request,
"display_title": display_title,
"display_type": display_type,
"display_artist": display_artist
})
except FileNotFoundError:
abort(404, "File not found")

View File

@@ -21,7 +21,6 @@ def handle_search():
main = config['spotify']
print(f"Using main from config: {main}")
print(f"Search request: query={query}, type={search_type}, limit={limit}, main={main}")
# Validate parameters
if not query:
@@ -39,21 +38,16 @@ def handle_search():
main=main # Pass the main parameter
)
print(f"Search response keys: {raw_results.keys() if raw_results else 'None'}")
# Extract items from the appropriate section of the response based on search_type
items = []
if raw_results and search_type + 's' in raw_results:
# Handle plural form (e.g., 'tracks' instead of 'track')
type_key = search_type + 's'
print(f"Using type key: {type_key}")
items = raw_results[type_key].get('items', [])
elif raw_results and search_type in raw_results:
# Handle singular form
print(f"Using type key: {search_type}")
items = raw_results[search_type].get('items', [])
print(f"Found {len(items)} items")
# Return both the items array and the full data for debugging
return jsonify({

View File

@@ -2,27 +2,60 @@ from flask import Blueprint, Response, request
import os
import json
import traceback
from routes.utils.queue import download_queue_manager
from routes.utils.queue import download_queue_manager, get_config_params
track_bp = Blueprint('track', __name__)
@track_bp.route('/download', methods=['GET'])
def handle_download():
# Retrieve parameters from the request.
# Retrieve essential parameters from the request.
service = request.args.get('service')
url = request.args.get('url')
# Get common parameters from config
config_params = get_config_params()
# Allow request parameters to override config values
main = request.args.get('main')
fallback = request.args.get('fallback')
quality = request.args.get('quality')
fall_quality = request.args.get('fall_quality')
real_time_arg = request.args.get('real_time')
custom_dir_format = request.args.get('custom_dir_format')
custom_track_format = request.args.get('custom_track_format')
pad_tracks_arg = request.args.get('tracknum_padding')
# Normalize the real_time parameter; default to False.
real_time_arg = request.args.get('real_time', 'false')
real_time = real_time_arg.lower() in ['true', '1', 'yes']
# Use config values as defaults when parameters are not provided
if not main:
main = config_params['spotify'] if service == 'spotify' else config_params['deezer']
# New custom formatting parameters (with defaults).
custom_dir_format = request.args.get('custom_dir_format', "%ar_album%/%album%/%copyright%")
custom_track_format = request.args.get('custom_track_format', "%tracknum%. %music% - %artist%")
if not fallback and config_params['fallback'] and service == 'spotify':
fallback = config_params['spotify']
if not quality:
quality = config_params['spotifyQuality'] if service == 'spotify' else config_params['deezerQuality']
if not fall_quality and fallback:
fall_quality = config_params['spotifyQuality']
# Parse boolean parameters
real_time = real_time_arg.lower() in ['true', '1', 'yes'] if real_time_arg is not None else config_params['realTime']
pad_tracks = pad_tracks_arg.lower() in ['true', '1', 'yes'] if pad_tracks_arg is not None else config_params['tracknum_padding']
# Use config values for formatting if not provided
if not custom_dir_format:
custom_dir_format = config_params['customDirFormat']
if not custom_track_format:
custom_track_format = config_params['customTrackFormat']
# Validate required parameters
if not all([service, url, main]):
return Response(
json.dumps({"error": "Missing parameters: service, url, or main account"}),
status=400,
mimetype='application/json'
)
# Sanitize main and fallback to prevent directory traversal.
if main:
@@ -30,13 +63,6 @@ def handle_download():
if fallback:
fallback = os.path.basename(fallback)
if not all([service, url, main]):
return Response(
json.dumps({"error": "Missing parameters"}),
status=400,
mimetype='application/json'
)
# Validate credentials based on service and fallback.
try:
if service == 'spotify':
@@ -103,6 +129,7 @@ def handle_download():
"real_time": real_time,
"custom_dir_format": custom_dir_format,
"custom_track_format": custom_track_format,
"pad_tracks": pad_tracks,
"orig_request": orig_request,
# Additional parameters if needed.
"type": "track",
@@ -149,7 +176,6 @@ def get_track_info():
Expects a query parameter 'id' that contains the Spotify track ID.
"""
spotify_id = request.args.get('id')
main = request.args.get('main', '')
if not spotify_id:
return Response(
@@ -158,26 +184,10 @@ def get_track_info():
mimetype='application/json'
)
# If main parameter is not provided in the request, get it from config
if not main:
from routes.config import get_config
config = get_config()
if config and 'spotify' in config:
main = config['spotify']
print(f"Using main from config for track info: {main}")
# Validate main parameter
if not main:
return Response(
json.dumps({"error": "Missing parameter: main (Spotify account)"}),
status=400,
mimetype='application/json'
)
try:
# Import and use the get_spotify_info function from the utility module.
from routes.utils.get_info import get_spotify_info
track_info = get_spotify_info(spotify_id, "track", main=main)
track_info = get_spotify_info(spotify_id, "track")
return Response(
json.dumps(track_info),
status=200,

View File

@@ -14,7 +14,11 @@ def download_album(
fall_quality=None,
real_time=False,
custom_dir_format="%ar_album%/%album%/%copyright%",
custom_track_format="%tracknum%. %music% - %artist%"
custom_track_format="%tracknum%. %music% - %artist%",
pad_tracks=True,
initial_retry_delay=5,
retry_delay_increase=5,
max_retries=3
):
try:
# Load Spotify client credentials if available
@@ -60,7 +64,11 @@ def download_album(
make_zip=False,
method_save=1,
custom_dir_format=custom_dir_format,
custom_track_format=custom_track_format
custom_track_format=custom_track_format,
pad_tracks=pad_tracks,
initial_retry_delay=initial_retry_delay,
retry_delay_increase=retry_delay_increase,
max_retries=max_retries
)
except Exception as e:
# Load fallback Spotify credentials and attempt download
@@ -97,7 +105,11 @@ def download_album(
make_zip=False,
real_time_dl=real_time,
custom_dir_format=custom_dir_format,
custom_track_format=custom_track_format
custom_track_format=custom_track_format,
pad_tracks=pad_tracks,
initial_retry_delay=initial_retry_delay,
retry_delay_increase=retry_delay_increase,
max_retries=max_retries
)
except Exception as e2:
# If fallback also fails, raise an error indicating both attempts failed
@@ -127,7 +139,11 @@ def download_album(
make_zip=False,
real_time_dl=real_time,
custom_dir_format=custom_dir_format,
custom_track_format=custom_track_format
custom_track_format=custom_track_format,
pad_tracks=pad_tracks,
initial_retry_delay=initial_retry_delay,
retry_delay_increase=retry_delay_increase,
max_retries=max_retries
)
elif service == 'deezer':
if quality is None:
@@ -151,7 +167,11 @@ def download_album(
method_save=1,
make_zip=False,
custom_dir_format=custom_dir_format,
custom_track_format=custom_track_format
custom_track_format=custom_track_format,
pad_tracks=pad_tracks,
initial_retry_delay=initial_retry_delay,
retry_delay_increase=retry_delay_increase,
max_retries=max_retries
)
else:
raise ValueError(f"Unsupported service: {service}")

View File

@@ -63,7 +63,11 @@ def download_artist_albums(service, url, main, fallback=None, quality=None,
fall_quality=None, real_time=False,
album_type='album,single,compilation,appears_on',
custom_dir_format="%ar_album%/%album%/%copyright%",
custom_track_format="%tracknum%. %music% - %artist%"):
custom_track_format="%tracknum%. %music% - %artist%",
pad_tracks=True,
initial_retry_delay=5,
retry_delay_increase=5,
max_retries=3):
"""
Retrieves the artist discography and, for each album with a valid Spotify URL,
creates a download task that is queued via the global download queue. The queue
@@ -110,6 +114,10 @@ def download_artist_albums(service, url, main, fallback=None, quality=None,
"real_time": real_time,
"custom_dir_format": custom_dir_format,
"custom_track_format": custom_track_format,
"pad_tracks": pad_tracks,
"initial_retry_delay": initial_retry_delay,
"retry_delay_increase": retry_delay_increase,
"max_retries": max_retries,
# Extra info for logging in the PRG file.
"name": album_name,
"type": "album",

View File

@@ -4,20 +4,45 @@ from deezspot.easy_spoty import Spo
import json
from pathlib import Path
def get_spotify_info(spotify_id, spotify_type, main=None):
# 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 = ""
def get_spotify_info(spotify_id, spotify_type):
"""
Get info from Spotify API using the default Spotify account configured in main.json
Args:
spotify_id: The Spotify ID of the entity
spotify_type: The type of entity (track, album, playlist, artist)
Returns:
Dictionary with the entity information
"""
client_id = None
client_secret = None
# Use the default account from config
main = DEFAULT_SPOTIFY_ACCOUNT
if not main:
raise ValueError("No Spotify account configured in settings")
if spotify_id:
search_creds_path = Path(f'./creds/spotify/{main}/search.json')
print(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')
print(client_id)
client_secret = search_creds.get('client_secret')
print(client_secret)
except Exception as e:
print(f"Error loading search credentials: {e}")

View File

@@ -14,7 +14,11 @@ def download_playlist(
fall_quality=None,
real_time=False,
custom_dir_format="%ar_album%/%album%/%copyright%",
custom_track_format="%tracknum%. %music% - %artist%"
custom_track_format="%tracknum%. %music% - %artist%",
pad_tracks=True,
initial_retry_delay=5,
retry_delay_increase=5,
max_retries=3
):
try:
# Load Spotify client credentials if available
@@ -60,7 +64,11 @@ def download_playlist(
make_zip=False,
method_save=1,
custom_dir_format=custom_dir_format,
custom_track_format=custom_track_format
custom_track_format=custom_track_format,
pad_tracks=pad_tracks,
initial_retry_delay=initial_retry_delay,
retry_delay_increase=retry_delay_increase,
max_retries=max_retries
)
except Exception as e:
# Load fallback Spotify credentials and attempt download
@@ -97,7 +105,11 @@ def download_playlist(
make_zip=False,
real_time_dl=real_time,
custom_dir_format=custom_dir_format,
custom_track_format=custom_track_format
custom_track_format=custom_track_format,
pad_tracks=pad_tracks,
initial_retry_delay=initial_retry_delay,
retry_delay_increase=retry_delay_increase,
max_retries=max_retries
)
except Exception as e2:
# If fallback also fails, raise an error indicating both attempts failed
@@ -127,7 +139,11 @@ def download_playlist(
make_zip=False,
real_time_dl=real_time,
custom_dir_format=custom_dir_format,
custom_track_format=custom_track_format
custom_track_format=custom_track_format,
pad_tracks=pad_tracks,
initial_retry_delay=initial_retry_delay,
retry_delay_increase=retry_delay_increase,
max_retries=max_retries
)
elif service == 'deezer':
if quality is None:
@@ -151,7 +167,11 @@ def download_playlist(
method_save=1,
make_zip=False,
custom_dir_format=custom_dir_format,
custom_track_format=custom_track_format
custom_track_format=custom_track_format,
pad_tracks=pad_tracks,
initial_retry_delay=initial_retry_delay,
retry_delay_increase=retry_delay_increase,
max_retries=max_retries
)
else:
raise ValueError(f"Unsupported service: {service}")

File diff suppressed because it is too large Load Diff

View File

@@ -14,15 +14,13 @@ def search(
if main:
search_creds_path = Path(f'./creds/spotify/{main}/search.json')
print(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')
print(client_id)
client_secret = search_creds.get('client_secret')
print(client_secret)
except Exception as e:
print(f"Error loading search credentials: {e}")

View File

@@ -14,7 +14,11 @@ def download_track(
fall_quality=None,
real_time=False,
custom_dir_format="%ar_album%/%album%/%copyright%",
custom_track_format="%tracknum%. %music% - %artist%"
custom_track_format="%tracknum%. %music% - %artist%",
pad_tracks=True,
initial_retry_delay=5,
retry_delay_increase=5,
max_retries=3
):
try:
# Load Spotify client credentials if available
@@ -56,7 +60,10 @@ def download_track(
not_interface=False,
method_save=1,
custom_dir_format=custom_dir_format,
custom_track_format=custom_track_format
custom_track_format=custom_track_format,
initial_retry_delay=initial_retry_delay,
retry_delay_increase=retry_delay_increase,
max_retries=max_retries
)
except Exception as e:
# If the first attempt fails, use the fallback Spotify credentials
@@ -91,7 +98,11 @@ def download_track(
method_save=1,
real_time_dl=real_time,
custom_dir_format=custom_dir_format,
custom_track_format=custom_track_format
custom_track_format=custom_track_format,
pad_tracks=pad_tracks,
initial_retry_delay=initial_retry_delay,
retry_delay_increase=retry_delay_increase,
max_retries=max_retries
)
else:
# Directly use Spotify main account
@@ -114,7 +125,11 @@ def download_track(
method_save=1,
real_time_dl=real_time,
custom_dir_format=custom_dir_format,
custom_track_format=custom_track_format
custom_track_format=custom_track_format,
pad_tracks=pad_tracks,
initial_retry_delay=initial_retry_delay,
retry_delay_increase=retry_delay_increase,
max_retries=max_retries
)
elif service == 'deezer':
if quality is None:
@@ -137,7 +152,11 @@ def download_track(
recursive_download=False,
method_save=1,
custom_dir_format=custom_dir_format,
custom_track_format=custom_track_format
custom_track_format=custom_track_format,
pad_tracks=pad_tracks,
initial_retry_delay=initial_retry_delay,
retry_delay_increase=retry_delay_increase,
max_retries=max_retries
)
else:
raise ValueError(f"Unsupported service: {service}")

View File

@@ -201,6 +201,14 @@ input:checked + .slider:before {
transform: translateX(20px);
}
/* Setting description */
.setting-description {
margin-top: 0.4rem;
font-size: 0.8rem;
color: #b3b3b3;
line-height: 1.4;
}
/* Service Tabs */
.service-tabs {
display: flex;
@@ -387,6 +395,16 @@ input:checked + .slider:before {
min-height: 1.2rem;
}
/* Success Messages */
#configSuccess {
color: #1db954;
margin-top: 1rem;
text-align: center;
font-size: 0.9rem;
min-height: 1.2rem;
font-weight: 500;
}
/* MOBILE RESPONSIVENESS */
@media (max-width: 768px) {
.config-container {

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@@ -9,18 +9,8 @@ document.addEventListener('DOMContentLoaded', () => {
return;
}
// Fetch the config to get active Spotify account first
fetch('/api/config')
.then(response => {
if (!response.ok) throw new Error('Failed to fetch config');
return response.json();
})
.then(config => {
const mainAccount = config.spotify || '';
// Then fetch album info with the main parameter
return fetch(`/api/album/info?id=${encodeURIComponent(albumId)}&main=${mainAccount}`);
})
// Fetch album info directly
fetch(`/api/album/info?id=${encodeURIComponent(albumId)}`)
.then(response => {
if (!response.ok) throw new Error('Network response was not ok');
return response.json();
@@ -48,19 +38,21 @@ function renderAlbum(album) {
// Set album header info.
document.getElementById('album-name').innerHTML =
`<a href="${baseUrl}/album/${album.id}">${album.name}</a>`;
`<a href="${baseUrl}/album/${album.id || ''}">${album.name || 'Unknown Album'}</a>`;
document.getElementById('album-artist').innerHTML =
`By ${album.artists.map(artist => `<a href="${baseUrl}/artist/${artist.id}">${artist.name}</a>`).join(', ')}`;
`By ${album.artists?.map(artist =>
`<a href="${baseUrl}/artist/${artist?.id || ''}">${artist?.name || 'Unknown Artist'}</a>`
).join(', ') || 'Unknown Artist'}`;
const releaseYear = new Date(album.release_date).getFullYear();
const releaseYear = album.release_date ? new Date(album.release_date).getFullYear() : 'N/A';
document.getElementById('album-stats').textContent =
`${releaseYear}${album.total_tracks} songs • ${album.label}`;
`${releaseYear}${album.total_tracks || '0'} songs • ${album.label || 'Unknown Label'}`;
document.getElementById('album-copyright').textContent =
album.copyrights.map(c => c.text).join(' • ');
album.copyrights?.map(c => c?.text || '').filter(text => text).join(' • ') || '';
const image = album.images[0]?.url || 'placeholder.jpg';
const image = album.images?.[0]?.url || '/static/images/placeholder.jpg';
document.getElementById('album-image').src = image;
// Create (if needed) the Home Button.
@@ -107,7 +99,7 @@ function renderAlbum(album) {
downloadAlbumBtn.textContent = 'Queued!';
})
.catch(err => {
showError('Failed to queue album download: ' + err.message);
showError('Failed to queue album download: ' + (err?.message || 'Unknown error'));
downloadAlbumBtn.disabled = false;
});
});
@@ -116,30 +108,36 @@ function renderAlbum(album) {
const tracksList = document.getElementById('tracks-list');
tracksList.innerHTML = '';
album.tracks.items.forEach((track, index) => {
const trackElement = document.createElement('div');
trackElement.className = 'track';
trackElement.innerHTML = `
<div class="track-number">${index + 1}</div>
<div class="track-info">
<div class="track-name">
<a href="${baseUrl}/track/${track.id}">${track.name}</a>
if (album.tracks?.items) {
album.tracks.items.forEach((track, index) => {
if (!track) return; // Skip null or undefined tracks
const trackElement = document.createElement('div');
trackElement.className = 'track';
trackElement.innerHTML = `
<div class="track-number">${index + 1}</div>
<div class="track-info">
<div class="track-name">
<a href="${baseUrl}/track/${track.id || ''}">${track.name || 'Unknown Track'}</a>
</div>
<div class="track-artist">
${track.artists?.map(a =>
`<a href="${baseUrl}/artist/${a?.id || ''}">${a?.name || 'Unknown Artist'}</a>`
).join(', ') || 'Unknown Artist'}
</div>
</div>
<div class="track-artist">
${track.artists.map(a => `<a href="${baseUrl}/artist/${a.id}">${a.name}</a>`).join(', ')}
</div>
</div>
<div class="track-duration">${msToTime(track.duration_ms)}</div>
<button class="download-btn download-btn--circle"
data-url="${track.external_urls.spotify}"
data-type="track"
data-name="${track.name}"
title="Download">
<img src="/static/images/download.svg" alt="Download">
</button>
`;
tracksList.appendChild(trackElement);
});
<div class="track-duration">${msToTime(track.duration_ms || 0)}</div>
<button class="download-btn download-btn--circle"
data-url="${track.external_urls?.spotify || ''}"
data-type="track"
data-name="${track.name || 'Unknown Track'}"
title="Download">
<img src="/static/images/download.svg" alt="Download">
</button>
`;
tracksList.appendChild(trackElement);
});
}
// Reveal header and track list.
document.getElementById('album-header').classList.remove('hidden');
@@ -166,11 +164,15 @@ function renderAlbum(album) {
}
async function downloadWholeAlbum(album) {
const url = album.external_urls.spotify;
const url = album.external_urls?.spotify || '';
if (!url) {
throw new Error('Missing album URL');
}
try {
await downloadQueue.startAlbumDownload(url, { name: album.name });
await downloadQueue.startAlbumDownload(url, { name: album.name || 'Unknown Album' });
} catch (error) {
showError('Album download failed: ' + error.message);
showError('Album download failed: ' + (error?.message || 'Unknown error'));
throw error;
}
}
@@ -183,7 +185,7 @@ function msToTime(duration) {
function showError(message) {
const errorEl = document.getElementById('error');
errorEl.textContent = message;
errorEl.textContent = message || 'An error occurred';
errorEl.classList.remove('hidden');
}
@@ -192,9 +194,9 @@ function attachDownloadListeners() {
if (btn.id === 'downloadAlbumBtn') return;
btn.addEventListener('click', (e) => {
e.stopPropagation();
const url = e.currentTarget.dataset.url;
const type = e.currentTarget.dataset.type;
const name = e.currentTarget.dataset.name || extractName(url);
const url = e.currentTarget.dataset.url || '';
const type = e.currentTarget.dataset.type || '';
const name = e.currentTarget.dataset.name || extractName(url) || 'Unknown';
// Remove the button immediately after click.
e.currentTarget.remove();
startDownload(url, type, { name });
@@ -203,47 +205,25 @@ function attachDownloadListeners() {
}
async function startDownload(url, type, item, albumType) {
const config = JSON.parse(localStorage.getItem('activeConfig')) || {};
const {
fallback = false,
spotify = '',
deezer = '',
spotifyQuality = 'NORMAL',
deezerQuality = 'MP3_128',
realTime = false,
customDirFormat = '',
customTrackFormat = ''
} = config;
if (!url) {
showError('Missing URL for download');
return;
}
const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer';
let apiUrl = '';
let apiUrl = `/api/${type}/download?service=${service}&url=${encodeURIComponent(url)}`;
if (type === 'album') {
apiUrl = `/api/album/download?service=${service}&url=${encodeURIComponent(url)}`;
} else if (type === 'artist') {
apiUrl = `/api/artist/download?service=${service}&artist_url=${encodeURIComponent(url)}&album_type=${encodeURIComponent(albumType || 'album,single,compilation')}`;
} else {
apiUrl = `/api/${type}/download?service=${service}&url=${encodeURIComponent(url)}`;
// Add name and artist if available for better progress display
if (item.name) {
apiUrl += `&name=${encodeURIComponent(item.name)}`;
}
if (fallback && service === 'spotify') {
apiUrl += `&main=${deezer}&fallback=${spotify}`;
apiUrl += `&quality=${deezerQuality}&fall_quality=${spotifyQuality}`;
} else {
const mainAccount = service === 'spotify' ? spotify : deezer;
apiUrl += `&main=${mainAccount}&quality=${service === 'spotify' ? spotifyQuality : deezerQuality}`;
if (item.artist) {
apiUrl += `&artist=${encodeURIComponent(item.artist)}`;
}
if (realTime) {
apiUrl += '&real_time=true';
}
// Append custom directory and file format settings if provided.
if (customDirFormat) {
apiUrl += `&custom_dir_format=${encodeURIComponent(customDirFormat)}`;
}
if (customTrackFormat) {
apiUrl += `&custom_file_format=${encodeURIComponent(customTrackFormat)}`;
// For artist downloads, include album_type
if (type === 'artist' && albumType) {
apiUrl += `&album_type=${encodeURIComponent(albumType)}`;
}
try {
@@ -251,10 +231,10 @@ async function startDownload(url, type, item, albumType) {
const data = await response.json();
downloadQueue.addDownload(item, type, data.prg_file);
} catch (error) {
showError('Download failed: ' + error.message);
showError('Download failed: ' + (error?.message || 'Unknown error'));
}
}
function extractName(url) {
return url;
return url || 'Unknown';
}

View File

@@ -10,18 +10,8 @@ document.addEventListener('DOMContentLoaded', () => {
return;
}
// Fetch the config to get active Spotify account first
fetch('/api/config')
.then(response => {
if (!response.ok) throw new Error('Failed to fetch config');
return response.json();
})
.then(config => {
const mainAccount = config.spotify || '';
// Then fetch artist info with the main parameter
return fetch(`/api/artist/info?id=${encodeURIComponent(artistId)}&main=${mainAccount}`);
})
// Fetch artist info directly
fetch(`/api/artist/info?id=${encodeURIComponent(artistId)}`)
.then(response => {
if (!response.ok) throw new Error('Network response was not ok');
return response.json();
@@ -42,13 +32,13 @@ function renderArtist(artistData, artistId) {
document.getElementById('loading').classList.add('hidden');
document.getElementById('error').classList.add('hidden');
const firstAlbum = artistData.items[0];
const artistName = firstAlbum?.artists[0]?.name || 'Unknown Artist';
const artistImage = firstAlbum?.images[0]?.url || 'placeholder.jpg';
const firstAlbum = artistData.items?.[0] || {};
const artistName = firstAlbum?.artists?.[0]?.name || 'Unknown Artist';
const artistImage = firstAlbum?.images?.[0]?.url || '/static/images/placeholder.jpg';
document.getElementById('artist-name').innerHTML =
`<a href="/artist/${artistId}" class="artist-link">${artistName}</a>`;
document.getElementById('artist-stats').textContent = `${artistData.total} albums`;
document.getElementById('artist-stats').textContent = `${artistData.total || '0'} albums`;
document.getElementById('artist-image').src = artistImage;
// Define the artist URL (used by both full-discography and group downloads)
@@ -93,13 +83,14 @@ function renderArtist(artistData, artistId) {
.catch(err => {
downloadArtistBtn.textContent = 'Download All Discography';
downloadArtistBtn.disabled = false;
showError('Failed to queue artist download: ' + err.message);
showError('Failed to queue artist download: ' + (err?.message || 'Unknown error'));
});
});
// Group albums by type (album, single, compilation, etc.)
const albumGroups = artistData.items.reduce((groups, album) => {
const type = album.album_type.toLowerCase();
const albumGroups = (artistData.items || []).reduce((groups, album) => {
if (!album) return groups;
const type = (album.album_type || 'unknown').toLowerCase();
if (!groups[type]) groups[type] = [];
groups[type].push(album);
return groups;
@@ -126,22 +117,24 @@ function renderArtist(artistData, artistId) {
const albumsContainer = groupSection.querySelector('.albums-list');
albums.forEach(album => {
if (!album) return;
const albumElement = document.createElement('div');
albumElement.className = 'album-card';
albumElement.innerHTML = `
<a href="/album/${album.id}" class="album-link">
<img src="${album.images[1]?.url || album.images[0]?.url || 'placeholder.jpg'}"
<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}</div>
<div class="album-artist">${album.artists.map(a => a.name).join(', ')}</div>
<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}"
data-name="${album.name}"
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>
@@ -164,7 +157,7 @@ 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; // e.g. "album", "single", "compilation"
const groupType = e.target.dataset.groupType || 'album'; // e.g. "album", "single", "compilation"
e.target.disabled = true;
e.target.textContent = `Queueing all ${capitalize(groupType)}s...`;
@@ -172,14 +165,14 @@ function attachGroupDownloadListeners(artistUrl, artistName) {
// Use the artist download function with the group type filter.
await downloadQueue.startArtistDownload(
artistUrl,
{ name: artistName, artist: artistName },
{ name: artistName || 'Unknown Artist', artist: artistName || 'Unknown Artist' },
groupType // Only queue releases of this specific type.
);
e.target.textContent = `Queued all ${capitalize(groupType)}s`;
} catch (error) {
e.target.textContent = `Download All ${capitalize(groupType)}s`;
e.target.disabled = false;
showError(`Failed to queue download for all ${groupType}s: ${error.message}`);
showError(`Failed to queue download for all ${groupType}s: ${error?.message || 'Unknown error'}`);
}
});
});
@@ -190,10 +183,13 @@ function attachDownloadListeners() {
document.querySelectorAll('.download-btn:not(.group-download-btn)').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const { url, name, type } = e.currentTarget.dataset;
const url = e.currentTarget.dataset.url || '';
const name = e.currentTarget.dataset.name || 'Unknown';
const type = e.currentTarget.dataset.type || 'album';
e.currentTarget.remove();
downloadQueue.startAlbumDownload(url, { name, type })
.catch(err => showError('Download failed: ' + err.message));
.catch(err => showError('Download failed: ' + (err?.message || 'Unknown error')));
});
});
}
@@ -201,8 +197,10 @@ function attachDownloadListeners() {
// UI Helpers
function showError(message) {
const errorEl = document.getElementById('error');
errorEl.textContent = message;
errorEl.classList.remove('hidden');
if (errorEl) {
errorEl.textContent = message || 'An error occurred';
errorEl.classList.remove('hidden');
}
}
function capitalize(str) {

View File

@@ -83,6 +83,9 @@ function setupEventListeners() {
document.getElementById('realTimeToggle').addEventListener('change', saveConfig);
document.getElementById('spotifyQualitySelect').addEventListener('change', saveConfig);
document.getElementById('deezerQualitySelect').addEventListener('change', saveConfig);
document.getElementById('tracknumPaddingToggle').addEventListener('change', saveConfig);
document.getElementById('maxRetries').addEventListener('change', saveConfig);
document.getElementById('retryDelaySeconds').addEventListener('change', saveConfig);
// Update active account globals when the account selector is changed.
document.getElementById('spotifyAccountSelect').addEventListener('change', (e) => {
@@ -350,11 +353,41 @@ function toggleSearchFieldsVisibility(showSearchFields) {
const searchFieldsDiv = document.getElementById('searchFields');
if (showSearchFields) {
// Hide regular fields and remove 'required' attribute
serviceFieldsDiv.style.display = 'none';
// Remove required attribute from service fields
serviceConfig[currentService].fields.forEach(field => {
const input = document.getElementById(field.id);
if (input) input.removeAttribute('required');
});
// Show search fields and add 'required' attribute
searchFieldsDiv.style.display = 'block';
// Make search fields required
if (currentService === 'spotify' && serviceConfig[currentService].searchFields) {
serviceConfig[currentService].searchFields.forEach(field => {
const input = document.getElementById(field.id);
if (input) input.setAttribute('required', '');
});
}
} else {
// Show regular fields and add 'required' attribute
serviceFieldsDiv.style.display = 'block';
// Make service fields required
serviceConfig[currentService].fields.forEach(field => {
const input = document.getElementById(field.id);
if (input) input.setAttribute('required', '');
});
// Hide search fields and remove 'required' attribute
searchFieldsDiv.style.display = 'none';
// Remove required from search fields
if (currentService === 'spotify' && serviceConfig[currentService].searchFields) {
serviceConfig[currentService].searchFields.forEach(field => {
const input = document.getElementById(field.id);
if (input) input.removeAttribute('required');
});
}
}
}
@@ -431,9 +464,25 @@ async function handleCredentialSubmit(e) {
if (isEditingSearch && service === 'spotify') {
// Handle search credentials
const formData = {};
let isValid = true;
let firstInvalidField = null;
// Manually validate search fields
serviceConfig[service].searchFields.forEach(field => {
formData[field.id] = document.getElementById(field.id).value.trim();
const input = document.getElementById(field.id);
const value = input ? input.value.trim() : '';
formData[field.id] = value;
if (!value) {
isValid = false;
if (!firstInvalidField) firstInvalidField = input;
}
});
if (!isValid) {
if (firstInvalidField) firstInvalidField.focus();
throw new Error('All fields are required');
}
data = serviceConfig[service].searchValidator(formData);
endpoint = `/api/credentials/${service}/${endpointName}?type=search`;
@@ -444,9 +493,25 @@ async function handleCredentialSubmit(e) {
} else {
// Handle regular account credentials
const formData = {};
let isValid = true;
let firstInvalidField = null;
// Manually validate account fields
serviceConfig[service].fields.forEach(field => {
formData[field.id] = document.getElementById(field.id).value.trim();
const input = document.getElementById(field.id);
const value = input ? input.value.trim() : '';
formData[field.id] = value;
if (!value) {
isValid = false;
if (!firstInvalidField) firstInvalidField = input;
}
});
if (!isValid) {
if (firstInvalidField) firstInvalidField.focus();
throw new Error('All fields are required');
}
data = serviceConfig[service].validator(formData);
endpoint = `/api/credentials/${service}/${endpointName}`;
@@ -468,6 +533,9 @@ async function handleCredentialSubmit(e) {
await saveConfig();
loadCredentials(service);
resetForm();
// Show success message
showConfigSuccess(isEditingSearch ? 'API credentials saved successfully' : 'Account saved successfully');
} catch (error) {
showConfigError(error.message);
}
@@ -501,7 +569,11 @@ async function saveConfig() {
realTime: document.getElementById('realTimeToggle').checked,
customDirFormat: document.getElementById('customDirFormat').value,
customTrackFormat: document.getElementById('customTrackFormat').value,
maxConcurrentDownloads: parseInt(document.getElementById('maxConcurrentDownloads').value, 10) || 3
maxConcurrentDownloads: parseInt(document.getElementById('maxConcurrentDownloads').value, 10) || 3,
maxRetries: parseInt(document.getElementById('maxRetries').value, 10) || 3,
retryDelaySeconds: parseInt(document.getElementById('retryDelaySeconds').value, 10) || 5,
retry_delay_increase: parseInt(document.getElementById('retryDelayIncrease').value, 10) || 5,
tracknum_padding: document.getElementById('tracknumPaddingToggle').checked
};
try {
@@ -546,6 +618,10 @@ async function loadConfig() {
document.getElementById('customDirFormat').value = savedConfig.customDirFormat || '%ar_album%/%album%';
document.getElementById('customTrackFormat').value = savedConfig.customTrackFormat || '%tracknum%. %music%';
document.getElementById('maxConcurrentDownloads').value = savedConfig.maxConcurrentDownloads || '3';
document.getElementById('maxRetries').value = savedConfig.maxRetries || '3';
document.getElementById('retryDelaySeconds').value = savedConfig.retryDelaySeconds || '5';
document.getElementById('retryDelayIncrease').value = savedConfig.retry_delay_increase || '5';
document.getElementById('tracknumPaddingToggle').checked = savedConfig.tracknum_padding === undefined ? true : !!savedConfig.tracknum_padding;
} catch (error) {
showConfigError('Error loading config: ' + error.message);
}
@@ -556,3 +632,9 @@ function showConfigError(message) {
errorDiv.textContent = message;
setTimeout(() => (errorDiv.textContent = ''), 5000);
}
function showConfigSuccess(message) {
const successDiv = document.getElementById('configSuccess');
successDiv.textContent = message;
setTimeout(() => (successDiv.textContent = ''), 5000);
}

View File

@@ -14,25 +14,42 @@ document.addEventListener('DOMContentLoaded', () => {
}
// Save the search type to local storage whenever it changes
searchType.addEventListener('change', () => {
localStorage.setItem('searchType', searchType.value);
});
if (searchType) {
searchType.addEventListener('change', () => {
localStorage.setItem('searchType', searchType.value);
});
}
// Initialize queue icon
queueIcon.addEventListener('click', () => downloadQueue.toggleVisibility());
if (queueIcon) {
queueIcon.addEventListener('click', () => downloadQueue.toggleVisibility());
}
// Search functionality
searchButton.addEventListener('click', performSearch);
searchInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') performSearch();
});
if (searchButton) {
searchButton.addEventListener('click', performSearch);
}
if (searchInput) {
searchInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') performSearch();
});
}
});
async function performSearch() {
const query = document.getElementById('searchInput').value.trim();
const searchType = document.getElementById('searchType').value;
const searchInput = document.getElementById('searchInput');
const searchType = document.getElementById('searchType');
const resultsContainer = document.getElementById('resultsContainer');
if (!searchInput || !searchType || !resultsContainer) {
console.error('Required DOM elements not found');
return;
}
const query = searchInput.value.trim();
const typeValue = searchType.value;
if (!query) {
showError('Please enter a search term');
return;
@@ -50,7 +67,7 @@ async function performSearch() {
window.location.href = `${window.location.origin}/${type}/${id}`;
return;
} catch (error) {
showError(`Invalid Spotify URL: ${error.message}`);
showError(`Invalid Spotify URL: ${error?.message || 'Unknown error'}`);
return;
}
}
@@ -61,26 +78,27 @@ async function performSearch() {
// Fetch config to get active Spotify account
const configResponse = await fetch('/api/config');
const config = await configResponse.json();
const mainAccount = config.spotify || '';
const mainAccount = config?.spotify || '';
// Add the main parameter to the search API call
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}&search_type=${searchType}&limit=50&main=${mainAccount}`);
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}&search_type=${typeValue}&limit=50&main=${mainAccount}`);
const data = await response.json();
if (data.error) throw new Error(data.error);
// When mapping the items, include the index so that each card gets a data-index attribute.
const items = data.data[`${searchType}s`]?.items;
const items = data.data?.[`${typeValue}s`]?.items;
if (!items?.length) {
resultsContainer.innerHTML = '<div class="error">No results found</div>';
return;
}
resultsContainer.innerHTML = items
.map((item, index) => createResultCard(item, searchType, index))
.map((item, index) => item ? createResultCard(item, typeValue, index) : '')
.filter(card => card) // Filter out empty strings
.join('');
attachDownloadListeners(items);
} catch (error) {
showError(error.message);
showError(error?.message || 'Search failed');
}
}
@@ -93,21 +111,24 @@ function attachDownloadListeners(items) {
document.querySelectorAll('.download-btn, .download-btn-small').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const url = e.currentTarget.dataset.url;
const type = e.currentTarget.dataset.type;
const albumType = e.currentTarget.dataset.albumType;
const url = e.currentTarget.dataset.url || '';
const type = e.currentTarget.dataset.type || '';
const albumType = e.currentTarget.dataset.albumType || '';
// Get the parent result card and its data-index
const card = e.currentTarget.closest('.result-card');
const idx = card ? card.getAttribute('data-index') : null;
const item = (idx !== null) ? items[idx] : null;
const item = (idx !== null && items[idx]) ? items[idx] : null;
// Remove the button or card from the UI as appropriate.
if (e.currentTarget.classList.contains('main-download')) {
card.remove();
if (card) card.remove();
} else {
e.currentTarget.remove();
}
startDownload(url, type, item, albumType);
if (url && type) {
startDownload(url, type, item, albumType);
}
});
});
}
@@ -118,13 +139,22 @@ function attachDownloadListeners(items) {
* so that the backend endpoint (at /artist/download) receives the required query parameters.
*/
async function startDownload(url, type, item, albumType) {
if (!url || !type) {
showError('Missing URL or type for download');
return;
}
// Enrich the item object with the artist property.
if (type === 'track' || type === 'album') {
item.artist = item.artists.map(a => a.name).join(', ');
} else if (type === 'playlist') {
item.artist = item.owner.display_name;
} else if (type === 'artist') {
item.artist = item.name;
if (item) {
if (type === 'track' || type === 'album') {
item.artist = item.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist';
} else if (type === 'playlist') {
item.artist = item.owner?.display_name || 'Unknown Owner';
} else if (type === 'artist') {
item.artist = item.name || 'Unknown Artist';
}
} else {
item = { name: 'Unknown', artist: 'Unknown Artist' };
}
try {
@@ -142,17 +172,20 @@ async function startDownload(url, type, item, albumType) {
throw new Error(`Unsupported type: ${type}`);
}
} catch (error) {
showError('Download failed: ' + error.message);
showError('Download failed: ' + (error?.message || 'Unknown error'));
}
}
// UI Helper Functions
function showError(message) {
document.getElementById('resultsContainer').innerHTML = `<div class="error">${message}</div>`;
const resultsContainer = document.getElementById('resultsContainer');
if (resultsContainer) {
resultsContainer.innerHTML = `<div class="error">${message || 'An error occurred'}</div>`;
}
}
function isSpotifyUrl(url) {
return url.startsWith('https://open.spotify.com/');
return url && url.startsWith('https://open.spotify.com/');
}
/**
@@ -160,6 +193,8 @@ function isSpotifyUrl(url) {
* Expected URL format: https://open.spotify.com/{type}/{id}
*/
function getSpotifyResourceDetails(url) {
if (!url) throw new Error('Empty URL provided');
const urlObj = new URL(url);
const pathParts = urlObj.pathname.split('/');
if (pathParts.length < 3 || !pathParts[1] || !pathParts[2]) {
@@ -172,6 +207,8 @@ function getSpotifyResourceDetails(url) {
}
function msToMinutesSeconds(ms) {
if (!ms || isNaN(ms)) return '0:00';
const minutes = Math.floor(ms / 60000);
const seconds = ((ms % 60000) / 1000).toFixed(0);
return `${minutes}:${seconds.padStart(2, '0')}`;
@@ -182,11 +219,15 @@ function msToMinutesSeconds(ms) {
* The additional parameter "index" is used to set a data-index attribute on the card.
*/
function createResultCard(item, type, index) {
if (!item) return '';
let newUrl = '#';
try {
const spotifyUrl = item.external_urls.spotify;
const parsedUrl = new URL(spotifyUrl);
newUrl = window.location.origin + parsedUrl.pathname;
const spotifyUrl = item.external_urls?.spotify;
if (spotifyUrl) {
const parsedUrl = new URL(spotifyUrl);
newUrl = window.location.origin + parsedUrl.pathname;
}
} catch (e) {
console.error('Error parsing URL:', e);
}
@@ -195,15 +236,15 @@ function createResultCard(item, type, index) {
switch (type) {
case 'track':
imageUrl = item.album.images[0]?.url || '';
title = item.name;
subtitle = item.artists.map(a => a.name).join(', ');
imageUrl = item.album?.images?.[0]?.url || '/static/images/placeholder.jpg';
title = item.name || 'Unknown Track';
subtitle = item.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist';
details = `
<span>${item.album.name}</span>
<span>${item.album?.name || 'Unknown Album'}</span>
<span class="duration">${msToMinutesSeconds(item.duration_ms)}</span>
`;
return `
<div class="result-card" data-id="${item.id}" data-index="${index}">
<div class="result-card" data-id="${item.id || ''}" data-index="${index}">
<div class="album-art-wrapper">
<img src="${imageUrl}" class="album-art" alt="${type} cover">
</div>
@@ -211,7 +252,7 @@ function createResultCard(item, type, index) {
<div class="track-title">${title}</div>
<div class="title-buttons">
<button class="download-btn-small"
data-url="${item.external_urls.spotify}"
data-url="${item.external_urls?.spotify || ''}"
data-type="${type}"
title="Download">
<img src="/static/images/download.svg" alt="Download">
@@ -226,15 +267,15 @@ function createResultCard(item, type, index) {
</div>
`;
case 'playlist':
imageUrl = item.images[0]?.url || '';
title = item.name;
subtitle = item.owner.display_name;
imageUrl = item.images?.[0]?.url || '/static/images/placeholder.jpg';
title = item.name || 'Unknown Playlist';
subtitle = item.owner?.display_name || 'Unknown Owner';
details = `
<span>${item.tracks.total} tracks</span>
<span>${item.tracks?.total || '0'} tracks</span>
<span class="duration">${item.description || 'No description'}</span>
`;
return `
<div class="result-card" data-id="${item.id}" data-index="${index}">
<div class="result-card" data-id="${item.id || ''}" data-index="${index}">
<div class="album-art-wrapper">
<img src="${imageUrl}" class="album-art" alt="${type} cover">
</div>
@@ -242,7 +283,7 @@ function createResultCard(item, type, index) {
<div class="track-title">${title}</div>
<div class="title-buttons">
<button class="download-btn-small"
data-url="${item.external_urls.spotify}"
data-url="${item.external_urls?.spotify || ''}"
data-type="${type}"
title="Download">
<img src="/static/images/download.svg" alt="Download">
@@ -257,15 +298,15 @@ function createResultCard(item, type, index) {
</div>
`;
case 'album':
imageUrl = item.images[0]?.url || '';
title = item.name;
subtitle = item.artists.map(a => a.name).join(', ');
imageUrl = item.images?.[0]?.url || '/static/images/placeholder.jpg';
title = item.name || 'Unknown Album';
subtitle = item.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist';
details = `
<span>${item.release_date}</span>
<span class="duration">${item.total_tracks} tracks</span>
<span>${item.release_date || 'Unknown release date'}</span>
<span class="duration">${item.total_tracks || '0'} tracks</span>
`;
return `
<div class="result-card" data-id="${item.id}" data-index="${index}">
<div class="result-card" data-id="${item.id || ''}" data-index="${index}">
<div class="album-art-wrapper">
<img src="${imageUrl}" class="album-art" alt="${type} cover">
</div>
@@ -273,7 +314,7 @@ function createResultCard(item, type, index) {
<div class="track-title">${title}</div>
<div class="title-buttons">
<button class="download-btn-small"
data-url="${item.external_urls.spotify}"
data-url="${item.external_urls?.spotify || ''}"
data-type="${type}"
title="Download">
<img src="/static/images/download.svg" alt="Download">
@@ -288,12 +329,12 @@ function createResultCard(item, type, index) {
</div>
`;
case 'artist':
imageUrl = (item.images && item.images.length) ? item.images[0].url : '';
title = item.name;
imageUrl = (item.images && item.images.length) ? item.images[0].url : '/static/images/placeholder.jpg';
title = item.name || 'Unknown Artist';
subtitle = (item.genres && item.genres.length) ? item.genres.join(', ') : 'Unknown genres';
details = `<span>Followers: ${item.followers?.total || 'N/A'}</span>`;
return `
<div class="result-card" data-id="${item.id}" data-index="${index}">
<div class="result-card" data-id="${item.id || ''}" data-index="${index}">
<div class="album-art-wrapper">
<img src="${imageUrl}" class="album-art" alt="${type} cover">
</div>
@@ -302,7 +343,7 @@ function createResultCard(item, type, index) {
<div class="title-buttons">
<!-- A primary download button (if you want one for a "default" download) -->
<button class="download-btn-small"
data-url="${item.external_urls.spotify}"
data-url="${item.external_urls?.spotify || ''}"
data-type="${type}"
title="Download">
<img src="/static/images/download.svg" alt="Download">
@@ -325,7 +366,7 @@ function createResultCard(item, type, index) {
</button>
<div class="secondary-options">
<button class="download-btn option-btn"
data-url="${item.external_urls.spotify}"
data-url="${item.external_urls?.spotify || ''}"
data-type="${type}"
data-album-type="album">
<img src="https://www.svgrepo.com/show/40029/vinyl-record.svg"
@@ -334,7 +375,7 @@ function createResultCard(item, type, index) {
Albums
</button>
<button class="download-btn option-btn"
data-url="${item.external_urls.spotify}"
data-url="${item.external_urls?.spotify || ''}"
data-type="${type}"
data-album-type="single">
<img src="https://www.svgrepo.com/show/147837/cassette.svg"
@@ -343,7 +384,7 @@ function createResultCard(item, type, index) {
Singles
</button>
<button class="download-btn option-btn"
data-url="${item.external_urls.spotify}"
data-url="${item.external_urls?.spotify || ''}"
data-type="${type}"
data-album-type="compilation">
<img src="https://brandeps.com/icon-download/C/Collection-icon-vector-01.svg"
@@ -361,15 +402,15 @@ function createResultCard(item, type, index) {
subtitle = '';
details = '';
return `
<div class="result-card" data-id="${item.id}" data-index="${index}">
<div class="result-card" data-id="${item.id || ''}" data-index="${index}">
<div class="album-art-wrapper">
<img src="${imageUrl}" class="album-art" alt="${type} cover">
<img src="${imageUrl || '/static/images/placeholder.jpg'}" class="album-art" alt="${type} cover">
</div>
<div class="title-and-view">
<div class="track-title">${title}</div>
<div class="title-buttons">
<button class="download-btn-small"
data-url="${item.external_urls.spotify}"
data-url="${item.external_urls?.spotify || ''}"
data-type="${type}"
title="Download">
<img src="/static/images/download.svg" alt="Download">

View File

@@ -11,18 +11,8 @@ document.addEventListener('DOMContentLoaded', () => {
return;
}
// Fetch the config to get active Spotify account first
fetch('/api/config')
.then(response => {
if (!response.ok) throw new Error('Failed to fetch config');
return response.json();
})
.then(config => {
const mainAccount = config.spotify || '';
// Then fetch playlist info with the main parameter
return fetch(`/api/playlist/info?id=${encodeURIComponent(playlistId)}&main=${mainAccount}`);
})
// Fetch playlist info directly
fetch(`/api/playlist/info?id=${encodeURIComponent(playlistId)}`)
.then(response => {
if (!response.ok) throw new Error('Network response was not ok');
return response.json();
@@ -50,12 +40,12 @@ function renderPlaylist(playlist) {
document.getElementById('error').classList.add('hidden');
// Update header info
document.getElementById('playlist-name').textContent = playlist.name;
document.getElementById('playlist-owner').textContent = `By ${playlist.owner.display_name}`;
document.getElementById('playlist-name').textContent = playlist.name || 'Unknown Playlist';
document.getElementById('playlist-owner').textContent = `By ${playlist.owner?.display_name || 'Unknown User'}`;
document.getElementById('playlist-stats').textContent =
`${playlist.followers.total} followers • ${playlist.tracks.total} songs`;
document.getElementById('playlist-description').textContent = playlist.description;
const image = playlist.images[0]?.url || 'placeholder.jpg';
`${playlist.followers?.total || '0'} followers • ${playlist.tracks?.total || '0'} songs`;
document.getElementById('playlist-description').textContent = playlist.description || '';
const image = playlist.images?.[0]?.url || '/static/images/placeholder.jpg';
document.getElementById('playlist-image').src = image;
// --- Add Home Button ---
@@ -68,7 +58,9 @@ function renderPlaylist(playlist) {
homeButton.innerHTML = `<img src="/static/images/home.svg" alt="Home">`;
// Insert the home button at the beginning of the header container.
const headerContainer = document.getElementById('playlist-header');
headerContainer.insertBefore(homeButton, headerContainer.firstChild);
if (headerContainer) {
headerContainer.insertBefore(homeButton, headerContainer.firstChild);
}
}
homeButton.addEventListener('click', () => {
// Navigate to the site's base URL.
@@ -84,7 +76,9 @@ function renderPlaylist(playlist) {
downloadPlaylistBtn.className = 'download-btn download-btn--main';
// Insert the button into the header container.
const headerContainer = document.getElementById('playlist-header');
headerContainer.appendChild(downloadPlaylistBtn);
if (headerContainer) {
headerContainer.appendChild(downloadPlaylistBtn);
}
}
downloadPlaylistBtn.addEventListener('click', () => {
// Remove individual track download buttons (but leave the whole playlist button).
@@ -102,7 +96,7 @@ function renderPlaylist(playlist) {
downloadWholePlaylist(playlist).then(() => {
downloadPlaylistBtn.textContent = 'Queued!';
}).catch(err => {
showError('Failed to queue playlist download: ' + err.message);
showError('Failed to queue playlist download: ' + (err?.message || 'Unknown error'));
downloadPlaylistBtn.disabled = false;
});
});
@@ -116,7 +110,9 @@ function renderPlaylist(playlist) {
downloadAlbumsBtn.className = 'download-btn download-btn--main';
// Insert the new button into the header container.
const headerContainer = document.getElementById('playlist-header');
headerContainer.appendChild(downloadAlbumsBtn);
if (headerContainer) {
headerContainer.appendChild(downloadAlbumsBtn);
}
}
downloadAlbumsBtn.addEventListener('click', () => {
// Remove individual track download buttons (but leave this album button).
@@ -132,48 +128,54 @@ function renderPlaylist(playlist) {
downloadAlbumsBtn.textContent = 'Queued!';
})
.catch(err => {
showError('Failed to queue album downloads: ' + err.message);
showError('Failed to queue album downloads: ' + (err?.message || 'Unknown error'));
downloadAlbumsBtn.disabled = false;
});
});
// Render tracks list
const tracksList = document.getElementById('tracks-list');
if (!tracksList) return;
tracksList.innerHTML = ''; // Clear any existing content
playlist.tracks.items.forEach((item, index) => {
const track = item.track;
// Create links for track, artist, and album using their IDs.
const trackLink = `/track/${track.id}`;
const artistLink = `/artist/${track.artists[0].id}`;
const albumLink = `/album/${track.album.id}`;
if (playlist.tracks?.items) {
playlist.tracks.items.forEach((item, index) => {
if (!item || !item.track) return; // Skip null/undefined tracks
const track = item.track;
// Create links for track, artist, and album using their IDs.
const trackLink = `/track/${track.id || ''}`;
const artistLink = `/artist/${track.artists?.[0]?.id || ''}`;
const albumLink = `/album/${track.album?.id || ''}`;
const trackElement = document.createElement('div');
trackElement.className = 'track';
trackElement.innerHTML = `
<div class="track-number">${index + 1}</div>
<div class="track-info">
<div class="track-name">
<a href="${trackLink}" title="View track details">${track.name}</a>
const trackElement = document.createElement('div');
trackElement.className = 'track';
trackElement.innerHTML = `
<div class="track-number">${index + 1}</div>
<div class="track-info">
<div class="track-name">
<a href="${trackLink}" title="View track details">${track.name || 'Unknown Track'}</a>
</div>
<div class="track-artist">
<a href="${artistLink}" title="View artist details">${track.artists?.[0]?.name || 'Unknown Artist'}</a>
</div>
</div>
<div class="track-artist">
<a href="${artistLink}" title="View artist details">${track.artists[0].name}</a>
<div class="track-album">
<a href="${albumLink}" title="View album details">${track.album?.name || 'Unknown Album'}</a>
</div>
</div>
<div class="track-album">
<a href="${albumLink}" title="View album details">${track.album.name}</a>
</div>
<div class="track-duration">${msToTime(track.duration_ms)}</div>
<button class="download-btn download-btn--circle"
data-url="${track.external_urls.spotify}"
data-type="track"
data-name="${track.name}"
title="Download">
<img src="/static/images/download.svg" alt="Download">
</button>
`;
tracksList.appendChild(trackElement);
});
<div class="track-duration">${msToTime(track.duration_ms || 0)}</div>
<button class="download-btn download-btn--circle"
data-url="${track.external_urls?.spotify || ''}"
data-type="track"
data-name="${track.name || 'Unknown Track'}"
title="Download">
<img src="/static/images/download.svg" alt="Download">
</button>
`;
tracksList.appendChild(trackElement);
});
}
// Reveal header and tracks container
document.getElementById('playlist-header').classList.remove('hidden');
@@ -187,6 +189,8 @@ function renderPlaylist(playlist) {
* Converts milliseconds to minutes:seconds.
*/
function msToTime(duration) {
if (!duration || isNaN(duration)) return '0:00';
const minutes = Math.floor(duration / 60000);
const seconds = ((duration % 60000) / 1000).toFixed(0);
return `${minutes}:${seconds.padStart(2, '0')}`;
@@ -197,8 +201,10 @@ function msToTime(duration) {
*/
function showError(message) {
const errorEl = document.getElementById('error');
errorEl.textContent = message;
errorEl.classList.remove('hidden');
if (errorEl) {
errorEl.textContent = message || 'An error occurred';
errorEl.classList.remove('hidden');
}
}
/**
@@ -210,9 +216,9 @@ function attachDownloadListeners() {
if (btn.id === 'downloadPlaylistBtn' || btn.id === 'downloadAlbumsBtn') return;
btn.addEventListener('click', (e) => {
e.stopPropagation();
const url = e.currentTarget.dataset.url;
const type = e.currentTarget.dataset.type;
const name = e.currentTarget.dataset.name || extractName(url);
const url = e.currentTarget.dataset.url || '';
const type = e.currentTarget.dataset.type || '';
const name = e.currentTarget.dataset.name || extractName(url) || 'Unknown';
// Remove the button immediately after click.
e.currentTarget.remove();
startDownload(url, type, { name });
@@ -224,11 +230,19 @@ function attachDownloadListeners() {
* Initiates the whole playlist download by calling the playlist endpoint.
*/
async function downloadWholePlaylist(playlist) {
const url = playlist.external_urls.spotify;
if (!playlist) {
throw new Error('Invalid playlist data');
}
const url = playlist.external_urls?.spotify || '';
if (!url) {
throw new Error('Missing playlist URL');
}
try {
await downloadQueue.startPlaylistDownload(url, { name: playlist.name });
await downloadQueue.startPlaylistDownload(url, { name: playlist.name || 'Unknown Playlist' });
} catch (error) {
showError('Playlist download failed: ' + error.message);
showError('Playlist download failed: ' + (error?.message || 'Unknown error'));
throw error;
}
}
@@ -239,9 +253,16 @@ async function downloadWholePlaylist(playlist) {
* with the progress (queued_albums/total_albums).
*/
async function downloadPlaylistAlbums(playlist) {
if (!playlist?.tracks?.items) {
showError('No tracks found in this playlist.');
return;
}
// Build a map of unique albums (using album ID as the key).
const albumMap = new Map();
playlist.tracks.items.forEach(item => {
if (!item?.track?.album) return;
const album = item.track.album;
if (album && album.id) {
albumMap.set(album.id, album);
@@ -266,9 +287,14 @@ async function downloadPlaylistAlbums(playlist) {
// Process each album sequentially.
for (let i = 0; i < totalAlbums; i++) {
const album = uniqueAlbums[i];
if (!album) continue;
const albumUrl = album.external_urls?.spotify || '';
if (!albumUrl) continue;
await downloadQueue.startAlbumDownload(
album.external_urls.spotify,
{ name: album.name }
albumUrl,
{ name: album.name || 'Unknown Album' }
);
// Update button text with current progress.
@@ -291,56 +317,29 @@ async function downloadPlaylistAlbums(playlist) {
}
/**
* Starts the download process by building the API URL,
* fetching download details, and then adding the download to the queue.
* Starts the download process by building a minimal API URL with only the necessary parameters,
* since the server will use config defaults for others.
*/
async function startDownload(url, type, item, albumType) {
// Retrieve configuration (if any) from localStorage.
const config = JSON.parse(localStorage.getItem('activeConfig')) || {};
const {
fallback = false,
spotify = '',
deezer = '',
spotifyQuality = 'NORMAL',
deezerQuality = 'MP3_128',
realTime = false,
customTrackFormat = '',
customDirFormat = ''
} = config;
if (!url || !type) {
showError('Missing URL or type for download');
return;
}
const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer';
let apiUrl = '';
let apiUrl = `/api/${type}/download?service=${service}&url=${encodeURIComponent(url)}`;
// Build API URL based on the download type.
if (type === 'playlist') {
// Use the dedicated playlist download endpoint.
apiUrl = `/api/playlist/download?service=${service}&url=${encodeURIComponent(url)}`;
} else if (type === 'artist') {
apiUrl = `/api/artist/download?service=${service}&artist_url=${encodeURIComponent(url)}&album_type=${encodeURIComponent(albumType || 'album,single,compilation')}`;
} else {
// Default is track download.
apiUrl = `/api/${type}/download?service=${service}&url=${encodeURIComponent(url)}`;
// Add name and artist if available for better progress display
if (item.name) {
apiUrl += `&name=${encodeURIComponent(item.name)}`;
}
// Append account and quality details.
if (fallback && service === 'spotify') {
apiUrl += `&main=${deezer}&fallback=${spotify}`;
apiUrl += `&quality=${deezerQuality}&fall_quality=${spotifyQuality}`;
} else {
const mainAccount = service === 'spotify' ? spotify : deezer;
apiUrl += `&main=${mainAccount}&quality=${service === 'spotify' ? spotifyQuality : deezerQuality}`;
if (item.artist) {
apiUrl += `&artist=${encodeURIComponent(item.artist)}`;
}
if (realTime) {
apiUrl += '&real_time=true';
}
// Append custom formatting parameters.
if (customTrackFormat) {
apiUrl += `&custom_track_format=${encodeURIComponent(customTrackFormat)}`;
}
if (customDirFormat) {
apiUrl += `&custom_dir_format=${encodeURIComponent(customDirFormat)}`;
// For artist downloads, include album_type
if (type === 'artist' && albumType) {
apiUrl += `&album_type=${encodeURIComponent(albumType)}`;
}
try {
@@ -349,7 +348,7 @@ async function startDownload(url, type, item, albumType) {
// Add the download to the queue using the working queue implementation.
downloadQueue.addDownload(item, type, data.prg_file);
} catch (error) {
showError('Download failed: ' + error.message);
showError('Download failed: ' + (error?.message || 'Unknown error'));
}
}
@@ -357,5 +356,5 @@ async function startDownload(url, type, item, albumType) {
* A helper function to extract a display name from the URL.
*/
function extractName(url) {
return url;
return url || 'Unknown';
}

View File

@@ -16,6 +16,11 @@ class CustomURLSearchParams {
class DownloadQueue {
constructor() {
// Constants read from the server config
this.MAX_RETRIES = 3; // Default max retries
this.RETRY_DELAY = 5; // Default retry delay in seconds
this.RETRY_DELAY_INCREASE = 5; // Default retry delay increase in seconds
this.downloadQueue = {}; // keyed by unique queueId
this.currentConfig = {}; // Cache for current config
@@ -277,13 +282,18 @@ class DownloadQueue {
*/
createQueueItem(item, type, prgFile, queueId) {
const defaultMessage = (type === 'playlist') ? 'Reading track list' : 'Initializing download...';
// Use display values if available, or fall back to standard fields
const displayTitle = item.name || 'Unknown';
const displayType = type.charAt(0).toUpperCase() + type.slice(1);
const div = document.createElement('article');
div.className = 'queue-item';
div.setAttribute('aria-live', 'polite');
div.setAttribute('aria-atomic', 'true');
div.innerHTML = `
<div class="title">${item.name}</div>
<div class="type">${type.charAt(0).toUpperCase() + type.slice(1)}</div>
<div class="title">${displayTitle}</div>
<div class="type">${displayType}</div>
<div class="log" id="log-${queueId}-${prgFile}">${defaultMessage}</div>
<button class="cancel-btn" data-prg="${prgFile}" data-type="${type}" data-queueid="${queueId}" title="Cancel Download">
<img src="https://www.svgrepo.com/show/488384/skull-head.svg" alt="Cancel Download">
@@ -453,13 +463,25 @@ class DownloadQueue {
return `Queued track "${data.name}"${data.artist ? ` by ${data.artist}` : ''}`;
}
return `Queued ${data.type} "${data.name}"`;
case 'started':
return `Download started`;
case 'processing':
return `Processing download...`;
case 'cancel':
return 'Download cancelled';
case 'interrupted':
return 'Download was interrupted';
case 'downloading':
if (data.type === 'track') {
return `Downloading track "${data.song}" by ${data.artist}...`;
}
return `Downloading ${data.type}...`;
case 'initializing':
if (data.type === 'playlist') {
return `Initializing playlist download "${data.name}" with ${data.total_tracks} tracks...`;
@@ -482,6 +504,7 @@ class DownloadQueue {
return `Initializing download for ${data.artist} with ${data.total_albums} album(s) [${data.album_type}]...`;
}
return `Initializing ${data.type} download...`;
case 'progress':
if (data.track && data.current_track) {
const parts = data.current_track.split('/');
@@ -498,6 +521,7 @@ class DownloadQueue {
}
}
return `Progress: ${data.status}...`;
case 'done':
if (data.type === 'track') {
return `Finished track "${data.song}" by ${data.artist}`;
@@ -509,14 +533,30 @@ class DownloadQueue {
return `Finished artist "${data.artist}" (${data.album_type})`;
}
return `Finished ${data.type}`;
case 'retrying':
return `Track "${data.song}" by ${data.artist}" failed, retrying (${data.retry_count}/5) in ${data.seconds_left}s`;
if (data.retry_count !== undefined) {
return `Retrying download (attempt ${data.retry_count}/${this.MAX_RETRIES})`;
}
return `Retrying download...`;
case 'error':
return `Error: ${data.message || 'Unknown error'}`;
let errorMsg = `Error: ${data.message || 'Unknown error'}`;
if (data.can_retry !== undefined) {
if (data.can_retry) {
errorMsg += ` (Can be retried)`;
} else {
errorMsg += ` (Max retries reached)`;
}
}
return errorMsg;
case 'complete':
return 'Download completed successfully';
case 'skipped':
return `Track "${data.song}" skipped, it already exists!`;
case 'real_time': {
const totalMs = data.time_elapsed;
const minutes = Math.floor(totalMs / 60000);
@@ -524,6 +564,7 @@ class DownloadQueue {
const paddedSeconds = seconds < 10 ? '0' + seconds : seconds;
return `Real-time downloading track "${data.song}" by ${data.artist} (${(data.percentage * 100).toFixed(1)}%). Time elapsed: ${minutes}:${paddedSeconds}`;
}
default:
return data.status;
}
@@ -540,47 +581,83 @@ class DownloadQueue {
if (cancelBtn) {
cancelBtn.style.display = 'none';
}
logElement.innerHTML = `
<div class="error-message">${this.getStatusMessage(progress)}</div>
<div class="error-buttons">
<button class="close-error-btn" title="Close">&times;</button>
<button class="retry-btn" title="Retry">Retry</button>
</div>
`;
logElement.querySelector('.close-error-btn').addEventListener('click', () => {
if (entry.autoRetryInterval) {
clearInterval(entry.autoRetryInterval);
entry.autoRetryInterval = null;
}
this.cleanupEntry(queueId);
});
logElement.querySelector('.retry-btn').addEventListener('click', async () => {
if (entry.autoRetryInterval) {
clearInterval(entry.autoRetryInterval);
entry.autoRetryInterval = null;
}
this.retryDownload(queueId, logElement);
});
if (entry.requestUrl) {
const maxRetries = 10;
if (entry.retryCount < maxRetries) {
const autoRetryDelay = 300; // seconds
let secondsLeft = autoRetryDelay;
entry.autoRetryInterval = setInterval(() => {
secondsLeft--;
const errorMsgEl = logElement.querySelector('.error-message');
if (errorMsgEl) {
errorMsgEl.textContent = `Error: ${progress.message || 'Unknown error'}. Retrying in ${secondsLeft} seconds... (attempt ${entry.retryCount + 1}/${maxRetries})`;
}
if (secondsLeft <= 0) {
clearInterval(entry.autoRetryInterval);
entry.autoRetryInterval = null;
this.retryDownload(queueId, logElement);
}
}, 1000);
// Check if we're under the max retries threshold for auto-retry
const canRetry = entry.retryCount < this.MAX_RETRIES;
if (canRetry) {
logElement.innerHTML = `
<div class="error-message">${this.getStatusMessage(progress)}</div>
<div class="error-buttons">
<button class="close-error-btn" title="Close">&times;</button>
<button class="retry-btn" title="Retry">Retry</button>
</div>
`;
logElement.querySelector('.close-error-btn').addEventListener('click', () => {
if (entry.autoRetryInterval) {
clearInterval(entry.autoRetryInterval);
entry.autoRetryInterval = null;
}
this.cleanupEntry(queueId);
});
logElement.querySelector('.retry-btn').addEventListener('click', async () => {
if (entry.autoRetryInterval) {
clearInterval(entry.autoRetryInterval);
entry.autoRetryInterval = null;
}
this.retryDownload(queueId, logElement);
});
// Implement auto-retry if we have the original request URL
if (entry.requestUrl) {
const maxRetries = this.MAX_RETRIES;
if (entry.retryCount < maxRetries) {
// Calculate the delay based on retry count (exponential backoff)
const baseDelay = this.RETRY_DELAY || 5; // seconds, use server's retry delay or default to 5
const increase = this.RETRY_DELAY_INCREASE || 5;
const retryDelay = baseDelay + (entry.retryCount * increase);
let secondsLeft = retryDelay;
entry.autoRetryInterval = setInterval(() => {
secondsLeft--;
const errorMsgEl = logElement.querySelector('.error-message');
if (errorMsgEl) {
errorMsgEl.textContent = `Error: ${progress.message || 'Unknown error'}. Retrying in ${secondsLeft} seconds... (attempt ${entry.retryCount + 1}/${maxRetries})`;
}
if (secondsLeft <= 0) {
clearInterval(entry.autoRetryInterval);
entry.autoRetryInterval = null;
this.retryDownload(queueId, logElement);
}
}, 1000);
}
}
} else {
// Cannot be retried - just show the error
logElement.innerHTML = `
<div class="error-message">${this.getStatusMessage(progress)}</div>
<div class="error-buttons">
<button class="close-error-btn" title="Close">&times;</button>
</div>
`;
logElement.querySelector('.close-error-btn').addEventListener('click', () => {
this.cleanupEntry(queueId);
});
}
return;
} else if (progress.status === 'interrupted') {
logElement.textContent = 'Download was interrupted';
setTimeout(() => this.cleanupEntry(queueId), 5000);
} else if (progress.status === 'complete') {
logElement.textContent = 'Download completed successfully';
// Hide the cancel button
const cancelBtn = entry.element.querySelector('.cancel-btn');
if (cancelBtn) {
cancelBtn.style.display = 'none';
}
// Add success styling
entry.element.classList.add('download-success');
setTimeout(() => this.cleanupEntry(queueId), 5000);
} else {
logElement.textContent = this.getStatusMessage(progress);
setTimeout(() => this.cleanupEntry(queueId), 5000);
@@ -608,17 +685,36 @@ class DownloadQueue {
async retryDownload(queueId, logElement) {
const entry = this.downloadQueue[queueId];
if (!entry) return;
logElement.textContent = 'Retrying download...';
// If we don't have the request URL, we can't retry
if (!entry.requestUrl) {
logElement.textContent = 'Retry not available: missing original request information.';
return;
}
try {
// Use the stored original request URL to create a new download
const retryResponse = await fetch(entry.requestUrl);
if (!retryResponse.ok) {
throw new Error(`Server returned ${retryResponse.status}`);
}
const retryData = await retryResponse.json();
if (retryData.prg_file) {
// If the old PRG file exists, we should delete it
const oldPrgFile = entry.prgFile;
await fetch(`/api/prgs/delete/${oldPrgFile}`, { method: 'DELETE' });
if (oldPrgFile) {
try {
await fetch(`/api/prgs/delete/${oldPrgFile}`, { method: 'DELETE' });
} catch (deleteError) {
console.error('Error deleting old PRG file:', deleteError);
}
}
// Update the entry with the new PRG file
const logEl = entry.element.querySelector('.log');
logEl.id = `log-${entry.uniqueId}-${retryData.prg_file}`;
entry.prgFile = retryData.prg_file;
@@ -627,60 +723,27 @@ class DownloadQueue {
entry.lastUpdated = Date.now();
entry.retryCount = (entry.retryCount || 0) + 1;
logEl.textContent = 'Retry initiated...';
// Start monitoring the new PRG file
this.startEntryMonitoring(queueId);
} else {
logElement.textContent = 'Retry failed: invalid response from server';
}
} catch (error) {
console.error('Retry error:', error);
logElement.textContent = 'Retry failed: ' + error.message;
}
}
/**
* Builds common URL parameters for download API requests.
*/
_buildCommonParams(url, service, config) {
const params = new CustomURLSearchParams();
params.append('service', service);
params.append('url', url);
if (service === 'spotify') {
if (config.fallback) {
params.append('main', config.deezer);
params.append('fallback', config.spotify);
params.append('quality', config.deezerQuality);
params.append('fall_quality', config.spotifyQuality);
} else {
params.append('main', config.spotify);
params.append('quality', config.spotifyQuality);
}
} else {
params.append('main', config.deezer);
params.append('quality', config.deezerQuality);
}
if (config.realTime) {
params.append('real_time', 'true');
}
if (config.customTrackFormat) {
params.append('custom_track_format', config.customTrackFormat);
}
if (config.customDirFormat) {
params.append('custom_dir_format', config.customDirFormat);
}
return params;
}
async startTrackDownload(url, item) {
await this.loadConfig();
const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer';
const params = this._buildCommonParams(url, service, this.currentConfig);
params.append('name', item.name || '');
params.append('artist', item.artist || '');
const apiUrl = `/api/track/download?${params.toString()}`;
// Use minimal parameters in the URL, letting server use config for defaults
const apiUrl = `/api/track/download?service=${service}&url=${encodeURIComponent(url)}` +
(item.name ? `&name=${encodeURIComponent(item.name)}` : '') +
(item.artist ? `&artist=${encodeURIComponent(item.artist)}` : '');
try {
const response = await fetch(apiUrl);
if (!response.ok) throw new Error('Network error');
@@ -695,12 +758,15 @@ class DownloadQueue {
async startPlaylistDownload(url, item) {
await this.loadConfig();
const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer';
const params = this._buildCommonParams(url, service, this.currentConfig);
params.append('name', item.name || '');
params.append('artist', item.artist || '');
const apiUrl = `/api/playlist/download?${params.toString()}`;
// Use minimal parameters in the URL, letting server use config for defaults
const apiUrl = `/api/playlist/download?service=${service}&url=${encodeURIComponent(url)}` +
(item.name ? `&name=${encodeURIComponent(item.name)}` : '') +
(item.artist ? `&artist=${encodeURIComponent(item.artist)}` : '');
try {
const response = await fetch(apiUrl);
if (!response.ok) throw new Error('Network error');
const data = await response.json();
this.addDownload(item, 'playlist', data.prg_file, apiUrl);
} catch (error) {
@@ -709,14 +775,16 @@ class DownloadQueue {
}
}
async startArtistDownload(url, item, albumType = 'album,single,compilation,appears_on') {
async startArtistDownload(url, item, albumType = 'album,single,compilation') {
await this.loadConfig();
const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer';
const params = this._buildCommonParams(url, service, this.currentConfig);
params.append('album_type', albumType);
params.append('name', item.name || '');
params.append('artist', item.artist || '');
const apiUrl = `/api/artist/download?${params.toString()}`;
// Use minimal parameters in the URL, letting server use config for defaults
const apiUrl = `/api/artist/download?service=${service}&url=${encodeURIComponent(url)}` +
`&album_type=${albumType}` +
(item.name ? `&name=${encodeURIComponent(item.name)}` : '') +
(item.artist ? `&artist=${encodeURIComponent(item.artist)}` : '');
try {
const response = await fetch(apiUrl);
if (!response.ok) throw new Error('Network error');
@@ -737,12 +805,15 @@ class DownloadQueue {
async startAlbumDownload(url, item) {
await this.loadConfig();
const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer';
const params = this._buildCommonParams(url, service, this.currentConfig);
params.append('name', item.name || '');
params.append('artist', item.artist || '');
const apiUrl = `/api/album/download?${params.toString()}`;
// Use minimal parameters in the URL, letting server use config for defaults
const apiUrl = `/api/album/download?service=${service}&url=${encodeURIComponent(url)}` +
(item.name ? `&name=${encodeURIComponent(item.name)}` : '') +
(item.artist ? `&artist=${encodeURIComponent(item.artist)}` : '');
try {
const response = await fetch(apiUrl);
if (!response.ok) throw new Error('Network error');
const data = await response.json();
this.addDownload(item, 'album', data.prg_file, apiUrl);
} catch (error) {
@@ -772,16 +843,81 @@ class DownloadQueue {
const prgResponse = await fetch(`/api/prgs/${prgFile}`);
if (!prgResponse.ok) continue;
const prgData = await prgResponse.json();
// Skip prg files that are marked as cancelled or completed
if (prgData.last_line &&
(prgData.last_line.status === 'cancel' ||
prgData.last_line.status === 'complete')) {
// Delete old completed or cancelled PRG files
try {
await fetch(`/api/prgs/delete/${prgFile}`, { method: 'DELETE' });
console.log(`Cleaned up old PRG file: ${prgFile}`);
} catch (error) {
console.error(`Failed to delete completed/cancelled PRG file ${prgFile}:`, error);
}
continue;
}
// Use the enhanced original request info from the first line
const originalRequest = prgData.original_request || {};
// Use the explicit display fields if available, or fall back to other fields
const dummyItem = {
name: prgData.original_request && prgData.original_request.name ? prgData.original_request.name : prgFile,
artist: prgData.original_request && prgData.original_request.artist ? prgData.original_request.artist : '',
type: prgData.original_request && prgData.original_request.type ? prgData.original_request.type : 'unknown'
name: prgData.display_title || originalRequest.display_title || originalRequest.name || prgFile,
artist: prgData.display_artist || originalRequest.display_artist || originalRequest.artist || '',
type: prgData.display_type || originalRequest.display_type || originalRequest.type || 'unknown',
service: originalRequest.service || '',
url: originalRequest.url || '',
endpoint: originalRequest.endpoint || '',
download_type: originalRequest.download_type || ''
};
this.addDownload(dummyItem, dummyItem.type, prgFile);
// Check if this is a retry file and get the retry count
let retryCount = 0;
if (prgFile.includes('_retry')) {
const retryMatch = prgFile.match(/_retry(\d+)/);
if (retryMatch && retryMatch[1]) {
retryCount = parseInt(retryMatch[1], 10);
} else if (prgData.last_line && prgData.last_line.retry_count) {
retryCount = prgData.last_line.retry_count;
}
} else if (prgData.last_line && prgData.last_line.retry_count) {
retryCount = prgData.last_line.retry_count;
}
// Build a potential requestUrl from the original information
let requestUrl = null;
if (dummyItem.endpoint && dummyItem.url) {
const params = new CustomURLSearchParams();
params.append('service', dummyItem.service);
params.append('url', dummyItem.url);
if (dummyItem.name) params.append('name', dummyItem.name);
if (dummyItem.artist) params.append('artist', dummyItem.artist);
// Add any other parameters from the original request
for (const [key, value] of Object.entries(originalRequest)) {
if (!['service', 'url', 'name', 'artist', 'type', 'endpoint', 'download_type',
'display_title', 'display_type', 'display_artist'].includes(key)) {
params.append(key, value);
}
}
requestUrl = `${dummyItem.endpoint}?${params.toString()}`;
}
// Add to download queue
const queueId = this.generateQueueId();
const entry = this.createQueueEntry(dummyItem, dummyItem.type, prgFile, queueId, requestUrl);
entry.retryCount = retryCount;
this.downloadQueue[queueId] = entry;
} catch (error) {
console.error("Error fetching details for", prgFile, error);
}
}
// After adding all entries, update the queue
this.updateQueueOrder();
} catch (error) {
console.error("Error loading existing PRG files:", error);
}
@@ -792,6 +928,19 @@ class DownloadQueue {
const response = await fetch('/api/config');
if (!response.ok) throw new Error('Failed to fetch config');
this.currentConfig = await response.json();
// Update our retry constants from the server config
if (this.currentConfig.maxRetries !== undefined) {
this.MAX_RETRIES = this.currentConfig.maxRetries;
}
if (this.currentConfig.retryDelaySeconds !== undefined) {
this.RETRY_DELAY = this.currentConfig.retryDelaySeconds;
}
if (this.currentConfig.retry_delay_increase !== undefined) {
this.RETRY_DELAY_INCREASE = this.currentConfig.retry_delay_increase;
}
console.log(`Loaded retry settings from config: max=${this.MAX_RETRIES}, delay=${this.RETRY_DELAY}, increase=${this.RETRY_DELAY_INCREASE}`);
} catch (error) {
console.error('Error loading config:', error);
this.currentConfig = {};

View File

@@ -11,18 +11,8 @@ document.addEventListener('DOMContentLoaded', () => {
return;
}
// Fetch the config to get active Spotify account first
fetch('/api/config')
.then(response => {
if (!response.ok) throw new Error('Failed to fetch config');
return response.json();
})
.then(config => {
const mainAccount = config.spotify || '';
// Then fetch track info with the main parameter
return fetch(`/api/track/info?id=${encodeURIComponent(trackId)}&main=${mainAccount}`);
})
// Fetch track info directly
fetch(`/api/track/info?id=${encodeURIComponent(trackId)}`)
.then(response => {
if (!response.ok) throw new Error('Network response was not ok');
return response.json();
@@ -52,25 +42,25 @@ function renderTrack(track) {
// Update track information fields.
document.getElementById('track-name').innerHTML =
`<a href="/track/${track.id}" title="View track details">${track.name}</a>`;
`<a href="/track/${track.id || ''}" title="View track details">${track.name || 'Unknown Track'}</a>`;
document.getElementById('track-artist').innerHTML =
`By ${track.artists.map(a =>
`<a href="/artist/${a.id}" title="View artist details">${a.name}</a>`
).join(', ')}`;
`By ${track.artists?.map(a =>
`<a href="/artist/${a?.id || ''}" title="View artist details">${a?.name || 'Unknown Artist'}</a>`
).join(', ') || 'Unknown Artist'}`;
document.getElementById('track-album').innerHTML =
`Album: <a href="/album/${track.album.id}" title="View album details">${track.album.name}</a> (${track.album.album_type})`;
`Album: <a href="/album/${track.album?.id || ''}" title="View album details">${track.album?.name || 'Unknown Album'}</a> (${track.album?.album_type || 'album'})`;
document.getElementById('track-duration').textContent =
`Duration: ${msToTime(track.duration_ms)}`;
`Duration: ${msToTime(track.duration_ms || 0)}`;
document.getElementById('track-explicit').textContent =
track.explicit ? 'Explicit' : 'Clean';
const imageUrl = (track.album.images && track.album.images[0])
const imageUrl = (track.album?.images && track.album.images[0])
? track.album.images[0].url
: 'placeholder.jpg';
: '/static/images/placeholder.jpg';
document.getElementById('track-album-image').src = imageUrl;
// --- Insert Home Button (if not already present) ---
@@ -81,7 +71,10 @@ function renderTrack(track) {
homeButton.className = 'home-btn';
homeButton.innerHTML = `<img src="/static/images/home.svg" alt="Home" />`;
// Prepend the home button into the header.
document.getElementById('track-header').insertBefore(homeButton, document.getElementById('track-header').firstChild);
const trackHeader = document.getElementById('track-header');
if (trackHeader) {
trackHeader.insertBefore(homeButton, trackHeader.firstChild);
}
}
homeButton.addEventListener('click', () => {
window.location.href = window.location.origin;
@@ -93,28 +86,41 @@ function renderTrack(track) {
// Remove the parent container (#actions) if needed.
const actionsContainer = document.getElementById('actions');
if (actionsContainer) {
actionsContainer.parentNode.removeChild(actionsContainer);
actionsContainer.parentNode?.removeChild(actionsContainer);
}
// Set the inner HTML to use the download.svg icon.
downloadBtn.innerHTML = `<img src="/static/images/download.svg" alt="Download">`;
// Append the download button to the track header so it appears at the right.
document.getElementById('track-header').appendChild(downloadBtn);
const trackHeader = document.getElementById('track-header');
if (trackHeader) {
trackHeader.appendChild(downloadBtn);
}
}
downloadBtn.addEventListener('click', () => {
downloadBtn.disabled = true;
downloadBtn.innerHTML = `<span>Queueing...</span>`;
downloadQueue.startTrackDownload(track.external_urls.spotify, { name: track.name })
.then(() => {
downloadBtn.innerHTML = `<span>Queued!</span>`;
})
.catch(err => {
showError('Failed to queue track download: ' + err.message);
if (downloadBtn) {
downloadBtn.addEventListener('click', () => {
downloadBtn.disabled = true;
downloadBtn.innerHTML = `<span>Queueing...</span>`;
const trackUrl = track.external_urls?.spotify || '';
if (!trackUrl) {
showError('Missing track URL');
downloadBtn.disabled = false;
downloadBtn.innerHTML = `<img src="/static/images/download.svg" alt="Download">`;
});
});
return;
}
downloadQueue.startTrackDownload(trackUrl, { name: track.name || 'Unknown Track' })
.then(() => {
downloadBtn.innerHTML = `<span>Queued!</span>`;
})
.catch(err => {
showError('Failed to queue track download: ' + (err?.message || 'Unknown error'));
downloadBtn.disabled = false;
downloadBtn.innerHTML = `<img src="/static/images/download.svg" alt="Download">`;
});
});
}
// Reveal the header now that track info is loaded.
document.getElementById('track-header').classList.remove('hidden');
@@ -124,6 +130,8 @@ function renderTrack(track) {
* Converts milliseconds to minutes:seconds.
*/
function msToTime(duration) {
if (!duration || isNaN(duration)) return '0:00';
const minutes = Math.floor(duration / 60000);
const seconds = Math.floor((duration % 60000) / 1000);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
@@ -135,49 +143,30 @@ function msToTime(duration) {
function showError(message) {
const errorEl = document.getElementById('error');
if (errorEl) {
errorEl.textContent = message;
errorEl.textContent = message || 'An error occurred';
errorEl.classList.remove('hidden');
}
}
/**
* Starts the download process by building the API URL,
* fetching download details, and then adding the download to the queue.
* Starts the download process by building a minimal API URL with only the necessary parameters,
* since the server will use config defaults for others.
*/
async function startDownload(url, type, item) {
const config = JSON.parse(localStorage.getItem('activeConfig')) || {};
const {
fallback = false,
spotify = '',
deezer = '',
spotifyQuality = 'NORMAL',
deezerQuality = 'MP3_128',
realTime = false,
customTrackFormat = '',
customDirFormat = ''
} = config;
if (!url || !type) {
showError('Missing URL or type for download');
return;
}
const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer';
let apiUrl = `/api/${type}/download?service=${service}&url=${encodeURIComponent(url)}`;
if (fallback && service === 'spotify') {
apiUrl += `&main=${deezer}&fallback=${spotify}`;
apiUrl += `&quality=${deezerQuality}&fall_quality=${spotifyQuality}`;
} else {
const mainAccount = service === 'spotify' ? spotify : deezer;
apiUrl += `&main=${mainAccount}&quality=${service === 'spotify' ? spotifyQuality : deezerQuality}`;
// Add name and artist if available for better progress display
if (item.name) {
apiUrl += `&name=${encodeURIComponent(item.name)}`;
}
if (realTime) {
apiUrl += '&real_time=true';
}
// Append custom formatting parameters if they are set.
if (customTrackFormat) {
apiUrl += `&custom_track_format=${encodeURIComponent(customTrackFormat)}`;
}
if (customDirFormat) {
apiUrl += `&custom_dir_format=${encodeURIComponent(customDirFormat)}`;
if (item.artist) {
apiUrl += `&artist=${encodeURIComponent(item.artist)}`;
}
try {
@@ -185,7 +174,7 @@ async function startDownload(url, type, item) {
const data = await response.json();
downloadQueue.addDownload(item, type, data.prg_file);
} catch (error) {
showError('Download failed: ' + error.message);
showError('Download failed: ' + (error?.message || 'Unknown error'));
throw error;
}
}

View File

@@ -11,7 +11,7 @@
<body>
<div id="app">
<div id="album-header" class="hidden">
<img id="album-image" alt="Album cover">
<img id="album-image" alt="Album cover" onerror="this.src='/static/images/placeholder.jpg'">
<div id="album-info">
<h1 id="album-name"></h1>
<p id="album-artist"></p>

View File

@@ -14,7 +14,7 @@
<div id="app">
<!-- Artist header container -->
<div id="artist-header" class="hidden">
<img id="artist-image" alt="Artist image">
<img id="artist-image" alt="Artist image" onerror="this.src='/static/images/placeholder.jpg'">
<div id="artist-info">
<h1 id="artist-name"></h1>
<!-- For example, show the total number of albums -->

View File

@@ -68,6 +68,22 @@
<label for="maxConcurrentDownloads">Max Concurrent Downloads:</label>
<input type="number" id="maxConcurrentDownloads" min="1" value="3">
</div>
<!-- New Retry Options -->
<div class="form-group">
<label for="maxRetries">Max Retry Attempts:</label>
<input type="number" id="maxRetries" min="0" max="10" value="3">
</div>
<div class="form-group">
<label for="retryDelaySeconds">Initial Retry Delay (seconds):</label>
<input type="number" id="retryDelaySeconds" min="1" value="5">
</div>
<div class="form-group">
<label for="retryDelayIncrease">Retry Delay Increase (seconds):</label>
<input type="number" id="retryDelayIncrease" min="0" value="5">
<div class="setting-description">
The amount of additional delay to add for each retry attempt
</div>
</div>
<!-- New Formatting Options -->
<div class="config-item">
<label>Custom Directory Format:</label>
@@ -85,6 +101,17 @@
placeholder="e.g. %tracknum% - %music%"
/>
</div>
<!-- New Track Number Padding Toggle -->
<div class="config-item">
<label>Track Number Padding:</label>
<label class="switch">
<input type="checkbox" id="tracknumPaddingToggle" />
<span class="slider"></span>
</label>
<div class="setting-description">
When enabled: "01. Track" - When disabled: "1. Track"
</div>
</div>
</div>
<div class="service-tabs">
@@ -105,6 +132,7 @@
<div id="searchFields" style="display: none;"></div>
<button type="submit" id="submitCredentialBtn" class="save-btn">Save Account</button>
</form>
<div id="configSuccess" class="success"></div>
<div id="configError" class="error"></div>
</div>
</div>

View File

@@ -7,13 +7,19 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/main.css') }}" />
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/icons.css') }}" />
<link rel="stylesheet" href="{{ url_for('static', filename='css/queue/queue.css') }}">
<script>
// Helper function to handle image loading errors
function handleImageError(img) {
img.src = '/static/images/placeholder.jpg';
}
</script>
</head>
<body>
<div class="container">
<div class="search-header">
<!-- Settings icon linking to the config page -->
<a href="/config" class="settings-icon">
<img src="{{ url_for('static', filename='images/settings.svg') }}" alt="Settings" />
<img src="{{ url_for('static', filename='images/settings.svg') }}" alt="Settings" onerror="handleImageError(this)"/>
</a>
<input
type="text"
@@ -38,7 +44,7 @@
aria-controls="downloadQueue"
aria-expanded="false"
>
<img src="{{ url_for('static', filename='images/queue.svg') }}" alt="" />
<img src="{{ url_for('static', filename='images/queue.svg') }}" alt="" onerror="handleImageError(this)"/>
</button>
</div>
<div id="resultsContainer" class="results-grid"></div>

View File

@@ -12,7 +12,7 @@
<body>
<div id="app">
<div id="playlist-header" class="hidden">
<img id="playlist-image" alt="Playlist cover">
<img id="playlist-image" alt="Playlist cover" onerror="this.src='/static/images/placeholder.jpg'">
<div id="playlist-info">
<h1 id="playlist-name"></h1>
<p id="playlist-owner"></p>

View File

@@ -13,7 +13,7 @@
<div id="app">
<div id="track-header" class="hidden">
<!-- Back Button will be inserted here via JavaScript -->
<img id="track-album-image" alt="Album cover">
<img id="track-album-image" alt="Album cover" onerror="this.src='/static/images/placeholder.jpg'">
<div id="track-info">
<h1 id="track-name"></h1>
<p id="track-artist"></p>