added artist functionality
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -24,3 +24,4 @@ routes/utils/__pycache__/search.cpython-312.pyc
|
|||||||
routes/utils/__pycache__/__init__.cpython-312.pyc
|
routes/utils/__pycache__/__init__.cpython-312.pyc
|
||||||
routes/utils/__pycache__/credentials.cpython-312.pyc
|
routes/utils/__pycache__/credentials.cpython-312.pyc
|
||||||
routes/utils/__pycache__/search.cpython-312.pyc
|
routes/utils/__pycache__/search.cpython-312.pyc
|
||||||
|
search_test.py
|
||||||
|
|||||||
2
app.py
2
app.py
@@ -5,6 +5,7 @@ from routes.credentials import credentials_bp
|
|||||||
from routes.album import album_bp
|
from routes.album import album_bp
|
||||||
from routes.track import track_bp
|
from routes.track import track_bp
|
||||||
from routes.playlist import playlist_bp
|
from routes.playlist import playlist_bp
|
||||||
|
from routes.artist import artist_bp
|
||||||
from routes.prgs import prgs_bp
|
from routes.prgs import prgs_bp
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
@@ -39,6 +40,7 @@ def create_app():
|
|||||||
app.register_blueprint(album_bp, url_prefix='/api/album')
|
app.register_blueprint(album_bp, url_prefix='/api/album')
|
||||||
app.register_blueprint(track_bp, url_prefix='/api/track')
|
app.register_blueprint(track_bp, url_prefix='/api/track')
|
||||||
app.register_blueprint(playlist_bp, url_prefix='/api/playlist')
|
app.register_blueprint(playlist_bp, url_prefix='/api/playlist')
|
||||||
|
app.register_blueprint(artist_bp, url_prefix='/api/artist')
|
||||||
app.register_blueprint(prgs_bp, url_prefix='/api/prgs')
|
app.register_blueprint(prgs_bp, url_prefix='/api/prgs')
|
||||||
|
|
||||||
# Serve frontend
|
# Serve frontend
|
||||||
|
|||||||
229
routes/artist.py
Normal file
229
routes/artist.py
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Artist endpoint blueprint.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, Response, request
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
import sys
|
||||||
|
import traceback
|
||||||
|
from multiprocessing import Process
|
||||||
|
|
||||||
|
artist_bp = Blueprint('artist', __name__)
|
||||||
|
|
||||||
|
# Global dictionary to keep track of running download processes.
|
||||||
|
download_processes = {}
|
||||||
|
|
||||||
|
def generate_random_filename(length=6):
|
||||||
|
chars = string.ascii_lowercase + string.digits
|
||||||
|
return ''.join(random.choice(chars) for _ in range(length)) + '.prg'
|
||||||
|
|
||||||
|
class FlushingFileWrapper:
|
||||||
|
def __init__(self, file):
|
||||||
|
self.file = file
|
||||||
|
|
||||||
|
def write(self, text):
|
||||||
|
# Only write lines that start with '{'
|
||||||
|
for line in text.split('\n'):
|
||||||
|
if line.startswith('{'):
|
||||||
|
self.file.write(line + '\n')
|
||||||
|
self.file.flush()
|
||||||
|
|
||||||
|
def flush(self):
|
||||||
|
self.file.flush()
|
||||||
|
|
||||||
|
def download_artist_task(service, artist_url, main, fallback, quality, fall_quality, real_time, album_type, prg_path):
|
||||||
|
"""
|
||||||
|
This function wraps the call to download_artist_albums and writes JSON status to the prg file.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from routes.utils.artist import download_artist_albums
|
||||||
|
with open(prg_path, 'w') as f:
|
||||||
|
flushing_file = FlushingFileWrapper(f)
|
||||||
|
original_stdout = sys.stdout
|
||||||
|
sys.stdout = flushing_file # Redirect stdout to our flushing file wrapper
|
||||||
|
|
||||||
|
try:
|
||||||
|
download_artist_albums(
|
||||||
|
service=service,
|
||||||
|
artist_url=artist_url,
|
||||||
|
main=main,
|
||||||
|
fallback=fallback,
|
||||||
|
quality=quality,
|
||||||
|
fall_quality=fall_quality,
|
||||||
|
real_time=real_time,
|
||||||
|
album_type=album_type,
|
||||||
|
)
|
||||||
|
flushing_file.write(json.dumps({"status": "complete"}) + "\n")
|
||||||
|
except Exception as e:
|
||||||
|
error_data = json.dumps({
|
||||||
|
"status": "error",
|
||||||
|
"message": str(e),
|
||||||
|
"traceback": traceback.format_exc()
|
||||||
|
})
|
||||||
|
flushing_file.write(error_data + "\n")
|
||||||
|
finally:
|
||||||
|
sys.stdout = original_stdout # Restore stdout
|
||||||
|
except Exception as e:
|
||||||
|
with open(prg_path, 'w') as f:
|
||||||
|
error_data = json.dumps({
|
||||||
|
"status": "error",
|
||||||
|
"message": str(e),
|
||||||
|
"traceback": traceback.format_exc()
|
||||||
|
})
|
||||||
|
f.write(error_data + "\n")
|
||||||
|
|
||||||
|
@artist_bp.route('/download', methods=['GET'])
|
||||||
|
def handle_artist_download():
|
||||||
|
"""
|
||||||
|
Starts the artist album download process.
|
||||||
|
Expected query parameters:
|
||||||
|
- artist_url: string (e.g., a Spotify artist URL)
|
||||||
|
- service: string (e.g., "deezer" or "spotify")
|
||||||
|
- main: string (e.g., "MX")
|
||||||
|
- fallback: string (optional, e.g., "JP")
|
||||||
|
- 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); one or more of "album", "single", "appears_on", "compilation" (if multiple, comma-separated)
|
||||||
|
"""
|
||||||
|
service = request.args.get('service')
|
||||||
|
artist_url = request.args.get('artist_url')
|
||||||
|
main = request.args.get('main')
|
||||||
|
fallback = request.args.get('fallback')
|
||||||
|
quality = request.args.get('quality')
|
||||||
|
fall_quality = request.args.get('fall_quality')
|
||||||
|
album_type = request.args.get('album_type')
|
||||||
|
real_time_arg = request.args.get('real_time', 'false')
|
||||||
|
real_time = real_time_arg.lower() in ['true', '1', 'yes']
|
||||||
|
|
||||||
|
# Sanitize main and fallback to prevent directory traversal
|
||||||
|
if main:
|
||||||
|
main = os.path.basename(main)
|
||||||
|
if fallback:
|
||||||
|
fallback = os.path.basename(fallback)
|
||||||
|
|
||||||
|
# Check for required parameters.
|
||||||
|
if not all([service, artist_url, main, quality, album_type]):
|
||||||
|
return Response(
|
||||||
|
json.dumps({"error": "Missing parameters"}),
|
||||||
|
status=400,
|
||||||
|
mimetype='application/json'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate credentials based on the selected service.
|
||||||
|
try:
|
||||||
|
if service == 'spotify':
|
||||||
|
if fallback:
|
||||||
|
# When using Spotify as the main service with a fallback, assume main credentials for Deezer and fallback for Spotify.
|
||||||
|
deezer_creds_path = os.path.abspath(os.path.join('./creds/deezer', main, 'credentials.json'))
|
||||||
|
if not os.path.isfile(deezer_creds_path):
|
||||||
|
return Response(
|
||||||
|
json.dumps({"error": "Invalid Deezer credentials directory"}),
|
||||||
|
status=400,
|
||||||
|
mimetype='application/json'
|
||||||
|
)
|
||||||
|
spotify_fallback_path = os.path.abspath(os.path.join('./creds/spotify', fallback, 'credentials.json'))
|
||||||
|
if not os.path.isfile(spotify_fallback_path):
|
||||||
|
return Response(
|
||||||
|
json.dumps({"error": "Invalid Spotify fallback credentials directory"}),
|
||||||
|
status=400,
|
||||||
|
mimetype='application/json'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Validate Spotify main credentials.
|
||||||
|
spotify_creds_path = os.path.abspath(os.path.join('./creds/spotify', main, 'credentials.json'))
|
||||||
|
if not os.path.isfile(spotify_creds_path):
|
||||||
|
return Response(
|
||||||
|
json.dumps({"error": "Invalid Spotify credentials directory"}),
|
||||||
|
status=400,
|
||||||
|
mimetype='application/json'
|
||||||
|
)
|
||||||
|
elif service == 'deezer':
|
||||||
|
# Validate Deezer main credentials.
|
||||||
|
deezer_creds_path = os.path.abspath(os.path.join('./creds/deezer', main, 'credentials.json'))
|
||||||
|
if not os.path.isfile(deezer_creds_path):
|
||||||
|
return Response(
|
||||||
|
json.dumps({"error": "Invalid Deezer credentials directory"}),
|
||||||
|
status=400,
|
||||||
|
mimetype='application/json'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return Response(
|
||||||
|
json.dumps({"error": "Unsupported service"}),
|
||||||
|
status=400,
|
||||||
|
mimetype='application/json'
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return Response(
|
||||||
|
json.dumps({"error": f"Credential validation failed: {str(e)}"}),
|
||||||
|
status=500,
|
||||||
|
mimetype='application/json'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a random filename for the progress file.
|
||||||
|
filename = generate_random_filename()
|
||||||
|
prg_dir = './prgs'
|
||||||
|
os.makedirs(prg_dir, exist_ok=True)
|
||||||
|
prg_path = os.path.join(prg_dir, filename)
|
||||||
|
|
||||||
|
# Create and start the download process.
|
||||||
|
process = Process(
|
||||||
|
target=download_artist_task,
|
||||||
|
args=(service, artist_url, main, fallback, quality, fall_quality, real_time, album_type, prg_path)
|
||||||
|
)
|
||||||
|
process.start()
|
||||||
|
download_processes[filename] = process
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
json.dumps({"prg_file": filename}),
|
||||||
|
status=202,
|
||||||
|
mimetype='application/json'
|
||||||
|
)
|
||||||
|
|
||||||
|
@artist_bp.route('/download/cancel', methods=['GET'])
|
||||||
|
def cancel_artist_download():
|
||||||
|
"""
|
||||||
|
Cancel a running artist download process by its prg file name.
|
||||||
|
"""
|
||||||
|
prg_file = request.args.get('prg_file')
|
||||||
|
if not prg_file:
|
||||||
|
return Response(
|
||||||
|
json.dumps({"error": "Missing process id (prg_file) parameter"}),
|
||||||
|
status=400,
|
||||||
|
mimetype='application/json'
|
||||||
|
)
|
||||||
|
|
||||||
|
process = download_processes.get(prg_file)
|
||||||
|
prg_dir = './prgs'
|
||||||
|
prg_path = os.path.join(prg_dir, prg_file)
|
||||||
|
|
||||||
|
if process and process.is_alive():
|
||||||
|
process.terminate()
|
||||||
|
process.join() # Wait for termination
|
||||||
|
del download_processes[prg_file]
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(prg_path, 'a') as f:
|
||||||
|
f.write(json.dumps({"status": "cancel"}) + "\n")
|
||||||
|
except Exception as e:
|
||||||
|
return Response(
|
||||||
|
json.dumps({"error": f"Failed to write cancel status to file: {str(e)}"}),
|
||||||
|
status=500,
|
||||||
|
mimetype='application/json'
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
json.dumps({"status": "cancel"}),
|
||||||
|
status=200,
|
||||||
|
mimetype='application/json'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return Response(
|
||||||
|
json.dumps({"error": "Process not found or already terminated"}),
|
||||||
|
status=404,
|
||||||
|
mimetype='application/json'
|
||||||
|
)
|
||||||
112
routes/utils/artist.py
Normal file
112
routes/utils/artist.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import json
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
from deezspot.easy_spoty import Spo
|
||||||
|
from deezspot.libutils.utils import get_ids, link_is_valid
|
||||||
|
from routes.utils.album import download_album # Assumes album.py is in routes/utils/
|
||||||
|
|
||||||
|
def log_json(message_dict):
|
||||||
|
"""Helper function to output a JSON-formatted log message."""
|
||||||
|
print(json.dumps(message_dict))
|
||||||
|
|
||||||
|
|
||||||
|
def get_artist_discography(url, album_type='album,single,compilation,appears_on'):
|
||||||
|
if not url:
|
||||||
|
message = "No artist URL provided."
|
||||||
|
log_json({"status": "error", "message": message})
|
||||||
|
raise ValueError(message)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Validate the URL (this function should raise an error if invalid).
|
||||||
|
link_is_valid(link=url)
|
||||||
|
except Exception as validation_error:
|
||||||
|
message = f"Link validation failed: {validation_error}"
|
||||||
|
log_json({"status": "error", "message": message})
|
||||||
|
raise ValueError(message)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Extract the artist ID from the URL.
|
||||||
|
artist_id = get_ids(url)
|
||||||
|
except Exception as id_error:
|
||||||
|
message = f"Failed to extract artist ID from URL: {id_error}"
|
||||||
|
log_json({"status": "error", "message": message})
|
||||||
|
raise ValueError(message)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Retrieve the discography using the artist ID.
|
||||||
|
discography = Spo.get_artist(artist_id, album_type=album_type)
|
||||||
|
return discography
|
||||||
|
except Exception as fetch_error:
|
||||||
|
message = f"An error occurred while fetching the discography: {fetch_error}"
|
||||||
|
log_json({"status": "error", "message": message})
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def download_artist_albums(service, artist_url, main, fallback=None, quality=None,
|
||||||
|
fall_quality=None, real_time=False, album_type='album,single,compilation,appears_on'):
|
||||||
|
try:
|
||||||
|
discography = get_artist_discography(artist_url, album_type=album_type)
|
||||||
|
except Exception as e:
|
||||||
|
log_json({"status": "error", "message": f"Error retrieving artist discography: {e}"})
|
||||||
|
raise
|
||||||
|
|
||||||
|
albums = discography.get('items', [])
|
||||||
|
# Attempt to extract the artist name from the discography; fallback to artist_url if not available.
|
||||||
|
artist_name = discography.get("name", artist_url)
|
||||||
|
|
||||||
|
if not albums:
|
||||||
|
log_json({
|
||||||
|
"status": "done",
|
||||||
|
"type": "artist",
|
||||||
|
"artist": artist_name,
|
||||||
|
"album_type": album_type,
|
||||||
|
"message": "No albums found for the artist."
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
|
log_json({"status": "initializing", "type": "artist", "total_albums": len(albums)})
|
||||||
|
|
||||||
|
for album in albums:
|
||||||
|
try:
|
||||||
|
album_url = album.get('external_urls', {}).get('spotify')
|
||||||
|
album_name = album.get('name', 'Unknown Album')
|
||||||
|
# Extract artist names if available.
|
||||||
|
artists = []
|
||||||
|
if "artists" in album:
|
||||||
|
artists = [artist.get("name", "Unknown") for artist in album["artists"]]
|
||||||
|
if not album_url:
|
||||||
|
log_json({
|
||||||
|
"status": "warning",
|
||||||
|
"type": "album",
|
||||||
|
"album": album_name,
|
||||||
|
"artist": artists,
|
||||||
|
"message": "No Spotify URL found; skipping."
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
download_album(
|
||||||
|
service=service,
|
||||||
|
url=album_url,
|
||||||
|
main=main,
|
||||||
|
fallback=fallback,
|
||||||
|
quality=quality,
|
||||||
|
fall_quality=fall_quality,
|
||||||
|
real_time=real_time
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as album_error:
|
||||||
|
log_json({
|
||||||
|
"status": "error",
|
||||||
|
"type": "album",
|
||||||
|
"album": album.get('name', 'Unknown'),
|
||||||
|
"error": str(album_error)
|
||||||
|
})
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
# When everything has been processed, print the final status.
|
||||||
|
log_json({
|
||||||
|
"status": "done",
|
||||||
|
"type": "artist",
|
||||||
|
"artist": artist_name,
|
||||||
|
"album_type": album_type
|
||||||
|
})
|
||||||
@@ -995,3 +995,174 @@ html {
|
|||||||
.cancel-btn:active img {
|
.cancel-btn:active img {
|
||||||
transform: scale(0.9);
|
transform: scale(0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- ICON SIZING FOR ARTIST DOWNLOAD OPTION BUTTONS --- */
|
||||||
|
/* This ensures that any <img> inside an option-download button is small */
|
||||||
|
.option-download img {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
/* --- ARTIST DOWNLOAD OPTIONS SEPARATION --- */
|
||||||
|
.artist-download-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-download-buttons .option-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Optional: ensure that each option button takes up a reasonable amount of space */
|
||||||
|
.artist-download-buttons .option-download {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
max-width: 32%;
|
||||||
|
}
|
||||||
|
/* Artist Download Buttons */
|
||||||
|
.artist-download-buttons {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Download Button */
|
||||||
|
.main-download {
|
||||||
|
background: #1DB954;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-download:hover {
|
||||||
|
background: #1ed760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Options Container */
|
||||||
|
.download-options-container {
|
||||||
|
width: 100%;
|
||||||
|
background: #282828;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Options Toggle */
|
||||||
|
.options-toggle {
|
||||||
|
width: 100%;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #b3b3b3;
|
||||||
|
padding: 8px 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.options-toggle:hover {
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-chevron {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
fill: currentColor;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.options-toggle[aria-expanded="true"] .toggle-chevron {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Secondary Options */
|
||||||
|
.secondary-options {
|
||||||
|
display: none;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 12px 12px;
|
||||||
|
background: #181818;
|
||||||
|
border-radius: 0 0 8px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-options.expanded {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-btn {
|
||||||
|
flex: 1 1 calc(33.333% - 4px);
|
||||||
|
min-width: 100px;
|
||||||
|
background: #2a2a2a;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-btn:hover {
|
||||||
|
background: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.option-btn {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-options {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-download {
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.options-toggle {
|
||||||
|
padding: 10px 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* Add margin to the options container */
|
||||||
|
.download-options-container {
|
||||||
|
margin-top: 8px; /* Adds separation from main button */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add padding to top of expanded options */
|
||||||
|
.secondary-options.expanded {
|
||||||
|
padding: 12px 12px 12px 12px; /* Added top padding */
|
||||||
|
gap: 8px; /* Ensure gap between buttons */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add subtle separation between toggle and options */
|
||||||
|
.secondary-options {
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.05);
|
||||||
|
margin-top: 4px; /* Small separation from toggle */
|
||||||
|
padding-top: 8px !important;
|
||||||
|
}
|
||||||
202
static/js/app.js
202
static/js/app.js
@@ -183,11 +183,12 @@ function performSearch() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle direct Spotify URLs
|
// Handle direct Spotify URLs for tracks, albums, playlists, and artists
|
||||||
if (isSpotifyUrl(query)) {
|
if (isSpotifyUrl(query)) {
|
||||||
try {
|
try {
|
||||||
const type = getResourceTypeFromUrl(query);
|
const type = getResourceTypeFromUrl(query);
|
||||||
if (!['track', 'album', 'playlist'].includes(type)) {
|
const supportedTypes = ['track', 'album', 'playlist', 'artist'];
|
||||||
|
if (!supportedTypes.includes(type)) {
|
||||||
throw new Error('Unsupported URL type');
|
throw new Error('Unsupported URL type');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +197,9 @@ function performSearch() {
|
|||||||
external_urls: { spotify: query }
|
external_urls: { spotify: query }
|
||||||
};
|
};
|
||||||
|
|
||||||
startDownload(query, type, item);
|
// For artist URLs, download all album types by default
|
||||||
|
const albumType = type === 'artist' ? 'album,single,compilation' : undefined;
|
||||||
|
startDownload(query, type, item, albumType);
|
||||||
document.getElementById('searchInput').value = '';
|
document.getElementById('searchInput').value = '';
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@@ -206,7 +209,7 @@ function performSearch() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Existing search functionality
|
// Standard search
|
||||||
resultsContainer.innerHTML = '<div class="loading">Searching...</div>';
|
resultsContainer.innerHTML = '<div class="loading">Searching...</div>';
|
||||||
|
|
||||||
fetch(`/api/search?q=${encodeURIComponent(query)}&search_type=${searchType}&limit=50`)
|
fetch(`/api/search?q=${encodeURIComponent(query)}&search_type=${searchType}&limit=50`)
|
||||||
@@ -222,14 +225,18 @@ function performSearch() {
|
|||||||
|
|
||||||
resultsContainer.innerHTML = items.map(item => createResultCard(item, searchType)).join('');
|
resultsContainer.innerHTML = items.map(item => createResultCard(item, searchType)).join('');
|
||||||
|
|
||||||
|
// Attach event listeners for every download button in each card
|
||||||
const cards = resultsContainer.querySelectorAll('.result-card');
|
const cards = resultsContainer.querySelectorAll('.result-card');
|
||||||
cards.forEach((card, index) => {
|
cards.forEach((card, index) => {
|
||||||
card.querySelector('.download-btn').addEventListener('click', async (e) => {
|
card.querySelectorAll('.download-btn').forEach(btn => {
|
||||||
e.stopPropagation();
|
btn.addEventListener('click', async (e) => {
|
||||||
const url = e.target.dataset.url;
|
e.stopPropagation();
|
||||||
const type = e.target.dataset.type;
|
const url = e.currentTarget.dataset.url;
|
||||||
startDownload(url, type, items[index]);
|
const type = e.currentTarget.dataset.type;
|
||||||
card.remove();
|
const albumType = e.currentTarget.dataset.albumType; // for artist downloads
|
||||||
|
startDownload(url, type, items[index], albumType);
|
||||||
|
card.remove();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
@@ -249,7 +256,19 @@ function createResultCard(item, type) {
|
|||||||
<span>${item.album.name}</span>
|
<span>${item.album.name}</span>
|
||||||
<span class="duration">${msToMinutesSeconds(item.duration_ms)}</span>
|
<span class="duration">${msToMinutesSeconds(item.duration_ms)}</span>
|
||||||
`;
|
`;
|
||||||
break;
|
return `
|
||||||
|
<div class="result-card" data-id="${item.id}">
|
||||||
|
<img src="${imageUrl}" class="album-art" alt="${type} cover">
|
||||||
|
<div class="track-title">${title}</div>
|
||||||
|
<div class="track-artist">${subtitle}</div>
|
||||||
|
<div class="track-details">${details}</div>
|
||||||
|
<button class="download-btn"
|
||||||
|
data-url="${item.external_urls.spotify}"
|
||||||
|
data-type="${type}">
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
case 'playlist':
|
case 'playlist':
|
||||||
imageUrl = item.images[0]?.url || '';
|
imageUrl = item.images[0]?.url || '';
|
||||||
title = item.name;
|
title = item.name;
|
||||||
@@ -258,7 +277,19 @@ function createResultCard(item, type) {
|
|||||||
<span>${item.tracks.total} tracks</span>
|
<span>${item.tracks.total} tracks</span>
|
||||||
<span class="duration">${item.description || 'No description'}</span>
|
<span class="duration">${item.description || 'No description'}</span>
|
||||||
`;
|
`;
|
||||||
break;
|
return `
|
||||||
|
<div class="result-card" data-id="${item.id}">
|
||||||
|
<img src="${imageUrl}" class="album-art" alt="${type} cover">
|
||||||
|
<div class="track-title">${title}</div>
|
||||||
|
<div class="track-artist">${subtitle}</div>
|
||||||
|
<div class="track-details">${details}</div>
|
||||||
|
<button class="download-btn"
|
||||||
|
data-url="${item.external_urls.spotify}"
|
||||||
|
data-type="${type}">
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
case 'album':
|
case 'album':
|
||||||
imageUrl = item.images[0]?.url || '';
|
imageUrl = item.images[0]?.url || '';
|
||||||
title = item.name;
|
title = item.name;
|
||||||
@@ -267,25 +298,106 @@ function createResultCard(item, type) {
|
|||||||
<span>${item.release_date}</span>
|
<span>${item.release_date}</span>
|
||||||
<span class="duration">${item.total_tracks} tracks</span>
|
<span class="duration">${item.total_tracks} tracks</span>
|
||||||
`;
|
`;
|
||||||
break;
|
return `
|
||||||
}
|
<div class="result-card" data-id="${item.id}">
|
||||||
|
<img src="${imageUrl}" class="album-art" alt="${type} cover">
|
||||||
|
<div class="track-title">${title}</div>
|
||||||
|
<div class="track-artist">${subtitle}</div>
|
||||||
|
<div class="track-details">${details}</div>
|
||||||
|
<button class="download-btn"
|
||||||
|
data-url="${item.external_urls.spotify}"
|
||||||
|
data-type="${type}">
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
case 'artist':
|
||||||
|
imageUrl = item.images && item.images.length ? item.images[0].url : '';
|
||||||
|
title = item.name;
|
||||||
|
subtitle = item.genres && item.genres.length ? item.genres.join(', ') : 'Unknown genres';
|
||||||
|
details = `<span>Followers: ${item.followers?.total || 'N/A'}</span>`;
|
||||||
|
return `
|
||||||
|
<div class="result-card" data-id="${item.id}">
|
||||||
|
<img src="${imageUrl}" class="album-art" alt="${type} cover">
|
||||||
|
<div class="track-title">${title}</div>
|
||||||
|
<div class="track-artist">${subtitle}</div>
|
||||||
|
<div class="track-details">${details}</div>
|
||||||
|
<div class="artist-download-buttons">
|
||||||
|
<!-- Main Download Button -->
|
||||||
|
<button class="download-btn main-download"
|
||||||
|
data-url="${item.external_urls.spotify}"
|
||||||
|
data-type="${type}"
|
||||||
|
data-album-type="album,single,compilation">
|
||||||
|
<svg class="download-icon" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/>
|
||||||
|
</svg>
|
||||||
|
Download All Discography
|
||||||
|
</button>
|
||||||
|
|
||||||
return `
|
<!-- Collapsible Options -->
|
||||||
<div class="result-card" data-id="${item.id}">
|
<div class="download-options-container">
|
||||||
<img src="${imageUrl}" class="album-art" alt="${type} cover">
|
<button class="options-toggle" onclick="this.nextElementSibling.classList.toggle('expanded')">
|
||||||
<div class="track-title">${title}</div>
|
More Options
|
||||||
<div class="track-artist">${subtitle}</div>
|
<svg class="toggle-chevron" viewBox="0 0 24 24">
|
||||||
<div class="track-details">${details}</div>
|
<path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/>
|
||||||
<button class="download-btn"
|
</svg>
|
||||||
data-url="${item.external_urls.spotify}"
|
</button>
|
||||||
data-type="${type}">
|
|
||||||
Download
|
<div class="secondary-options">
|
||||||
</button>
|
<button class="download-btn option-btn"
|
||||||
</div>
|
data-url="${item.external_urls.spotify}"
|
||||||
`;
|
data-type="${type}"
|
||||||
|
data-album-type="album">
|
||||||
|
<img src="https://www.svgrepo.com/show/40029/vinyl-record.svg"
|
||||||
|
alt="Albums"
|
||||||
|
class="type-icon" />
|
||||||
|
Albums
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="download-btn option-btn"
|
||||||
|
data-url="${item.external_urls.spotify}"
|
||||||
|
data-type="${type}"
|
||||||
|
data-album-type="single">
|
||||||
|
<img src="https://www.svgrepo.com/show/147837/cassette.svg"
|
||||||
|
alt="Singles"
|
||||||
|
class="type-icon" />
|
||||||
|
Singles
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="download-btn option-btn"
|
||||||
|
data-url="${item.external_urls.spotify}"
|
||||||
|
data-type="${type}"
|
||||||
|
data-album-type="compilation">
|
||||||
|
<img src="https://brandeps.com/icon-download/C/Collection-icon-vector-01.svg"
|
||||||
|
alt="Compilations"
|
||||||
|
class="type-icon" />
|
||||||
|
Compilations
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
default:
|
||||||
|
title = item.name || 'Unknown';
|
||||||
|
subtitle = '';
|
||||||
|
details = '';
|
||||||
|
return `
|
||||||
|
<div class="result-card" data-id="${item.id}">
|
||||||
|
<div class="track-title">${title}</div>
|
||||||
|
<div class="track-artist">${subtitle}</div>
|
||||||
|
<div class="track-details">${details}</div>
|
||||||
|
<button class="download-btn"
|
||||||
|
data-url="${item.external_urls.spotify}"
|
||||||
|
data-type="${type}">
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startDownload(url, type, item) {
|
async function startDownload(url, type, item, albumType) {
|
||||||
const fallbackEnabled = document.getElementById('fallbackToggle').checked;
|
const fallbackEnabled = document.getElementById('fallbackToggle').checked;
|
||||||
const spotifyAccount = document.getElementById('spotifyAccountSelect').value;
|
const spotifyAccount = document.getElementById('spotifyAccountSelect').value;
|
||||||
const deezerAccount = document.getElementById('deezerAccountSelect').value;
|
const deezerAccount = document.getElementById('deezerAccountSelect').value;
|
||||||
@@ -301,7 +413,15 @@ async function startDownload(url, type, item) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let apiUrl = `/api/${type}/download?service=${service}&url=${encodeURIComponent(url)}`;
|
let apiUrl = '';
|
||||||
|
if (type === 'artist') {
|
||||||
|
// Build the API URL for artist downloads.
|
||||||
|
// Use albumType if provided; otherwise, default to "compilation" (or you could default to "album,single,compilation")
|
||||||
|
const albumParam = albumType || 'compilation';
|
||||||
|
apiUrl = `/api/artist/download?service=${service}&artist_url=${encodeURIComponent(url)}&album_type=${encodeURIComponent(albumParam)}`;
|
||||||
|
} else {
|
||||||
|
apiUrl = `/api/${type}/download?service=${service}&url=${encodeURIComponent(url)}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Get quality settings
|
// Get quality settings
|
||||||
const spotifyQuality = document.getElementById('spotifyQualitySelect').value;
|
const spotifyQuality = document.getElementById('spotifyQualitySelect').value;
|
||||||
@@ -334,7 +454,6 @@ async function startDownload(url, type, item) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function addToQueue(item, type, prgFile) {
|
function addToQueue(item, type, prgFile) {
|
||||||
const queueId = Date.now().toString() + Math.random().toString(36).substr(2, 9);
|
const queueId = Date.now().toString() + Math.random().toString(36).substr(2, 9);
|
||||||
const entry = {
|
const entry = {
|
||||||
@@ -493,7 +612,7 @@ function createQueueItem(item, type, prgFile, queueId) {
|
|||||||
const type = e.target.closest('button').dataset.type;
|
const type = e.target.closest('button').dataset.type;
|
||||||
const queueId = e.target.closest('button').dataset.queueid;
|
const queueId = e.target.closest('button').dataset.queueid;
|
||||||
// Determine the correct cancel endpoint based on the type.
|
// Determine the correct cancel endpoint based on the type.
|
||||||
// For example: `/api/album/download/cancel`, `/api/playlist/download/cancel`, `/api/track/download/cancel`
|
// For example: `/api/album/download/cancel`, `/api/playlist/download/cancel`, `/api/track/download/cancel`, or `/api/artist/download/cancel`
|
||||||
const cancelEndpoint = `/api/${type}/download/cancel?prg_file=${encodeURIComponent(prg)}`;
|
const cancelEndpoint = `/api/${type}/download/cancel?prg_file=${encodeURIComponent(prg)}`;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(cancelEndpoint);
|
const response = await fetch(cancelEndpoint);
|
||||||
@@ -702,13 +821,23 @@ function getStatusMessage(data) {
|
|||||||
return `Downloading ${data.song || 'track'} by ${data.artist || 'artist'}...`;
|
return `Downloading ${data.song || 'track'} by ${data.artist || 'artist'}...`;
|
||||||
case 'progress':
|
case 'progress':
|
||||||
if (data.type === 'album') {
|
if (data.type === 'album') {
|
||||||
return `Processing track ${data.current_track}/${data.total_tracks} (${data.percentage.toFixed(1)}%): ${data.song}`;
|
return `Processing track ${data.current_track}/${data.total_tracks} (${data.percentage.toFixed(1)}%): ${data.song} from ${data.album}`;
|
||||||
} else {
|
} else {
|
||||||
return `${data.percentage.toFixed(1)}% complete`;
|
return `${data.percentage.toFixed(1)}% complete`;
|
||||||
}
|
}
|
||||||
case 'done':
|
case 'done':
|
||||||
return `Finished: ${data.song} by ${data.artist}`;
|
if (data.type === 'track') {
|
||||||
|
return `Finished: ${data.song} by ${data.artist}`;
|
||||||
|
} else if (data.type === 'album'){
|
||||||
|
return `Finished: ${data.album} by ${data.artist}`;
|
||||||
|
}
|
||||||
|
else if (data.type === 'artist'){
|
||||||
|
return `Finished: Artist's ${data.album_type}s`;
|
||||||
|
}
|
||||||
case 'initializing':
|
case 'initializing':
|
||||||
|
if (data.type === 'artist') {
|
||||||
|
return `Initializing artist download, ${data.total_albums} albums to process...`;
|
||||||
|
}
|
||||||
return `Initializing ${data.type} download for ${data.album || data.artist}...`;
|
return `Initializing ${data.type} download for ${data.album || data.artist}...`;
|
||||||
case 'retrying':
|
case 'retrying':
|
||||||
return `Track ${data.song} by ${data.artist} failed, retrying (${data.retries}/${data.max_retries}) in ${data.seconds_left}s`;
|
return `Track ${data.song} by ${data.artist} failed, retrying (${data.retries}/${data.max_retries}) in ${data.seconds_left}s`;
|
||||||
@@ -723,12 +852,9 @@ function getStatusMessage(data) {
|
|||||||
const totalMs = data.time_elapsed;
|
const totalMs = data.time_elapsed;
|
||||||
const minutes = Math.floor(totalMs / 60000);
|
const minutes = Math.floor(totalMs / 60000);
|
||||||
const seconds = Math.floor((totalMs % 60000) / 1000);
|
const seconds = Math.floor((totalMs % 60000) / 1000);
|
||||||
// Optionally pad seconds with a leading zero if needed:
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
@@ -778,5 +904,5 @@ function isSpotifyUrl(url) {
|
|||||||
|
|
||||||
function getResourceTypeFromUrl(url) {
|
function getResourceTypeFromUrl(url) {
|
||||||
const pathParts = new URL(url).pathname.split('/');
|
const pathParts = new URL(url).pathname.split('/');
|
||||||
return pathParts[1]; // Returns 'track', 'album', or 'playlist'
|
return pathParts[1]; // Returns 'track', 'album', 'playlist', or 'artist'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,112 +1,113 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Spotizerr</title>
|
<title>Spotizerr</title>
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="settingsSidebar" class="sidebar">
|
<div id="settingsSidebar" class="sidebar">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<h2>Settings</h2>
|
<h2>Settings</h2>
|
||||||
<button id="closeSidebar" class="close-btn">×</button>
|
<button id="closeSidebar" class="close-btn">×</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Account Configuration Section -->
|
|
||||||
<div class="account-config">
|
|
||||||
<div class="config-item">
|
|
||||||
<label>Active Spotify Account:</label>
|
|
||||||
<select id="spotifyAccountSelect"></select>
|
|
||||||
</div>
|
|
||||||
<div class="config-item">
|
|
||||||
<label>Spotify Quality:</label>
|
|
||||||
<select id="spotifyQualitySelect">
|
|
||||||
<option value="NORMAL">OGG 96</option>
|
|
||||||
<option value="HIGH">OGG 160</option>
|
|
||||||
<option value="VERY_HIGH">OGG 320 (premium)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="config-item">
|
|
||||||
<label>Active Deezer Account:</label>
|
|
||||||
<select id="deezerAccountSelect"></select>
|
|
||||||
</div>
|
|
||||||
<div class="config-item">
|
|
||||||
<label>Deezer Quality:</label>
|
|
||||||
<select id="deezerQualitySelect">
|
|
||||||
<option value="MP3_128">MP3 128</option>
|
|
||||||
<option value="MP3_320">MP3 320 (sometimes premium)</option>
|
|
||||||
<option value="FLAC">FLAC (premium)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="config-item">
|
|
||||||
<label>Download Fallback:</label>
|
|
||||||
<label class="switch">
|
|
||||||
<input type="checkbox" id="fallbackToggle">
|
|
||||||
<span class="slider"></span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="config-item">
|
|
||||||
<label>Real time downloading:</label>
|
|
||||||
<label class="switch">
|
|
||||||
<input type="checkbox" id="realTimeToggle">
|
|
||||||
<span class="slider"></span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Service Tabs -->
|
|
||||||
<div class="service-tabs">
|
|
||||||
<button class="tab-button active" data-service="spotify">Spotify</button>
|
|
||||||
<button class="tab-button" data-service="deezer">Deezer</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Credentials List -->
|
|
||||||
<div class="credentials-list"></div>
|
|
||||||
|
|
||||||
<!-- Credentials Form -->
|
|
||||||
<div class="credentials-form">
|
|
||||||
<h3>Add/Edit Credential</h3>
|
|
||||||
<form id="credentialForm">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Name:</label>
|
|
||||||
<input type="text" id="credentialName" required>
|
|
||||||
</div>
|
|
||||||
<div id="serviceFields"></div>
|
|
||||||
<button type="submit" class="save-btn">Save</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div id="sidebarError" class="error"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container">
|
<!-- Account Configuration Section -->
|
||||||
<div class="search-header">
|
<div class="account-config">
|
||||||
<button id="settingsIcon" class="settings-icon">
|
<div class="config-item">
|
||||||
<img src="{{ url_for('static', filename='images/settings.svg') }}" alt="Settings">
|
<label>Active Spotify Account:</label>
|
||||||
</button>
|
<select id="spotifyAccountSelect"></select>
|
||||||
<input type="text" class="search-input" placeholder="Search tracks, albums, or playlists..." id="searchInput">
|
</div>
|
||||||
<select class="search-type" id="searchType">
|
<div class="config-item">
|
||||||
<option value="track">Tracks</option>
|
<label>Spotify Quality:</label>
|
||||||
<option value="album">Albums</option>
|
<select id="spotifyQualitySelect">
|
||||||
<option value="playlist">Playlists</option>
|
<option value="NORMAL">OGG 96</option>
|
||||||
</select>
|
<option value="HIGH">OGG 160</option>
|
||||||
<button class="search-button" id="searchButton">Search</button>
|
<option value="VERY_HIGH">OGG 320 (premium)</option>
|
||||||
<button id="queueIcon" class="queue-icon" onclick="toggleDownloadQueue()">
|
</select>
|
||||||
<img src="{{ url_for('static', filename='images/queue.svg') }}" alt="Download Queue">
|
</div>
|
||||||
</button>
|
<div class="config-item">
|
||||||
</div>
|
<label>Active Deezer Account:</label>
|
||||||
<div id="resultsContainer" class="results-grid"></div>
|
<select id="deezerAccountSelect"></select>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<label>Deezer Quality:</label>
|
||||||
|
<select id="deezerQualitySelect">
|
||||||
|
<option value="MP3_128">MP3 128</option>
|
||||||
|
<option value="MP3_320">MP3 320 (sometimes premium)</option>
|
||||||
|
<option value="FLAC">FLAC (premium)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<label>Download Fallback:</label>
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" id="fallbackToggle">
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<label>Real time downloading:</label>
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" id="realTimeToggle">
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Download Queue Sidebar -->
|
<!-- Service Tabs -->
|
||||||
<div id="downloadQueue" class="sidebar right">
|
<div class="service-tabs">
|
||||||
<div class="sidebar-header">
|
<button class="tab-button active" data-service="spotify">Spotify</button>
|
||||||
<h2>Download Queue</h2>
|
<button class="tab-button" data-service="deezer">Deezer</button>
|
||||||
<button class="close-btn" onclick="toggleDownloadQueue()">×</button>
|
|
||||||
</div>
|
|
||||||
<div id="queueItems"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
<!-- Credentials List -->
|
||||||
|
<div class="credentials-list"></div>
|
||||||
|
|
||||||
|
<!-- Credentials Form -->
|
||||||
|
<div class="credentials-form">
|
||||||
|
<h3>Add/Edit Credential</h3>
|
||||||
|
<form id="credentialForm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Name:</label>
|
||||||
|
<input type="text" id="credentialName" required>
|
||||||
|
</div>
|
||||||
|
<div id="serviceFields"></div>
|
||||||
|
<button type="submit" class="save-btn">Save</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div id="sidebarError" class="error"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="search-header">
|
||||||
|
<button id="settingsIcon" class="settings-icon">
|
||||||
|
<img src="{{ url_for('static', filename='images/settings.svg') }}" alt="Settings">
|
||||||
|
</button>
|
||||||
|
<input type="text" class="search-input" placeholder="Search tracks, albums, playlists or artists..." id="searchInput">
|
||||||
|
<select class="search-type" id="searchType">
|
||||||
|
<option value="track">Tracks</option>
|
||||||
|
<option value="album">Albums</option>
|
||||||
|
<option value="playlist">Playlists</option>
|
||||||
|
<option value="artist">Artists</option>
|
||||||
|
</select>
|
||||||
|
<button class="search-button" id="searchButton">Search</button>
|
||||||
|
<button id="queueIcon" class="queue-icon" onclick="toggleDownloadQueue()">
|
||||||
|
<img src="{{ url_for('static', filename='images/queue.svg') }}" alt="Download Queue">
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="resultsContainer" class="results-grid"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Download Queue Sidebar -->
|
||||||
|
<div id="downloadQueue" class="sidebar right">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<h2>Download Queue</h2>
|
||||||
|
<button class="close-btn" onclick="toggleDownloadQueue()">×</button>
|
||||||
|
</div>
|
||||||
|
<div id="queueItems"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Reference in New Issue
Block a user