mirror of
https://lavaforge.org/spotizerr/spotizerr.git
synced 2025-12-24 02:39:14 -05:00
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
|
search_test.py
|
||||||
config/main.json
|
config/main.json
|
||||||
.cache
|
.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
|
return app
|
||||||
|
|
||||||
app = create_app()
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
import os
|
# Configure waitress logger
|
||||||
|
logger = logging.getLogger('waitress')
|
||||||
DEBUG = os.getenv("FLASK_DEBUG", "0") == "1"
|
logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
if DEBUG:
|
app = create_app()
|
||||||
logging.info("Starting Flask in DEBUG mode on port 7171")
|
logging.info("Starting Flask server on port 7171")
|
||||||
app.run(debug=True, host='0.0.0.0', port=7171) # Use Flask's built-in server
|
from waitress import serve
|
||||||
else:
|
serve(app, host='0.0.0.0', port=7171)
|
||||||
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
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,27 +2,60 @@ from flask import Blueprint, Response, request
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import traceback
|
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 = Blueprint('album', __name__)
|
||||||
|
|
||||||
@album_bp.route('/download', methods=['GET'])
|
@album_bp.route('/download', methods=['GET'])
|
||||||
def handle_download():
|
def handle_download():
|
||||||
# Retrieve parameters from the request.
|
# Retrieve essential parameters from the request.
|
||||||
service = request.args.get('service')
|
service = request.args.get('service')
|
||||||
url = request.args.get('url')
|
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')
|
main = request.args.get('main')
|
||||||
fallback = request.args.get('fallback')
|
fallback = request.args.get('fallback')
|
||||||
quality = request.args.get('quality')
|
quality = request.args.get('quality')
|
||||||
fall_quality = request.args.get('fall_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.
|
# Use config values as defaults when parameters are not provided
|
||||||
real_time_arg = request.args.get('real_time', 'false')
|
if not main:
|
||||||
real_time = real_time_arg.lower() in ['true', '1', 'yes']
|
main = config_params['spotify'] if service == 'spotify' else config_params['deezer']
|
||||||
|
|
||||||
# New custom formatting parameters (with defaults)
|
if not fallback and config_params['fallback'] and service == 'spotify':
|
||||||
custom_dir_format = request.args.get('custom_dir_format', "%ar_album%/%album%")
|
fallback = config_params['spotify']
|
||||||
custom_track_format = request.args.get('custom_track_format', "%tracknum%. %music% - %artist%")
|
|
||||||
|
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.
|
# Sanitize main and fallback to prevent directory traversal.
|
||||||
if main:
|
if main:
|
||||||
@@ -30,13 +63,6 @@ def handle_download():
|
|||||||
if fallback:
|
if fallback:
|
||||||
fallback = os.path.basename(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.
|
# Validate credentials based on service and fallback.
|
||||||
try:
|
try:
|
||||||
if service == 'spotify':
|
if service == 'spotify':
|
||||||
@@ -101,6 +127,7 @@ def handle_download():
|
|||||||
"real_time": real_time,
|
"real_time": real_time,
|
||||||
"custom_dir_format": custom_dir_format,
|
"custom_dir_format": custom_dir_format,
|
||||||
"custom_track_format": custom_track_format,
|
"custom_track_format": custom_track_format,
|
||||||
|
"pad_tracks": pad_tracks,
|
||||||
"orig_request": request.args.to_dict(),
|
"orig_request": request.args.to_dict(),
|
||||||
# New additional parameters:
|
# New additional parameters:
|
||||||
"type": "album",
|
"type": "album",
|
||||||
@@ -147,7 +174,6 @@ def get_album_info():
|
|||||||
Expects a query parameter 'id' that contains the Spotify album ID.
|
Expects a query parameter 'id' that contains the Spotify album ID.
|
||||||
"""
|
"""
|
||||||
spotify_id = request.args.get('id')
|
spotify_id = request.args.get('id')
|
||||||
main = request.args.get('main', '')
|
|
||||||
|
|
||||||
if not spotify_id:
|
if not spotify_id:
|
||||||
return Response(
|
return Response(
|
||||||
@@ -156,26 +182,10 @@ def get_album_info():
|
|||||||
mimetype='application/json'
|
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:
|
try:
|
||||||
# Import and use the get_spotify_info function from the utility module.
|
# Import and use the get_spotify_info function from the utility module.
|
||||||
from routes.utils.get_info import get_spotify_info
|
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(
|
return Response(
|
||||||
json.dumps(album_info),
|
json.dumps(album_info),
|
||||||
status=200,
|
status=200,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import os
|
|||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
import traceback
|
import traceback
|
||||||
|
from routes.utils.queue import download_queue_manager, get_config_params
|
||||||
|
|
||||||
artist_bp = Blueprint('artist', __name__)
|
artist_bp = Blueprint('artist', __name__)
|
||||||
|
|
||||||
@@ -23,32 +24,61 @@ def handle_artist_download():
|
|||||||
Expected query parameters:
|
Expected query parameters:
|
||||||
- url: string (a Spotify artist URL)
|
- url: string (a Spotify artist URL)
|
||||||
- service: string ("spotify" or "deezer")
|
- 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"
|
- 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')
|
service = request.args.get('service')
|
||||||
url = request.args.get('url')
|
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')
|
main = request.args.get('main')
|
||||||
fallback = request.args.get('fallback')
|
fallback = request.args.get('fallback')
|
||||||
quality = request.args.get('quality')
|
quality = request.args.get('quality')
|
||||||
fall_quality = request.args.get('fall_quality')
|
fall_quality = request.args.get('fall_quality')
|
||||||
album_type = request.args.get('album_type')
|
real_time_arg = request.args.get('real_time')
|
||||||
real_time_arg = request.args.get('real_time', 'false')
|
custom_dir_format = request.args.get('custom_dir_format')
|
||||||
real_time = real_time_arg.lower() in ['true', '1', 'yes']
|
custom_track_format = request.args.get('custom_track_format')
|
||||||
|
pad_tracks_arg = request.args.get('tracknum_padding')
|
||||||
# New query parameters for custom formatting.
|
|
||||||
custom_dir_format = request.args.get('custom_dir_format', "%ar_album%/%album%/%copyright%")
|
# Use config values as defaults when parameters are not provided
|
||||||
custom_track_format = request.args.get('custom_track_format', "%tracknum%. %music% - %artist%")
|
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.
|
# Sanitize main and fallback to prevent directory traversal.
|
||||||
if main:
|
if main:
|
||||||
@@ -56,14 +86,6 @@ def handle_artist_download():
|
|||||||
if fallback:
|
if fallback:
|
||||||
fallback = os.path.basename(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.
|
# Validate credentials based on the selected service.
|
||||||
try:
|
try:
|
||||||
if service == 'spotify':
|
if service == 'spotify':
|
||||||
@@ -125,7 +147,8 @@ def handle_artist_download():
|
|||||||
real_time=real_time,
|
real_time=real_time,
|
||||||
album_type=album_type,
|
album_type=album_type,
|
||||||
custom_dir_format=custom_dir_format,
|
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 the list of album PRG filenames.
|
||||||
return Response(
|
return Response(
|
||||||
@@ -169,7 +192,6 @@ def get_artist_info():
|
|||||||
Expects a query parameter 'id' with the Spotify artist ID.
|
Expects a query parameter 'id' with the Spotify artist ID.
|
||||||
"""
|
"""
|
||||||
spotify_id = request.args.get('id')
|
spotify_id = request.args.get('id')
|
||||||
main = request.args.get('main', '')
|
|
||||||
|
|
||||||
if not spotify_id:
|
if not spotify_id:
|
||||||
return Response(
|
return Response(
|
||||||
@@ -178,25 +200,9 @@ def get_artist_info():
|
|||||||
mimetype='application/json'
|
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:
|
try:
|
||||||
from routes.utils.get_info import get_spotify_info
|
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(
|
return Response(
|
||||||
json.dumps(artist_info),
|
json.dumps(artist_info),
|
||||||
status=200,
|
status=200,
|
||||||
|
|||||||
@@ -24,6 +24,30 @@ def handle_config():
|
|||||||
config = get_config()
|
config = get_config()
|
||||||
if config is None:
|
if config is None:
|
||||||
return jsonify({"error": "Could not read config file"}), 500
|
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)
|
return jsonify(config)
|
||||||
|
|
||||||
@config_bp.route('/config', methods=['POST', 'PUT'])
|
@config_bp.route('/config', methods=['POST', 'PUT'])
|
||||||
|
|||||||
@@ -2,27 +2,60 @@ from flask import Blueprint, Response, request
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import traceback
|
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 = Blueprint('playlist', __name__)
|
||||||
|
|
||||||
@playlist_bp.route('/download', methods=['GET'])
|
@playlist_bp.route('/download', methods=['GET'])
|
||||||
def handle_download():
|
def handle_download():
|
||||||
# Retrieve parameters from the request.
|
# Retrieve essential parameters from the request.
|
||||||
service = request.args.get('service')
|
service = request.args.get('service')
|
||||||
url = request.args.get('url')
|
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')
|
main = request.args.get('main')
|
||||||
fallback = request.args.get('fallback')
|
fallback = request.args.get('fallback')
|
||||||
quality = request.args.get('quality')
|
quality = request.args.get('quality')
|
||||||
fall_quality = request.args.get('fall_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.
|
# Use config values as defaults when parameters are not provided
|
||||||
real_time_arg = request.args.get('real_time', 'false')
|
if not main:
|
||||||
real_time = real_time_arg.lower() in ['true', '1', 'yes']
|
main = config_params['spotify'] if service == 'spotify' else config_params['deezer']
|
||||||
|
|
||||||
# New custom formatting parameters (with defaults)
|
if not fallback and config_params['fallback'] and service == 'spotify':
|
||||||
custom_dir_format = request.args.get('custom_dir_format', "%ar_album%/%album%/%copyright%")
|
fallback = config_params['spotify']
|
||||||
custom_track_format = request.args.get('custom_track_format', "%tracknum%. %music% - %artist%")
|
|
||||||
|
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.
|
# Sanitize main and fallback to prevent directory traversal.
|
||||||
if main:
|
if main:
|
||||||
@@ -30,13 +63,6 @@ def handle_download():
|
|||||||
if fallback:
|
if fallback:
|
||||||
fallback = os.path.basename(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.
|
# Build the task dictionary.
|
||||||
# Note: the key "download_type" tells the queue handler which download function to call.
|
# Note: the key "download_type" tells the queue handler which download function to call.
|
||||||
task = {
|
task = {
|
||||||
@@ -50,6 +76,7 @@ def handle_download():
|
|||||||
"real_time": real_time,
|
"real_time": real_time,
|
||||||
"custom_dir_format": custom_dir_format,
|
"custom_dir_format": custom_dir_format,
|
||||||
"custom_track_format": custom_track_format,
|
"custom_track_format": custom_track_format,
|
||||||
|
"pad_tracks": pad_tracks,
|
||||||
"orig_request": request.args.to_dict(),
|
"orig_request": request.args.to_dict(),
|
||||||
# If provided, these additional parameters can be used by your download function.
|
# If provided, these additional parameters can be used by your download function.
|
||||||
"type": "playlist",
|
"type": "playlist",
|
||||||
@@ -96,7 +123,6 @@ def get_playlist_info():
|
|||||||
Expects a query parameter 'id' that contains the Spotify playlist ID.
|
Expects a query parameter 'id' that contains the Spotify playlist ID.
|
||||||
"""
|
"""
|
||||||
spotify_id = request.args.get('id')
|
spotify_id = request.args.get('id')
|
||||||
main = request.args.get('main', '')
|
|
||||||
|
|
||||||
if not spotify_id:
|
if not spotify_id:
|
||||||
return Response(
|
return Response(
|
||||||
@@ -105,26 +131,10 @@ def get_playlist_info():
|
|||||||
mimetype='application/json'
|
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:
|
try:
|
||||||
# Import and use the get_spotify_info function from the utility module.
|
# Import and use the get_spotify_info function from the utility module.
|
||||||
from routes.utils.get_info import get_spotify_info
|
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(
|
return Response(
|
||||||
json.dumps(playlist_info),
|
json.dumps(playlist_info),
|
||||||
status=200,
|
status=200,
|
||||||
|
|||||||
@@ -32,32 +32,57 @@ def get_prg_file(filename):
|
|||||||
return jsonify({
|
return jsonify({
|
||||||
"type": "",
|
"type": "",
|
||||||
"name": "",
|
"name": "",
|
||||||
|
"artist": "",
|
||||||
"last_line": None,
|
"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.
|
# Attempt to extract the original request from the first line.
|
||||||
original_request = None
|
original_request = None
|
||||||
|
display_title = ""
|
||||||
|
display_type = ""
|
||||||
|
display_artist = ""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
first_line = json.loads(lines[0])
|
first_line = json.loads(lines[0])
|
||||||
if "original_request" in first_line:
|
if isinstance(first_line, dict):
|
||||||
original_request = first_line["original_request"]
|
if "original_request" in first_line:
|
||||||
except Exception:
|
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
|
original_request = None
|
||||||
|
|
||||||
# For resource type and name, use the second line if available.
|
# For resource type and name, use the second line if available.
|
||||||
|
resource_type = ""
|
||||||
|
resource_name = ""
|
||||||
|
resource_artist = ""
|
||||||
if len(lines) > 1:
|
if len(lines) > 1:
|
||||||
try:
|
try:
|
||||||
second_line = json.loads(lines[1])
|
second_line = json.loads(lines[1])
|
||||||
# Directly extract 'type' and 'name' from the JSON
|
# Directly extract 'type' and 'name' from the JSON
|
||||||
resource_type = second_line.get("type", "")
|
resource_type = second_line.get("type", "")
|
||||||
resource_name = second_line.get("name", "")
|
resource_name = second_line.get("name", "")
|
||||||
|
resource_artist = second_line.get("artist", "")
|
||||||
except Exception:
|
except Exception:
|
||||||
resource_type = ""
|
resource_type = ""
|
||||||
resource_name = ""
|
resource_name = ""
|
||||||
|
resource_artist = ""
|
||||||
else:
|
else:
|
||||||
resource_type = ""
|
resource_type = ""
|
||||||
resource_name = ""
|
resource_name = ""
|
||||||
|
resource_artist = ""
|
||||||
|
|
||||||
# Get the last line from the file.
|
# Get the last line from the file.
|
||||||
last_line_raw = lines[-1]
|
last_line_raw = lines[-1]
|
||||||
@@ -69,8 +94,12 @@ def get_prg_file(filename):
|
|||||||
return jsonify({
|
return jsonify({
|
||||||
"type": resource_type,
|
"type": resource_type,
|
||||||
"name": resource_name,
|
"name": resource_name,
|
||||||
|
"artist": resource_artist,
|
||||||
"last_line": last_line_parsed,
|
"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:
|
except FileNotFoundError:
|
||||||
abort(404, "File not found")
|
abort(404, "File not found")
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ def handle_search():
|
|||||||
main = config['spotify']
|
main = config['spotify']
|
||||||
print(f"Using main from config: {main}")
|
print(f"Using main from config: {main}")
|
||||||
|
|
||||||
print(f"Search request: query={query}, type={search_type}, limit={limit}, main={main}")
|
|
||||||
|
|
||||||
# Validate parameters
|
# Validate parameters
|
||||||
if not query:
|
if not query:
|
||||||
@@ -39,21 +38,16 @@ def handle_search():
|
|||||||
main=main # Pass the main parameter
|
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
|
# Extract items from the appropriate section of the response based on search_type
|
||||||
items = []
|
items = []
|
||||||
if raw_results and search_type + 's' in raw_results:
|
if raw_results and search_type + 's' in raw_results:
|
||||||
# Handle plural form (e.g., 'tracks' instead of 'track')
|
|
||||||
type_key = search_type + 's'
|
type_key = search_type + 's'
|
||||||
print(f"Using type key: {type_key}")
|
|
||||||
items = raw_results[type_key].get('items', [])
|
items = raw_results[type_key].get('items', [])
|
||||||
elif raw_results and search_type in raw_results:
|
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', [])
|
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 both the items array and the full data for debugging
|
||||||
return jsonify({
|
return jsonify({
|
||||||
|
|||||||
@@ -2,27 +2,60 @@ from flask import Blueprint, Response, request
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import traceback
|
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 = Blueprint('track', __name__)
|
||||||
|
|
||||||
@track_bp.route('/download', methods=['GET'])
|
@track_bp.route('/download', methods=['GET'])
|
||||||
def handle_download():
|
def handle_download():
|
||||||
# Retrieve parameters from the request.
|
# Retrieve essential parameters from the request.
|
||||||
service = request.args.get('service')
|
service = request.args.get('service')
|
||||||
url = request.args.get('url')
|
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')
|
main = request.args.get('main')
|
||||||
fallback = request.args.get('fallback')
|
fallback = request.args.get('fallback')
|
||||||
quality = request.args.get('quality')
|
quality = request.args.get('quality')
|
||||||
fall_quality = request.args.get('fall_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.
|
# Use config values as defaults when parameters are not provided
|
||||||
real_time_arg = request.args.get('real_time', 'false')
|
if not main:
|
||||||
real_time = real_time_arg.lower() in ['true', '1', 'yes']
|
main = config_params['spotify'] if service == 'spotify' else config_params['deezer']
|
||||||
|
|
||||||
# New custom formatting parameters (with defaults).
|
if not fallback and config_params['fallback'] and service == 'spotify':
|
||||||
custom_dir_format = request.args.get('custom_dir_format', "%ar_album%/%album%/%copyright%")
|
fallback = config_params['spotify']
|
||||||
custom_track_format = request.args.get('custom_track_format', "%tracknum%. %music% - %artist%")
|
|
||||||
|
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.
|
# Sanitize main and fallback to prevent directory traversal.
|
||||||
if main:
|
if main:
|
||||||
@@ -30,13 +63,6 @@ def handle_download():
|
|||||||
if fallback:
|
if fallback:
|
||||||
fallback = os.path.basename(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.
|
# Validate credentials based on service and fallback.
|
||||||
try:
|
try:
|
||||||
if service == 'spotify':
|
if service == 'spotify':
|
||||||
@@ -103,6 +129,7 @@ def handle_download():
|
|||||||
"real_time": real_time,
|
"real_time": real_time,
|
||||||
"custom_dir_format": custom_dir_format,
|
"custom_dir_format": custom_dir_format,
|
||||||
"custom_track_format": custom_track_format,
|
"custom_track_format": custom_track_format,
|
||||||
|
"pad_tracks": pad_tracks,
|
||||||
"orig_request": orig_request,
|
"orig_request": orig_request,
|
||||||
# Additional parameters if needed.
|
# Additional parameters if needed.
|
||||||
"type": "track",
|
"type": "track",
|
||||||
@@ -149,7 +176,6 @@ def get_track_info():
|
|||||||
Expects a query parameter 'id' that contains the Spotify track ID.
|
Expects a query parameter 'id' that contains the Spotify track ID.
|
||||||
"""
|
"""
|
||||||
spotify_id = request.args.get('id')
|
spotify_id = request.args.get('id')
|
||||||
main = request.args.get('main', '')
|
|
||||||
|
|
||||||
if not spotify_id:
|
if not spotify_id:
|
||||||
return Response(
|
return Response(
|
||||||
@@ -158,26 +184,10 @@ def get_track_info():
|
|||||||
mimetype='application/json'
|
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:
|
try:
|
||||||
# Import and use the get_spotify_info function from the utility module.
|
# Import and use the get_spotify_info function from the utility module.
|
||||||
from routes.utils.get_info import get_spotify_info
|
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(
|
return Response(
|
||||||
json.dumps(track_info),
|
json.dumps(track_info),
|
||||||
status=200,
|
status=200,
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ def download_album(
|
|||||||
fall_quality=None,
|
fall_quality=None,
|
||||||
real_time=False,
|
real_time=False,
|
||||||
custom_dir_format="%ar_album%/%album%/%copyright%",
|
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:
|
try:
|
||||||
# Load Spotify client credentials if available
|
# Load Spotify client credentials if available
|
||||||
@@ -60,7 +64,11 @@ def download_album(
|
|||||||
make_zip=False,
|
make_zip=False,
|
||||||
method_save=1,
|
method_save=1,
|
||||||
custom_dir_format=custom_dir_format,
|
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:
|
except Exception as e:
|
||||||
# Load fallback Spotify credentials and attempt download
|
# Load fallback Spotify credentials and attempt download
|
||||||
@@ -97,7 +105,11 @@ def download_album(
|
|||||||
make_zip=False,
|
make_zip=False,
|
||||||
real_time_dl=real_time,
|
real_time_dl=real_time,
|
||||||
custom_dir_format=custom_dir_format,
|
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:
|
except Exception as e2:
|
||||||
# If fallback also fails, raise an error indicating both attempts failed
|
# If fallback also fails, raise an error indicating both attempts failed
|
||||||
@@ -127,7 +139,11 @@ def download_album(
|
|||||||
make_zip=False,
|
make_zip=False,
|
||||||
real_time_dl=real_time,
|
real_time_dl=real_time,
|
||||||
custom_dir_format=custom_dir_format,
|
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':
|
elif service == 'deezer':
|
||||||
if quality is None:
|
if quality is None:
|
||||||
@@ -151,7 +167,11 @@ def download_album(
|
|||||||
method_save=1,
|
method_save=1,
|
||||||
make_zip=False,
|
make_zip=False,
|
||||||
custom_dir_format=custom_dir_format,
|
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:
|
else:
|
||||||
raise ValueError(f"Unsupported service: {service}")
|
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,
|
fall_quality=None, real_time=False,
|
||||||
album_type='album,single,compilation,appears_on',
|
album_type='album,single,compilation,appears_on',
|
||||||
custom_dir_format="%ar_album%/%album%/%copyright%",
|
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,
|
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
|
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,
|
"real_time": real_time,
|
||||||
"custom_dir_format": custom_dir_format,
|
"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,
|
||||||
# Extra info for logging in the PRG file.
|
# Extra info for logging in the PRG file.
|
||||||
"name": album_name,
|
"name": album_name,
|
||||||
"type": "album",
|
"type": "album",
|
||||||
|
|||||||
@@ -4,20 +4,45 @@ from deezspot.easy_spoty import Spo
|
|||||||
import json
|
import json
|
||||||
from pathlib import Path
|
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_id = None
|
||||||
client_secret = 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:
|
if spotify_id:
|
||||||
search_creds_path = Path(f'./creds/spotify/{main}/search.json')
|
search_creds_path = Path(f'./creds/spotify/{main}/search.json')
|
||||||
print(search_creds_path)
|
|
||||||
if search_creds_path.exists():
|
if search_creds_path.exists():
|
||||||
try:
|
try:
|
||||||
with open(search_creds_path, 'r') as f:
|
with open(search_creds_path, 'r') as f:
|
||||||
search_creds = json.load(f)
|
search_creds = json.load(f)
|
||||||
client_id = search_creds.get('client_id')
|
client_id = search_creds.get('client_id')
|
||||||
print(client_id)
|
|
||||||
client_secret = search_creds.get('client_secret')
|
client_secret = search_creds.get('client_secret')
|
||||||
print(client_secret)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error loading search credentials: {e}")
|
print(f"Error loading search credentials: {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ def download_playlist(
|
|||||||
fall_quality=None,
|
fall_quality=None,
|
||||||
real_time=False,
|
real_time=False,
|
||||||
custom_dir_format="%ar_album%/%album%/%copyright%",
|
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:
|
try:
|
||||||
# Load Spotify client credentials if available
|
# Load Spotify client credentials if available
|
||||||
@@ -60,7 +64,11 @@ def download_playlist(
|
|||||||
make_zip=False,
|
make_zip=False,
|
||||||
method_save=1,
|
method_save=1,
|
||||||
custom_dir_format=custom_dir_format,
|
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:
|
except Exception as e:
|
||||||
# Load fallback Spotify credentials and attempt download
|
# Load fallback Spotify credentials and attempt download
|
||||||
@@ -97,7 +105,11 @@ def download_playlist(
|
|||||||
make_zip=False,
|
make_zip=False,
|
||||||
real_time_dl=real_time,
|
real_time_dl=real_time,
|
||||||
custom_dir_format=custom_dir_format,
|
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:
|
except Exception as e2:
|
||||||
# If fallback also fails, raise an error indicating both attempts failed
|
# If fallback also fails, raise an error indicating both attempts failed
|
||||||
@@ -127,7 +139,11 @@ def download_playlist(
|
|||||||
make_zip=False,
|
make_zip=False,
|
||||||
real_time_dl=real_time,
|
real_time_dl=real_time,
|
||||||
custom_dir_format=custom_dir_format,
|
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':
|
elif service == 'deezer':
|
||||||
if quality is None:
|
if quality is None:
|
||||||
@@ -151,7 +167,11 @@ def download_playlist(
|
|||||||
method_save=1,
|
method_save=1,
|
||||||
make_zip=False,
|
make_zip=False,
|
||||||
custom_dir_format=custom_dir_format,
|
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:
|
else:
|
||||||
raise ValueError(f"Unsupported service: {service}")
|
raise ValueError(f"Unsupported service: {service}")
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -14,15 +14,13 @@ def search(
|
|||||||
|
|
||||||
if main:
|
if main:
|
||||||
search_creds_path = Path(f'./creds/spotify/{main}/search.json')
|
search_creds_path = Path(f'./creds/spotify/{main}/search.json')
|
||||||
print(search_creds_path)
|
|
||||||
if search_creds_path.exists():
|
if search_creds_path.exists():
|
||||||
try:
|
try:
|
||||||
with open(search_creds_path, 'r') as f:
|
with open(search_creds_path, 'r') as f:
|
||||||
search_creds = json.load(f)
|
search_creds = json.load(f)
|
||||||
client_id = search_creds.get('client_id')
|
client_id = search_creds.get('client_id')
|
||||||
print(client_id)
|
|
||||||
client_secret = search_creds.get('client_secret')
|
client_secret = search_creds.get('client_secret')
|
||||||
print(client_secret)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error loading search credentials: {e}")
|
print(f"Error loading search credentials: {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ def download_track(
|
|||||||
fall_quality=None,
|
fall_quality=None,
|
||||||
real_time=False,
|
real_time=False,
|
||||||
custom_dir_format="%ar_album%/%album%/%copyright%",
|
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:
|
try:
|
||||||
# Load Spotify client credentials if available
|
# Load Spotify client credentials if available
|
||||||
@@ -56,7 +60,10 @@ def download_track(
|
|||||||
not_interface=False,
|
not_interface=False,
|
||||||
method_save=1,
|
method_save=1,
|
||||||
custom_dir_format=custom_dir_format,
|
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:
|
except Exception as e:
|
||||||
# If the first attempt fails, use the fallback Spotify credentials
|
# If the first attempt fails, use the fallback Spotify credentials
|
||||||
@@ -91,7 +98,11 @@ def download_track(
|
|||||||
method_save=1,
|
method_save=1,
|
||||||
real_time_dl=real_time,
|
real_time_dl=real_time,
|
||||||
custom_dir_format=custom_dir_format,
|
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:
|
else:
|
||||||
# Directly use Spotify main account
|
# Directly use Spotify main account
|
||||||
@@ -114,7 +125,11 @@ def download_track(
|
|||||||
method_save=1,
|
method_save=1,
|
||||||
real_time_dl=real_time,
|
real_time_dl=real_time,
|
||||||
custom_dir_format=custom_dir_format,
|
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':
|
elif service == 'deezer':
|
||||||
if quality is None:
|
if quality is None:
|
||||||
@@ -137,7 +152,11 @@ def download_track(
|
|||||||
recursive_download=False,
|
recursive_download=False,
|
||||||
method_save=1,
|
method_save=1,
|
||||||
custom_dir_format=custom_dir_format,
|
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:
|
else:
|
||||||
raise ValueError(f"Unsupported service: {service}")
|
raise ValueError(f"Unsupported service: {service}")
|
||||||
|
|||||||
@@ -201,6 +201,14 @@ input:checked + .slider:before {
|
|||||||
transform: translateX(20px);
|
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 */
|
||||||
.service-tabs {
|
.service-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -387,6 +395,16 @@ input:checked + .slider:before {
|
|||||||
min-height: 1.2rem;
|
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 */
|
/* MOBILE RESPONSIVENESS */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.config-container {
|
.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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch the config to get active Spotify account first
|
// Fetch album info directly
|
||||||
fetch('/api/config')
|
fetch(`/api/album/info?id=${encodeURIComponent(albumId)}`)
|
||||||
.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}`);
|
|
||||||
})
|
|
||||||
.then(response => {
|
.then(response => {
|
||||||
if (!response.ok) throw new Error('Network response was not ok');
|
if (!response.ok) throw new Error('Network response was not ok');
|
||||||
return response.json();
|
return response.json();
|
||||||
@@ -48,19 +38,21 @@ function renderAlbum(album) {
|
|||||||
|
|
||||||
// Set album header info.
|
// Set album header info.
|
||||||
document.getElementById('album-name').innerHTML =
|
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 =
|
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 =
|
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 =
|
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;
|
document.getElementById('album-image').src = image;
|
||||||
|
|
||||||
// Create (if needed) the Home Button.
|
// Create (if needed) the Home Button.
|
||||||
@@ -107,7 +99,7 @@ function renderAlbum(album) {
|
|||||||
downloadAlbumBtn.textContent = 'Queued!';
|
downloadAlbumBtn.textContent = 'Queued!';
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
showError('Failed to queue album download: ' + err.message);
|
showError('Failed to queue album download: ' + (err?.message || 'Unknown error'));
|
||||||
downloadAlbumBtn.disabled = false;
|
downloadAlbumBtn.disabled = false;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -116,30 +108,36 @@ function renderAlbum(album) {
|
|||||||
const tracksList = document.getElementById('tracks-list');
|
const tracksList = document.getElementById('tracks-list');
|
||||||
tracksList.innerHTML = '';
|
tracksList.innerHTML = '';
|
||||||
|
|
||||||
album.tracks.items.forEach((track, index) => {
|
if (album.tracks?.items) {
|
||||||
const trackElement = document.createElement('div');
|
album.tracks.items.forEach((track, index) => {
|
||||||
trackElement.className = 'track';
|
if (!track) return; // Skip null or undefined tracks
|
||||||
trackElement.innerHTML = `
|
|
||||||
<div class="track-number">${index + 1}</div>
|
const trackElement = document.createElement('div');
|
||||||
<div class="track-info">
|
trackElement.className = 'track';
|
||||||
<div class="track-name">
|
trackElement.innerHTML = `
|
||||||
<a href="${baseUrl}/track/${track.id}">${track.name}</a>
|
<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>
|
||||||
<div class="track-artist">
|
<div class="track-duration">${msToTime(track.duration_ms || 0)}</div>
|
||||||
${track.artists.map(a => `<a href="${baseUrl}/artist/${a.id}">${a.name}</a>`).join(', ')}
|
<button class="download-btn download-btn--circle"
|
||||||
</div>
|
data-url="${track.external_urls?.spotify || ''}"
|
||||||
</div>
|
data-type="track"
|
||||||
<div class="track-duration">${msToTime(track.duration_ms)}</div>
|
data-name="${track.name || 'Unknown Track'}"
|
||||||
<button class="download-btn download-btn--circle"
|
title="Download">
|
||||||
data-url="${track.external_urls.spotify}"
|
<img src="/static/images/download.svg" alt="Download">
|
||||||
data-type="track"
|
</button>
|
||||||
data-name="${track.name}"
|
`;
|
||||||
title="Download">
|
tracksList.appendChild(trackElement);
|
||||||
<img src="/static/images/download.svg" alt="Download">
|
});
|
||||||
</button>
|
}
|
||||||
`;
|
|
||||||
tracksList.appendChild(trackElement);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reveal header and track list.
|
// Reveal header and track list.
|
||||||
document.getElementById('album-header').classList.remove('hidden');
|
document.getElementById('album-header').classList.remove('hidden');
|
||||||
@@ -166,11 +164,15 @@ function renderAlbum(album) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function downloadWholeAlbum(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 {
|
try {
|
||||||
await downloadQueue.startAlbumDownload(url, { name: album.name });
|
await downloadQueue.startAlbumDownload(url, { name: album.name || 'Unknown Album' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError('Album download failed: ' + error.message);
|
showError('Album download failed: ' + (error?.message || 'Unknown error'));
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,7 +185,7 @@ function msToTime(duration) {
|
|||||||
|
|
||||||
function showError(message) {
|
function showError(message) {
|
||||||
const errorEl = document.getElementById('error');
|
const errorEl = document.getElementById('error');
|
||||||
errorEl.textContent = message;
|
errorEl.textContent = message || 'An error occurred';
|
||||||
errorEl.classList.remove('hidden');
|
errorEl.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,9 +194,9 @@ function attachDownloadListeners() {
|
|||||||
if (btn.id === 'downloadAlbumBtn') return;
|
if (btn.id === 'downloadAlbumBtn') return;
|
||||||
btn.addEventListener('click', (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const url = e.currentTarget.dataset.url;
|
const url = e.currentTarget.dataset.url || '';
|
||||||
const type = e.currentTarget.dataset.type;
|
const type = e.currentTarget.dataset.type || '';
|
||||||
const name = e.currentTarget.dataset.name || extractName(url);
|
const name = e.currentTarget.dataset.name || extractName(url) || 'Unknown';
|
||||||
// Remove the button immediately after click.
|
// Remove the button immediately after click.
|
||||||
e.currentTarget.remove();
|
e.currentTarget.remove();
|
||||||
startDownload(url, type, { name });
|
startDownload(url, type, { name });
|
||||||
@@ -203,47 +205,25 @@ function attachDownloadListeners() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startDownload(url, type, item, albumType) {
|
async function startDownload(url, type, item, albumType) {
|
||||||
const config = JSON.parse(localStorage.getItem('activeConfig')) || {};
|
if (!url) {
|
||||||
const {
|
showError('Missing URL for download');
|
||||||
fallback = false,
|
return;
|
||||||
spotify = '',
|
}
|
||||||
deezer = '',
|
|
||||||
spotifyQuality = 'NORMAL',
|
|
||||||
deezerQuality = 'MP3_128',
|
|
||||||
realTime = false,
|
|
||||||
customDirFormat = '',
|
|
||||||
customTrackFormat = ''
|
|
||||||
} = config;
|
|
||||||
|
|
||||||
const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer';
|
const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer';
|
||||||
let apiUrl = '';
|
let apiUrl = `/api/${type}/download?service=${service}&url=${encodeURIComponent(url)}`;
|
||||||
|
|
||||||
if (type === 'album') {
|
// Add name and artist if available for better progress display
|
||||||
apiUrl = `/api/album/download?service=${service}&url=${encodeURIComponent(url)}`;
|
if (item.name) {
|
||||||
} else if (type === 'artist') {
|
apiUrl += `&name=${encodeURIComponent(item.name)}`;
|
||||||
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)}`;
|
|
||||||
}
|
}
|
||||||
|
if (item.artist) {
|
||||||
if (fallback && service === 'spotify') {
|
apiUrl += `&artist=${encodeURIComponent(item.artist)}`;
|
||||||
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 (realTime) {
|
// For artist downloads, include album_type
|
||||||
apiUrl += '&real_time=true';
|
if (type === 'artist' && albumType) {
|
||||||
}
|
apiUrl += `&album_type=${encodeURIComponent(albumType)}`;
|
||||||
|
|
||||||
// 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)}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -251,10 +231,10 @@ async function startDownload(url, type, item, albumType) {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
downloadQueue.addDownload(item, type, data.prg_file);
|
downloadQueue.addDownload(item, type, data.prg_file);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError('Download failed: ' + error.message);
|
showError('Download failed: ' + (error?.message || 'Unknown error'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractName(url) {
|
function extractName(url) {
|
||||||
return url;
|
return url || 'Unknown';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,18 +10,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch the config to get active Spotify account first
|
// Fetch artist info directly
|
||||||
fetch('/api/config')
|
fetch(`/api/artist/info?id=${encodeURIComponent(artistId)}`)
|
||||||
.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}`);
|
|
||||||
})
|
|
||||||
.then(response => {
|
.then(response => {
|
||||||
if (!response.ok) throw new Error('Network response was not ok');
|
if (!response.ok) throw new Error('Network response was not ok');
|
||||||
return response.json();
|
return response.json();
|
||||||
@@ -42,13 +32,13 @@ function renderArtist(artistData, artistId) {
|
|||||||
document.getElementById('loading').classList.add('hidden');
|
document.getElementById('loading').classList.add('hidden');
|
||||||
document.getElementById('error').classList.add('hidden');
|
document.getElementById('error').classList.add('hidden');
|
||||||
|
|
||||||
const firstAlbum = artistData.items[0];
|
const firstAlbum = artistData.items?.[0] || {};
|
||||||
const artistName = firstAlbum?.artists[0]?.name || 'Unknown Artist';
|
const artistName = firstAlbum?.artists?.[0]?.name || 'Unknown Artist';
|
||||||
const artistImage = firstAlbum?.images[0]?.url || 'placeholder.jpg';
|
const artistImage = firstAlbum?.images?.[0]?.url || '/static/images/placeholder.jpg';
|
||||||
|
|
||||||
document.getElementById('artist-name').innerHTML =
|
document.getElementById('artist-name').innerHTML =
|
||||||
`<a href="/artist/${artistId}" class="artist-link">${artistName}</a>`;
|
`<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;
|
document.getElementById('artist-image').src = artistImage;
|
||||||
|
|
||||||
// Define the artist URL (used by both full-discography and group downloads)
|
// Define the artist URL (used by both full-discography and group downloads)
|
||||||
@@ -93,13 +83,14 @@ function renderArtist(artistData, artistId) {
|
|||||||
.catch(err => {
|
.catch(err => {
|
||||||
downloadArtistBtn.textContent = 'Download All Discography';
|
downloadArtistBtn.textContent = 'Download All Discography';
|
||||||
downloadArtistBtn.disabled = false;
|
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.)
|
// Group albums by type (album, single, compilation, etc.)
|
||||||
const albumGroups = artistData.items.reduce((groups, album) => {
|
const albumGroups = (artistData.items || []).reduce((groups, album) => {
|
||||||
const type = album.album_type.toLowerCase();
|
if (!album) return groups;
|
||||||
|
const type = (album.album_type || 'unknown').toLowerCase();
|
||||||
if (!groups[type]) groups[type] = [];
|
if (!groups[type]) groups[type] = [];
|
||||||
groups[type].push(album);
|
groups[type].push(album);
|
||||||
return groups;
|
return groups;
|
||||||
@@ -126,22 +117,24 @@ function renderArtist(artistData, artistId) {
|
|||||||
|
|
||||||
const albumsContainer = groupSection.querySelector('.albums-list');
|
const albumsContainer = groupSection.querySelector('.albums-list');
|
||||||
albums.forEach(album => {
|
albums.forEach(album => {
|
||||||
|
if (!album) return;
|
||||||
|
|
||||||
const albumElement = document.createElement('div');
|
const albumElement = document.createElement('div');
|
||||||
albumElement.className = 'album-card';
|
albumElement.className = 'album-card';
|
||||||
albumElement.innerHTML = `
|
albumElement.innerHTML = `
|
||||||
<a href="/album/${album.id}" class="album-link">
|
<a href="/album/${album.id || ''}" class="album-link">
|
||||||
<img src="${album.images[1]?.url || album.images[0]?.url || 'placeholder.jpg'}"
|
<img src="${album.images?.[1]?.url || album.images?.[0]?.url || '/static/images/placeholder.jpg'}"
|
||||||
alt="Album cover"
|
alt="Album cover"
|
||||||
class="album-cover">
|
class="album-cover">
|
||||||
</a>
|
</a>
|
||||||
<div class="album-info">
|
<div class="album-info">
|
||||||
<div class="album-title">${album.name}</div>
|
<div class="album-title">${album.name || 'Unknown Album'}</div>
|
||||||
<div class="album-artist">${album.artists.map(a => a.name).join(', ')}</div>
|
<div class="album-artist">${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="download-btn download-btn--circle"
|
<button class="download-btn download-btn--circle"
|
||||||
data-url="${album.external_urls.spotify}"
|
data-url="${album.external_urls?.spotify || ''}"
|
||||||
data-type="${album.album_type}"
|
data-type="${album.album_type || 'album'}"
|
||||||
data-name="${album.name}"
|
data-name="${album.name || 'Unknown Album'}"
|
||||||
title="Download">
|
title="Download">
|
||||||
<img src="/static/images/download.svg" alt="Download">
|
<img src="/static/images/download.svg" alt="Download">
|
||||||
</button>
|
</button>
|
||||||
@@ -164,7 +157,7 @@ function renderArtist(artistData, artistId) {
|
|||||||
function attachGroupDownloadListeners(artistUrl, artistName) {
|
function attachGroupDownloadListeners(artistUrl, artistName) {
|
||||||
document.querySelectorAll('.group-download-btn').forEach(btn => {
|
document.querySelectorAll('.group-download-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', async (e) => {
|
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.disabled = true;
|
||||||
e.target.textContent = `Queueing all ${capitalize(groupType)}s...`;
|
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.
|
// Use the artist download function with the group type filter.
|
||||||
await downloadQueue.startArtistDownload(
|
await downloadQueue.startArtistDownload(
|
||||||
artistUrl,
|
artistUrl,
|
||||||
{ name: artistName, artist: artistName },
|
{ name: artistName || 'Unknown Artist', artist: artistName || 'Unknown Artist' },
|
||||||
groupType // Only queue releases of this specific type.
|
groupType // Only queue releases of this specific type.
|
||||||
);
|
);
|
||||||
e.target.textContent = `Queued all ${capitalize(groupType)}s`;
|
e.target.textContent = `Queued all ${capitalize(groupType)}s`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
e.target.textContent = `Download All ${capitalize(groupType)}s`;
|
e.target.textContent = `Download All ${capitalize(groupType)}s`;
|
||||||
e.target.disabled = false;
|
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 => {
|
document.querySelectorAll('.download-btn:not(.group-download-btn)').forEach(btn => {
|
||||||
btn.addEventListener('click', (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
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();
|
e.currentTarget.remove();
|
||||||
downloadQueue.startAlbumDownload(url, { name, type })
|
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
|
// UI Helpers
|
||||||
function showError(message) {
|
function showError(message) {
|
||||||
const errorEl = document.getElementById('error');
|
const errorEl = document.getElementById('error');
|
||||||
errorEl.textContent = message;
|
if (errorEl) {
|
||||||
errorEl.classList.remove('hidden');
|
errorEl.textContent = message || 'An error occurred';
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function capitalize(str) {
|
function capitalize(str) {
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ function setupEventListeners() {
|
|||||||
document.getElementById('realTimeToggle').addEventListener('change', saveConfig);
|
document.getElementById('realTimeToggle').addEventListener('change', saveConfig);
|
||||||
document.getElementById('spotifyQualitySelect').addEventListener('change', saveConfig);
|
document.getElementById('spotifyQualitySelect').addEventListener('change', saveConfig);
|
||||||
document.getElementById('deezerQualitySelect').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.
|
// Update active account globals when the account selector is changed.
|
||||||
document.getElementById('spotifyAccountSelect').addEventListener('change', (e) => {
|
document.getElementById('spotifyAccountSelect').addEventListener('change', (e) => {
|
||||||
@@ -350,11 +353,41 @@ function toggleSearchFieldsVisibility(showSearchFields) {
|
|||||||
const searchFieldsDiv = document.getElementById('searchFields');
|
const searchFieldsDiv = document.getElementById('searchFields');
|
||||||
|
|
||||||
if (showSearchFields) {
|
if (showSearchFields) {
|
||||||
|
// Hide regular fields and remove 'required' attribute
|
||||||
serviceFieldsDiv.style.display = 'none';
|
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';
|
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 {
|
} else {
|
||||||
|
// Show regular fields and add 'required' attribute
|
||||||
serviceFieldsDiv.style.display = 'block';
|
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';
|
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') {
|
if (isEditingSearch && service === 'spotify') {
|
||||||
// Handle search credentials
|
// Handle search credentials
|
||||||
const formData = {};
|
const formData = {};
|
||||||
|
let isValid = true;
|
||||||
|
let firstInvalidField = null;
|
||||||
|
|
||||||
|
// Manually validate search fields
|
||||||
serviceConfig[service].searchFields.forEach(field => {
|
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);
|
data = serviceConfig[service].searchValidator(formData);
|
||||||
endpoint = `/api/credentials/${service}/${endpointName}?type=search`;
|
endpoint = `/api/credentials/${service}/${endpointName}?type=search`;
|
||||||
@@ -444,9 +493,25 @@ async function handleCredentialSubmit(e) {
|
|||||||
} else {
|
} else {
|
||||||
// Handle regular account credentials
|
// Handle regular account credentials
|
||||||
const formData = {};
|
const formData = {};
|
||||||
|
let isValid = true;
|
||||||
|
let firstInvalidField = null;
|
||||||
|
|
||||||
|
// Manually validate account fields
|
||||||
serviceConfig[service].fields.forEach(field => {
|
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);
|
data = serviceConfig[service].validator(formData);
|
||||||
endpoint = `/api/credentials/${service}/${endpointName}`;
|
endpoint = `/api/credentials/${service}/${endpointName}`;
|
||||||
@@ -468,6 +533,9 @@ async function handleCredentialSubmit(e) {
|
|||||||
await saveConfig();
|
await saveConfig();
|
||||||
loadCredentials(service);
|
loadCredentials(service);
|
||||||
resetForm();
|
resetForm();
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
showConfigSuccess(isEditingSearch ? 'API credentials saved successfully' : 'Account saved successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showConfigError(error.message);
|
showConfigError(error.message);
|
||||||
}
|
}
|
||||||
@@ -501,7 +569,11 @@ async function saveConfig() {
|
|||||||
realTime: document.getElementById('realTimeToggle').checked,
|
realTime: document.getElementById('realTimeToggle').checked,
|
||||||
customDirFormat: document.getElementById('customDirFormat').value,
|
customDirFormat: document.getElementById('customDirFormat').value,
|
||||||
customTrackFormat: document.getElementById('customTrackFormat').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 {
|
try {
|
||||||
@@ -546,6 +618,10 @@ async function loadConfig() {
|
|||||||
document.getElementById('customDirFormat').value = savedConfig.customDirFormat || '%ar_album%/%album%';
|
document.getElementById('customDirFormat').value = savedConfig.customDirFormat || '%ar_album%/%album%';
|
||||||
document.getElementById('customTrackFormat').value = savedConfig.customTrackFormat || '%tracknum%. %music%';
|
document.getElementById('customTrackFormat').value = savedConfig.customTrackFormat || '%tracknum%. %music%';
|
||||||
document.getElementById('maxConcurrentDownloads').value = savedConfig.maxConcurrentDownloads || '3';
|
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) {
|
} catch (error) {
|
||||||
showConfigError('Error loading config: ' + error.message);
|
showConfigError('Error loading config: ' + error.message);
|
||||||
}
|
}
|
||||||
@@ -556,3 +632,9 @@ function showConfigError(message) {
|
|||||||
errorDiv.textContent = message;
|
errorDiv.textContent = message;
|
||||||
setTimeout(() => (errorDiv.textContent = ''), 5000);
|
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
|
// Save the search type to local storage whenever it changes
|
||||||
searchType.addEventListener('change', () => {
|
if (searchType) {
|
||||||
localStorage.setItem('searchType', searchType.value);
|
searchType.addEventListener('change', () => {
|
||||||
});
|
localStorage.setItem('searchType', searchType.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize queue icon
|
// Initialize queue icon
|
||||||
queueIcon.addEventListener('click', () => downloadQueue.toggleVisibility());
|
if (queueIcon) {
|
||||||
|
queueIcon.addEventListener('click', () => downloadQueue.toggleVisibility());
|
||||||
|
}
|
||||||
|
|
||||||
// Search functionality
|
// Search functionality
|
||||||
searchButton.addEventListener('click', performSearch);
|
if (searchButton) {
|
||||||
searchInput.addEventListener('keypress', (e) => {
|
searchButton.addEventListener('click', performSearch);
|
||||||
if (e.key === 'Enter') performSearch();
|
}
|
||||||
});
|
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.addEventListener('keypress', (e) => {
|
||||||
|
if (e.key === 'Enter') performSearch();
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function performSearch() {
|
async function performSearch() {
|
||||||
const query = document.getElementById('searchInput').value.trim();
|
const searchInput = document.getElementById('searchInput');
|
||||||
const searchType = document.getElementById('searchType').value;
|
const searchType = document.getElementById('searchType');
|
||||||
const resultsContainer = document.getElementById('resultsContainer');
|
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) {
|
if (!query) {
|
||||||
showError('Please enter a search term');
|
showError('Please enter a search term');
|
||||||
return;
|
return;
|
||||||
@@ -50,7 +67,7 @@ async function performSearch() {
|
|||||||
window.location.href = `${window.location.origin}/${type}/${id}`;
|
window.location.href = `${window.location.origin}/${type}/${id}`;
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(`Invalid Spotify URL: ${error.message}`);
|
showError(`Invalid Spotify URL: ${error?.message || 'Unknown error'}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,26 +78,27 @@ async function performSearch() {
|
|||||||
// Fetch config to get active Spotify account
|
// Fetch config to get active Spotify account
|
||||||
const configResponse = await fetch('/api/config');
|
const configResponse = await fetch('/api/config');
|
||||||
const config = await configResponse.json();
|
const config = await configResponse.json();
|
||||||
const mainAccount = config.spotify || '';
|
const mainAccount = config?.spotify || '';
|
||||||
|
|
||||||
// Add the main parameter to the search API call
|
// 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();
|
const data = await response.json();
|
||||||
if (data.error) throw new Error(data.error);
|
if (data.error) throw new Error(data.error);
|
||||||
|
|
||||||
// When mapping the items, include the index so that each card gets a data-index attribute.
|
// 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) {
|
if (!items?.length) {
|
||||||
resultsContainer.innerHTML = '<div class="error">No results found</div>';
|
resultsContainer.innerHTML = '<div class="error">No results found</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
resultsContainer.innerHTML = items
|
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('');
|
.join('');
|
||||||
attachDownloadListeners(items);
|
attachDownloadListeners(items);
|
||||||
} catch (error) {
|
} 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) => {
|
document.querySelectorAll('.download-btn, .download-btn-small').forEach((btn) => {
|
||||||
btn.addEventListener('click', (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const url = e.currentTarget.dataset.url;
|
const url = e.currentTarget.dataset.url || '';
|
||||||
const type = e.currentTarget.dataset.type;
|
const type = e.currentTarget.dataset.type || '';
|
||||||
const albumType = e.currentTarget.dataset.albumType;
|
const albumType = e.currentTarget.dataset.albumType || '';
|
||||||
// Get the parent result card and its data-index
|
// Get the parent result card and its data-index
|
||||||
const card = e.currentTarget.closest('.result-card');
|
const card = e.currentTarget.closest('.result-card');
|
||||||
const idx = card ? card.getAttribute('data-index') : null;
|
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.
|
// Remove the button or card from the UI as appropriate.
|
||||||
if (e.currentTarget.classList.contains('main-download')) {
|
if (e.currentTarget.classList.contains('main-download')) {
|
||||||
card.remove();
|
if (card) card.remove();
|
||||||
} else {
|
} else {
|
||||||
e.currentTarget.remove();
|
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.
|
* so that the backend endpoint (at /artist/download) receives the required query parameters.
|
||||||
*/
|
*/
|
||||||
async function startDownload(url, type, item, albumType) {
|
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.
|
// Enrich the item object with the artist property.
|
||||||
if (type === 'track' || type === 'album') {
|
if (item) {
|
||||||
item.artist = item.artists.map(a => a.name).join(', ');
|
if (type === 'track' || type === 'album') {
|
||||||
} else if (type === 'playlist') {
|
item.artist = item.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist';
|
||||||
item.artist = item.owner.display_name;
|
} else if (type === 'playlist') {
|
||||||
} else if (type === 'artist') {
|
item.artist = item.owner?.display_name || 'Unknown Owner';
|
||||||
item.artist = item.name;
|
} else if (type === 'artist') {
|
||||||
|
item.artist = item.name || 'Unknown Artist';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
item = { name: 'Unknown', artist: 'Unknown Artist' };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -142,17 +172,20 @@ async function startDownload(url, type, item, albumType) {
|
|||||||
throw new Error(`Unsupported type: ${type}`);
|
throw new Error(`Unsupported type: ${type}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError('Download failed: ' + error.message);
|
showError('Download failed: ' + (error?.message || 'Unknown error'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// UI Helper Functions
|
// UI Helper Functions
|
||||||
function showError(message) {
|
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) {
|
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}
|
* Expected URL format: https://open.spotify.com/{type}/{id}
|
||||||
*/
|
*/
|
||||||
function getSpotifyResourceDetails(url) {
|
function getSpotifyResourceDetails(url) {
|
||||||
|
if (!url) throw new Error('Empty URL provided');
|
||||||
|
|
||||||
const urlObj = new URL(url);
|
const urlObj = new URL(url);
|
||||||
const pathParts = urlObj.pathname.split('/');
|
const pathParts = urlObj.pathname.split('/');
|
||||||
if (pathParts.length < 3 || !pathParts[1] || !pathParts[2]) {
|
if (pathParts.length < 3 || !pathParts[1] || !pathParts[2]) {
|
||||||
@@ -172,6 +207,8 @@ function getSpotifyResourceDetails(url) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function msToMinutesSeconds(ms) {
|
function msToMinutesSeconds(ms) {
|
||||||
|
if (!ms || isNaN(ms)) return '0:00';
|
||||||
|
|
||||||
const minutes = Math.floor(ms / 60000);
|
const minutes = Math.floor(ms / 60000);
|
||||||
const seconds = ((ms % 60000) / 1000).toFixed(0);
|
const seconds = ((ms % 60000) / 1000).toFixed(0);
|
||||||
return `${minutes}:${seconds.padStart(2, '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.
|
* The additional parameter "index" is used to set a data-index attribute on the card.
|
||||||
*/
|
*/
|
||||||
function createResultCard(item, type, index) {
|
function createResultCard(item, type, index) {
|
||||||
|
if (!item) return '';
|
||||||
|
|
||||||
let newUrl = '#';
|
let newUrl = '#';
|
||||||
try {
|
try {
|
||||||
const spotifyUrl = item.external_urls.spotify;
|
const spotifyUrl = item.external_urls?.spotify;
|
||||||
const parsedUrl = new URL(spotifyUrl);
|
if (spotifyUrl) {
|
||||||
newUrl = window.location.origin + parsedUrl.pathname;
|
const parsedUrl = new URL(spotifyUrl);
|
||||||
|
newUrl = window.location.origin + parsedUrl.pathname;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error parsing URL:', e);
|
console.error('Error parsing URL:', e);
|
||||||
}
|
}
|
||||||
@@ -195,15 +236,15 @@ function createResultCard(item, type, index) {
|
|||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'track':
|
case 'track':
|
||||||
imageUrl = item.album.images[0]?.url || '';
|
imageUrl = item.album?.images?.[0]?.url || '/static/images/placeholder.jpg';
|
||||||
title = item.name;
|
title = item.name || 'Unknown Track';
|
||||||
subtitle = item.artists.map(a => a.name).join(', ');
|
subtitle = item.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist';
|
||||||
details = `
|
details = `
|
||||||
<span>${item.album.name}</span>
|
<span>${item.album?.name || 'Unknown Album'}</span>
|
||||||
<span class="duration">${msToMinutesSeconds(item.duration_ms)}</span>
|
<span class="duration">${msToMinutesSeconds(item.duration_ms)}</span>
|
||||||
`;
|
`;
|
||||||
return `
|
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">
|
<div class="album-art-wrapper">
|
||||||
<img src="${imageUrl}" class="album-art" alt="${type} cover">
|
<img src="${imageUrl}" class="album-art" alt="${type} cover">
|
||||||
</div>
|
</div>
|
||||||
@@ -211,7 +252,7 @@ function createResultCard(item, type, index) {
|
|||||||
<div class="track-title">${title}</div>
|
<div class="track-title">${title}</div>
|
||||||
<div class="title-buttons">
|
<div class="title-buttons">
|
||||||
<button class="download-btn-small"
|
<button class="download-btn-small"
|
||||||
data-url="${item.external_urls.spotify}"
|
data-url="${item.external_urls?.spotify || ''}"
|
||||||
data-type="${type}"
|
data-type="${type}"
|
||||||
title="Download">
|
title="Download">
|
||||||
<img src="/static/images/download.svg" alt="Download">
|
<img src="/static/images/download.svg" alt="Download">
|
||||||
@@ -226,15 +267,15 @@ function createResultCard(item, type, index) {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
case 'playlist':
|
case 'playlist':
|
||||||
imageUrl = item.images[0]?.url || '';
|
imageUrl = item.images?.[0]?.url || '/static/images/placeholder.jpg';
|
||||||
title = item.name;
|
title = item.name || 'Unknown Playlist';
|
||||||
subtitle = item.owner.display_name;
|
subtitle = item.owner?.display_name || 'Unknown Owner';
|
||||||
details = `
|
details = `
|
||||||
<span>${item.tracks.total} tracks</span>
|
<span>${item.tracks?.total || '0'} tracks</span>
|
||||||
<span class="duration">${item.description || 'No description'}</span>
|
<span class="duration">${item.description || 'No description'}</span>
|
||||||
`;
|
`;
|
||||||
return `
|
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">
|
<div class="album-art-wrapper">
|
||||||
<img src="${imageUrl}" class="album-art" alt="${type} cover">
|
<img src="${imageUrl}" class="album-art" alt="${type} cover">
|
||||||
</div>
|
</div>
|
||||||
@@ -242,7 +283,7 @@ function createResultCard(item, type, index) {
|
|||||||
<div class="track-title">${title}</div>
|
<div class="track-title">${title}</div>
|
||||||
<div class="title-buttons">
|
<div class="title-buttons">
|
||||||
<button class="download-btn-small"
|
<button class="download-btn-small"
|
||||||
data-url="${item.external_urls.spotify}"
|
data-url="${item.external_urls?.spotify || ''}"
|
||||||
data-type="${type}"
|
data-type="${type}"
|
||||||
title="Download">
|
title="Download">
|
||||||
<img src="/static/images/download.svg" alt="Download">
|
<img src="/static/images/download.svg" alt="Download">
|
||||||
@@ -257,15 +298,15 @@ function createResultCard(item, type, index) {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
case 'album':
|
case 'album':
|
||||||
imageUrl = item.images[0]?.url || '';
|
imageUrl = item.images?.[0]?.url || '/static/images/placeholder.jpg';
|
||||||
title = item.name;
|
title = item.name || 'Unknown Album';
|
||||||
subtitle = item.artists.map(a => a.name).join(', ');
|
subtitle = item.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist';
|
||||||
details = `
|
details = `
|
||||||
<span>${item.release_date}</span>
|
<span>${item.release_date || 'Unknown release date'}</span>
|
||||||
<span class="duration">${item.total_tracks} tracks</span>
|
<span class="duration">${item.total_tracks || '0'} tracks</span>
|
||||||
`;
|
`;
|
||||||
return `
|
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">
|
<div class="album-art-wrapper">
|
||||||
<img src="${imageUrl}" class="album-art" alt="${type} cover">
|
<img src="${imageUrl}" class="album-art" alt="${type} cover">
|
||||||
</div>
|
</div>
|
||||||
@@ -273,7 +314,7 @@ function createResultCard(item, type, index) {
|
|||||||
<div class="track-title">${title}</div>
|
<div class="track-title">${title}</div>
|
||||||
<div class="title-buttons">
|
<div class="title-buttons">
|
||||||
<button class="download-btn-small"
|
<button class="download-btn-small"
|
||||||
data-url="${item.external_urls.spotify}"
|
data-url="${item.external_urls?.spotify || ''}"
|
||||||
data-type="${type}"
|
data-type="${type}"
|
||||||
title="Download">
|
title="Download">
|
||||||
<img src="/static/images/download.svg" alt="Download">
|
<img src="/static/images/download.svg" alt="Download">
|
||||||
@@ -288,12 +329,12 @@ function createResultCard(item, type, index) {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
case 'artist':
|
case 'artist':
|
||||||
imageUrl = (item.images && item.images.length) ? item.images[0].url : '';
|
imageUrl = (item.images && item.images.length) ? item.images[0].url : '/static/images/placeholder.jpg';
|
||||||
title = item.name;
|
title = item.name || 'Unknown Artist';
|
||||||
subtitle = (item.genres && item.genres.length) ? item.genres.join(', ') : 'Unknown genres';
|
subtitle = (item.genres && item.genres.length) ? item.genres.join(', ') : 'Unknown genres';
|
||||||
details = `<span>Followers: ${item.followers?.total || 'N/A'}</span>`;
|
details = `<span>Followers: ${item.followers?.total || 'N/A'}</span>`;
|
||||||
return `
|
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">
|
<div class="album-art-wrapper">
|
||||||
<img src="${imageUrl}" class="album-art" alt="${type} cover">
|
<img src="${imageUrl}" class="album-art" alt="${type} cover">
|
||||||
</div>
|
</div>
|
||||||
@@ -302,7 +343,7 @@ function createResultCard(item, type, index) {
|
|||||||
<div class="title-buttons">
|
<div class="title-buttons">
|
||||||
<!-- A primary download button (if you want one for a "default" download) -->
|
<!-- A primary download button (if you want one for a "default" download) -->
|
||||||
<button class="download-btn-small"
|
<button class="download-btn-small"
|
||||||
data-url="${item.external_urls.spotify}"
|
data-url="${item.external_urls?.spotify || ''}"
|
||||||
data-type="${type}"
|
data-type="${type}"
|
||||||
title="Download">
|
title="Download">
|
||||||
<img src="/static/images/download.svg" alt="Download">
|
<img src="/static/images/download.svg" alt="Download">
|
||||||
@@ -325,7 +366,7 @@ function createResultCard(item, type, index) {
|
|||||||
</button>
|
</button>
|
||||||
<div class="secondary-options">
|
<div class="secondary-options">
|
||||||
<button class="download-btn option-btn"
|
<button class="download-btn option-btn"
|
||||||
data-url="${item.external_urls.spotify}"
|
data-url="${item.external_urls?.spotify || ''}"
|
||||||
data-type="${type}"
|
data-type="${type}"
|
||||||
data-album-type="album">
|
data-album-type="album">
|
||||||
<img src="https://www.svgrepo.com/show/40029/vinyl-record.svg"
|
<img src="https://www.svgrepo.com/show/40029/vinyl-record.svg"
|
||||||
@@ -334,7 +375,7 @@ function createResultCard(item, type, index) {
|
|||||||
Albums
|
Albums
|
||||||
</button>
|
</button>
|
||||||
<button class="download-btn option-btn"
|
<button class="download-btn option-btn"
|
||||||
data-url="${item.external_urls.spotify}"
|
data-url="${item.external_urls?.spotify || ''}"
|
||||||
data-type="${type}"
|
data-type="${type}"
|
||||||
data-album-type="single">
|
data-album-type="single">
|
||||||
<img src="https://www.svgrepo.com/show/147837/cassette.svg"
|
<img src="https://www.svgrepo.com/show/147837/cassette.svg"
|
||||||
@@ -343,7 +384,7 @@ function createResultCard(item, type, index) {
|
|||||||
Singles
|
Singles
|
||||||
</button>
|
</button>
|
||||||
<button class="download-btn option-btn"
|
<button class="download-btn option-btn"
|
||||||
data-url="${item.external_urls.spotify}"
|
data-url="${item.external_urls?.spotify || ''}"
|
||||||
data-type="${type}"
|
data-type="${type}"
|
||||||
data-album-type="compilation">
|
data-album-type="compilation">
|
||||||
<img src="https://brandeps.com/icon-download/C/Collection-icon-vector-01.svg"
|
<img src="https://brandeps.com/icon-download/C/Collection-icon-vector-01.svg"
|
||||||
@@ -361,15 +402,15 @@ function createResultCard(item, type, index) {
|
|||||||
subtitle = '';
|
subtitle = '';
|
||||||
details = '';
|
details = '';
|
||||||
return `
|
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">
|
<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>
|
||||||
<div class="title-and-view">
|
<div class="title-and-view">
|
||||||
<div class="track-title">${title}</div>
|
<div class="track-title">${title}</div>
|
||||||
<div class="title-buttons">
|
<div class="title-buttons">
|
||||||
<button class="download-btn-small"
|
<button class="download-btn-small"
|
||||||
data-url="${item.external_urls.spotify}"
|
data-url="${item.external_urls?.spotify || ''}"
|
||||||
data-type="${type}"
|
data-type="${type}"
|
||||||
title="Download">
|
title="Download">
|
||||||
<img src="/static/images/download.svg" alt="Download">
|
<img src="/static/images/download.svg" alt="Download">
|
||||||
|
|||||||
@@ -11,18 +11,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch the config to get active Spotify account first
|
// Fetch playlist info directly
|
||||||
fetch('/api/config')
|
fetch(`/api/playlist/info?id=${encodeURIComponent(playlistId)}`)
|
||||||
.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}`);
|
|
||||||
})
|
|
||||||
.then(response => {
|
.then(response => {
|
||||||
if (!response.ok) throw new Error('Network response was not ok');
|
if (!response.ok) throw new Error('Network response was not ok');
|
||||||
return response.json();
|
return response.json();
|
||||||
@@ -50,12 +40,12 @@ function renderPlaylist(playlist) {
|
|||||||
document.getElementById('error').classList.add('hidden');
|
document.getElementById('error').classList.add('hidden');
|
||||||
|
|
||||||
// Update header info
|
// Update header info
|
||||||
document.getElementById('playlist-name').textContent = playlist.name;
|
document.getElementById('playlist-name').textContent = playlist.name || 'Unknown Playlist';
|
||||||
document.getElementById('playlist-owner').textContent = `By ${playlist.owner.display_name}`;
|
document.getElementById('playlist-owner').textContent = `By ${playlist.owner?.display_name || 'Unknown User'}`;
|
||||||
document.getElementById('playlist-stats').textContent =
|
document.getElementById('playlist-stats').textContent =
|
||||||
`${playlist.followers.total} followers • ${playlist.tracks.total} songs`;
|
`${playlist.followers?.total || '0'} followers • ${playlist.tracks?.total || '0'} songs`;
|
||||||
document.getElementById('playlist-description').textContent = playlist.description;
|
document.getElementById('playlist-description').textContent = playlist.description || '';
|
||||||
const image = playlist.images[0]?.url || 'placeholder.jpg';
|
const image = playlist.images?.[0]?.url || '/static/images/placeholder.jpg';
|
||||||
document.getElementById('playlist-image').src = image;
|
document.getElementById('playlist-image').src = image;
|
||||||
|
|
||||||
// --- Add Home Button ---
|
// --- Add Home Button ---
|
||||||
@@ -68,7 +58,9 @@ function renderPlaylist(playlist) {
|
|||||||
homeButton.innerHTML = `<img src="/static/images/home.svg" alt="Home">`;
|
homeButton.innerHTML = `<img src="/static/images/home.svg" alt="Home">`;
|
||||||
// Insert the home button at the beginning of the header container.
|
// Insert the home button at the beginning of the header container.
|
||||||
const headerContainer = document.getElementById('playlist-header');
|
const headerContainer = document.getElementById('playlist-header');
|
||||||
headerContainer.insertBefore(homeButton, headerContainer.firstChild);
|
if (headerContainer) {
|
||||||
|
headerContainer.insertBefore(homeButton, headerContainer.firstChild);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
homeButton.addEventListener('click', () => {
|
homeButton.addEventListener('click', () => {
|
||||||
// Navigate to the site's base URL.
|
// Navigate to the site's base URL.
|
||||||
@@ -84,7 +76,9 @@ function renderPlaylist(playlist) {
|
|||||||
downloadPlaylistBtn.className = 'download-btn download-btn--main';
|
downloadPlaylistBtn.className = 'download-btn download-btn--main';
|
||||||
// Insert the button into the header container.
|
// Insert the button into the header container.
|
||||||
const headerContainer = document.getElementById('playlist-header');
|
const headerContainer = document.getElementById('playlist-header');
|
||||||
headerContainer.appendChild(downloadPlaylistBtn);
|
if (headerContainer) {
|
||||||
|
headerContainer.appendChild(downloadPlaylistBtn);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
downloadPlaylistBtn.addEventListener('click', () => {
|
downloadPlaylistBtn.addEventListener('click', () => {
|
||||||
// Remove individual track download buttons (but leave the whole playlist button).
|
// Remove individual track download buttons (but leave the whole playlist button).
|
||||||
@@ -102,7 +96,7 @@ function renderPlaylist(playlist) {
|
|||||||
downloadWholePlaylist(playlist).then(() => {
|
downloadWholePlaylist(playlist).then(() => {
|
||||||
downloadPlaylistBtn.textContent = 'Queued!';
|
downloadPlaylistBtn.textContent = 'Queued!';
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
showError('Failed to queue playlist download: ' + err.message);
|
showError('Failed to queue playlist download: ' + (err?.message || 'Unknown error'));
|
||||||
downloadPlaylistBtn.disabled = false;
|
downloadPlaylistBtn.disabled = false;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -116,7 +110,9 @@ function renderPlaylist(playlist) {
|
|||||||
downloadAlbumsBtn.className = 'download-btn download-btn--main';
|
downloadAlbumsBtn.className = 'download-btn download-btn--main';
|
||||||
// Insert the new button into the header container.
|
// Insert the new button into the header container.
|
||||||
const headerContainer = document.getElementById('playlist-header');
|
const headerContainer = document.getElementById('playlist-header');
|
||||||
headerContainer.appendChild(downloadAlbumsBtn);
|
if (headerContainer) {
|
||||||
|
headerContainer.appendChild(downloadAlbumsBtn);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
downloadAlbumsBtn.addEventListener('click', () => {
|
downloadAlbumsBtn.addEventListener('click', () => {
|
||||||
// Remove individual track download buttons (but leave this album button).
|
// Remove individual track download buttons (but leave this album button).
|
||||||
@@ -132,48 +128,54 @@ function renderPlaylist(playlist) {
|
|||||||
downloadAlbumsBtn.textContent = 'Queued!';
|
downloadAlbumsBtn.textContent = 'Queued!';
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
showError('Failed to queue album downloads: ' + err.message);
|
showError('Failed to queue album downloads: ' + (err?.message || 'Unknown error'));
|
||||||
downloadAlbumsBtn.disabled = false;
|
downloadAlbumsBtn.disabled = false;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Render tracks list
|
// Render tracks list
|
||||||
const tracksList = document.getElementById('tracks-list');
|
const tracksList = document.getElementById('tracks-list');
|
||||||
|
if (!tracksList) return;
|
||||||
|
|
||||||
tracksList.innerHTML = ''; // Clear any existing content
|
tracksList.innerHTML = ''; // Clear any existing content
|
||||||
|
|
||||||
playlist.tracks.items.forEach((item, index) => {
|
if (playlist.tracks?.items) {
|
||||||
const track = item.track;
|
playlist.tracks.items.forEach((item, index) => {
|
||||||
// Create links for track, artist, and album using their IDs.
|
if (!item || !item.track) return; // Skip null/undefined tracks
|
||||||
const trackLink = `/track/${track.id}`;
|
|
||||||
const artistLink = `/artist/${track.artists[0].id}`;
|
const track = item.track;
|
||||||
const albumLink = `/album/${track.album.id}`;
|
// 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');
|
const trackElement = document.createElement('div');
|
||||||
trackElement.className = 'track';
|
trackElement.className = 'track';
|
||||||
trackElement.innerHTML = `
|
trackElement.innerHTML = `
|
||||||
<div class="track-number">${index + 1}</div>
|
<div class="track-number">${index + 1}</div>
|
||||||
<div class="track-info">
|
<div class="track-info">
|
||||||
<div class="track-name">
|
<div class="track-name">
|
||||||
<a href="${trackLink}" title="View track details">${track.name}</a>
|
<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>
|
||||||
<div class="track-artist">
|
<div class="track-album">
|
||||||
<a href="${artistLink}" title="View artist details">${track.artists[0].name}</a>
|
<a href="${albumLink}" title="View album details">${track.album?.name || 'Unknown Album'}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="track-duration">${msToTime(track.duration_ms || 0)}</div>
|
||||||
<div class="track-album">
|
<button class="download-btn download-btn--circle"
|
||||||
<a href="${albumLink}" title="View album details">${track.album.name}</a>
|
data-url="${track.external_urls?.spotify || ''}"
|
||||||
</div>
|
data-type="track"
|
||||||
<div class="track-duration">${msToTime(track.duration_ms)}</div>
|
data-name="${track.name || 'Unknown Track'}"
|
||||||
<button class="download-btn download-btn--circle"
|
title="Download">
|
||||||
data-url="${track.external_urls.spotify}"
|
<img src="/static/images/download.svg" alt="Download">
|
||||||
data-type="track"
|
</button>
|
||||||
data-name="${track.name}"
|
`;
|
||||||
title="Download">
|
tracksList.appendChild(trackElement);
|
||||||
<img src="/static/images/download.svg" alt="Download">
|
});
|
||||||
</button>
|
}
|
||||||
`;
|
|
||||||
tracksList.appendChild(trackElement);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reveal header and tracks container
|
// Reveal header and tracks container
|
||||||
document.getElementById('playlist-header').classList.remove('hidden');
|
document.getElementById('playlist-header').classList.remove('hidden');
|
||||||
@@ -187,6 +189,8 @@ function renderPlaylist(playlist) {
|
|||||||
* Converts milliseconds to minutes:seconds.
|
* Converts milliseconds to minutes:seconds.
|
||||||
*/
|
*/
|
||||||
function msToTime(duration) {
|
function msToTime(duration) {
|
||||||
|
if (!duration || isNaN(duration)) return '0:00';
|
||||||
|
|
||||||
const minutes = Math.floor(duration / 60000);
|
const minutes = Math.floor(duration / 60000);
|
||||||
const seconds = ((duration % 60000) / 1000).toFixed(0);
|
const seconds = ((duration % 60000) / 1000).toFixed(0);
|
||||||
return `${minutes}:${seconds.padStart(2, '0')}`;
|
return `${minutes}:${seconds.padStart(2, '0')}`;
|
||||||
@@ -197,8 +201,10 @@ function msToTime(duration) {
|
|||||||
*/
|
*/
|
||||||
function showError(message) {
|
function showError(message) {
|
||||||
const errorEl = document.getElementById('error');
|
const errorEl = document.getElementById('error');
|
||||||
errorEl.textContent = message;
|
if (errorEl) {
|
||||||
errorEl.classList.remove('hidden');
|
errorEl.textContent = message || 'An error occurred';
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -210,9 +216,9 @@ function attachDownloadListeners() {
|
|||||||
if (btn.id === 'downloadPlaylistBtn' || btn.id === 'downloadAlbumsBtn') return;
|
if (btn.id === 'downloadPlaylistBtn' || btn.id === 'downloadAlbumsBtn') return;
|
||||||
btn.addEventListener('click', (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const url = e.currentTarget.dataset.url;
|
const url = e.currentTarget.dataset.url || '';
|
||||||
const type = e.currentTarget.dataset.type;
|
const type = e.currentTarget.dataset.type || '';
|
||||||
const name = e.currentTarget.dataset.name || extractName(url);
|
const name = e.currentTarget.dataset.name || extractName(url) || 'Unknown';
|
||||||
// Remove the button immediately after click.
|
// Remove the button immediately after click.
|
||||||
e.currentTarget.remove();
|
e.currentTarget.remove();
|
||||||
startDownload(url, type, { name });
|
startDownload(url, type, { name });
|
||||||
@@ -224,11 +230,19 @@ function attachDownloadListeners() {
|
|||||||
* Initiates the whole playlist download by calling the playlist endpoint.
|
* Initiates the whole playlist download by calling the playlist endpoint.
|
||||||
*/
|
*/
|
||||||
async function downloadWholePlaylist(playlist) {
|
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 {
|
try {
|
||||||
await downloadQueue.startPlaylistDownload(url, { name: playlist.name });
|
await downloadQueue.startPlaylistDownload(url, { name: playlist.name || 'Unknown Playlist' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError('Playlist download failed: ' + error.message);
|
showError('Playlist download failed: ' + (error?.message || 'Unknown error'));
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -239,9 +253,16 @@ async function downloadWholePlaylist(playlist) {
|
|||||||
* with the progress (queued_albums/total_albums).
|
* with the progress (queued_albums/total_albums).
|
||||||
*/
|
*/
|
||||||
async function downloadPlaylistAlbums(playlist) {
|
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).
|
// Build a map of unique albums (using album ID as the key).
|
||||||
const albumMap = new Map();
|
const albumMap = new Map();
|
||||||
playlist.tracks.items.forEach(item => {
|
playlist.tracks.items.forEach(item => {
|
||||||
|
if (!item?.track?.album) return;
|
||||||
|
|
||||||
const album = item.track.album;
|
const album = item.track.album;
|
||||||
if (album && album.id) {
|
if (album && album.id) {
|
||||||
albumMap.set(album.id, album);
|
albumMap.set(album.id, album);
|
||||||
@@ -266,9 +287,14 @@ async function downloadPlaylistAlbums(playlist) {
|
|||||||
// Process each album sequentially.
|
// Process each album sequentially.
|
||||||
for (let i = 0; i < totalAlbums; i++) {
|
for (let i = 0; i < totalAlbums; i++) {
|
||||||
const album = uniqueAlbums[i];
|
const album = uniqueAlbums[i];
|
||||||
|
if (!album) continue;
|
||||||
|
|
||||||
|
const albumUrl = album.external_urls?.spotify || '';
|
||||||
|
if (!albumUrl) continue;
|
||||||
|
|
||||||
await downloadQueue.startAlbumDownload(
|
await downloadQueue.startAlbumDownload(
|
||||||
album.external_urls.spotify,
|
albumUrl,
|
||||||
{ name: album.name }
|
{ name: album.name || 'Unknown Album' }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update button text with current progress.
|
// Update button text with current progress.
|
||||||
@@ -291,56 +317,29 @@ async function downloadPlaylistAlbums(playlist) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts the download process by building the API URL,
|
* Starts the download process by building a minimal API URL with only the necessary parameters,
|
||||||
* fetching download details, and then adding the download to the queue.
|
* since the server will use config defaults for others.
|
||||||
*/
|
*/
|
||||||
async function startDownload(url, type, item, albumType) {
|
async function startDownload(url, type, item, albumType) {
|
||||||
// Retrieve configuration (if any) from localStorage.
|
if (!url || !type) {
|
||||||
const config = JSON.parse(localStorage.getItem('activeConfig')) || {};
|
showError('Missing URL or type for download');
|
||||||
const {
|
return;
|
||||||
fallback = false,
|
}
|
||||||
spotify = '',
|
|
||||||
deezer = '',
|
|
||||||
spotifyQuality = 'NORMAL',
|
|
||||||
deezerQuality = 'MP3_128',
|
|
||||||
realTime = false,
|
|
||||||
customTrackFormat = '',
|
|
||||||
customDirFormat = ''
|
|
||||||
} = config;
|
|
||||||
|
|
||||||
const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer';
|
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.
|
// Add name and artist if available for better progress display
|
||||||
if (type === 'playlist') {
|
if (item.name) {
|
||||||
// Use the dedicated playlist download endpoint.
|
apiUrl += `&name=${encodeURIComponent(item.name)}`;
|
||||||
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)}`;
|
|
||||||
}
|
}
|
||||||
|
if (item.artist) {
|
||||||
// Append account and quality details.
|
apiUrl += `&artist=${encodeURIComponent(item.artist)}`;
|
||||||
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 (realTime) {
|
// For artist downloads, include album_type
|
||||||
apiUrl += '&real_time=true';
|
if (type === 'artist' && albumType) {
|
||||||
}
|
apiUrl += `&album_type=${encodeURIComponent(albumType)}`;
|
||||||
|
|
||||||
// Append custom formatting parameters.
|
|
||||||
if (customTrackFormat) {
|
|
||||||
apiUrl += `&custom_track_format=${encodeURIComponent(customTrackFormat)}`;
|
|
||||||
}
|
|
||||||
if (customDirFormat) {
|
|
||||||
apiUrl += `&custom_dir_format=${encodeURIComponent(customDirFormat)}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -349,7 +348,7 @@ async function startDownload(url, type, item, albumType) {
|
|||||||
// Add the download to the queue using the working queue implementation.
|
// Add the download to the queue using the working queue implementation.
|
||||||
downloadQueue.addDownload(item, type, data.prg_file);
|
downloadQueue.addDownload(item, type, data.prg_file);
|
||||||
} catch (error) {
|
} 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.
|
* A helper function to extract a display name from the URL.
|
||||||
*/
|
*/
|
||||||
function extractName(url) {
|
function extractName(url) {
|
||||||
return url;
|
return url || 'Unknown';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ class CustomURLSearchParams {
|
|||||||
|
|
||||||
class DownloadQueue {
|
class DownloadQueue {
|
||||||
constructor() {
|
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.downloadQueue = {}; // keyed by unique queueId
|
||||||
this.currentConfig = {}; // Cache for current config
|
this.currentConfig = {}; // Cache for current config
|
||||||
|
|
||||||
@@ -277,13 +282,18 @@ class DownloadQueue {
|
|||||||
*/
|
*/
|
||||||
createQueueItem(item, type, prgFile, queueId) {
|
createQueueItem(item, type, prgFile, queueId) {
|
||||||
const defaultMessage = (type === 'playlist') ? 'Reading track list' : 'Initializing download...';
|
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');
|
const div = document.createElement('article');
|
||||||
div.className = 'queue-item';
|
div.className = 'queue-item';
|
||||||
div.setAttribute('aria-live', 'polite');
|
div.setAttribute('aria-live', 'polite');
|
||||||
div.setAttribute('aria-atomic', 'true');
|
div.setAttribute('aria-atomic', 'true');
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<div class="title">${item.name}</div>
|
<div class="title">${displayTitle}</div>
|
||||||
<div class="type">${type.charAt(0).toUpperCase() + type.slice(1)}</div>
|
<div class="type">${displayType}</div>
|
||||||
<div class="log" id="log-${queueId}-${prgFile}">${defaultMessage}</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">
|
<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">
|
<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 track "${data.name}"${data.artist ? ` by ${data.artist}` : ''}`;
|
||||||
}
|
}
|
||||||
return `Queued ${data.type} "${data.name}"`;
|
return `Queued ${data.type} "${data.name}"`;
|
||||||
|
|
||||||
|
case 'started':
|
||||||
|
return `Download started`;
|
||||||
|
|
||||||
|
case 'processing':
|
||||||
|
return `Processing download...`;
|
||||||
|
|
||||||
case 'cancel':
|
case 'cancel':
|
||||||
return 'Download cancelled';
|
return 'Download cancelled';
|
||||||
|
|
||||||
|
case 'interrupted':
|
||||||
|
return 'Download was interrupted';
|
||||||
|
|
||||||
case 'downloading':
|
case 'downloading':
|
||||||
if (data.type === 'track') {
|
if (data.type === 'track') {
|
||||||
return `Downloading track "${data.song}" by ${data.artist}...`;
|
return `Downloading track "${data.song}" by ${data.artist}...`;
|
||||||
}
|
}
|
||||||
return `Downloading ${data.type}...`;
|
return `Downloading ${data.type}...`;
|
||||||
|
|
||||||
case 'initializing':
|
case 'initializing':
|
||||||
if (data.type === 'playlist') {
|
if (data.type === 'playlist') {
|
||||||
return `Initializing playlist download "${data.name}" with ${data.total_tracks} tracks...`;
|
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 download for ${data.artist} with ${data.total_albums} album(s) [${data.album_type}]...`;
|
||||||
}
|
}
|
||||||
return `Initializing ${data.type} download...`;
|
return `Initializing ${data.type} download...`;
|
||||||
|
|
||||||
case 'progress':
|
case 'progress':
|
||||||
if (data.track && data.current_track) {
|
if (data.track && data.current_track) {
|
||||||
const parts = data.current_track.split('/');
|
const parts = data.current_track.split('/');
|
||||||
@@ -498,6 +521,7 @@ class DownloadQueue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return `Progress: ${data.status}...`;
|
return `Progress: ${data.status}...`;
|
||||||
|
|
||||||
case 'done':
|
case 'done':
|
||||||
if (data.type === 'track') {
|
if (data.type === 'track') {
|
||||||
return `Finished track "${data.song}" by ${data.artist}`;
|
return `Finished track "${data.song}" by ${data.artist}`;
|
||||||
@@ -509,14 +533,30 @@ class DownloadQueue {
|
|||||||
return `Finished artist "${data.artist}" (${data.album_type})`;
|
return `Finished artist "${data.artist}" (${data.album_type})`;
|
||||||
}
|
}
|
||||||
return `Finished ${data.type}`;
|
return `Finished ${data.type}`;
|
||||||
|
|
||||||
case 'retrying':
|
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':
|
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':
|
case 'complete':
|
||||||
return 'Download completed successfully';
|
return 'Download completed successfully';
|
||||||
|
|
||||||
case 'skipped':
|
case 'skipped':
|
||||||
return `Track "${data.song}" skipped, it already exists!`;
|
return `Track "${data.song}" skipped, it already exists!`;
|
||||||
|
|
||||||
case 'real_time': {
|
case 'real_time': {
|
||||||
const totalMs = data.time_elapsed;
|
const totalMs = data.time_elapsed;
|
||||||
const minutes = Math.floor(totalMs / 60000);
|
const minutes = Math.floor(totalMs / 60000);
|
||||||
@@ -524,6 +564,7 @@ class DownloadQueue {
|
|||||||
const paddedSeconds = seconds < 10 ? '0' + seconds : seconds;
|
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}`;
|
return `Real-time downloading track "${data.song}" by ${data.artist} (${(data.percentage * 100).toFixed(1)}%). Time elapsed: ${minutes}:${paddedSeconds}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return data.status;
|
return data.status;
|
||||||
}
|
}
|
||||||
@@ -540,47 +581,83 @@ class DownloadQueue {
|
|||||||
if (cancelBtn) {
|
if (cancelBtn) {
|
||||||
cancelBtn.style.display = 'none';
|
cancelBtn.style.display = 'none';
|
||||||
}
|
}
|
||||||
logElement.innerHTML = `
|
|
||||||
<div class="error-message">${this.getStatusMessage(progress)}</div>
|
// Check if we're under the max retries threshold for auto-retry
|
||||||
<div class="error-buttons">
|
const canRetry = entry.retryCount < this.MAX_RETRIES;
|
||||||
<button class="close-error-btn" title="Close">×</button>
|
|
||||||
<button class="retry-btn" title="Retry">Retry</button>
|
if (canRetry) {
|
||||||
</div>
|
logElement.innerHTML = `
|
||||||
`;
|
<div class="error-message">${this.getStatusMessage(progress)}</div>
|
||||||
logElement.querySelector('.close-error-btn').addEventListener('click', () => {
|
<div class="error-buttons">
|
||||||
if (entry.autoRetryInterval) {
|
<button class="close-error-btn" title="Close">×</button>
|
||||||
clearInterval(entry.autoRetryInterval);
|
<button class="retry-btn" title="Retry">Retry</button>
|
||||||
entry.autoRetryInterval = null;
|
</div>
|
||||||
}
|
`;
|
||||||
this.cleanupEntry(queueId);
|
logElement.querySelector('.close-error-btn').addEventListener('click', () => {
|
||||||
});
|
if (entry.autoRetryInterval) {
|
||||||
logElement.querySelector('.retry-btn').addEventListener('click', async () => {
|
clearInterval(entry.autoRetryInterval);
|
||||||
if (entry.autoRetryInterval) {
|
entry.autoRetryInterval = null;
|
||||||
clearInterval(entry.autoRetryInterval);
|
}
|
||||||
entry.autoRetryInterval = null;
|
this.cleanupEntry(queueId);
|
||||||
}
|
});
|
||||||
this.retryDownload(queueId, logElement);
|
logElement.querySelector('.retry-btn').addEventListener('click', async () => {
|
||||||
});
|
if (entry.autoRetryInterval) {
|
||||||
if (entry.requestUrl) {
|
clearInterval(entry.autoRetryInterval);
|
||||||
const maxRetries = 10;
|
entry.autoRetryInterval = null;
|
||||||
if (entry.retryCount < maxRetries) {
|
}
|
||||||
const autoRetryDelay = 300; // seconds
|
this.retryDownload(queueId, logElement);
|
||||||
let secondsLeft = autoRetryDelay;
|
});
|
||||||
entry.autoRetryInterval = setInterval(() => {
|
|
||||||
secondsLeft--;
|
// Implement auto-retry if we have the original request URL
|
||||||
const errorMsgEl = logElement.querySelector('.error-message');
|
if (entry.requestUrl) {
|
||||||
if (errorMsgEl) {
|
const maxRetries = this.MAX_RETRIES;
|
||||||
errorMsgEl.textContent = `Error: ${progress.message || 'Unknown error'}. Retrying in ${secondsLeft} seconds... (attempt ${entry.retryCount + 1}/${maxRetries})`;
|
if (entry.retryCount < maxRetries) {
|
||||||
}
|
// Calculate the delay based on retry count (exponential backoff)
|
||||||
if (secondsLeft <= 0) {
|
const baseDelay = this.RETRY_DELAY || 5; // seconds, use server's retry delay or default to 5
|
||||||
clearInterval(entry.autoRetryInterval);
|
const increase = this.RETRY_DELAY_INCREASE || 5;
|
||||||
entry.autoRetryInterval = null;
|
const retryDelay = baseDelay + (entry.retryCount * increase);
|
||||||
this.retryDownload(queueId, logElement);
|
|
||||||
}
|
let secondsLeft = retryDelay;
|
||||||
}, 1000);
|
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;
|
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 {
|
} else {
|
||||||
logElement.textContent = this.getStatusMessage(progress);
|
logElement.textContent = this.getStatusMessage(progress);
|
||||||
setTimeout(() => this.cleanupEntry(queueId), 5000);
|
setTimeout(() => this.cleanupEntry(queueId), 5000);
|
||||||
@@ -608,17 +685,36 @@ class DownloadQueue {
|
|||||||
async retryDownload(queueId, logElement) {
|
async retryDownload(queueId, logElement) {
|
||||||
const entry = this.downloadQueue[queueId];
|
const entry = this.downloadQueue[queueId];
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
|
|
||||||
logElement.textContent = 'Retrying download...';
|
logElement.textContent = 'Retrying download...';
|
||||||
|
|
||||||
|
// If we don't have the request URL, we can't retry
|
||||||
if (!entry.requestUrl) {
|
if (!entry.requestUrl) {
|
||||||
logElement.textContent = 'Retry not available: missing original request information.';
|
logElement.textContent = 'Retry not available: missing original request information.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Use the stored original request URL to create a new download
|
||||||
const retryResponse = await fetch(entry.requestUrl);
|
const retryResponse = await fetch(entry.requestUrl);
|
||||||
|
if (!retryResponse.ok) {
|
||||||
|
throw new Error(`Server returned ${retryResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
const retryData = await retryResponse.json();
|
const retryData = await retryResponse.json();
|
||||||
|
|
||||||
if (retryData.prg_file) {
|
if (retryData.prg_file) {
|
||||||
|
// If the old PRG file exists, we should delete it
|
||||||
const oldPrgFile = entry.prgFile;
|
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');
|
const logEl = entry.element.querySelector('.log');
|
||||||
logEl.id = `log-${entry.uniqueId}-${retryData.prg_file}`;
|
logEl.id = `log-${entry.uniqueId}-${retryData.prg_file}`;
|
||||||
entry.prgFile = retryData.prg_file;
|
entry.prgFile = retryData.prg_file;
|
||||||
@@ -627,60 +723,27 @@ class DownloadQueue {
|
|||||||
entry.lastUpdated = Date.now();
|
entry.lastUpdated = Date.now();
|
||||||
entry.retryCount = (entry.retryCount || 0) + 1;
|
entry.retryCount = (entry.retryCount || 0) + 1;
|
||||||
logEl.textContent = 'Retry initiated...';
|
logEl.textContent = 'Retry initiated...';
|
||||||
|
|
||||||
|
// Start monitoring the new PRG file
|
||||||
this.startEntryMonitoring(queueId);
|
this.startEntryMonitoring(queueId);
|
||||||
} else {
|
} else {
|
||||||
logElement.textContent = 'Retry failed: invalid response from server';
|
logElement.textContent = 'Retry failed: invalid response from server';
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Retry error:', error);
|
||||||
logElement.textContent = 'Retry failed: ' + error.message;
|
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) {
|
async startTrackDownload(url, item) {
|
||||||
await this.loadConfig();
|
await this.loadConfig();
|
||||||
const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer';
|
const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer';
|
||||||
const params = this._buildCommonParams(url, service, this.currentConfig);
|
|
||||||
params.append('name', item.name || '');
|
// Use minimal parameters in the URL, letting server use config for defaults
|
||||||
params.append('artist', item.artist || '');
|
const apiUrl = `/api/track/download?service=${service}&url=${encodeURIComponent(url)}` +
|
||||||
const apiUrl = `/api/track/download?${params.toString()}`;
|
(item.name ? `&name=${encodeURIComponent(item.name)}` : '') +
|
||||||
|
(item.artist ? `&artist=${encodeURIComponent(item.artist)}` : '');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(apiUrl);
|
const response = await fetch(apiUrl);
|
||||||
if (!response.ok) throw new Error('Network error');
|
if (!response.ok) throw new Error('Network error');
|
||||||
@@ -695,12 +758,15 @@ class DownloadQueue {
|
|||||||
async startPlaylistDownload(url, item) {
|
async startPlaylistDownload(url, item) {
|
||||||
await this.loadConfig();
|
await this.loadConfig();
|
||||||
const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer';
|
const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer';
|
||||||
const params = this._buildCommonParams(url, service, this.currentConfig);
|
|
||||||
params.append('name', item.name || '');
|
// Use minimal parameters in the URL, letting server use config for defaults
|
||||||
params.append('artist', item.artist || '');
|
const apiUrl = `/api/playlist/download?service=${service}&url=${encodeURIComponent(url)}` +
|
||||||
const apiUrl = `/api/playlist/download?${params.toString()}`;
|
(item.name ? `&name=${encodeURIComponent(item.name)}` : '') +
|
||||||
|
(item.artist ? `&artist=${encodeURIComponent(item.artist)}` : '');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(apiUrl);
|
const response = await fetch(apiUrl);
|
||||||
|
if (!response.ok) throw new Error('Network error');
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
this.addDownload(item, 'playlist', data.prg_file, apiUrl);
|
this.addDownload(item, 'playlist', data.prg_file, apiUrl);
|
||||||
} catch (error) {
|
} 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();
|
await this.loadConfig();
|
||||||
const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer';
|
const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer';
|
||||||
const params = this._buildCommonParams(url, service, this.currentConfig);
|
|
||||||
params.append('album_type', albumType);
|
// Use minimal parameters in the URL, letting server use config for defaults
|
||||||
params.append('name', item.name || '');
|
const apiUrl = `/api/artist/download?service=${service}&url=${encodeURIComponent(url)}` +
|
||||||
params.append('artist', item.artist || '');
|
`&album_type=${albumType}` +
|
||||||
const apiUrl = `/api/artist/download?${params.toString()}`;
|
(item.name ? `&name=${encodeURIComponent(item.name)}` : '') +
|
||||||
|
(item.artist ? `&artist=${encodeURIComponent(item.artist)}` : '');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(apiUrl);
|
const response = await fetch(apiUrl);
|
||||||
if (!response.ok) throw new Error('Network error');
|
if (!response.ok) throw new Error('Network error');
|
||||||
@@ -737,12 +805,15 @@ class DownloadQueue {
|
|||||||
async startAlbumDownload(url, item) {
|
async startAlbumDownload(url, item) {
|
||||||
await this.loadConfig();
|
await this.loadConfig();
|
||||||
const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer';
|
const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer';
|
||||||
const params = this._buildCommonParams(url, service, this.currentConfig);
|
|
||||||
params.append('name', item.name || '');
|
// Use minimal parameters in the URL, letting server use config for defaults
|
||||||
params.append('artist', item.artist || '');
|
const apiUrl = `/api/album/download?service=${service}&url=${encodeURIComponent(url)}` +
|
||||||
const apiUrl = `/api/album/download?${params.toString()}`;
|
(item.name ? `&name=${encodeURIComponent(item.name)}` : '') +
|
||||||
|
(item.artist ? `&artist=${encodeURIComponent(item.artist)}` : '');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(apiUrl);
|
const response = await fetch(apiUrl);
|
||||||
|
if (!response.ok) throw new Error('Network error');
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
this.addDownload(item, 'album', data.prg_file, apiUrl);
|
this.addDownload(item, 'album', data.prg_file, apiUrl);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -772,16 +843,81 @@ class DownloadQueue {
|
|||||||
const prgResponse = await fetch(`/api/prgs/${prgFile}`);
|
const prgResponse = await fetch(`/api/prgs/${prgFile}`);
|
||||||
if (!prgResponse.ok) continue;
|
if (!prgResponse.ok) continue;
|
||||||
const prgData = await prgResponse.json();
|
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 = {
|
const dummyItem = {
|
||||||
name: prgData.original_request && prgData.original_request.name ? prgData.original_request.name : prgFile,
|
name: prgData.display_title || originalRequest.display_title || originalRequest.name || prgFile,
|
||||||
artist: prgData.original_request && prgData.original_request.artist ? prgData.original_request.artist : '',
|
artist: prgData.display_artist || originalRequest.display_artist || originalRequest.artist || '',
|
||||||
type: prgData.original_request && prgData.original_request.type ? prgData.original_request.type : 'unknown'
|
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) {
|
} catch (error) {
|
||||||
console.error("Error fetching details for", prgFile, error);
|
console.error("Error fetching details for", prgFile, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// After adding all entries, update the queue
|
||||||
|
this.updateQueueOrder();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error loading existing PRG files:", error);
|
console.error("Error loading existing PRG files:", error);
|
||||||
}
|
}
|
||||||
@@ -792,6 +928,19 @@ class DownloadQueue {
|
|||||||
const response = await fetch('/api/config');
|
const response = await fetch('/api/config');
|
||||||
if (!response.ok) throw new Error('Failed to fetch config');
|
if (!response.ok) throw new Error('Failed to fetch config');
|
||||||
this.currentConfig = await response.json();
|
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) {
|
} catch (error) {
|
||||||
console.error('Error loading config:', error);
|
console.error('Error loading config:', error);
|
||||||
this.currentConfig = {};
|
this.currentConfig = {};
|
||||||
|
|||||||
@@ -11,18 +11,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch the config to get active Spotify account first
|
// Fetch track info directly
|
||||||
fetch('/api/config')
|
fetch(`/api/track/info?id=${encodeURIComponent(trackId)}`)
|
||||||
.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}`);
|
|
||||||
})
|
|
||||||
.then(response => {
|
.then(response => {
|
||||||
if (!response.ok) throw new Error('Network response was not ok');
|
if (!response.ok) throw new Error('Network response was not ok');
|
||||||
return response.json();
|
return response.json();
|
||||||
@@ -52,25 +42,25 @@ function renderTrack(track) {
|
|||||||
|
|
||||||
// Update track information fields.
|
// Update track information fields.
|
||||||
document.getElementById('track-name').innerHTML =
|
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 =
|
document.getElementById('track-artist').innerHTML =
|
||||||
`By ${track.artists.map(a =>
|
`By ${track.artists?.map(a =>
|
||||||
`<a href="/artist/${a.id}" title="View artist details">${a.name}</a>`
|
`<a href="/artist/${a?.id || ''}" title="View artist details">${a?.name || 'Unknown Artist'}</a>`
|
||||||
).join(', ')}`;
|
).join(', ') || 'Unknown Artist'}`;
|
||||||
|
|
||||||
document.getElementById('track-album').innerHTML =
|
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 =
|
document.getElementById('track-duration').textContent =
|
||||||
`Duration: ${msToTime(track.duration_ms)}`;
|
`Duration: ${msToTime(track.duration_ms || 0)}`;
|
||||||
|
|
||||||
document.getElementById('track-explicit').textContent =
|
document.getElementById('track-explicit').textContent =
|
||||||
track.explicit ? 'Explicit' : 'Clean';
|
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
|
? track.album.images[0].url
|
||||||
: 'placeholder.jpg';
|
: '/static/images/placeholder.jpg';
|
||||||
document.getElementById('track-album-image').src = imageUrl;
|
document.getElementById('track-album-image').src = imageUrl;
|
||||||
|
|
||||||
// --- Insert Home Button (if not already present) ---
|
// --- Insert Home Button (if not already present) ---
|
||||||
@@ -81,7 +71,10 @@ function renderTrack(track) {
|
|||||||
homeButton.className = 'home-btn';
|
homeButton.className = 'home-btn';
|
||||||
homeButton.innerHTML = `<img src="/static/images/home.svg" alt="Home" />`;
|
homeButton.innerHTML = `<img src="/static/images/home.svg" alt="Home" />`;
|
||||||
// Prepend the home button into the header.
|
// 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', () => {
|
homeButton.addEventListener('click', () => {
|
||||||
window.location.href = window.location.origin;
|
window.location.href = window.location.origin;
|
||||||
@@ -93,28 +86,41 @@ function renderTrack(track) {
|
|||||||
// Remove the parent container (#actions) if needed.
|
// Remove the parent container (#actions) if needed.
|
||||||
const actionsContainer = document.getElementById('actions');
|
const actionsContainer = document.getElementById('actions');
|
||||||
if (actionsContainer) {
|
if (actionsContainer) {
|
||||||
actionsContainer.parentNode.removeChild(actionsContainer);
|
actionsContainer.parentNode?.removeChild(actionsContainer);
|
||||||
}
|
}
|
||||||
// Set the inner HTML to use the download.svg icon.
|
// Set the inner HTML to use the download.svg icon.
|
||||||
downloadBtn.innerHTML = `<img src="/static/images/download.svg" alt="Download">`;
|
downloadBtn.innerHTML = `<img src="/static/images/download.svg" alt="Download">`;
|
||||||
// Append the download button to the track header so it appears at the right.
|
// 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', () => {
|
if (downloadBtn) {
|
||||||
downloadBtn.disabled = true;
|
downloadBtn.addEventListener('click', () => {
|
||||||
downloadBtn.innerHTML = `<span>Queueing...</span>`;
|
downloadBtn.disabled = true;
|
||||||
|
downloadBtn.innerHTML = `<span>Queueing...</span>`;
|
||||||
downloadQueue.startTrackDownload(track.external_urls.spotify, { name: track.name })
|
|
||||||
.then(() => {
|
const trackUrl = track.external_urls?.spotify || '';
|
||||||
downloadBtn.innerHTML = `<span>Queued!</span>`;
|
if (!trackUrl) {
|
||||||
})
|
showError('Missing track URL');
|
||||||
.catch(err => {
|
|
||||||
showError('Failed to queue track download: ' + err.message);
|
|
||||||
downloadBtn.disabled = false;
|
downloadBtn.disabled = false;
|
||||||
downloadBtn.innerHTML = `<img src="/static/images/download.svg" alt="Download">`;
|
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.
|
// Reveal the header now that track info is loaded.
|
||||||
document.getElementById('track-header').classList.remove('hidden');
|
document.getElementById('track-header').classList.remove('hidden');
|
||||||
@@ -124,6 +130,8 @@ function renderTrack(track) {
|
|||||||
* Converts milliseconds to minutes:seconds.
|
* Converts milliseconds to minutes:seconds.
|
||||||
*/
|
*/
|
||||||
function msToTime(duration) {
|
function msToTime(duration) {
|
||||||
|
if (!duration || isNaN(duration)) return '0:00';
|
||||||
|
|
||||||
const minutes = Math.floor(duration / 60000);
|
const minutes = Math.floor(duration / 60000);
|
||||||
const seconds = Math.floor((duration % 60000) / 1000);
|
const seconds = Math.floor((duration % 60000) / 1000);
|
||||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||||
@@ -135,49 +143,30 @@ function msToTime(duration) {
|
|||||||
function showError(message) {
|
function showError(message) {
|
||||||
const errorEl = document.getElementById('error');
|
const errorEl = document.getElementById('error');
|
||||||
if (errorEl) {
|
if (errorEl) {
|
||||||
errorEl.textContent = message;
|
errorEl.textContent = message || 'An error occurred';
|
||||||
errorEl.classList.remove('hidden');
|
errorEl.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts the download process by building the API URL,
|
* Starts the download process by building a minimal API URL with only the necessary parameters,
|
||||||
* fetching download details, and then adding the download to the queue.
|
* since the server will use config defaults for others.
|
||||||
*/
|
*/
|
||||||
async function startDownload(url, type, item) {
|
async function startDownload(url, type, item) {
|
||||||
const config = JSON.parse(localStorage.getItem('activeConfig')) || {};
|
if (!url || !type) {
|
||||||
const {
|
showError('Missing URL or type for download');
|
||||||
fallback = false,
|
return;
|
||||||
spotify = '',
|
}
|
||||||
deezer = '',
|
|
||||||
spotifyQuality = 'NORMAL',
|
|
||||||
deezerQuality = 'MP3_128',
|
|
||||||
realTime = false,
|
|
||||||
customTrackFormat = '',
|
|
||||||
customDirFormat = ''
|
|
||||||
} = config;
|
|
||||||
|
|
||||||
const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer';
|
const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer';
|
||||||
let apiUrl = `/api/${type}/download?service=${service}&url=${encodeURIComponent(url)}`;
|
let apiUrl = `/api/${type}/download?service=${service}&url=${encodeURIComponent(url)}`;
|
||||||
|
|
||||||
if (fallback && service === 'spotify') {
|
// Add name and artist if available for better progress display
|
||||||
apiUrl += `&main=${deezer}&fallback=${spotify}`;
|
if (item.name) {
|
||||||
apiUrl += `&quality=${deezerQuality}&fall_quality=${spotifyQuality}`;
|
apiUrl += `&name=${encodeURIComponent(item.name)}`;
|
||||||
} else {
|
|
||||||
const mainAccount = service === 'spotify' ? spotify : deezer;
|
|
||||||
apiUrl += `&main=${mainAccount}&quality=${service === 'spotify' ? spotifyQuality : deezerQuality}`;
|
|
||||||
}
|
}
|
||||||
|
if (item.artist) {
|
||||||
if (realTime) {
|
apiUrl += `&artist=${encodeURIComponent(item.artist)}`;
|
||||||
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)}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -185,7 +174,7 @@ async function startDownload(url, type, item) {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
downloadQueue.addDownload(item, type, data.prg_file);
|
downloadQueue.addDownload(item, type, data.prg_file);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError('Download failed: ' + error.message);
|
showError('Download failed: ' + (error?.message || 'Unknown error'));
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<div id="album-header" class="hidden">
|
<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">
|
<div id="album-info">
|
||||||
<h1 id="album-name"></h1>
|
<h1 id="album-name"></h1>
|
||||||
<p id="album-artist"></p>
|
<p id="album-artist"></p>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<div id="app">
|
<div id="app">
|
||||||
<!-- Artist header container -->
|
<!-- Artist header container -->
|
||||||
<div id="artist-header" class="hidden">
|
<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">
|
<div id="artist-info">
|
||||||
<h1 id="artist-name"></h1>
|
<h1 id="artist-name"></h1>
|
||||||
<!-- For example, show the total number of albums -->
|
<!-- For example, show the total number of albums -->
|
||||||
|
|||||||
@@ -68,6 +68,22 @@
|
|||||||
<label for="maxConcurrentDownloads">Max Concurrent Downloads:</label>
|
<label for="maxConcurrentDownloads">Max Concurrent Downloads:</label>
|
||||||
<input type="number" id="maxConcurrentDownloads" min="1" value="3">
|
<input type="number" id="maxConcurrentDownloads" min="1" value="3">
|
||||||
</div>
|
</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 -->
|
<!-- New Formatting Options -->
|
||||||
<div class="config-item">
|
<div class="config-item">
|
||||||
<label>Custom Directory Format:</label>
|
<label>Custom Directory Format:</label>
|
||||||
@@ -85,6 +101,17 @@
|
|||||||
placeholder="e.g. %tracknum% - %music%"
|
placeholder="e.g. %tracknum% - %music%"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="service-tabs">
|
<div class="service-tabs">
|
||||||
@@ -105,6 +132,7 @@
|
|||||||
<div id="searchFields" style="display: none;"></div>
|
<div id="searchFields" style="display: none;"></div>
|
||||||
<button type="submit" id="submitCredentialBtn" class="save-btn">Save Account</button>
|
<button type="submit" id="submitCredentialBtn" class="save-btn">Save Account</button>
|
||||||
</form>
|
</form>
|
||||||
|
<div id="configSuccess" class="success"></div>
|
||||||
<div id="configError" class="error"></div>
|
<div id="configError" class="error"></div>
|
||||||
</div>
|
</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/main.css') }}" />
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/icons.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') }}">
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="search-header">
|
<div class="search-header">
|
||||||
<!-- Settings icon linking to the config page -->
|
<!-- Settings icon linking to the config page -->
|
||||||
<a href="/config" class="settings-icon">
|
<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>
|
</a>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -38,7 +44,7 @@
|
|||||||
aria-controls="downloadQueue"
|
aria-controls="downloadQueue"
|
||||||
aria-expanded="false"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="resultsContainer" class="results-grid"></div>
|
<div id="resultsContainer" class="results-grid"></div>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<div id="playlist-header" class="hidden">
|
<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">
|
<div id="playlist-info">
|
||||||
<h1 id="playlist-name"></h1>
|
<h1 id="playlist-name"></h1>
|
||||||
<p id="playlist-owner"></p>
|
<p id="playlist-owner"></p>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<div id="app">
|
<div id="app">
|
||||||
<div id="track-header" class="hidden">
|
<div id="track-header" class="hidden">
|
||||||
<!-- Back Button will be inserted here via JavaScript -->
|
<!-- 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">
|
<div id="track-info">
|
||||||
<h1 id="track-name"></h1>
|
<h1 id="track-name"></h1>
|
||||||
<p id="track-artist"></p>
|
<p id="track-artist"></p>
|
||||||
|
|||||||
Reference in New Issue
Block a user