meh
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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
23
app.py
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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
@@ -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}")
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
BIN
static/images/placeholder.jpg
Normal file
BIN
static/images/placeholder.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.0 KiB |
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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">×</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">×</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">×</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 = {};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user