Merge pull request #32 from Xoconoch/dev

Dev
This commit is contained in:
Xoconoch
2025-02-05 21:09:01 -06:00
committed by GitHub
34 changed files with 5195 additions and 2159 deletions

32
app.py
View File

@@ -16,7 +16,7 @@ def create_app():
app = Flask(__name__)
# Configure basic logging
log_file='flask_server.log'
log_file = 'flask_server.log'
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s %(levelname)s %(name)s %(threadName)s : %(message)s',
@@ -46,7 +46,33 @@ def create_app():
# Serve frontend
@app.route('/')
def serve_index():
return render_template('index.html')
return render_template('main.html')
# Config page route
@app.route('/config')
def serve_config():
return render_template('config.html')
# New route: Serve playlist.html under /playlist/<id>
@app.route('/playlist/<id>')
def serve_playlist(id):
# The id parameter is captured, but you can use it as needed.
return render_template('playlist.html')
# New route: Serve playlist.html under /playlist/<id>
@app.route('/album/<id>')
def serve_album(id):
# The id parameter is captured, but you can use it as needed.
return render_template('album.html')
@app.route('/track/<id>')
def serve_track(id):
# The id parameter is captured, but you can use it as needed.
return render_template('track.html')
@app.route('/artist/<id>')
def serve_artist(id):
# The id parameter is captured, but you can use it as needed.
return render_template('artist.html')
@app.route('/static/<path:path>')
def serve_static(path):
@@ -85,4 +111,4 @@ if __name__ == '__main__':
app = create_app()
logging.info("Starting Flask server on port 7171")
from waitress import serve
serve(app, host='0.0.0.0', port=7171)
serve(app, host='0.0.0.0', port=7171)

View File

@@ -40,7 +40,11 @@ class FlushingFileWrapper:
def flush(self):
self.file.flush()
def download_task(service, url, main, fallback, quality, fall_quality, real_time, prg_path):
def download_task(service, url, main, fallback, quality, fall_quality, real_time, prg_path, orig_request):
"""
The download task writes out the original request data into the progress file
and then runs the album download.
"""
try:
from routes.utils.album import download_album
with open(prg_path, 'w') as f:
@@ -48,6 +52,15 @@ def download_task(service, url, main, fallback, quality, fall_quality, real_time
original_stdout = sys.stdout
sys.stdout = flushing_file # Redirect stdout
# Write the original request data into the progress file.
try:
flushing_file.write(json.dumps({"original_request": orig_request}) + "\n")
except Exception as e:
flushing_file.write(json.dumps({
"status": "error",
"message": f"Failed to write original request data: {str(e)}"
}) + "\n")
try:
download_album(
service=service,
@@ -158,10 +171,13 @@ def handle_download():
os.makedirs(prg_dir, exist_ok=True)
prg_path = os.path.join(prg_dir, filename)
# Capture the original request parameters as a dictionary.
orig_request = request.args.to_dict()
# Create and start the download process, and track it in the global dictionary.
process = Process(
target=download_task,
args=(service, url, main, fallback, quality, fall_quality, real_time, prg_path)
args=(service, url, main, fallback, quality, fall_quality, real_time, prg_path, orig_request)
)
process.start()
download_processes[filename] = process
@@ -219,3 +235,39 @@ def cancel_download():
status=404,
mimetype='application/json'
)
# NEW ENDPOINT: Get Album Information
@album_bp.route('/info', methods=['GET'])
def get_album_info():
"""
Retrieve Spotify album metadata given a Spotify album ID.
Expects a query parameter 'id' that contains the Spotify album ID.
"""
spotify_id = request.args.get('id')
if not spotify_id:
return Response(
json.dumps({"error": "Missing parameter: id"}),
status=400,
mimetype='application/json'
)
try:
# Import the get_spotify_info function from the utility module.
from routes.utils.get_info import get_spotify_info
# Call the function with the album type.
album_info = get_spotify_info(spotify_id, "album")
return Response(
json.dumps(album_info),
status=200,
mimetype='application/json'
)
except Exception as e:
error_data = {
"error": str(e),
"traceback": traceback.format_exc()
}
return Response(
json.dumps(error_data),
status=500,
mimetype='application/json'
)

View File

@@ -44,9 +44,10 @@ class FlushingFileWrapper:
def flush(self):
self.file.flush()
def download_artist_task(service, artist_url, main, fallback, quality, fall_quality, real_time, album_type, prg_path):
def download_artist_task(service, artist_url, main, fallback, quality, fall_quality, real_time, album_type, prg_path, orig_request):
"""
This function wraps the call to download_artist_albums and writes JSON status to the prg file.
This function wraps the call to download_artist_albums, writes the original
request data to the progress file, and then writes JSON status updates.
"""
try:
from routes.utils.artist import download_artist_albums
@@ -55,6 +56,15 @@ def download_artist_task(service, artist_url, main, fallback, quality, fall_qual
original_stdout = sys.stdout
sys.stdout = flushing_file # Redirect stdout to our flushing file wrapper
# Write the original request data to the progress file.
try:
flushing_file.write(json.dumps({"original_request": orig_request}) + "\n")
except Exception as e:
flushing_file.write(json.dumps({
"status": "error",
"message": f"Failed to write original request data: {str(e)}"
}) + "\n")
try:
download_artist_albums(
service=service,
@@ -179,10 +189,13 @@ def handle_artist_download():
os.makedirs(prg_dir, exist_ok=True)
prg_path = os.path.join(prg_dir, filename)
# Capture the original request parameters as a dictionary.
orig_request = request.args.to_dict()
# 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)
args=(service, artist_url, main, fallback, quality, fall_quality, real_time, album_type, prg_path, orig_request)
)
process.start()
download_processes[filename] = process
@@ -236,3 +249,39 @@ def cancel_artist_download():
status=404,
mimetype='application/json'
)
# NEW ENDPOINT: Get Artist Information
@artist_bp.route('/info', methods=['GET'])
def get_artist_info():
"""
Retrieve Spotify artist metadata given a Spotify ID.
Expects a query parameter 'id' that contains the Spotify artist ID.
"""
spotify_id = request.args.get('id')
if not spotify_id:
return Response(
json.dumps({"error": "Missing parameter: id"}),
status=400,
mimetype='application/json'
)
try:
# Import the get_spotify_info function from the utility module.
from routes.utils.get_info import get_spotify_info
# Call the function with the artist type.
artist_info = get_spotify_info(spotify_id, "artist")
return Response(
json.dumps(artist_info),
status=200,
mimetype='application/json'
)
except Exception as e:
error_data = {
"error": str(e),
"traceback": traceback.format_exc()
}
return Response(
json.dumps(error_data),
status=500,
mimetype='application/json'
)

View File

@@ -40,14 +40,23 @@ class FlushingFileWrapper:
def flush(self):
self.file.flush()
def download_task(service, url, main, fallback, quality, fall_quality, real_time, prg_path):
def download_task(service, url, main, fallback, quality, fall_quality, real_time, prg_path, orig_request):
try:
from routes.utils.playlist import download_playlist
with open(prg_path, 'w') as f:
flushing_file = FlushingFileWrapper(f)
original_stdout = sys.stdout
sys.stdout = flushing_file # Process-specific stdout
# Write the original request data into the progress file.
try:
flushing_file.write(json.dumps({"original_request": orig_request}) + "\n")
except Exception as e:
flushing_file.write(json.dumps({
"status": "error",
"message": f"Failed to write original request data: {str(e)}"
}) + "\n")
try:
download_playlist(
service=service,
@@ -103,9 +112,12 @@ def handle_download():
os.makedirs(prg_dir, exist_ok=True)
prg_path = os.path.join(prg_dir, filename)
# Capture the original request parameters as a dictionary.
orig_request = request.args.to_dict()
process = Process(
target=download_task,
args=(service, url, main, fallback, quality, fall_quality, real_time, prg_path)
args=(service, url, main, fallback, quality, fall_quality, real_time, prg_path, orig_request)
)
process.start()
# Track the running process using the generated filename.
@@ -163,3 +175,39 @@ def cancel_download():
status=404,
mimetype='application/json'
)
# NEW ENDPOINT: Get Playlist Information
@playlist_bp.route('/info', methods=['GET'])
def get_playlist_info():
"""
Retrieve Spotify playlist metadata given a Spotify ID.
Expects a query parameter 'id' that contains the Spotify playlist ID.
"""
spotify_id = request.args.get('id')
if not spotify_id:
return Response(
json.dumps({"error": "Missing parameter: id"}),
status=400,
mimetype='application/json'
)
try:
# Import the get_spotify_info function from the utility module.
from routes.utils.get_info import get_spotify_info
# Call the function with the playlist type.
playlist_info = get_spotify_info(spotify_id, "playlist")
return Response(
json.dumps(playlist_info),
status=200,
mimetype='application/json'
)
except Exception as e:
error_data = {
"error": str(e),
"traceback": traceback.format_exc()
}
return Response(
json.dumps(error_data),
status=500,
mimetype='application/json'
)

View File

@@ -10,8 +10,11 @@ PRGS_DIR = os.path.join(os.getcwd(), 'prgs')
@prgs_bp.route('/<filename>', methods=['GET'])
def get_prg_file(filename):
"""
Return a JSON object with the resource type, its name (title) and the last line (progress update) of the PRG file.
If the file is empty, return default values.
Return a JSON object with the resource type, its name (title),
the last progress update (last line) of the PRG file, and, if available,
the original request parameters (from the first line of the file).
For resource type and name, the second line of the file is used.
"""
try:
# Security check to prevent path traversal attacks.
@@ -29,41 +32,53 @@ def get_prg_file(filename):
return jsonify({
"type": "",
"name": "",
"last_line": None
"last_line": None,
"original_request": None
})
# Process the initialization line (first line) to extract type and name.
# Attempt to extract the original request from the first line.
original_request = None
try:
init_data = json.loads(lines[0])
except Exception as e:
# If parsing fails, use defaults.
init_data = {}
first_line = json.loads(lines[0])
if "original_request" in first_line:
original_request = first_line["original_request"]
except Exception:
original_request = None
resource_type = init_data.get("type", "")
# Determine the name based on type.
if resource_type == "track":
resource_name = init_data.get("song", "")
elif resource_type == "album":
resource_name = init_data.get("album", "")
elif resource_type == "playlist":
resource_name = init_data.get("name", "")
elif resource_type == "artist":
resource_name = init_data.get("artist", "")
# For resource type and name, use the second line if available.
if len(lines) > 1:
try:
second_line = json.loads(lines[1])
resource_type = second_line.get("type", "")
if resource_type == "track":
resource_name = second_line.get("song", "")
elif resource_type == "album":
resource_name = second_line.get("album", "")
elif resource_type == "playlist":
resource_name = second_line.get("name", "")
elif resource_type == "artist":
resource_name = second_line.get("artist", "")
else:
resource_name = ""
except Exception:
resource_type = ""
resource_name = ""
else:
resource_type = ""
resource_name = ""
# Get the last line from the file.
last_line_raw = lines[-1]
# Try to parse the last line as JSON.
try:
last_line_parsed = json.loads(last_line_raw)
except Exception:
last_line_parsed = last_line_raw # Fallback to returning raw string if JSON parsing fails.
last_line_parsed = last_line_raw # Fallback to raw string if JSON parsing fails.
return jsonify({
"type": resource_type,
"name": resource_name,
"last_line": last_line_parsed
"last_line": last_line_parsed,
"original_request": original_request
})
except FileNotFoundError:
abort(404, "File not found")

View File

@@ -30,14 +30,23 @@ class FlushingFileWrapper:
def flush(self):
self.file.flush()
def download_task(service, url, main, fallback, quality, fall_quality, real_time, prg_path):
def download_task(service, url, main, fallback, quality, fall_quality, real_time, prg_path, orig_request):
try:
from routes.utils.track import download_track
with open(prg_path, 'w') as f:
flushing_file = FlushingFileWrapper(f)
original_stdout = sys.stdout
sys.stdout = flushing_file # Redirect stdout for this process
# Write the original request data into the progress file.
try:
flushing_file.write(json.dumps({"original_request": orig_request}) + "\n")
except Exception as e:
flushing_file.write(json.dumps({
"status": "error",
"message": f"Failed to write original request data: {str(e)}"
}) + "\n")
try:
download_track(
service=service,
@@ -148,9 +157,12 @@ def handle_download():
os.makedirs(prg_dir, exist_ok=True)
prg_path = os.path.join(prg_dir, filename)
# Capture the original request parameters as a dictionary.
orig_request = request.args.to_dict()
process = Process(
target=download_task,
args=(service, url, main, fallback, quality, fall_quality, real_time, prg_path)
args=(service, url, main, fallback, quality, fall_quality, real_time, prg_path, orig_request)
)
process.start()
# Track the running process using the generated filename.
@@ -208,3 +220,39 @@ def cancel_download():
status=404,
mimetype='application/json'
)
# NEW ENDPOINT: Get Track Information
@track_bp.route('/info', methods=['GET'])
def get_track_info():
"""
Retrieve Spotify track metadata given a Spotify track ID.
Expects a query parameter 'id' that contains the Spotify track ID.
"""
spotify_id = request.args.get('id')
if not spotify_id:
return Response(
json.dumps({"error": "Missing parameter: id"}),
status=400,
mimetype='application/json'
)
try:
# Import the get_spotify_info function from the utility module.
from routes.utils.get_info import get_spotify_info
# Call the function with the track type.
track_info = get_spotify_info(spotify_id, "track")
return Response(
json.dumps(track_info),
status=200,
mimetype='application/json'
)
except Exception as e:
error_data = {
"error": str(e),
"traceback": traceback.format_exc()
}
return Response(
json.dumps(error_data),
status=500,
mimetype='application/json'
)

View File

@@ -68,7 +68,7 @@ def download_artist_albums(service, artist_url, main, fallback=None, quality=Non
})
return
log_json({"status": "initializing", "type": "artist", "artist": artist_name, "total_albums": len(albums)})
log_json({"status": "initializing", "type": "artist", "artist": artist_name, "total_albums": len(albums), "album_type": album_type})
for album in albums:
try:

19
routes/utils/get_info.py Normal file
View File

@@ -0,0 +1,19 @@
#!/usr/bin/python3
from deezspot.easy_spoty import Spo
Spo()
def get_spotify_info(spotify_id, spotify_type):
if spotify_type == "track":
return Spo.get_track(spotify_id)
elif spotify_type == "album":
return Spo.get_album(spotify_id)
elif spotify_type == "playlist":
return Spo.get_playlist(spotify_id)
elif spotify_type == "artist":
return Spo.get_artist(spotify_id)
elif spotify_type == "episode":
return Spo.get_episode(spotify_id)
else:
raise ValueError(f"Unsupported Spotify type: {spotify_type}")

432
static/css/album/album.css Normal file
View File

@@ -0,0 +1,432 @@
/* Base Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
body {
background: linear-gradient(135deg, #121212, #1e1e1e);
color: #ffffff;
min-height: 100vh;
line-height: 1.4;
}
/* Main App Container */
#app {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
position: relative;
z-index: 1;
}
/* Album Header */
#album-header {
display: flex;
gap: 20px;
margin-bottom: 2rem;
align-items: center;
padding-bottom: 1.5rem;
border-bottom: 1px solid #2a2a2a;
flex-wrap: wrap;
transition: all 0.3s ease;
}
#album-image {
width: 200px;
height: 200px;
object-fit: cover;
border-radius: 8px;
flex-shrink: 0;
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
transition: transform 0.3s ease;
}
#album-image:hover {
transform: scale(1.02);
}
#album-info {
flex: 1;
min-width: 0;
}
#album-name {
font-size: 2.5rem;
margin-bottom: 0.5rem;
background: linear-gradient(90deg, #1db954, #17a44b);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
#album-artist,
#album-stats {
font-size: 1.1rem;
color: #b3b3b3;
margin-bottom: 0.5rem;
}
#album-copyright {
font-size: 0.9rem;
color: #b3b3b3;
opacity: 0.8;
margin-bottom: 0.5rem;
}
/* Playlist Header */
#playlist-header {
display: flex;
gap: 20px;
margin-bottom: 2rem;
align-items: center;
padding-bottom: 1.5rem;
border-bottom: 1px solid #2a2a2a;
flex-wrap: wrap;
transition: all 0.3s ease;
}
#playlist-image {
width: 200px;
height: 200px;
object-fit: cover;
border-radius: 8px;
flex-shrink: 0;
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
transition: transform 0.3s ease;
}
#playlist-image:hover {
transform: scale(1.02);
}
#playlist-info {
flex: 1;
min-width: 0;
}
#playlist-name {
font-size: 2.5rem;
margin-bottom: 0.5rem;
background: linear-gradient(90deg, #1db954, #17a44b);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
#playlist-owner,
#playlist-stats,
#playlist-description {
font-size: 1.1rem;
color: #b3b3b3;
margin-bottom: 0.5rem;
}
/* Tracks Container */
#tracks-container {
margin-top: 2rem;
}
#tracks-container h2 {
font-size: 1.75rem;
margin-bottom: 1rem;
border-bottom: 1px solid #2a2a2a;
padding-bottom: 0.5rem;
}
/* Tracks List */
#tracks-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Individual Track Styling */
.track {
display: flex;
align-items: center;
padding: 1rem;
background: #181818;
border-radius: 8px;
transition: background 0.3s ease;
flex-wrap: wrap;
}
.track:hover {
background: #2a2a2a;
}
.track-number {
width: 30px;
font-size: 1rem;
font-weight: 500;
text-align: center;
margin-right: 1rem;
flex-shrink: 0;
}
.track-info {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
align-items: flex-start;
}
.track-name {
font-size: 1rem;
font-weight: bold;
word-wrap: break-word;
}
.track-artist {
font-size: 0.9rem;
color: #b3b3b3;
}
.track-album {
max-width: 200px;
font-size: 0.9rem;
color: #b3b3b3;
margin-left: 1rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: right;
}
.track-duration {
width: 60px;
text-align: right;
font-size: 0.9rem;
color: #b3b3b3;
margin-left: 1rem;
flex-shrink: 0;
}
/* Loading and Error States */
.loading,
.error {
width: 100%;
text-align: center;
font-size: 1rem;
padding: 1rem;
}
.error {
color: #c0392b;
}
/* Utility Classes */
.hidden {
display: none !important;
}
/* Unified Download Button Base Style */
.download-btn {
background-color: #1db954;
color: #fff;
border: none;
border-radius: 4px;
padding: 0.6rem 1.2rem;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s ease, transform 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
margin: 0.5rem;
}
.download-btn:hover {
background-color: #17a44b;
}
.download-btn:active {
transform: scale(0.98);
}
/* Circular Variant for Compact Areas (e.g., in a queue list) */
.download-btn--circle {
width: 32px;
height: 32px;
padding: 0;
border-radius: 50%;
font-size: 0;
}
.download-btn--circle::before {
content: "↓";
font-size: 16px;
color: #fff;
display: inline-block;
}
/* Icon next to text */
.download-btn .btn-icon {
margin-right: 0.5rem;
display: inline-flex;
align-items: center;
}
/* Home Button Styling */
.home-btn {
background-color: transparent;
border: none;
cursor: pointer;
margin-right: 1rem;
padding: 0;
}
.home-btn img {
width: 32px;
height: 32px;
filter: invert(1); /* Makes the SVG icon appear white */
transition: transform 0.2s ease;
}
.home-btn:hover img {
transform: scale(1.05);
}
.home-btn:active img {
transform: scale(0.98);
}
/* Download Queue Toggle Button */
.queue-toggle {
position: fixed;
bottom: 20px;
right: 20px;
background: #1db954;
color: #fff;
border: none;
border-radius: 50%;
width: 56px;
height: 56px;
cursor: pointer;
font-size: 1rem;
font-weight: bold;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
transition: background-color 0.3s ease, transform 0.2s ease;
z-index: 1002;
}
.queue-toggle:hover {
background: #1ed760;
transform: scale(1.05);
}
.queue-toggle:active {
transform: scale(1);
}
/* Responsive Styles */
/* Medium Devices (Tablets) */
@media (max-width: 768px) {
#album-header, #playlist-header {
flex-direction: column;
align-items: center;
text-align: center;
}
#album-image, #playlist-image {
width: 180px;
height: 180px;
margin-bottom: 1rem;
}
#album-name, #playlist-name {
font-size: 2rem;
}
#album-artist,
#album-stats,
#playlist-owner,
#playlist-stats,
#playlist-description {
font-size: 1rem;
}
.track {
flex-direction: column;
align-items: flex-start;
}
.track-album,
.track-duration {
margin-left: 0;
margin-top: 0.5rem;
width: 100%;
text-align: left;
}
}
/* Small Devices (Mobile Phones) */
@media (max-width: 480px) {
#app {
padding: 10px;
}
#album-name, #playlist-name {
font-size: 1.75rem;
}
#album-artist,
#album-stats,
#album-copyright,
#playlist-owner,
#playlist-stats,
#playlist-description {
font-size: 0.9rem;
}
.track {
padding: 0.8rem;
flex-direction: column;
align-items: center;
text-align: center;
}
.track-number {
font-size: 0.9rem;
margin-right: 0;
margin-bottom: 0.5rem;
}
.track-info {
align-items: center;
}
.track-album,
.track-duration {
margin-left: 0;
margin-top: 0.5rem;
width: 100%;
text-align: center;
}
}
/* Prevent anchor links from appearing all blue */
a {
color: inherit; /* Inherit color from the parent */
text-decoration: none; /* Remove default underline */
transition: color 0.2s ease;
}
a:hover,
a:focus {
color: #1db954; /* Change to a themed green on hover/focus */
text-decoration: underline;
}
/* Override the default pseudo-element for the circular download button */
.download-btn--circle::before {
content: none;
}
/* Style the icon inside the circular download button */
.download-btn--circle img {
width: 20px;
height: 20px;
filter: brightness(0) invert(1); /* ensures the icon appears white */
display: block;
}

View File

@@ -0,0 +1,343 @@
/* Base Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
body {
background-color: #121212;
color: #ffffff;
min-height: 100vh;
line-height: 1.4;
}
/* Main App Container */
#app {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
position: relative;
z-index: 1;
}
/* Artist Header */
#artist-header {
display: flex;
gap: 20px;
margin-bottom: 2rem;
align-items: center;
padding: 20px;
border-bottom: 1px solid #2a2a2a;
flex-wrap: wrap;
border-radius: 8px;
background: linear-gradient(135deg, rgba(0,0,0,0.5), transparent);
}
#artist-image {
width: 200px;
height: 200px;
object-fit: cover;
border-radius: 8px;
flex-shrink: 0;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
}
#artist-info {
flex: 1;
min-width: 0;
}
#artist-name {
font-size: 2rem;
margin-bottom: 0.5rem;
}
#artist-stats {
font-size: 1rem;
color: #b3b3b3;
margin-bottom: 0.5rem;
}
/* Albums Container */
#albums-container {
margin-top: 2rem;
}
#albums-container h2 {
font-size: 1.5rem;
margin-bottom: 1rem;
border-bottom: 1px solid #2a2a2a;
padding-bottom: 0.5rem;
}
/* Album Groups */
.album-group {
margin-bottom: 2rem;
}
.album-group-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding: 0.5rem 0;
border-bottom: 1px solid #2a2a2a;
}
.album-group-header h3 {
font-size: 1.5rem;
margin: 0;
text-transform: capitalize;
}
.albums-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Track Card (for Albums) */
.track {
display: flex;
align-items: center;
padding: 1rem;
background: #181818;
border-radius: 8px;
transition: background 0.3s ease;
flex-wrap: wrap;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.track:hover {
background: #2a2a2a;
}
.track-number {
width: 30px;
font-size: 1rem;
font-weight: 500;
text-align: center;
margin-right: 1rem;
flex-shrink: 0;
}
.track-info {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
align-items: flex-start;
}
.track-name {
font-size: 1rem;
font-weight: bold;
word-wrap: break-word;
}
.track-artist {
font-size: 0.9rem;
color: #b3b3b3;
}
.track-album {
max-width: 200px;
font-size: 0.9rem;
color: #b3b3b3;
margin-left: 1rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: right;
}
.track-duration {
width: 60px;
text-align: right;
font-size: 0.9rem;
color: #b3b3b3;
margin-left: 1rem;
flex-shrink: 0;
}
/* Loading and Error States */
.loading,
.error {
width: 100%;
text-align: center;
font-size: 1rem;
padding: 1rem;
}
.error {
color: #c0392b;
}
/* Utility Classes */
.hidden {
display: none !important;
}
/* Unified Download Button Base Style */
.download-btn {
background-color: #1db954;
color: #fff;
border: none;
border-radius: 4px;
padding: 0.5rem 1rem;
font-size: 0.95rem;
cursor: pointer;
transition: background-color 0.2s ease, transform 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
margin: 0.5rem;
}
.download-btn:hover {
background-color: #17a44b;
}
.download-btn:active {
transform: scale(0.98);
}
/* Circular Variant for Compact Areas (e.g. album download buttons) */
.download-btn--circle {
width: 32px;
height: 32px;
padding: 0;
border-radius: 50%;
font-size: 0; /* Hide any text */
background-color: #1db954;
border: none;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background-color 0.2s ease, transform 0.2s ease;
margin: 0.5rem;
}
/* Image inside circular download button */
.download-btn--circle img {
width: 20px;
height: 20px;
filter: brightness(0) invert(1);
display: block;
}
.download-btn--circle:hover {
background-color: #17a44b;
transform: scale(1.05);
}
.download-btn--circle:active {
transform: scale(0.98);
}
/* Home Button Styling */
.home-btn {
background-color: transparent;
border: none;
cursor: pointer;
margin-right: 1rem;
padding: 0;
}
.home-btn img {
width: 32px;
height: 32px;
filter: invert(1);
transition: transform 0.2s ease;
}
.home-btn:hover img {
transform: scale(1.05);
}
.home-btn:active img {
transform: scale(0.98);
}
/* Responsive Styles */
/* Medium Devices (Tablets) */
@media (max-width: 768px) {
#artist-header {
flex-direction: column;
align-items: center;
text-align: center;
}
#artist-image {
width: 180px;
height: 180px;
margin-bottom: 1rem;
}
.track {
flex-direction: column;
align-items: center;
}
.track-album,
.track-duration {
margin-left: 0;
margin-top: 0.5rem;
width: 100%;
text-align: center;
}
}
/* Small Devices (Mobile Phones) */
@media (max-width: 480px) {
#app {
padding: 10px;
}
#artist-name {
font-size: 1.75rem;
}
/* Adjust album card layout */
.track {
padding: 0.8rem;
flex-direction: column;
align-items: center;
text-align: center;
}
.track-number {
font-size: 0.9rem;
margin-right: 0;
margin-bottom: 0.5rem;
}
.track-info {
align-items: center;
}
.track-album,
.track-duration {
margin-left: 0;
margin-top: 0.5rem;
width: 100%;
text-align: center;
}
}
/* Prevent anchor links from appearing blue */
a {
color: inherit;
text-decoration: none;
transition: color 0.2s ease;
}
a:hover,
a:focus {
color: #1db954;
text-decoration: underline;
}

View File

@@ -0,0 +1,412 @@
/* CONFIGURATION PAGE STYLES */
/* Base styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
body {
/* Modern dark gradient background */
background: linear-gradient(135deg, #121212, #1e1e1e);
color: #ffffff;
min-height: 100vh;
}
/* Config Container */
.config-container {
max-width: 800px;
margin: 0 auto;
padding: 2rem 1.5rem;
}
/* Header */
.config-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid #2a2a2a;
transition: all 0.3s ease;
}
/* Modern Back Button */
.back-button {
background: #1db954;
color: #ffffff;
padding: 0.8rem 1.5rem;
border-radius: 25px;
text-decoration: none;
font-weight: 500;
transition: background 0.3s ease, transform 0.2s ease;
}
.back-button:hover {
background: #1ed760;
transform: translateY(-2px);
}
/* Queue Icon in Header */
#queueIcon {
background: none;
border: none;
cursor: pointer;
padding: 4px;
}
#queueIcon img {
width: 24px;
height: 24px;
filter: invert(1);
transition: opacity 0.3s ease;
}
#queueIcon:hover img {
opacity: 0.8;
}
/* Account Configuration Section */
.account-config {
background: #181818;
padding: 1.5rem;
border-radius: 12px;
margin-bottom: 2rem;
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
transition: transform 0.3s ease;
}
.account-config:hover {
transform: translateY(-2px);
}
.config-item {
margin-bottom: 1.5rem;
position: relative;
}
.config-item label {
display: block;
margin-bottom: 0.5rem;
color: #b3b3b3;
font-size: 0.95rem;
}
/* Enhanced Dropdown Styling */
#spotifyAccountSelect,
#deezerAccountSelect,
#spotifyQualitySelect,
#deezerQualitySelect {
background: #2a2a2a;
color: #ffffff;
border: 1px solid #404040;
border-radius: 8px;
padding: 0.8rem 2.5rem 0.8rem 1rem;
width: 100%;
font-size: 0.95rem;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='white'%3e%3cpath d='M7 10l5 5 5-5z'/%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 1rem center;
background-size: 12px;
transition: all 0.3s ease;
}
#spotifyAccountSelect:focus,
#deezerAccountSelect:focus,
#spotifyQualitySelect:focus,
#deezerQualitySelect:focus {
outline: none;
border-color: #1db954;
box-shadow: 0 0 0 2px rgba(29, 185, 84, 0.2);
}
#spotifyAccountSelect option,
#deezerAccountSelect option,
#spotifyQualitySelect option,
#deezerQualitySelect option {
background: #181818;
color: #ffffff;
padding: 0.8rem;
}
#spotifyAccountSelect option:hover,
#deezerAccountSelect option:hover,
#spotifyQualitySelect option:hover,
#deezerQualitySelect option:hover {
background: #1db954;
}
/* Improved Toggle Switches */
.switch {
position: relative;
display: inline-block;
width: 40px;
height: 20px;
margin-left: 1rem;
vertical-align: middle;
overflow: hidden;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #666;
transition: 0.4s;
border-radius: 20px;
}
.slider:before {
position: absolute;
content: "";
height: 16px;
width: 16px;
left: 2px;
bottom: 2px;
background-color: #ffffff;
transition: 0.4s;
border-radius: 50%;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
input:checked + .slider {
background-color: #1db954;
}
input:checked + .slider:before {
transform: translateX(20px);
}
/* Service Tabs */
.service-tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.tab-button {
padding: 0.8rem 1.5rem;
border: none;
border-radius: 25px;
background: #2a2a2a;
color: #ffffff;
cursor: pointer;
font-size: 0.95rem;
transition: background 0.3s ease, transform 0.2s ease;
}
.tab-button.active {
background: #1db954;
transform: translateY(-2px);
}
/* Credentials List */
.credentials-list {
margin-bottom: 2rem;
}
.credential-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: #2a2a2a;
border-radius: 8px;
margin-bottom: 0.75rem;
transition: background 0.3s ease;
}
.credential-item:hover {
background: #3a3a3a;
}
.credential-actions button {
margin-left: 0.5rem;
padding: 0.4rem 0.8rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: opacity 0.3s ease, transform 0.2s ease;
}
.edit-btn {
background: #1db954;
color: #ffffff;
}
.delete-btn {
background: #ff5555;
color: #ffffff;
}
.credential-actions button:hover {
opacity: 0.9;
transform: translateY(-1px);
}
/* Credentials Form */
.credentials-form {
background: #181818;
padding: 1.5rem;
border-radius: 12px;
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
transition: transform 0.3s ease;
}
.credentials-form:hover {
transform: translateY(-2px);
}
#serviceFields {
margin: 1.5rem 0;
}
.form-group {
margin-bottom: 1.2rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: #b3b3b3;
}
.form-group input {
width: 100%;
padding: 0.8rem;
background: #2a2a2a;
border: 1px solid #404040;
border-radius: 8px;
color: #ffffff;
transition: border-color 0.3s ease;
}
.form-group input:focus {
outline: none;
border-color: #1db954;
}
.save-btn {
background: #1db954;
color: #ffffff;
padding: 0.8rem 1.5rem;
border: none;
border-radius: 25px;
width: 100%;
cursor: pointer;
font-weight: 500;
transition: background 0.3s ease, transform 0.2s ease;
}
.save-btn:hover {
background: #1ed760;
transform: translateY(-2px);
}
/* Error Messages */
#configError {
color: #ff5555;
margin-top: 1rem;
text-align: center;
font-size: 0.9rem;
}
/* MOBILE RESPONSIVENESS */
@media (max-width: 768px) {
.config-container {
padding: 1.5rem 1rem;
}
.config-header {
flex-direction: column;
gap: 1rem;
align-items: flex-start;
}
/* Increase touch target sizes for buttons and selects */
.back-button {
width: 100%;
text-align: center;
padding: 0.8rem;
}
#spotifyAccountSelect,
#deezerAccountSelect,
#spotifyQualitySelect,
#deezerQualitySelect {
padding: 0.8rem 2rem 0.8rem 1rem;
font-size: 0.9rem;
}
.service-tabs {
flex-wrap: wrap;
}
.tab-button {
flex: 1 1 auto;
text-align: center;
margin-bottom: 0.5rem;
}
.credential-item {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
.credential-actions {
width: 100%;
display: flex;
justify-content: flex-end;
}
/* Adjust toggle switch size for better touch support */
.switch {
width: 50px;
height: 24px;
}
.slider:before {
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
}
}
@media (max-width: 480px) {
.config-container {
padding: 1rem;
}
.account-config,
.credentials-form {
padding: 1rem;
}
.form-group input {
padding: 0.7rem;
}
.save-btn,
.back-button {
padding: 0.7rem;
font-size: 0.9rem;
}
/* Reduce dropdown padding for very small screens */
#spotifyAccountSelect,
#deezerAccountSelect,
#spotifyQualitySelect,
#deezerQualitySelect {
padding: 0.7rem 1.8rem 0.7rem 0.8rem;
}
}

124
static/css/main/icons.css Normal file
View File

@@ -0,0 +1,124 @@
/* ICON STYLES */
.settings-icon img,
#queueIcon img {
width: 24px;
height: 24px;
vertical-align: middle;
filter: invert(1);
transition: opacity 0.3s;
}
.settings-icon:hover img,
#queueIcon:hover img {
opacity: 0.8;
}
#queueIcon {
background: none;
border: none;
cursor: pointer;
padding: 4px;
}
.download-icon,
.type-icon,
.toggle-chevron {
width: 16px;
height: 16px;
vertical-align: middle;
margin-right: 6px;
}
.toggle-chevron {
transition: transform 0.2s ease;
}
.option-btn .type-icon {
width: 18px;
height: 18px;
margin-right: 0.3rem;
}
/* Container for Title and Buttons */
.title-and-view {
display: flex;
align-items: center;
justify-content: space-between;
padding-right: 1rem; /* Extra right padding so buttons don't touch the edge */
}
/* Container for the buttons next to the title */
.title-buttons {
display: flex;
align-items: center;
}
/* Small Download Button Styles */
.download-btn-small {
background-color: #1db954; /* White background */
border: none;
border-radius: 50%; /* Circular shape */
padding: 6px; /* Adjust padding for desired size */
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background-color 0.3s ease, transform 0.2s ease;
margin-left: 8px; /* Space between adjacent buttons */
}
.download-btn-small img {
width: 20px; /* Slightly bigger icon */
height: 20px;
filter: brightness(0) invert(1); /* Makes the icon white */
}
.download-btn-small:hover {
background-color: #1db954b4; /* Light gray on hover */
transform: translateY(-1px);
}
/* View Button Styles (unchanged) */
.view-btn {
background-color: #1db954;
border: none;
border-radius: 50%;
padding: 6px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background-color 0.3s ease, transform 0.2s ease;
margin-left: 8px;
}
.view-btn img {
width: 20px;
height: 20px;
filter: brightness(0) invert(1);
}
.view-btn:hover {
background-color: #1db954b0;
transform: translateY(-1px);
}
/* Mobile Compatibility Tweaks */
@media (max-width: 600px) {
.view-btn,
.download-btn-small {
padding: 6px 10px;
font-size: 13px;
margin: 4px;
}
}
/* Mobile compatibility tweaks */
@media (max-width: 600px) {
.view-btn {
padding: 6px 10px; /* Slightly larger padding on mobile for easier tap targets */
font-size: 13px; /* Ensure readability on smaller screens */
margin: 4px; /* Reduce margins to better fit mobile layouts */
}
}

284
static/css/main/main.css Normal file
View File

@@ -0,0 +1,284 @@
/* GENERAL STYLING & UTILITIES */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
body {
/* Use a subtle dark gradient for a modern feel */
background: linear-gradient(135deg, #121212, #1e1e1e);
color: #ffffff;
min-height: 100vh;
}
/* Main container for page content */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
position: relative;
z-index: 1;
}
/* LOADING & ERROR STATES */
.loading,
.error {
width: 100%;
text-align: center;
font-size: 1rem;
padding: 1rem;
}
.error {
color: #c0392b;
}
/* SEARCH HEADER COMPONENT */
.search-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 30px;
position: sticky;
top: 0;
background: rgba(18, 18, 18, 1);
backdrop-filter: blur(10px);
padding: 20px 0;
z-index: 100;
border-bottom: 1px solid #2a2a2a;
}
.search-input {
flex: 1;
padding: 12px 20px;
border: none;
border-radius: 25px;
background: #2a2a2a;
color: #ffffff;
font-size: 16px;
outline: none;
transition: background-color 0.3s ease;
}
.search-input:focus {
background: #333333;
}
.search-type {
padding: 12px 15px;
background: #2a2a2a;
border: none;
border-radius: 25px;
color: #ffffff;
cursor: pointer;
transition: background-color 0.3s ease;
}
.search-type:hover {
background: #3a3a3a;
}
.search-button {
padding: 12px 30px;
background-color: #1db954;
border: none;
border-radius: 25px;
color: #ffffff;
font-weight: bold;
cursor: pointer;
transition: background-color 0.3s ease, transform 0.2s ease;
}
.search-button:hover {
background-color: #1ed760;
transform: translateY(-2px);
}
/* RESULTS GRID COMPONENT Minimalistic Version */
.results-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 15px;
}
/* Each result card now features a clean, flat design with minimal decoration */
.result-card {
background: #1c1c1c; /* A uniform dark background */
border: 1px solid #2a2a2a; /* A subtle border for separation */
border-radius: 4px; /* Slight rounding for a modern look */
overflow: hidden;
display: flex;
flex-direction: column;
cursor: pointer;
transition: background-color 0.2s ease, transform 0.2s ease;
}
.result-card:hover {
background-color: #2a2a2a; /* Lightens the card on hover */
transform: translateY(-2px); /* A gentle lift effect */
}
/* Album/Art image wrapper Maintains aspect ratio and a clean presentation */
.album-art-wrapper {
position: relative;
width: 100%;
overflow: hidden;
}
.album-art-wrapper::before {
content: "";
display: block;
padding-top: 100%; /* 1:1 aspect ratio */
}
.album-art {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
transition: opacity 0.2s ease;
}
/* Text details are kept simple and legible */
.track-title {
padding: 0.75rem 1rem;
font-size: 1rem;
font-weight: bold;
color: #ffffff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.track-artist {
padding: 0 1rem;
font-size: 0.9rem;
color: #aaaaaa;
margin-top: 0.25rem;
}
.track-details {
padding: 0.75rem 1rem;
font-size: 0.85rem;
color: #bbbbbb;
border-top: 1px solid #2a2a2a;
display: flex;
justify-content: space-between;
align-items: center;
}
.duration {
font-style: italic;
color: #999;
}
/* Centered Download Button styling */
.download-btn {
margin: 0.75rem 1rem 1rem;
padding: 0.5rem 1rem;
background-color: #1db954;
color: #ffffff;
border: none;
border-radius: 4px;
font-size: 0.95rem;
cursor: pointer;
transition: background-color 0.2s ease, transform 0.2s ease;
display: block;
text-align: center;
width: calc(100% - 2rem);
}
.download-btn:hover {
background-color: #17a44b;
transform: scale(1.02);
}
/* ARTIST DOWNLOAD OPTIONS */
.artist-download-buttons {
border-top: 1px solid #2a2a2a;
padding: 0.5rem 1rem;
}
.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: #ffffff;
}
.download-options-container {
margin-top: 0.5rem;
}
.secondary-options {
display: none;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.secondary-options.expanded {
display: flex;
}
.option-btn {
flex: 1;
background-color: #2a2a2a;
color: #ffffff;
padding: 0.4rem 0.6rem;
border: none;
border-radius: 4px;
font-size: 0.85rem;
cursor: pointer;
transition: background-color 0.2s ease, transform 0.2s ease;
}
.option-btn:hover {
background-color: #3a3a3a;
transform: translateY(-1px);
}
/* MOBILE RESPONSIVENESS */
@media (max-width: 600px) {
.search-header {
flex-wrap: wrap;
justify-content: center;
padding: 10px 0;
}
.search-input,
.search-type,
.search-button {
flex: 1 1 100%;
margin-bottom: 10px;
}
.search-type,
.search-button {
padding: 10px;
font-size: 15px;
}
.results-grid {
justify-content: center;
}
.result-card {
width: 90%;
margin: 0 auto;
}
}

View File

@@ -0,0 +1,394 @@
/* Base Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
body {
background: linear-gradient(135deg, #121212, #1e1e1e);
color: #ffffff;
min-height: 100vh;
line-height: 1.4;
}
/* Main App Container */
#app {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
position: relative;
z-index: 1;
}
/* Playlist Header */
#playlist-header {
display: flex;
gap: 20px;
margin-bottom: 2rem;
align-items: center;
padding-bottom: 1.5rem;
border-bottom: 1px solid #2a2a2a;
flex-wrap: wrap;
transition: all 0.3s ease;
}
#playlist-image {
width: 200px;
height: 200px;
object-fit: cover;
border-radius: 8px;
flex-shrink: 0;
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
transition: transform 0.3s ease;
}
#playlist-image:hover {
transform: scale(1.02);
}
#playlist-info {
flex: 1;
min-width: 0;
}
#playlist-name {
font-size: 2.5rem;
margin-bottom: 0.5rem;
background: linear-gradient(90deg, #1db954, #17a44b);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
#playlist-owner,
#playlist-stats,
#playlist-description {
font-size: 1.1rem;
color: #b3b3b3;
margin-bottom: 0.5rem;
}
/* Tracks Container */
#tracks-container {
margin-top: 2rem;
}
#tracks-container h2 {
font-size: 1.75rem;
margin-bottom: 1rem;
border-bottom: 1px solid #2a2a2a;
padding-bottom: 0.5rem;
}
/* Tracks List */
#tracks-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Individual Track Styling */
.track {
display: flex;
align-items: center;
padding: 1rem;
background: #181818;
border-radius: 8px;
transition: background 0.3s ease;
flex-wrap: wrap;
}
.track:hover {
background: #2a2a2a;
}
.track-number {
width: 30px;
font-size: 1rem;
font-weight: 500;
text-align: center;
margin-right: 1rem;
flex-shrink: 0;
}
.track-info {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
align-items: flex-start;
}
.track-name {
font-size: 1rem;
font-weight: bold;
word-wrap: break-word;
}
.track-artist {
font-size: 0.9rem;
color: #b3b3b3;
}
/* When displaying track album info on the side */
.track-album {
max-width: 200px;
font-size: 0.9rem;
color: #b3b3b3;
margin-left: 1rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: right;
}
.track-duration {
width: 60px;
text-align: right;
font-size: 0.9rem;
color: #b3b3b3;
margin-left: 1rem;
flex-shrink: 0;
}
/* Loading and Error States */
.loading,
.error {
width: 100%;
text-align: center;
font-size: 1rem;
padding: 1rem;
}
.error {
color: #c0392b;
}
/* Utility Classes */
.hidden {
display: none !important;
}
/* Unified Download Button Base Style */
.download-btn {
background-color: #1db954;
color: #fff;
border: none;
border-radius: 4px;
padding: 0.6rem 1.2rem;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s ease, transform 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
margin: 0.5rem;
}
.download-btn:hover {
background-color: #17a44b;
}
.download-btn:active {
transform: scale(0.98);
}
/* Circular Variant for Compact Areas (e.g., in a queue list) */
.download-btn--circle {
width: 32px;
height: 32px;
padding: 0;
border-radius: 50%;
font-size: 0;
}
.download-btn--circle::before {
content: "↓";
font-size: 16px;
color: #fff;
display: inline-block;
}
/* Icon next to text */
.download-btn .btn-icon {
margin-right: 0.5rem;
display: inline-flex;
align-items: center;
}
/* Home Button Styling */
.home-btn {
background-color: transparent;
border: none;
cursor: pointer;
margin-right: 1rem;
padding: 0;
}
.home-btn img {
width: 32px;
height: 32px;
filter: invert(1); /* Makes the SVG icon appear white */
transition: transform 0.2s ease;
}
.home-btn:hover img {
transform: scale(1.05);
}
.home-btn:active img {
transform: scale(0.98);
}
/* Home Icon (SVG) */
.home-icon {
width: 24px;
height: 24px;
}
/* Download Queue Toggle Button */
.queue-toggle {
position: fixed;
bottom: 20px;
right: 20px;
background: #1db954;
color: #fff;
border: none;
border-radius: 50%;
width: 56px;
height: 56px;
cursor: pointer;
font-size: 1rem;
font-weight: bold;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
transition: background-color 0.3s ease, transform 0.2s ease;
z-index: 1002;
}
.queue-toggle:hover {
background: #1ed760;
transform: scale(1.05);
}
.queue-toggle:active {
transform: scale(1);
}
/* Responsive Styles */
/* Medium Devices (Tablets) */
@media (max-width: 768px) {
#playlist-header {
flex-direction: column;
align-items: center;
text-align: center;
}
#playlist-image {
width: 180px;
height: 180px;
margin-bottom: 1rem;
}
.track {
flex-direction: column;
align-items: flex-start;
}
.track-album,
.track-duration {
margin-left: 0;
margin-top: 0.5rem;
width: 100%;
text-align: left;
}
}
/* Small Devices (Mobile Phones) */
@media (max-width: 480px) {
#app {
padding: 10px;
}
#playlist-name {
font-size: 1.75rem;
}
/* Adjust track layout to vertical & centered */
.track {
padding: 0.8rem;
flex-direction: column;
align-items: center;
text-align: center;
}
.track-number {
font-size: 0.9rem;
margin-right: 0;
margin-bottom: 0.5rem;
}
.track-info {
align-items: center;
}
.track-album,
.track-duration {
margin-left: 0;
margin-top: 0.5rem;
width: 100%;
text-align: center;
}
}
/* Prevent anchor links from appearing all blue */
a {
color: inherit; /* Inherit color from the parent */
text-decoration: none; /* Remove default underline */
transition: color 0.2s ease;
}
a:hover,
a:focus {
color: #1db954; /* Change to a themed green on hover/focus */
text-decoration: underline;
}
/* Override for the circular download button variant */
.download-btn--circle {
width: 32px;
height: 32px;
padding: 0;
border-radius: 50%;
font-size: 0; /* Hide any text */
background-color: #1db954; /* Use the same green as the base button */
border: none;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background-color 0.2s ease, transform 0.2s ease;
margin: 0.5rem;
}
/* Remove the default pseudo-element that inserts an arrow */
.download-btn--circle::before {
content: none;
}
/* Style the image inside the circular download button */
.download-btn--circle img {
width: 20px; /* Control icon size */
height: 20px;
filter: brightness(0) invert(1); /* Ensure the icon appears white */
display: block;
}
/* Hover and active states for the circular download button */
.download-btn--circle:hover {
background-color: #17a44b;
transform: scale(1.05);
}
.download-btn--circle:active {
transform: scale(0.98);
}

340
static/css/queue/queue.css Normal file
View File

@@ -0,0 +1,340 @@
/* ---------------------- */
/* DOWNLOAD QUEUE STYLES */
/* ---------------------- */
/* Container for the download queue sidebar */
#downloadQueue {
position: fixed;
top: 0;
right: -350px; /* Hidden offscreen by default */
width: 350px;
height: 100vh;
background: #181818;
padding: 20px;
transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 1001;
overflow-y: auto;
box-shadow: -20px 0 30px rgba(0, 0, 0, 0.4);
}
/* When active, the sidebar slides into view */
#downloadQueue.active {
right: 0;
}
/* Header inside the queue sidebar */
.queue-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 15px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
margin-bottom: 20px;
}
.queue-title {
font-size: 1.25rem;
font-weight: 600;
color: #fff;
}
/* Close button for the queue sidebar */
.queue-close {
background: #2a2a2a;
border-radius: 50%;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.3s ease;
cursor: pointer;
}
.queue-close:hover {
background-color: #333;
}
/* X Icon style for the close button */
.queue-close::before {
content: "×";
font-size: 20px;
color: #fff;
line-height: 32px; /* Center the icon vertically within the button */
}
/* Container for all queue items */
#queueItems {
max-height: 60vh;
overflow-y: auto;
}
/* Each download queue item */
.queue-item {
background: #2a2a2a;
padding: 15px;
border-radius: 8px;
margin-bottom: 15px;
transition: background-color 0.3s ease, transform 0.2s ease;
display: flex;
flex-direction: column;
gap: 6px;
}
.queue-item:hover {
background-color: #333;
transform: translateY(-2px);
}
/* Title text in a queue item */
.queue-item .title {
font-weight: 500;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #fff;
}
/* Type indicator (e.g. track, album) */
.queue-item .type {
font-size: 12px;
color: #1DB954;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Log text for status messages */
.queue-item .log {
font-size: 13px;
color: #b3b3b3;
line-height: 1.4;
font-family: 'SF Mono', Menlo, monospace;
}
/* Optional state indicators for each queue item */
.queue-item--complete {
border-left: 4px solid #1DB954;
}
.queue-item--error {
border-left: 4px solid #ff5555;
}
.queue-item--processing {
border-left: 4px solid #4a90e2;
}
/* Progress bar for downloads */
.status-bar {
height: 3px;
background: #1DB954;
width: 0;
transition: width 0.3s ease;
margin-top: 8px;
}
/* Progress percentage text */
.progress-percent {
text-align: right;
font-weight: bold;
color: #1DB954;
}
/* Optional status message colors (if using state classes) */
.log--success {
color: #1DB954 !important;
}
.log--error {
color: #ff5555 !important;
}
.log--warning {
color: #ffaa00 !important;
}
.log--info {
color: #4a90e2 !important;
}
/* Loader animations for real-time progress */
@keyframes progress-pulse {
0% { opacity: 0.5; }
50% { opacity: 1; }
100% { opacity: 0.5; }
}
.progress-indicator {
display: inline-block;
margin-left: 8px;
animation: progress-pulse 1.5s infinite;
}
/* Loading spinner style */
.loading-spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: #1DB954;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.cancel-btn {
background: none;
border: none;
cursor: pointer;
padding: 5px;
outline: none;
margin-top: 10px;
/* Optionally constrain the overall size */
max-width: 24px;
max-height: 24px;
}
.cancel-btn img {
width: 16px; /* Reduced from 24px */
height: 16px; /* Reduced from 24px */
filter: invert(1);
transition: transform 0.3s ease;
}
.cancel-btn:hover img {
transform: scale(1.1);
}
.cancel-btn:active img {
transform: scale(0.9);
}
/* Close button for the download queue sidebar */
.close-btn {
background: #2a2a2a;
border: none;
border-radius: 50%;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 20px;
cursor: pointer;
transition: background-color 0.3s ease;
}
.close-btn:hover {
background-color: #333;
}
/* ------------------------------- */
/* MOBILE RESPONSIVE ADJUSTMENTS */
/* ------------------------------- */
@media (max-width: 600px) {
/* Make the sidebar full width on mobile */
#downloadQueue {
width: 100%;
right: -100%; /* Off-screen fully */
padding: 15px;
}
/* When active, the sidebar slides into view from full width */
#downloadQueue.active {
right: 0;
}
/* Adjust header and title for smaller screens */
.queue-header {
flex-direction: column;
align-items: flex-start;
}
.queue-title {
font-size: 1.1rem;
}
/* Reduce the size of the close buttons */
.queue-close,
.close-btn {
width: 28px;
height: 28px;
font-size: 18px;
}
/* Adjust queue items padding */
.queue-item {
padding: 12px;
margin-bottom: 12px;
}
/* Ensure text remains legible on smaller screens */
.queue-item .log,
.queue-item .type {
font-size: 12px;
}
}
/* -------------------------- */
/* ERROR BUTTONS STYLES */
/* -------------------------- */
/* Container for error action buttons */
.error-buttons {
display: flex;
gap: 10px;
margin-top: 8px;
}
/* Base styles for error buttons */
.error-buttons button {
background: #2a2a2a;
border: none;
padding: 8px 12px;
border-radius: 4px;
color: #fff;
cursor: pointer;
transition: background 0.3s ease, transform 0.2s ease;
font-size: 14px;
}
/* Hover state for all error buttons */
.error-buttons button:hover {
background: #333;
}
/* Specific styles for the Close (X) error button */
.close-error-btn {
background: #ff5555;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: 20px;
padding: 0;
}
/* Hover state for the Close (X) button */
.close-error-btn:hover {
background: #ff7777;
}
/* Specific styles for the Retry button */
.retry-btn {
background: #1DB954;
padding: 8px 16px;
border-radius: 4px;
font-weight: bold;
}
/* Hover state for the Retry button */
.retry-btn:hover {
background: #17a448;
}

File diff suppressed because it is too large Load Diff

262
static/css/track/track.css Normal file
View File

@@ -0,0 +1,262 @@
/* Base Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
body {
background: linear-gradient(135deg, #121212, #1e1e1e);
color: #ffffff;
min-height: 100vh;
line-height: 1.4;
}
/* App Container */
#app {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
position: relative;
z-index: 1;
}
/* Track Header:
We assume an HTML structure like:
<div id="track-header">
<img id="track-album-image" ... />
<div id="track-info">
... (track details: name, artist, album, duration, explicit)
</div>
<!-- Download button will be appended here -->
</div>
*/
#track-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid #2a2a2a;
flex-wrap: wrap;
}
/* Album Image */
#track-album-image {
width: 200px;
height: 200px;
object-fit: cover;
border-radius: 8px;
flex-shrink: 0;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
transition: transform 0.3s ease;
}
#track-album-image:hover {
transform: scale(1.02);
}
/* Track Info */
#track-info {
flex: 1;
min-width: 0;
/* For mobile, the text block can wrap if needed */
}
/* Track Text Elements */
#track-name {
font-size: 2.5rem;
margin-bottom: 0.5rem;
background: linear-gradient(90deg, #1db954, #17a44b);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
#track-artist,
#track-album,
#track-duration,
#track-explicit {
font-size: 1.1rem;
color: #b3b3b3;
margin-bottom: 0.5rem;
}
/* Download Button */
.download-btn {
background-color: #1db954;
border: none;
border-radius: 4px;
padding: 0.6rem;
cursor: pointer;
transition: background-color 0.2s ease, transform 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
}
/* Download Button Icon */
.download-btn img {
width: 20px;
height: 20px;
filter: brightness(0) invert(1);
display: block;
}
/* Hover and Active States */
.download-btn:hover {
background-color: #17a44b;
transform: scale(1.05);
}
.download-btn:active {
transform: scale(0.98);
}
/* Home Button Styling */
.home-btn {
background-color: transparent;
border: none;
cursor: pointer;
padding: 0;
}
.home-btn img {
width: 32px;
height: 32px;
filter: invert(1);
transition: transform 0.2s ease;
}
.home-btn:hover img {
transform: scale(1.05);
}
.home-btn:active img {
transform: scale(0.98);
}
/* Loading and Error Messages */
#loading,
#error {
width: 100%;
text-align: center;
font-size: 1rem;
padding: 1rem;
}
#error {
color: #c0392b;
}
/* Utility class to hide elements */
.hidden {
display: none !important;
}
/* Responsive Styles for Tablets and Smaller Devices */
@media (max-width: 768px) {
#app {
padding: 15px;
}
#track-header {
flex-direction: column;
align-items: center;
text-align: center;
}
#track-album-image {
width: 180px;
height: 180px;
}
#track-name {
font-size: 2rem;
}
#track-artist,
#track-album,
#track-duration,
#track-explicit {
font-size: 1rem;
}
.download-btn {
padding: 0.5rem;
margin-top: 0.8rem;
}
}
/* Responsive Styles for Mobile Phones */
@media (max-width: 480px) {
#track-album-image {
width: 150px;
height: 150px;
}
#track-name {
font-size: 1.75rem;
}
#track-artist,
#track-album,
#track-duration,
#track-explicit {
font-size: 0.9rem;
}
.download-btn {
padding: 0.5rem;
margin-top: 0.8rem;
}
}
/* Prevent anchor links from appearing all blue */
a {
color: inherit;
text-decoration: none;
transition: color 0.2s ease;
}
a:hover,
a:focus {
color: #1db954;
text-decoration: underline;
}
/* Ensure the header lays out its children with space-between */
#track-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid #2a2a2a;
flex-wrap: wrap;
}
/* (Optional) If you need to style the download button specifically: */
.download-btn {
background-color: #1db954;
border: none;
border-radius: 4px;
padding: 0.6rem;
cursor: pointer;
transition: background-color 0.2s ease, transform 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
}
/* Style the download buttons icon */
.download-btn img {
width: 20px;
height: 20px;
filter: brightness(0) invert(1);
display: block;
}
/* Hover and active states */
.download-btn:hover {
background-color: #17a44b;
transform: scale(1.05);
}
.download-btn:active {
transform: scale(0.98);
}
/* Responsive adjustments remain as before */
@media (max-width: 768px) {
#track-header {
flex-direction: column;
align-items: center;
text-align: center;
}
}

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" height="800px" width="800px" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 492 492" xml:space="preserve">
<g>
<g>
<path d="M442.668,268.536l-16.116-16.12c-5.06-5.068-11.824-7.872-19.024-7.872c-7.208,0-14.584,2.804-19.644,7.872
L283.688,355.992V26.924C283.688,12.084,272.856,0,258.02,0h-22.804c-14.832,0-28.404,12.084-28.404,26.924v330.24
L102.824,252.416c-5.068-5.068-11.444-7.872-18.652-7.872c-7.2,0-13.776,2.804-18.84,7.872l-16.028,16.12
c-10.488,10.492-10.444,27.56,0.044,38.052l177.576,177.556c5.056,5.056,11.84,7.856,19.1,7.856h0.076
c7.204,0,13.972-2.8,19.028-7.856l177.54-177.552C453.164,296.104,453.164,279.028,442.668,268.536z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 904 B

1
static/images/home.svg Normal file
View File

@@ -0,0 +1 @@
<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30" width="30px" height="30px"> <path d="M 15 2 A 1 1 0 0 0 14.300781 2.2851562 L 3.3925781 11.207031 A 1 1 0 0 0 3.3554688 11.236328 L 3.3183594 11.267578 L 3.3183594 11.269531 A 1 1 0 0 0 3 12 A 1 1 0 0 0 4 13 L 5 13 L 5 24 C 5 25.105 5.895 26 7 26 L 23 26 C 24.105 26 25 25.105 25 24 L 25 13 L 26 13 A 1 1 0 0 0 27 12 A 1 1 0 0 0 26.681641 11.267578 L 26.666016 11.255859 A 1 1 0 0 0 26.597656 11.199219 L 25 9.8925781 L 25 6 C 25 5.448 24.552 5 24 5 L 23 5 C 22.448 5 22 5.448 22 6 L 22 7.4394531 L 15.677734 2.2675781 A 1 1 0 0 0 15 2 z M 18 15 L 22 15 L 22 23 L 18 23 L 18 15 z"/></svg>

After

Width:  |  Height:  |  Size: 673 B

2
static/images/view.svg Normal file
View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10 4.4C3.439 4.4 0 9.232 0 10c0 .766 3.439 5.6 10 5.6 6.56 0 10-4.834 10-5.6 0-.768-3.44-5.6-10-5.6zm0 9.907c-2.455 0-4.445-1.928-4.445-4.307S7.545 5.691 10 5.691s4.444 1.93 4.444 4.309-1.989 4.307-4.444 4.307zM10 10c-.407-.447.663-2.154 0-2.154-1.228 0-2.223.965-2.223 2.154s.995 2.154 2.223 2.154c1.227 0 2.223-.965 2.223-2.154 0-.547-1.877.379-2.223 0z"/></svg>

After

Width:  |  Height:  |  Size: 597 B

220
static/js/album.js Normal file
View File

@@ -0,0 +1,220 @@
import { downloadQueue } from './queue.js';
document.addEventListener('DOMContentLoaded', () => {
const pathSegments = window.location.pathname.split('/');
const albumId = pathSegments[pathSegments.indexOf('album') + 1];
if (!albumId) {
showError('No album ID provided.');
return;
}
fetch(`/api/album/info?id=${encodeURIComponent(albumId)}`)
.then(response => {
if (!response.ok) throw new Error('Network response was not ok');
return response.json();
})
.then(data => renderAlbum(data))
.catch(error => {
console.error('Error:', error);
showError('Failed to load album.');
});
const queueIcon = document.getElementById('queueIcon');
if (queueIcon) {
queueIcon.addEventListener('click', () => {
downloadQueue.toggleVisibility();
});
}
});
function renderAlbum(album) {
document.getElementById('loading').classList.add('hidden');
document.getElementById('error').classList.add('hidden');
const baseUrl = window.location.origin;
// Album header info with embedded links
// Album name becomes a link to the album page.
document.getElementById('album-name').innerHTML =
`<a href="${baseUrl}/album/${album.id}">${album.name}</a>`;
// Album artists become links to their artist pages.
document.getElementById('album-artist').innerHTML =
`By ${album.artists.map(artist => `<a href="${baseUrl}/artist/${artist.id}">${artist.name}</a>`).join(', ')}`;
const releaseYear = new Date(album.release_date).getFullYear();
document.getElementById('album-stats').textContent =
`${releaseYear}${album.total_tracks} songs • ${album.label}`;
document.getElementById('album-copyright').textContent =
album.copyrights.map(c => c.text).join(' • ');
const image = album.images[0]?.url || 'placeholder.jpg';
document.getElementById('album-image').src = image;
// Home Button using SVG icon
let homeButton = document.getElementById('homeButton');
if (!homeButton) {
homeButton = document.createElement('button');
homeButton.id = 'homeButton';
homeButton.className = 'home-btn';
// Create an image element for the home icon.
const homeIcon = document.createElement('img');
homeIcon.src = '/static/images/home.svg';
homeIcon.alt = 'Home';
homeButton.appendChild(homeIcon);
const headerContainer = document.getElementById('album-header');
headerContainer.insertBefore(homeButton, headerContainer.firstChild);
}
homeButton.addEventListener('click', () => {
window.location.href = window.location.origin;
});
// Download Album Button
let downloadAlbumBtn = document.getElementById('downloadAlbumBtn');
if (!downloadAlbumBtn) {
downloadAlbumBtn = document.createElement('button');
downloadAlbumBtn.id = 'downloadAlbumBtn';
downloadAlbumBtn.textContent = 'Download Full Album';
downloadAlbumBtn.className = 'download-btn download-btn--main';
document.getElementById('album-header').appendChild(downloadAlbumBtn);
}
downloadAlbumBtn.addEventListener('click', () => {
document.querySelectorAll('.download-btn').forEach(btn => {
if (btn.id !== 'downloadAlbumBtn') btn.remove();
});
downloadAlbumBtn.disabled = true;
downloadAlbumBtn.textContent = 'Queueing...';
downloadWholeAlbum(album).then(() => {
downloadAlbumBtn.textContent = 'Queued!';
}).catch(err => {
showError('Failed to queue album download: ' + err.message);
downloadAlbumBtn.disabled = false;
});
});
// Render tracks
const tracksList = document.getElementById('tracks-list');
tracksList.innerHTML = '';
album.tracks.items.forEach((track, index) => {
const trackElement = document.createElement('div');
trackElement.className = 'track';
trackElement.innerHTML = `
<div class="track-number">${index + 1}</div>
<div class="track-info">
<div class="track-name">
<a href="${baseUrl}/track/${track.id}">${track.name}</a>
</div>
<div class="track-artist">
${track.artists.map(a => `<a href="${baseUrl}/artist/${a.id}">${a.name}</a>`).join(', ')}
</div>
</div>
<div class="track-duration">${msToTime(track.duration_ms)}</div>
<button class="download-btn download-btn--circle"
data-url="${track.external_urls.spotify}"
data-type="track"
data-name="${track.name}"
title="Download">
<img src="/static/images/download.svg" alt="Download">
</button>
`;
tracksList.appendChild(trackElement);
});
document.getElementById('album-header').classList.remove('hidden');
document.getElementById('tracks-container').classList.remove('hidden');
attachDownloadListeners();
}
async function downloadWholeAlbum(album) {
const url = album.external_urls.spotify;
startDownload(url, 'album', { name: album.name });
}
function msToTime(duration) {
const minutes = Math.floor(duration / 60000);
const seconds = ((duration % 60000) / 1000).toFixed(0);
return `${minutes}:${seconds.padStart(2, '0')}`;
}
function showError(message) {
const errorEl = document.getElementById('error');
errorEl.textContent = message;
errorEl.classList.remove('hidden');
}
function attachDownloadListeners() {
document.querySelectorAll('.download-btn').forEach((btn) => {
if (btn.id === 'downloadAlbumBtn') return;
btn.addEventListener('click', (e) => {
e.stopPropagation();
const url = e.currentTarget.dataset.url;
const type = e.currentTarget.dataset.type;
const name = e.currentTarget.dataset.name || extractName(url);
const albumType = e.currentTarget.dataset.albumType;
// Remove the button after click
e.currentTarget.remove();
// Start the download for this track.
startDownload(url, type, { name }, albumType);
});
});
}
async function startDownload(url, type, item, albumType) {
// Retrieve configuration (if any) from localStorage
const config = JSON.parse(localStorage.getItem('activeConfig')) || {};
const {
fallback = false,
spotify = '',
deezer = '',
spotifyQuality = 'NORMAL',
deezerQuality = 'MP3_128',
realTime = false
} = config;
const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer';
let apiUrl = '';
// Build API URL based on the download type.
if (type === 'album') {
apiUrl = `/api/album/download?service=${service}&url=${encodeURIComponent(url)}`;
} else if (type === 'artist') {
apiUrl = `/api/artist/download?service=${service}&artist_url=${encodeURIComponent(url)}&album_type=${encodeURIComponent(albumType || 'album,single,compilation')}`;
} else {
// Default is track download.
apiUrl = `/api/${type}/download?service=${service}&url=${encodeURIComponent(url)}`;
}
// Append account and quality details.
if (fallback && service === 'spotify') {
apiUrl += `&main=${deezer}&fallback=${spotify}`;
apiUrl += `&quality=${deezerQuality}&fall_quality=${spotifyQuality}`;
} else {
const mainAccount = service === 'spotify' ? spotify : deezer;
apiUrl += `&main=${mainAccount}&quality=${service === 'spotify' ? spotifyQuality : deezerQuality}`;
}
if (realTime) {
apiUrl += '&real_time=true';
}
try {
const response = await fetch(apiUrl);
const data = await response.json();
// Add the download to the queue using the working queue implementation.
downloadQueue.addDownload(item, type, data.prg_file);
} catch (error) {
showError('Download failed: ' + error.message);
}
}

View File

@@ -1,980 +0,0 @@
const serviceConfig = {
spotify: {
fields: [
{ id: 'username', label: 'Username', type: 'text' },
{ id: 'credentials', label: 'Credentials', type: 'text' }
],
validator: (data) => ({
username: data.username,
credentials: data.credentials
})
},
deezer: {
fields: [
{ id: 'arl', label: 'ARL', type: 'text' }
],
validator: (data) => ({
arl: data.arl
})
}
};
let currentService = 'spotify';
let currentCredential = null;
let downloadQueue = {};
let prgInterval = null;
document.addEventListener('DOMContentLoaded', () => {
const searchButton = document.getElementById('searchButton');
const searchInput = document.getElementById('searchInput');
const settingsIcon = document.getElementById('settingsIcon');
const sidebar = document.getElementById('settingsSidebar');
const closeSidebar = document.getElementById('closeSidebar');
const serviceTabs = document.querySelectorAll('.tab-button');
// Initialize configuration
initConfig();
// Search functionality
searchButton.addEventListener('click', performSearch);
searchInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') performSearch();
});
// Settings functionality
settingsIcon.addEventListener('click', () => {
if (sidebar.classList.contains('active')) {
// Collapse sidebar if already expanded
sidebar.classList.remove('active');
resetForm();
} else {
// Expand sidebar and load credentials
sidebar.classList.add('active');
loadCredentials(currentService);
updateFormFields();
}
});
closeSidebar.addEventListener('click', () => {
sidebar.classList.remove('active');
resetForm();
});
serviceTabs.forEach(tab => {
tab.addEventListener('click', () => {
serviceTabs.forEach(t => t.classList.remove('active'));
tab.classList.add('active');
currentService = tab.dataset.service;
loadCredentials(currentService);
updateFormFields();
});
});
document.getElementById('credentialForm').addEventListener('submit', handleCredentialSubmit);
});
async function initConfig() {
loadConfig();
await updateAccountSelectors();
// Existing listeners
const fallbackToggle = document.getElementById('fallbackToggle');
if (fallbackToggle) {
fallbackToggle.addEventListener('change', () => {
saveConfig();
updateAccountSelectors();
});
}
const accountSelects = ['spotifyAccountSelect', 'deezerAccountSelect'];
accountSelects.forEach(id => {
const element = document.getElementById(id);
if (element) {
element.addEventListener('change', () => {
saveConfig();
updateAccountSelectors();
});
}
});
const spotifyQuality = document.getElementById('spotifyQualitySelect');
if (spotifyQuality) {
spotifyQuality.addEventListener('change', saveConfig);
}
const deezerQuality = document.getElementById('deezerQualitySelect');
if (deezerQuality) {
deezerQuality.addEventListener('change', saveConfig);
}
// Load existing PRG files after initial setup
await loadExistingPrgFiles();
}
async function updateAccountSelectors() {
try {
// Get current saved configuration
const saved = JSON.parse(localStorage.getItem('activeConfig')) || {};
// Fetch available credentials
const [spotifyResponse, deezerResponse] = await Promise.all([
fetch('/api/credentials/spotify'),
fetch('/api/credentials/deezer')
]);
const spotifyAccounts = await spotifyResponse.json();
const deezerAccounts = await deezerResponse.json();
// Update Spotify selector
const spotifySelect = document.getElementById('spotifyAccountSelect');
const isValidSpotify = spotifyAccounts.includes(saved.spotify);
spotifySelect.innerHTML = spotifyAccounts.map(a =>
`<option value="${a}" ${a === saved.spotify ? 'selected' : ''}>${a}</option>`
).join('');
// Validate/correct Spotify selection
if (!isValidSpotify && spotifyAccounts.length > 0) {
spotifySelect.value = spotifyAccounts[0];
saved.spotify = spotifyAccounts[0];
localStorage.setItem('activeConfig', JSON.stringify(saved));
}
// Update Deezer selector
const deezerSelect = document.getElementById('deezerAccountSelect');
const isValidDeezer = deezerAccounts.includes(saved.deezer);
deezerSelect.innerHTML = deezerAccounts.map(a =>
`<option value="${a}" ${a === saved.deezer ? 'selected' : ''}>${a}</option>`
).join('');
// Validate/correct Deezer selection
if (!isValidDeezer && deezerAccounts.length > 0) {
deezerSelect.value = deezerAccounts[0];
saved.deezer = deezerAccounts[0];
localStorage.setItem('activeConfig', JSON.stringify(saved));
}
// Handle empty states
[spotifySelect, deezerSelect].forEach((select, index) => {
const accounts = index === 0 ? spotifyAccounts : deezerAccounts;
if (accounts.length === 0) {
select.innerHTML = '<option value="">No accounts available</option>';
select.value = '';
}
});
} catch (error) {
console.error('Error updating account selectors:', error);
}
}
function toggleDownloadQueue() {
const queueSidebar = document.getElementById('downloadQueue');
queueSidebar.classList.toggle('active');
}
function performSearch() {
const query = document.getElementById('searchInput').value.trim();
const searchType = document.getElementById('searchType').value;
const resultsContainer = document.getElementById('resultsContainer');
if (!query) {
showError('Please enter a search term');
return;
}
// Handle direct Spotify URLs for tracks, albums, playlists, and artists
if (isSpotifyUrl(query)) {
try {
const type = getResourceTypeFromUrl(query);
const supportedTypes = ['track', 'album', 'playlist', 'artist'];
if (!supportedTypes.includes(type)) {
throw new Error('Unsupported URL type');
}
const item = {
name: `Direct URL (${type})`,
external_urls: { spotify: query }
};
// 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 = '';
return;
} catch (error) {
showError(`Invalid Spotify URL: ${error.message}`);
return;
}
}
// Standard search
resultsContainer.innerHTML = '<div class="loading">Searching...</div>';
fetch(`/api/search?q=${encodeURIComponent(query)}&search_type=${searchType}&limit=50`)
.then(response => response.json())
.then(data => {
if (data.error) throw new Error(data.error);
const items = data.data[`${searchType}s`]?.items;
if (!items || !items.length) {
resultsContainer.innerHTML = '<div class="error">No results found</div>';
return;
}
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');
cards.forEach((card, index) => {
card.querySelectorAll('.download-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const url = e.currentTarget.dataset.url;
const type = e.currentTarget.dataset.type;
const albumType = e.currentTarget.dataset.albumType;
// Check if the clicked button is the main download button
const isMainButton = e.currentTarget.classList.contains('main-download');
if (isMainButton) {
// Remove the entire card for main download button
card.remove();
} else {
// Only remove the clicked specific button
e.currentTarget.remove();
}
startDownload(url, type, items[index], albumType);
});
});
});
})
.catch(error => showError(error.message));
}
function createResultCard(item, type) {
let imageUrl, title, subtitle, details;
switch(type) {
case 'track':
imageUrl = item.album.images[0]?.url || '';
title = item.name;
subtitle = item.artists.map(a => a.name).join(', ');
details = `
<span>${item.album.name}</span>
<span class="duration">${msToMinutesSeconds(item.duration_ms)}</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>
<button class="download-btn"
data-url="${item.external_urls.spotify}"
data-type="${type}">
Download
</button>
</div>
`;
case 'playlist':
imageUrl = item.images[0]?.url || '';
title = item.name;
subtitle = item.owner.display_name;
details = `
<span>${item.tracks.total} tracks</span>
<span class="duration">${item.description || 'No description'}</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>
<button class="download-btn"
data-url="${item.external_urls.spotify}"
data-type="${type}">
Download
</button>
</div>
`;
case 'album':
imageUrl = item.images[0]?.url || '';
title = item.name;
subtitle = item.artists.map(a => a.name).join(', ');
details = `
<span>${item.release_date}</span>
<span class="duration">${item.total_tracks} tracks</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>
<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>
<!-- Collapsible Options -->
<div class="download-options-container">
<button class="options-toggle" onclick="this.nextElementSibling.classList.toggle('expanded')">
More Options
<svg class="toggle-chevron" viewBox="0 0 24 24">
<path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/>
</svg>
</button>
<div class="secondary-options">
<button class="download-btn option-btn"
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, albumType) {
const fallbackEnabled = document.getElementById('fallbackToggle').checked;
const spotifyAccount = document.getElementById('spotifyAccountSelect').value;
const deezerAccount = document.getElementById('deezerAccountSelect').value;
// Determine service from URL
let service;
if (url.includes('open.spotify.com')) {
service = 'spotify';
} else if (url.includes('deezer.com')) {
service = 'deezer';
} else {
showError('Unsupported service URL');
return;
}
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
const spotifyQuality = document.getElementById('spotifyQualitySelect').value;
const deezerQuality = document.getElementById('deezerQualitySelect').value;
if (fallbackEnabled && service === 'spotify') {
// Deezer fallback for Spotify URLs
apiUrl += `&main=${deezerAccount}&fallback=${spotifyAccount}`;
apiUrl += `&quality=${encodeURIComponent(deezerQuality)}`;
apiUrl += `&fall_quality=${encodeURIComponent(spotifyQuality)}`;
} else {
// Standard download without fallback
const mainAccount = service === 'spotify' ? spotifyAccount : deezerAccount;
apiUrl += `&main=${mainAccount}`;
apiUrl += `&quality=${encodeURIComponent(service === 'spotify' ? spotifyQuality : deezerQuality)}`;
}
// New: append real_time parameter if Real time downloading is enabled
const realTimeEnabled = document.getElementById('realTimeToggle').checked;
if (realTimeEnabled) {
apiUrl += `&real_time=true`;
}
try {
const response = await fetch(apiUrl);
const data = await response.json();
addToQueue(item, type, data.prg_file);
} catch (error) {
showError('Download failed: ' + error.message);
}
}
function addToQueue(item, type, prgFile) {
const queueId = Date.now().toString() + Math.random().toString(36).substr(2, 9);
const entry = {
item,
type,
prgFile,
element: createQueueItem(item, type, prgFile, queueId),
lastStatus: null,
lastUpdated: Date.now(),
hasEnded: false,
intervalId: null,
uniqueId: queueId // Add unique identifier
};
downloadQueue[queueId] = entry;
document.getElementById('queueItems').appendChild(entry.element);
startEntryMonitoring(queueId);
}
async function startEntryMonitoring(queueId) {
const entry = downloadQueue[queueId];
if (!entry || entry.hasEnded) return;
entry.intervalId = setInterval(async () => {
const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`);
if (entry.hasEnded) {
clearInterval(entry.intervalId);
return;
}
try {
const response = await fetch(`/api/prgs/${entry.prgFile}`);
const data = await response.json();
// data contains: { type, name, last_line }
const progress = data.last_line;
if (entry.type !== 'track' && progress?.type === 'track') {
return; // Skip track-type messages for non-track downloads
}
// If there is no progress data, handle as inactivity.
if (!progress) {
handleInactivity(entry, queueId, logElement);
return;
}
// Check for unchanged status to handle inactivity.
if (JSON.stringify(entry.lastStatus) === JSON.stringify(progress)) {
handleInactivity(entry, queueId, logElement);
return;
}
// Update entry state and log.
entry.lastStatus = progress;
entry.lastUpdated = Date.now();
entry.status = progress.status;
logElement.textContent = getStatusMessage(progress);
// Handle terminal states.
if (progress.status === 'error' || progress.status === 'complete' || progress.status === 'cancel') {
handleTerminalState(entry, queueId, progress);
}
} catch (error) {
console.error('Status check failed:', error);
handleTerminalState(entry, queueId, {
status: 'error',
message: 'Status check error'
});
}
}, 2000);
}
function handleInactivity(entry, queueId, logElement) {
// Check if real time downloading is enabled
const realTimeEnabled = document.getElementById('realTimeToggle')?.checked;
if (realTimeEnabled) {
// Do nothing if real time downloading is enabled (no timeout)
return;
}
// Only trigger timeout if more than 3 minutes (180000 ms) of inactivity
if (Date.now() - entry.lastUpdated > 180000) {
logElement.textContent = 'Download timed out (3 minutes inactivity)';
handleTerminalState(entry, queueId, { status: 'timeout' });
}
}
// Update the handleTerminalState function to handle 'cancel' status:
function handleTerminalState(entry, queueId, data) {
const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`);
entry.hasEnded = true;
entry.status = data.status;
if (data.status === 'error') {
logElement.innerHTML = `
<span class="error-status">${getStatusMessage(data)}</span>
<button class="retry-btn">Retry</button>
<button class="close-btn">×</button>
`;
logElement.querySelector('.retry-btn').addEventListener('click', () => {
startDownload(entry.item.external_urls.spotify, entry.type, entry.item);
cleanupEntry(queueId);
});
logElement.querySelector('.close-btn').addEventListener('click', () => {
cleanupEntry(queueId);
});
entry.element.classList.add('failed');
} else if (data.status === 'cancel') {
logElement.textContent = 'Download cancelled by user';
setTimeout(() => cleanupEntry(queueId), 5000);
} else if (data.status === 'complete') {
setTimeout(() => cleanupEntry(queueId), 5000);
}
clearInterval(entry.intervalId);
}
function cleanupEntry(queueId) {
const entry = downloadQueue[queueId];
if (entry) {
clearInterval(entry.intervalId);
entry.element.remove();
const prgFile = entry.prgFile;
delete downloadQueue[queueId];
// Send delete request for the PRG file
fetch(`/api/prgs/delete/${encodeURIComponent(prgFile)}`, { method: 'DELETE' })
.catch(err => console.error('Error deleting PRG file:', err));
}
}
async function loadExistingPrgFiles() {
try {
const response = await fetch('/api/prgs/list');
if (!response.ok) throw new Error('Failed to fetch PRG files');
const prgFiles = await response.json();
for (const prgFile of prgFiles) {
try {
const prgResponse = await fetch(`/api/prgs/${prgFile}`);
const prgData = await prgResponse.json();
// If name is empty, fallback to using the prgFile as title.
const title = prgData.name || prgFile;
const type = prgData.type || "unknown";
const dummyItem = {
name: title,
external_urls: {} // You can expand this if needed.
};
addToQueue(dummyItem, type, prgFile);
} catch (innerError) {
console.error('Error processing PRG file', prgFile, ':', innerError);
}
}
} catch (error) {
console.error('Error loading existing PRG files:', error);
}
}
function createQueueItem(item, type, prgFile, queueId) {
const div = document.createElement('div');
div.className = 'queue-item';
div.innerHTML = `
<div class="title">${item.name}</div>
<div class="type">${type.charAt(0).toUpperCase() + type.slice(1)}</div>
<div class="log" id="log-${queueId}-${prgFile}">Initializing download...</div>
<button class="cancel-btn" data-prg="${prgFile}" data-type="${type}" data-queueid="${queueId}" title="Cancel Download">
<img src="https://www.svgrepo.com/show/488384/skull-head.svg" alt="Cancel Download">
</button>
`;
// Attach cancel event listener
const cancelBtn = div.querySelector('.cancel-btn');
cancelBtn.addEventListener('click', async (e) => {
e.stopPropagation();
// Hide the cancel button immediately so the user cant click it again.
cancelBtn.style.display = 'none';
const prg = e.target.closest('button').dataset.prg;
const type = e.target.closest('button').dataset.type;
const queueId = e.target.closest('button').dataset.queueid;
// Determine the correct cancel endpoint based on the type.
// 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)}`;
try {
const response = await fetch(cancelEndpoint);
const data = await response.json();
if (data.status === "cancel") {
const logElement = document.getElementById(`log-${queueId}-${prg}`);
logElement.textContent = "Download cancelled";
// Mark the entry as ended and clear its monitoring interval.
const entry = downloadQueue[queueId];
if (entry) {
entry.hasEnded = true;
clearInterval(entry.intervalId);
}
// Remove the queue item after 5 seconds, same as when a download finishes.
setTimeout(() => cleanupEntry(queueId), 5000);
} else {
alert("Cancel error: " + (data.error || "Unknown error"));
}
} catch (error) {
alert("Cancel error: " + error.message);
}
});
return div;
}
async function loadCredentials(service) {
try {
const response = await fetch(`/api/credentials/${service}`);
renderCredentialsList(service, await response.json());
} catch (error) {
showSidebarError(error.message);
}
}
function renderCredentialsList(service, credentials) {
const list = document.querySelector('.credentials-list');
list.innerHTML = credentials.map(name => `
<div class="credential-item">
<span>${name}</span>
<div class="credential-actions">
<button class="edit-btn" data-name="${name}" data-service="${service}">Edit</button>
<button class="delete-btn" data-name="${name}" data-service="${service}">Delete</button>
</div>
</div>
`).join('');
list.querySelectorAll('.delete-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
try {
const service = e.target.dataset.service;
const name = e.target.dataset.name;
if (!service || !name) {
throw new Error('Missing credential information');
}
const response = await fetch(`/api/credentials/${service}/${name}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Failed to delete credential');
}
// Update active account if deleted credential was selected
const accountSelect = document.getElementById(`${service}AccountSelect`);
if (accountSelect.value === name) {
accountSelect.value = '';
saveConfig();
}
// Refresh UI
loadCredentials(service);
await updateAccountSelectors();
} catch (error) {
showSidebarError(error.message);
console.error('Delete error:', error);
}
});
});
list.querySelectorAll('.edit-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
const service = e.target.dataset.service;
const name = e.target.dataset.name;
try {
// Switch to correct service tab
document.querySelector(`[data-service="${service}"]`).click();
await new Promise(resolve => setTimeout(resolve, 50));
// Load credential data
const response = await fetch(`/api/credentials/${service}/${name}`);
const data = await response.json();
currentCredential = name;
document.getElementById('credentialName').value = name;
document.getElementById('credentialName').disabled = true;
populateFormFields(service, data);
} catch (error) {
showSidebarError(error.message);
}
});
});
}
function updateFormFields() {
const serviceFields = document.getElementById('serviceFields');
serviceFields.innerHTML = serviceConfig[currentService].fields.map(field => `
<div class="form-group">
<label>${field.label}:</label>
<input type="${field.type}"
id="${field.id}"
name="${field.id}"
required
${field.type === 'password' ? 'autocomplete="new-password"' : ''}>
</div>
`).join('');
}
function populateFormFields(service, data) {
serviceConfig[service].fields.forEach(field => {
const element = document.getElementById(field.id);
if (element) element.value = data[field.id] || '';
});
}
async function handleCredentialSubmit(e) {
e.preventDefault();
const service = document.querySelector('.tab-button.active').dataset.service;
const nameInput = document.getElementById('credentialName');
const name = nameInput.value.trim();
try {
if (!currentCredential && !name) {
throw new Error('Credential name is required');
}
const formData = {};
serviceConfig[service].fields.forEach(field => {
formData[field.id] = document.getElementById(field.id).value.trim();
});
const data = serviceConfig[service].validator(formData);
const endpointName = currentCredential || name;
const method = currentCredential ? 'PUT' : 'POST';
const response = await fetch(`/api/credentials/${service}/${endpointName}`, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to save credentials');
}
// Refresh and persist after credential changes
await updateAccountSelectors();
saveConfig();
loadCredentials(service);
resetForm();
} catch (error) {
showSidebarError(error.message);
console.error('Submission error:', error);
}
}
function resetForm() {
currentCredential = null;
const nameInput = document.getElementById('credentialName');
nameInput.value = '';
nameInput.disabled = false;
document.getElementById('credentialForm').reset();
}
// Helper functions
function msToMinutesSeconds(ms) {
const minutes = Math.floor(ms / 60000);
const seconds = ((ms % 60000) / 1000).toFixed(0);
return `${minutes}:${seconds.padStart(2, '0')}`;
}
function showError(message) {
document.getElementById('resultsContainer').innerHTML = `<div class="error">${message}</div>`;
}
function showSidebarError(message) {
const errorDiv = document.getElementById('sidebarError');
errorDiv.textContent = message;
setTimeout(() => errorDiv.textContent = '', 3000);
}
function getStatusMessage(data) {
switch (data.status) {
case 'downloading':
// For track downloads only.
if (data.type === 'track') {
return `Downloading track "${data.song}" by ${data.artist}...`;
}
return `Downloading ${data.type}...`;
case 'initializing':
if (data.type === 'playlist') {
return `Initializing playlist download "${data.name}" with ${data.total_tracks} tracks...`;
} else if (data.type === 'album') {
return `Initializing album download "${data.album}" by ${data.artist}...`;
} else if (data.type === 'artist') {
return `Initializing artist download for ${data.artist} with ${data.total_albums} album(s) [${data.album_type}]...`;
}
return `Initializing ${data.type} download...`;
case 'progress':
// Expect progress messages for playlists, albums (or artists albums) to include a "track" and "current_track".
if (data.track && data.current_track) {
// current_track is a string in the format "current/total"
const parts = data.current_track.split('/');
const current = parts[0];
const total = parts[1] || '?';
if (data.type === 'playlist') {
return `Downloading playlist: Track ${current} of ${total} - ${data.track}`;
} else if (data.type === 'album') {
// For album progress, the "album" and "artist" fields may be available on a done message.
// In some cases (like artist downloads) only track info is passed.
if (data.album && data.artist) {
return `Downloading album "${data.album}" by ${data.artist}: track ${current} of ${total} - ${data.track}`;
} else {
return `Downloading track ${current} of ${total}: ${data.track} from ${data.album}`;
}
}
}
// Fallback if fields are missing:
return `Progress: ${data.status}...`;
case 'done':
if (data.type === 'track') {
return `Finished track "${data.song}" by ${data.artist}`;
} else if (data.type === 'playlist') {
return `Finished playlist "${data.name}" with ${data.total_tracks} tracks`;
} else if (data.type === 'album') {
return `Finished album "${data.album}" by ${data.artist}`;
} else if (data.type === 'artist') {
return `Finished artist "${data.artist}" (${data.album_type})`;
}
return `Finished ${data.type}`;
case 'retrying':
return `Track "${data.song}" by ${data.artist}" failed, retrying (${data.retry_count}/10) in ${data.seconds_left}s`;
case 'error':
return `Error: ${data.message || 'Unknown error'}`;
case 'complete':
return 'Download completed successfully';
case 'skipped':
return `Track "${data.song}" skipped, it already exists!`;
case 'real_time': {
// Convert milliseconds to minutes and seconds.
const totalMs = data.time_elapsed;
const minutes = Math.floor(totalMs / 60000);
const seconds = Math.floor((totalMs % 60000) / 1000);
const paddedSeconds = seconds < 10 ? '0' + seconds : seconds;
return `Real-time downloading track "${data.song}" by ${data.artist} (${(data.percentage * 100).toFixed(1)}%). Time elapsed: ${minutes}:${paddedSeconds}`;
}
default:
return data.status;
}
}
function saveConfig() {
const config = {
spotify: document.getElementById('spotifyAccountSelect').value,
deezer: document.getElementById('deezerAccountSelect').value,
fallback: document.getElementById('fallbackToggle').checked,
spotifyQuality: document.getElementById('spotifyQualitySelect').value,
deezerQuality: document.getElementById('deezerQualitySelect').value,
realTime: document.getElementById('realTimeToggle').checked // new property
};
localStorage.setItem('activeConfig', JSON.stringify(config));
}
function loadConfig() {
const saved = JSON.parse(localStorage.getItem('activeConfig')) || {};
// Account selects
const spotifySelect = document.getElementById('spotifyAccountSelect');
if (spotifySelect) spotifySelect.value = saved.spotify || '';
const deezerSelect = document.getElementById('deezerAccountSelect');
if (deezerSelect) deezerSelect.value = saved.deezer || '';
// Fallback toggle
const fallbackToggle = document.getElementById('fallbackToggle');
if (fallbackToggle) fallbackToggle.checked = !!saved.fallback;
// Quality selects
const spotifyQuality = document.getElementById('spotifyQualitySelect');
if (spotifyQuality) spotifyQuality.value = saved.spotifyQuality || 'NORMAL';
const deezerQuality = document.getElementById('deezerQualitySelect');
if (deezerQuality) deezerQuality.value = saved.deezerQuality || 'MP3_128';
// New: Real time downloading toggle
const realTimeToggle = document.getElementById('realTimeToggle');
if (realTimeToggle) realTimeToggle.checked = !!saved.realTime;
}
function isSpotifyUrl(url) {
return url.startsWith('https://open.spotify.com/');
}
function getResourceTypeFromUrl(url) {
const pathParts = new URL(url).pathname.split('/');
return pathParts[1]; // Returns 'track', 'album', 'playlist', or 'artist'
}

298
static/js/artist.js Normal file
View File

@@ -0,0 +1,298 @@
// Import the downloadQueue singleton from your working queue.js implementation.
import { downloadQueue } from './queue.js';
document.addEventListener('DOMContentLoaded', () => {
// Parse artist ID from the URL (expected route: /artist/{id})
const pathSegments = window.location.pathname.split('/');
const artistId = pathSegments[pathSegments.indexOf('artist') + 1];
if (!artistId) {
showError('No artist ID provided.');
return;
}
// Fetch the artist info (which includes a list of albums)
fetch(`/api/artist/info?id=${encodeURIComponent(artistId)}`)
.then(response => {
if (!response.ok) throw new Error('Network response was not ok');
return response.json();
})
.then(data => renderArtist(data, artistId)) // Pass artistId along
.catch(error => {
console.error('Error:', error);
showError('Failed to load artist info.');
});
const queueIcon = document.getElementById('queueIcon');
if (queueIcon) {
queueIcon.addEventListener('click', () => {
downloadQueue.toggleVisibility();
});
}
});
/**
* Renders the artist header and groups the albums by type.
*/
function renderArtist(artistData, artistId) {
// Hide loading and error messages
document.getElementById('loading').classList.add('hidden');
document.getElementById('error').classList.add('hidden');
// Use the first album to extract artist details
const firstAlbum = artistData.items[0];
const artistName = firstAlbum?.artists[0]?.name || 'Unknown Artist';
const artistImage = firstAlbum?.images[0]?.url || 'placeholder.jpg';
// Embed the artist name in a link
document.getElementById('artist-name').innerHTML =
`<a href="/artist/${artistId}" class="artist-link">${artistName}</a>`;
document.getElementById('artist-stats').textContent = `${artistData.total} albums`;
document.getElementById('artist-image').src = artistImage;
// --- Add Home Button ---
let homeButton = document.getElementById('homeButton');
if (!homeButton) {
homeButton = document.createElement('button');
homeButton.id = 'homeButton';
homeButton.className = 'home-btn';
homeButton.innerHTML = `<img src="/static/images/home.svg" alt="Home" class="home-icon">`;
const headerContainer = document.getElementById('artist-header');
headerContainer.insertBefore(homeButton, headerContainer.firstChild);
}
homeButton.addEventListener('click', () => {
window.location.href = window.location.origin;
});
// --- Add "Download Whole Artist" Button ---
let downloadArtistBtn = document.getElementById('downloadArtistBtn');
if (!downloadArtistBtn) {
downloadArtistBtn = document.createElement('button');
downloadArtistBtn.id = 'downloadArtistBtn';
downloadArtistBtn.textContent = 'Download Whole Artist';
downloadArtistBtn.className = 'download-btn download-btn--main';
const headerContainer = document.getElementById('artist-header');
headerContainer.appendChild(downloadArtistBtn);
}
downloadArtistBtn.addEventListener('click', () => {
// Remove individual album and group download buttons (but leave the whole artist button).
document.querySelectorAll('.download-btn').forEach(btn => {
if (btn.id !== 'downloadArtistBtn') {
btn.remove();
}
});
downloadArtistBtn.disabled = true;
downloadArtistBtn.textContent = 'Queueing...';
downloadWholeArtist(artistData).then(() => {
downloadArtistBtn.textContent = 'Queued!';
}).catch(err => {
showError('Failed to queue artist download: ' + err.message);
downloadArtistBtn.disabled = false;
});
});
// Group albums by album type.
const albumGroups = {};
artistData.items.forEach(album => {
const type = album.album_type.toLowerCase();
if (!albumGroups[type]) {
albumGroups[type] = [];
}
albumGroups[type].push(album);
});
// Render groups into the #album-groups container.
const groupsContainer = document.getElementById('album-groups');
groupsContainer.innerHTML = ''; // Clear previous content
// For each album type, render a section header, a "Download All" button, and the album list.
for (const [groupType, albums] of Object.entries(albumGroups)) {
const groupSection = document.createElement('section');
groupSection.className = 'album-group';
// Header with a download-all button.
const header = document.createElement('div');
header.className = 'album-group-header';
header.innerHTML = `
<h3>${capitalize(groupType)}s</h3>
<button class="download-btn download-btn--main group-download-btn"
data-album-type="${groupType}"
data-artist-url="${firstAlbum.artists[0].external_urls.spotify}">
Download All ${capitalize(groupType)}s
</button>
`;
groupSection.appendChild(header);
// Container for individual albums in this group.
const albumsContainer = document.createElement('div');
albumsContainer.className = 'albums-list';
albums.forEach((album, index) => {
const albumElement = document.createElement('div');
albumElement.className = 'track'; // reusing styling from the playlist view
// Use an <a> around the album image and name.
albumElement.innerHTML = `
<div class="track-number">${index + 1}</div>
<a href="/album/${album.id}" class="album-link">
<img class="track-image" src="${album.images[1]?.url || album.images[0]?.url || 'placeholder.jpg'}"
alt="Album cover"
style="width: 64px; height: 64px; border-radius: 4px; margin-right: 1rem;">
</a>
<div class="track-info">
<a href="/album/${album.id}" class="track-name">${album.name}</a>
<div class="track-artist"></div>
</div>
<div class="track-album">${album.release_date}</div>
<div class="track-duration">${album.total_tracks} tracks</div>
<button class="download-btn download-btn--circle"
data-url="${album.external_urls.spotify}"
data-type="album"
data-album-type="${album.album_type}"
data-name="${album.name}"
title="Download">
<img src="/static/images/download.svg" alt="Download">
</button>
`;
albumsContainer.appendChild(albumElement);
});
groupSection.appendChild(albumsContainer);
groupsContainer.appendChild(groupSection);
}
// Reveal header and albums container.
document.getElementById('artist-header').classList.remove('hidden');
document.getElementById('albums-container').classList.remove('hidden');
// Attach event listeners for individual album download buttons.
attachDownloadListeners();
// Attach event listeners for group download buttons.
attachGroupDownloadListeners();
}
/**
* Displays an error message in the UI.
*/
function showError(message) {
const errorEl = document.getElementById('error');
errorEl.textContent = message;
errorEl.classList.remove('hidden');
}
/**
* Attaches event listeners to all individual download buttons.
*/
function attachDownloadListeners() {
document.querySelectorAll('.download-btn').forEach((btn) => {
// Skip the whole artist and group download buttons.
if (btn.id === 'downloadArtistBtn' || btn.classList.contains('group-download-btn')) return;
btn.addEventListener('click', (e) => {
e.stopPropagation();
const url = e.currentTarget.dataset.url;
const type = e.currentTarget.dataset.type;
const name = e.currentTarget.dataset.name || extractName(url);
const albumType = e.currentTarget.dataset.albumType;
// Remove button after click.
e.currentTarget.remove();
startDownload(url, type, { name }, albumType);
});
});
}
/**
* Attaches event listeners to all group download buttons.
*/
function attachGroupDownloadListeners() {
document.querySelectorAll('.group-download-btn').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const albumType = e.currentTarget.dataset.albumType;
const artistUrl = e.currentTarget.dataset.artistUrl;
e.currentTarget.disabled = true;
e.currentTarget.textContent = `Queueing ${capitalize(albumType)}s...`;
startDownload(artistUrl, 'artist', { name: `All ${capitalize(albumType)}s` }, albumType)
.then(() => {
e.currentTarget.textContent = `Queued!`;
})
.catch(err => {
showError('Failed to queue group download: ' + err.message);
e.currentTarget.disabled = false;
});
});
});
}
/**
* Initiates the whole artist download by calling the artist endpoint.
*/
async function downloadWholeArtist(artistData) {
const artistUrl = artistData.items[0]?.artists[0]?.external_urls.spotify;
if (!artistUrl) throw new Error('Artist URL not found.');
startDownload(artistUrl, 'artist', { name: artistData.items[0]?.artists[0]?.name || 'Artist' });
}
/**
* Starts the download process by building the API URL,
* fetching download details, and then adding the download to the queue.
*/
async function startDownload(url, type, item, albumType) {
const config = JSON.parse(localStorage.getItem('activeConfig')) || {};
const {
fallback = false,
spotify = '',
deezer = '',
spotifyQuality = 'NORMAL',
deezerQuality = 'MP3_128',
realTime = false
} = config;
const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer';
let apiUrl = '';
if (type === 'artist') {
apiUrl = `/api/artist/download?service=${service}&artist_url=${encodeURIComponent(url)}&album_type=${encodeURIComponent(albumType || 'album,single,compilation')}`;
} else {
apiUrl = `/api/${type}/download?service=${service}&url=${encodeURIComponent(url)}`;
}
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) {
apiUrl += '&real_time=true';
}
try {
const response = await fetch(apiUrl);
const data = await response.json();
const downloadType = apiUrl.includes('/artist/download')
? 'artist'
: apiUrl.includes('/album/download')
? 'album'
: type;
downloadQueue.addDownload(item, downloadType, data.prg_file);
} catch (error) {
showError('Download failed: ' + error.message);
}
}
/**
* A helper function to extract a display name from the URL.
*/
function extractName(url) {
return url;
}
/**
* Helper to capitalize the first letter of a string.
*/
function capitalize(str) {
if (!str) return '';
return str.charAt(0).toUpperCase() + str.slice(1);
}

301
static/js/config.js Normal file
View File

@@ -0,0 +1,301 @@
import { downloadQueue } from './queue.js';
const serviceConfig = {
spotify: {
fields: [
{ id: 'username', label: 'Username', type: 'text' },
{ id: 'credentials', label: 'Credentials', type: 'text' }
],
validator: (data) => ({
username: data.username,
credentials: data.credentials
})
},
deezer: {
fields: [
{ id: 'arl', label: 'ARL', type: 'text' }
],
validator: (data) => ({
arl: data.arl
})
}
};
let currentService = 'spotify';
let currentCredential = null;
document.addEventListener('DOMContentLoaded', () => {
initConfig();
setupServiceTabs();
setupEventListeners();
// Attach click listener for the queue icon to toggle the download queue sidebar.
const queueIcon = document.getElementById('queueIcon');
if (queueIcon) {
queueIcon.addEventListener('click', () => {
downloadQueue.toggleVisibility();
});
}
});
function initConfig() {
loadConfig();
updateAccountSelectors();
loadCredentials(currentService);
updateFormFields();
}
function setupServiceTabs() {
const serviceTabs = document.querySelectorAll('.tab-button');
serviceTabs.forEach(tab => {
tab.addEventListener('click', () => {
serviceTabs.forEach(t => t.classList.remove('active'));
tab.classList.add('active');
currentService = tab.dataset.service;
loadCredentials(currentService);
updateFormFields();
});
});
}
function setupEventListeners() {
document.getElementById('credentialForm').addEventListener('submit', handleCredentialSubmit);
// Config change listeners
document.getElementById('fallbackToggle').addEventListener('change', saveConfig);
document.getElementById('realTimeToggle').addEventListener('change', saveConfig);
document.getElementById('spotifyQualitySelect').addEventListener('change', saveConfig);
document.getElementById('deezerQualitySelect').addEventListener('change', saveConfig);
// Account select changes
document.getElementById('spotifyAccountSelect').addEventListener('change', saveConfig);
document.getElementById('deezerAccountSelect').addEventListener('change', saveConfig);
}
async function updateAccountSelectors() {
try {
const saved = JSON.parse(localStorage.getItem('activeConfig')) || {};
const [spotifyResponse, deezerResponse] = await Promise.all([
fetch('/api/credentials/spotify'),
fetch('/api/credentials/deezer')
]);
const spotifyAccounts = await spotifyResponse.json();
const deezerAccounts = await deezerResponse.json();
// Update Spotify selector
const spotifySelect = document.getElementById('spotifyAccountSelect');
const isValidSpotify = spotifyAccounts.includes(saved.spotify);
spotifySelect.innerHTML = spotifyAccounts.map(a =>
`<option value="${a}" ${a === saved.spotify ? 'selected' : ''}>${a}</option>`
).join('');
if (!isValidSpotify && spotifyAccounts.length > 0) {
spotifySelect.value = spotifyAccounts[0];
saved.spotify = spotifyAccounts[0];
localStorage.setItem('activeConfig', JSON.stringify(saved));
}
// Update Deezer selector
const deezerSelect = document.getElementById('deezerAccountSelect');
const isValidDeezer = deezerAccounts.includes(saved.deezer);
deezerSelect.innerHTML = deezerAccounts.map(a =>
`<option value="${a}" ${a === saved.deezer ? 'selected' : ''}>${a}</option>`
).join('');
if (!isValidDeezer && deezerAccounts.length > 0) {
deezerSelect.value = deezerAccounts[0];
saved.deezer = deezerAccounts[0];
localStorage.setItem('activeConfig', JSON.stringify(saved));
}
[spotifySelect, deezerSelect].forEach((select, index) => {
const accounts = index === 0 ? spotifyAccounts : deezerAccounts;
if (accounts.length === 0) {
select.innerHTML = '<option value="">No accounts available</option>';
select.value = '';
}
});
} catch (error) {
showConfigError('Error updating accounts: ' + error.message);
}
}
async function loadCredentials(service) {
try {
const response = await fetch(`/api/credentials/${service}`);
renderCredentialsList(service, await response.json());
} catch (error) {
showConfigError(error.message);
}
}
function renderCredentialsList(service, credentials) {
const list = document.querySelector('.credentials-list');
list.innerHTML = credentials.map(name => `
<div class="credential-item">
<span>${name}</span>
<div class="credential-actions">
<button class="edit-btn" data-name="${name}" data-service="${service}">Edit</button>
<button class="delete-btn" data-name="${name}" data-service="${service}">Delete</button>
</div>
</div>
`).join('');
list.querySelectorAll('.delete-btn').forEach(btn => {
btn.addEventListener('click', handleDeleteCredential);
});
list.querySelectorAll('.edit-btn').forEach(btn => {
btn.addEventListener('click', handleEditCredential);
});
}
async function handleDeleteCredential(e) {
try {
const service = e.target.dataset.service;
const name = e.target.dataset.name;
if (!service || !name) {
throw new Error('Missing credential information');
}
const response = await fetch(`/api/credentials/${service}/${name}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Failed to delete credential');
}
const accountSelect = document.getElementById(`${service}AccountSelect`);
if (accountSelect.value === name) {
accountSelect.value = '';
saveConfig();
}
loadCredentials(service);
await updateAccountSelectors();
} catch (error) {
showConfigError(error.message);
}
}
async function handleEditCredential(e) {
const service = e.target.dataset.service;
const name = e.target.dataset.name;
try {
// Switch to the appropriate service tab
document.querySelector(`[data-service="${service}"]`).click();
await new Promise(resolve => setTimeout(resolve, 50));
const response = await fetch(`/api/credentials/${service}/${name}`);
const data = await response.json();
currentCredential = name;
document.getElementById('credentialName').value = name;
document.getElementById('credentialName').disabled = true;
populateFormFields(service, data);
} catch (error) {
showConfigError(error.message);
}
}
function updateFormFields() {
const serviceFields = document.getElementById('serviceFields');
serviceFields.innerHTML = serviceConfig[currentService].fields.map(field => `
<div class="form-group">
<label>${field.label}:</label>
<input type="${field.type}"
id="${field.id}"
name="${field.id}"
required
${field.type === 'password' ? 'autocomplete="new-password"' : ''}>
</div>
`).join('');
}
function populateFormFields(service, data) {
serviceConfig[service].fields.forEach(field => {
const element = document.getElementById(field.id);
if (element) element.value = data[field.id] || '';
});
}
async function handleCredentialSubmit(e) {
e.preventDefault();
const service = document.querySelector('.tab-button.active').dataset.service;
const nameInput = document.getElementById('credentialName');
const name = nameInput.value.trim();
try {
if (!currentCredential && !name) {
throw new Error('Credential name is required');
}
const formData = {};
serviceConfig[service].fields.forEach(field => {
formData[field.id] = document.getElementById(field.id).value.trim();
});
const data = serviceConfig[service].validator(formData);
const endpointName = currentCredential || name;
const method = currentCredential ? 'PUT' : 'POST';
const response = await fetch(`/api/credentials/${service}/${endpointName}`, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to save credentials');
}
await updateAccountSelectors();
saveConfig();
loadCredentials(service);
resetForm();
} catch (error) {
showConfigError(error.message);
}
}
function resetForm() {
currentCredential = null;
const nameInput = document.getElementById('credentialName');
nameInput.value = '';
nameInput.disabled = false;
document.getElementById('credentialForm').reset();
}
function saveConfig() {
const config = {
spotify: document.getElementById('spotifyAccountSelect').value,
deezer: document.getElementById('deezerAccountSelect').value,
fallback: document.getElementById('fallbackToggle').checked,
spotifyQuality: document.getElementById('spotifyQualitySelect').value,
deezerQuality: document.getElementById('deezerQualitySelect').value,
realTime: document.getElementById('realTimeToggle').checked
};
localStorage.setItem('activeConfig', JSON.stringify(config));
}
function loadConfig() {
const saved = JSON.parse(localStorage.getItem('activeConfig')) || {};
document.getElementById('spotifyAccountSelect').value = saved.spotify || '';
document.getElementById('deezerAccountSelect').value = saved.deezer || '';
document.getElementById('fallbackToggle').checked = !!saved.fallback;
document.getElementById('spotifyQualitySelect').value = saved.spotifyQuality || 'NORMAL';
document.getElementById('deezerQualitySelect').value = saved.deezerQuality || 'MP3_128';
document.getElementById('realTimeToggle').checked = !!saved.realTime;
}
function showConfigError(message) {
const errorDiv = document.getElementById('configError');
errorDiv.textContent = message;
setTimeout(() => errorDiv.textContent = '', 3000);
}

371
static/js/main.js Normal file
View File

@@ -0,0 +1,371 @@
// Import the downloadQueue singleton from your working queue.js implementation.
import { downloadQueue } from './queue.js';
document.addEventListener('DOMContentLoaded', () => {
const searchButton = document.getElementById('searchButton');
const searchInput = document.getElementById('searchInput');
const queueIcon = document.getElementById('queueIcon');
const searchType = document.getElementById('searchType'); // Ensure this element exists in your HTML
// Preselect the saved search type if available
const storedSearchType = localStorage.getItem('searchType');
if (storedSearchType && searchType) {
searchType.value = storedSearchType;
}
// Save the search type to local storage whenever it changes
searchType.addEventListener('change', () => {
localStorage.setItem('searchType', searchType.value);
});
// Initialize queue icon
queueIcon.addEventListener('click', () => downloadQueue.toggleVisibility());
// Search functionality
searchButton.addEventListener('click', performSearch);
searchInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') performSearch();
});
});
async function performSearch() {
const query = document.getElementById('searchInput').value.trim();
const searchType = document.getElementById('searchType').value;
const resultsContainer = document.getElementById('resultsContainer');
if (!query) {
showError('Please enter a search term');
return;
}
// If the query is a Spotify URL for a supported resource, redirect to our route.
if (isSpotifyUrl(query)) {
try {
const { type, id } = getSpotifyResourceDetails(query);
const supportedTypes = ['track', 'album', 'playlist', 'artist'];
if (!supportedTypes.includes(type))
throw new Error('Unsupported URL type');
// Redirect to {base_url}/{type}/{id}
window.location.href = `${window.location.origin}/${type}/${id}`;
return;
} catch (error) {
showError(`Invalid Spotify URL: ${error.message}`);
return;
}
}
resultsContainer.innerHTML = '<div class="loading">Searching...</div>';
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}&search_type=${searchType}&limit=50`);
const data = await response.json();
if (data.error) throw new Error(data.error);
const items = data.data[`${searchType}s`]?.items;
if (!items?.length) {
resultsContainer.innerHTML = '<div class="error">No results found</div>';
return;
}
resultsContainer.innerHTML = items.map(item => createResultCard(item, searchType)).join('');
attachDownloadListeners(items);
} catch (error) {
showError(error.message);
}
}
/**
* Attaches event listeners to all download buttons (both standard and small versions).
*/
function attachDownloadListeners(items) {
// Query for both download-btn and download-btn-small buttons.
document.querySelectorAll('.download-btn, .download-btn-small').forEach((btn, index) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const url = e.currentTarget.dataset.url;
const type = e.currentTarget.dataset.type;
const albumType = e.currentTarget.dataset.albumType;
// If a main-download button is clicked (if present), remove its entire result card; otherwise just remove the button.
if (e.currentTarget.classList.contains('main-download')) {
e.currentTarget.closest('.result-card').remove();
} else {
e.currentTarget.remove();
}
startDownload(url, type, items[index], albumType);
});
});
}
async function startDownload(url, type, item, albumType) {
const config = JSON.parse(localStorage.getItem('activeConfig')) || {};
const {
fallback = false,
spotify = '',
deezer = '',
spotifyQuality = 'NORMAL',
deezerQuality = 'MP3_128',
realTime = false
} = config;
let service = url.includes('open.spotify.com') ? 'spotify' : 'deezer';
let apiUrl = `/api/${type}/download?service=${service}&url=${encodeURIComponent(url)}`;
if (type === 'artist') {
apiUrl = `/api/artist/download?service=${service}&artist_url=${encodeURIComponent(url)}&album_type=${encodeURIComponent(albumType || 'album,single,compilation')}`;
}
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) apiUrl += '&real_time=true';
try {
const response = await fetch(apiUrl);
const data = await response.json();
downloadQueue.addDownload(item, type, data.prg_file);
} catch (error) {
showError('Download failed: ' + error.message);
}
}
// UI Helper Functions
function showError(message) {
document.getElementById('resultsContainer').innerHTML = `<div class="error">${message}</div>`;
}
function isSpotifyUrl(url) {
return url.startsWith('https://open.spotify.com/');
}
/**
* Extracts the resource type and ID from a Spotify URL.
* Expected URL format: https://open.spotify.com/{type}/{id}
*/
function getSpotifyResourceDetails(url) {
const urlObj = new URL(url);
const pathParts = urlObj.pathname.split('/');
// Expecting ['', type, id, ...]
if (pathParts.length < 3 || !pathParts[1] || !pathParts[2]) {
throw new Error('Invalid Spotify URL');
}
return {
type: pathParts[1],
id: pathParts[2]
};
}
function msToMinutesSeconds(ms) {
const minutes = Math.floor(ms / 60000);
const seconds = ((ms % 60000) / 1000).toFixed(0);
return `${minutes}:${seconds.padStart(2, '0')}`;
}
function createResultCard(item, type) {
let newUrl = '#';
try {
const spotifyUrl = item.external_urls.spotify;
const parsedUrl = new URL(spotifyUrl);
newUrl = window.location.origin + parsedUrl.pathname;
} catch (e) {
console.error('Error parsing URL:', e);
}
let imageUrl, title, subtitle, details;
switch (type) {
case 'track':
imageUrl = item.album.images[0]?.url || '';
title = item.name;
subtitle = item.artists.map(a => a.name).join(', ');
details = `
<span>${item.album.name}</span>
<span class="duration">${msToMinutesSeconds(item.duration_ms)}</span>
`;
return `
<div class="result-card" data-id="${item.id}">
<div class="album-art-wrapper">
<img src="${imageUrl}" class="album-art" alt="${type} cover">
</div>
<div class="title-and-view">
<div class="track-title">${title}</div>
<div class="title-buttons">
<button class="download-btn-small"
data-url="${item.external_urls.spotify}"
data-type="${type}"
title="Download">
<img src="/static/images/download.svg" alt="Download">
</button>
<button class="view-btn" onclick="window.location.href='${newUrl}'" title="View">
<img src="/static/images/view.svg" alt="View">
</button>
</div>
</div>
<div class="track-artist">${subtitle}</div>
<div class="track-details">${details}</div>
</div>
`;
case 'playlist':
imageUrl = item.images[0]?.url || '';
title = item.name;
subtitle = item.owner.display_name;
details = `
<span>${item.tracks.total} tracks</span>
<span class="duration">${item.description || 'No description'}</span>
`;
return `
<div class="result-card" data-id="${item.id}">
<div class="album-art-wrapper">
<img src="${imageUrl}" class="album-art" alt="${type} cover">
</div>
<div class="title-and-view">
<div class="track-title">${title}</div>
<div class="title-buttons">
<button class="download-btn-small"
data-url="${item.external_urls.spotify}"
data-type="${type}"
title="Download">
<img src="/static/images/download.svg" alt="Download">
</button>
<button class="view-btn" onclick="window.location.href='${newUrl}'" title="View">
<img src="/static/images/view.svg" alt="View">
</button>
</div>
</div>
<div class="track-artist">${subtitle}</div>
<div class="track-details">${details}</div>
</div>
`;
case 'album':
imageUrl = item.images[0]?.url || '';
title = item.name;
subtitle = item.artists.map(a => a.name).join(', ');
details = `
<span>${item.release_date}</span>
<span class="duration">${item.total_tracks} tracks</span>
`;
return `
<div class="result-card" data-id="${item.id}">
<div class="album-art-wrapper">
<img src="${imageUrl}" class="album-art" alt="${type} cover">
</div>
<div class="title-and-view">
<div class="track-title">${title}</div>
<div class="title-buttons">
<button class="download-btn-small"
data-url="${item.external_urls.spotify}"
data-type="${type}"
title="Download">
<img src="/static/images/download.svg" alt="Download">
</button>
<button class="view-btn" onclick="window.location.href='${newUrl}'" title="View">
<img src="/static/images/view.svg" alt="View">
</button>
</div>
</div>
<div class="track-artist">${subtitle}</div>
<div class="track-details">${details}</div>
</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}">
<div class="album-art-wrapper">
<img src="${imageUrl}" class="album-art" alt="${type} cover">
</div>
<div class="title-and-view">
<div class="track-title">${title}</div>
<div class="title-buttons">
<button class="download-btn-small"
data-url="${item.external_urls.spotify}"
data-type="${type}"
title="Download">
<img src="/static/images/download.svg" alt="Download">
</button>
<button class="view-btn" onclick="window.location.href='${newUrl}'" title="View">
<img src="/static/images/view.svg" alt="View">
</button>
</div>
</div>
<div class="track-artist">${subtitle}</div>
<div class="track-details">${details}</div>
<!-- Removed the main "Download All Discography" button -->
<div class="artist-download-buttons">
<div class="download-options-container">
<button class="options-toggle" onclick="this.nextElementSibling.classList.toggle('expanded')">
More Options
<svg class="toggle-chevron" viewBox="0 0 24 24">
<path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/>
</svg>
</button>
<div class="secondary-options">
<button class="download-btn option-btn"
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="album-art-wrapper">
<img src="${imageUrl}" class="album-art" alt="${type} cover">
</div>
<div class="title-and-view">
<div class="track-title">${title}</div>
<div class="title-buttons">
<button class="download-btn-small"
data-url="${item.external_urls.spotify}"
data-type="${type}"
title="Download">
<img src="/static/images/download.svg" alt="Download">
</button>
<button class="view-btn" onclick="window.location.href='${newUrl}'" title="View">
<img src="/static/images/view.svg" alt="View">
</button>
</div>
</div>
<div class="track-artist">${subtitle}</div>
<div class="track-details">${details}</div>
</div>
`;
}
}

256
static/js/playlist.js Normal file
View File

@@ -0,0 +1,256 @@
// Import the downloadQueue singleton from your working queue.js implementation.
import { downloadQueue } from './queue.js';
document.addEventListener('DOMContentLoaded', () => {
// Parse playlist ID from URL
const pathSegments = window.location.pathname.split('/');
const playlistId = pathSegments[pathSegments.indexOf('playlist') + 1];
if (!playlistId) {
showError('No playlist ID provided.');
return;
}
// Fetch playlist info and render it
fetch(`/api/playlist/info?id=${encodeURIComponent(playlistId)}`)
.then(response => {
if (!response.ok) throw new Error('Network response was not ok');
return response.json();
})
.then(data => renderPlaylist(data))
.catch(error => {
console.error('Error:', error);
showError('Failed to load playlist.');
});
const queueIcon = document.getElementById('queueIcon');
if (queueIcon) {
queueIcon.addEventListener('click', () => {
downloadQueue.toggleVisibility();
});
}
});
/**
* Renders playlist header and tracks.
*/
function renderPlaylist(playlist) {
// Hide loading and error messages
document.getElementById('loading').classList.add('hidden');
document.getElementById('error').classList.add('hidden');
// Update header info
document.getElementById('playlist-name').textContent = playlist.name;
document.getElementById('playlist-owner').textContent = `By ${playlist.owner.display_name}`;
document.getElementById('playlist-stats').textContent =
`${playlist.followers.total} followers • ${playlist.tracks.total} songs`;
document.getElementById('playlist-description').textContent = playlist.description;
const image = playlist.images[0]?.url || 'placeholder.jpg';
document.getElementById('playlist-image').src = image;
// --- Add Home Button ---
let homeButton = document.getElementById('homeButton');
if (!homeButton) {
homeButton = document.createElement('button');
homeButton.id = 'homeButton';
homeButton.className = 'home-btn';
// Use an <img> tag to display the SVG icon.
homeButton.innerHTML = `<img src="/static/images/home.svg" alt="Home">`;
// Insert the home button at the beginning of the header container.
const headerContainer = document.getElementById('playlist-header');
headerContainer.insertBefore(homeButton, headerContainer.firstChild);
}
homeButton.addEventListener('click', () => {
// Navigate to the site's base URL.
window.location.href = window.location.origin;
});
// --- Add "Download Whole Playlist" Button ---
let downloadPlaylistBtn = document.getElementById('downloadPlaylistBtn');
if (!downloadPlaylistBtn) {
downloadPlaylistBtn = document.createElement('button');
downloadPlaylistBtn.id = 'downloadPlaylistBtn';
downloadPlaylistBtn.textContent = 'Download Whole Playlist';
downloadPlaylistBtn.className = 'download-btn download-btn--main';
// Insert the button into the header container (e.g. after the description)
const headerContainer = document.getElementById('playlist-header');
headerContainer.appendChild(downloadPlaylistBtn);
}
downloadPlaylistBtn.addEventListener('click', () => {
// Remove individual track download buttons (but leave the whole playlist button).
document.querySelectorAll('.download-btn').forEach(btn => {
if (btn.id !== 'downloadPlaylistBtn') {
btn.remove();
}
});
// Disable the whole playlist button to prevent repeated clicks.
downloadPlaylistBtn.disabled = true;
downloadPlaylistBtn.textContent = 'Queueing...';
// Initiate the playlist download.
downloadWholePlaylist(playlist).then(() => {
downloadPlaylistBtn.textContent = 'Queued!';
}).catch(err => {
showError('Failed to queue playlist download: ' + err.message);
downloadPlaylistBtn.disabled = false;
});
});
// Render tracks list
const tracksList = document.getElementById('tracks-list');
tracksList.innerHTML = ''; // Clear any existing content
playlist.tracks.items.forEach((item, index) => {
const track = item.track;
// Create links for track, artist, and album using their IDs.
// Ensure that track.id, track.artists[0].id, and track.album.id are available.
const trackLink = `/track/${track.id}`;
const artistLink = `/artist/${track.artists[0].id}`;
const albumLink = `/album/${track.album.id}`;
const trackElement = document.createElement('div');
trackElement.className = 'track';
trackElement.innerHTML = `
<div class="track-number">${index + 1}</div>
<div class="track-info">
<div class="track-name">
<a href="${trackLink}" title="View track details">${track.name}</a>
</div>
<div class="track-artist">
<a href="${artistLink}" title="View artist details">${track.artists[0].name}</a>
</div>
</div>
<div class="track-album">
<a href="${albumLink}" title="View album details">${track.album.name}</a>
</div>
<div class="track-duration">${msToTime(track.duration_ms)}</div>
<button class="download-btn download-btn--circle"
data-url="${track.external_urls.spotify}"
data-type="track"
data-name="${track.name}"
title="Download">
<img src="/static/images/download.svg" alt="Download">
</button>
`;
tracksList.appendChild(trackElement);
});
// Reveal header and tracks container
document.getElementById('playlist-header').classList.remove('hidden');
document.getElementById('tracks-container').classList.remove('hidden');
// Attach download listeners to newly rendered download buttons
attachDownloadListeners();
}
/**
* Converts milliseconds to minutes:seconds.
*/
function msToTime(duration) {
const minutes = Math.floor(duration / 60000);
const seconds = ((duration % 60000) / 1000).toFixed(0);
return `${minutes}:${seconds.padStart(2, '0')}`;
}
/**
* Displays an error message in the UI.
*/
function showError(message) {
const errorEl = document.getElementById('error');
errorEl.textContent = message;
errorEl.classList.remove('hidden');
}
/**
* Attaches event listeners to all individual download buttons.
*/
function attachDownloadListeners() {
document.querySelectorAll('.download-btn').forEach((btn) => {
// Skip the whole playlist button.
if (btn.id === 'downloadPlaylistBtn') return;
btn.addEventListener('click', (e) => {
e.stopPropagation();
const url = e.currentTarget.dataset.url;
const type = e.currentTarget.dataset.type;
const name = e.currentTarget.dataset.name || extractName(url);
const albumType = e.currentTarget.dataset.albumType;
// Remove the button after click
e.currentTarget.remove();
// Start the download for this track.
startDownload(url, type, { name }, albumType);
});
});
}
/**
* Initiates the whole playlist download by calling the playlist endpoint.
*/
async function downloadWholePlaylist(playlist) {
// Use the playlist external URL (assumed available) for the download.
const url = playlist.external_urls.spotify;
// Queue the whole playlist download with the descriptive playlist name.
startDownload(url, 'playlist', { name: playlist.name });
}
/**
* Starts the download process by building the API URL,
* fetching download details, and then adding the download to the queue.
*/
async function startDownload(url, type, item, albumType) {
// Retrieve configuration (if any) from localStorage
const config = JSON.parse(localStorage.getItem('activeConfig')) || {};
const {
fallback = false,
spotify = '',
deezer = '',
spotifyQuality = 'NORMAL',
deezerQuality = 'MP3_128',
realTime = false
} = config;
const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer';
let apiUrl = '';
// Build API URL based on the download type.
if (type === 'playlist') {
// Use the dedicated playlist download endpoint.
apiUrl = `/api/playlist/download?service=${service}&url=${encodeURIComponent(url)}`;
} else if (type === 'artist') {
apiUrl = `/api/artist/download?service=${service}&artist_url=${encodeURIComponent(url)}&album_type=${encodeURIComponent(albumType || 'album,single,compilation')}`;
} else {
// Default is track download.
apiUrl = `/api/${type}/download?service=${service}&url=${encodeURIComponent(url)}`;
}
// Append account and quality details.
if (fallback && service === 'spotify') {
apiUrl += `&main=${deezer}&fallback=${spotify}`;
apiUrl += `&quality=${deezerQuality}&fall_quality=${spotifyQuality}`;
} else {
const mainAccount = service === 'spotify' ? spotify : deezer;
apiUrl += `&main=${mainAccount}&quality=${service === 'spotify' ? spotifyQuality : deezerQuality}`;
}
if (realTime) {
apiUrl += '&real_time=true';
}
try {
const response = await fetch(apiUrl);
const data = await response.json();
// Add the download to the queue using the working queue implementation.
downloadQueue.addDownload(item, type, data.prg_file);
} catch (error) {
showError('Download failed: ' + error.message);
}
}
/**
* A helper function to extract a display name from the URL.
*/
function extractName(url) {
return url;
}

428
static/js/queue.js Normal file
View File

@@ -0,0 +1,428 @@
// queue.js
class DownloadQueue {
constructor() {
this.downloadQueue = {};
this.prgInterval = null;
this.initDOM();
this.initEventListeners();
this.loadExistingPrgFiles();
}
/* DOM Management */
initDOM() {
const queueHTML = `
<div id="downloadQueue" class="sidebar right" hidden>
<div class="sidebar-header">
<h2>Download Queue</h2>
<button class="close-btn" aria-label="Close queue">&times;</button>
</div>
<div id="queueItems" aria-live="polite"></div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', queueHTML);
// Restore the sidebar visibility from LocalStorage
const savedVisibility = localStorage.getItem('downloadQueueVisible');
const queueSidebar = document.getElementById('downloadQueue');
if (savedVisibility === 'true') {
queueSidebar.classList.add('active');
queueSidebar.hidden = false;
} else {
queueSidebar.classList.remove('active');
queueSidebar.hidden = true;
}
}
/* Event Handling */
initEventListeners() {
// Escape key handler
document.addEventListener('keydown', (e) => {
const queueSidebar = document.getElementById('downloadQueue');
if (e.key === 'Escape' && queueSidebar.classList.contains('active')) {
this.toggleVisibility();
}
});
// Close button handler
document.getElementById('downloadQueue').addEventListener('click', (e) => {
if (e.target.closest('.close-btn')) {
this.toggleVisibility();
}
});
}
/* Public API */
toggleVisibility() {
const queueSidebar = document.getElementById('downloadQueue');
queueSidebar.classList.toggle('active');
queueSidebar.hidden = !queueSidebar.classList.contains('active');
// Save the current visibility state to LocalStorage.
localStorage.setItem('downloadQueueVisible', queueSidebar.classList.contains('active'));
this.dispatchEvent('queueVisibilityChanged', { visible: queueSidebar.classList.contains('active') });
}
/**
* Now accepts an extra argument "requestUrl" which is the same API call used to initiate the download.
*/
addDownload(item, type, prgFile, requestUrl = null) {
const queueId = this.generateQueueId();
const entry = this.createQueueEntry(item, type, prgFile, queueId, requestUrl);
this.downloadQueue[queueId] = entry;
document.getElementById('queueItems').appendChild(entry.element);
this.startEntryMonitoring(queueId);
this.dispatchEvent('downloadAdded', { queueId, item, type });
}
/* Core Functionality */
async startEntryMonitoring(queueId) {
const entry = this.downloadQueue[queueId];
if (!entry || entry.hasEnded) return;
entry.intervalId = setInterval(async () => {
// Note: use the current prgFile value stored in the entry to build the log element id.
const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`);
if (entry.hasEnded) {
clearInterval(entry.intervalId);
return;
}
try {
const response = await fetch(`/api/prgs/${entry.prgFile}`);
const data = await response.json();
// Update the entry type from the API response if available.
if (data.type) {
entry.type = data.type;
}
// If the prg file info contains the original_request parameters and we haven't stored a retry URL yet,
// build one using the updated type and original_request parameters.
if (!entry.requestUrl && data.original_request) {
const params = new URLSearchParams(data.original_request).toString();
entry.requestUrl = `/api/${entry.type}/download?${params}`;
}
const progress = data.last_line;
if (!progress) {
this.handleInactivity(entry, queueId, logElement);
return;
}
// If the new progress is the same as the last, also treat it as inactivity.
if (JSON.stringify(entry.lastStatus) === JSON.stringify(progress)) {
this.handleInactivity(entry, queueId, logElement);
return;
}
entry.lastStatus = progress;
entry.lastUpdated = Date.now();
entry.status = progress.status;
logElement.textContent = this.getStatusMessage(progress);
if (['error', 'complete', 'cancel'].includes(progress.status)) {
this.handleTerminalState(entry, queueId, progress);
}
} catch (error) {
console.error('Status check failed:', error);
this.handleTerminalState(entry, queueId, {
status: 'error',
message: 'Status check error'
});
}
}, 2000);
}
/* Helper Methods */
generateQueueId() {
return Date.now().toString() + Math.random().toString(36).substr(2, 9);
}
/**
* Now accepts a fifth parameter "requestUrl" and stores it in the entry.
*/
createQueueEntry(item, type, prgFile, queueId, requestUrl) {
return {
item,
type,
prgFile,
requestUrl, // store the original API request URL so we can retry later
element: this.createQueueItem(item, type, prgFile, queueId),
lastStatus: null,
lastUpdated: Date.now(),
hasEnded: false,
intervalId: null,
uniqueId: queueId
};
}
createQueueItem(item, type, prgFile, queueId) {
const div = document.createElement('article');
div.className = 'queue-item';
div.setAttribute('aria-live', 'polite');
div.setAttribute('aria-atomic', 'true');
div.innerHTML = `
<div class="title">${item.name}</div>
<div class="type">${type.charAt(0).toUpperCase() + type.slice(1)}</div>
<div class="log" id="log-${queueId}-${prgFile}">Initializing download...</div>
<button class="cancel-btn" data-prg="${prgFile}" data-type="${type}" data-queueid="${queueId}" title="Cancel Download">
<img src="https://www.svgrepo.com/show/488384/skull-head.svg" alt="Cancel Download">
</button>
`;
div.querySelector('.cancel-btn').addEventListener('click', (e) => this.handleCancelDownload(e));
return div;
}
async handleCancelDownload(e) {
const btn = e.target.closest('button');
btn.style.display = 'none';
const { prg, type, queueid } = btn.dataset;
try {
const response = await fetch(`/api/${type}/download/cancel?prg_file=${encodeURIComponent(prg)}`);
const data = await response.json();
if (data.status === "cancel") {
const logElement = document.getElementById(`log-${queueid}-${prg}`);
logElement.textContent = "Download cancelled";
const entry = this.downloadQueue[queueid];
if (entry) {
entry.hasEnded = true;
clearInterval(entry.intervalId);
}
setTimeout(() => this.cleanupEntry(queueid), 5000);
}
} catch (error) {
console.error('Cancel error:', error);
}
}
/* State Management */
async loadExistingPrgFiles() {
try {
const response = await fetch('/api/prgs/list');
const prgFiles = await response.json();
for (const prgFile of prgFiles) {
const prgResponse = await fetch(`/api/prgs/${prgFile}`);
const prgData = await prgResponse.json();
const dummyItem = { name: prgData.name || prgFile, external_urls: {} };
// In this case, no original request URL is available.
this.addDownload(dummyItem, prgData.type || "unknown", prgFile);
}
} catch (error) {
console.error('Error loading existing PRG files:', error);
}
}
cleanupEntry(queueId) {
const entry = this.downloadQueue[queueId];
if (entry) {
clearInterval(entry.intervalId);
entry.element.remove();
delete this.downloadQueue[queueId];
fetch(`/api/prgs/delete/${encodeURIComponent(entry.prgFile)}`, { method: 'DELETE' })
.catch(console.error);
}
}
/* Event Dispatching */
dispatchEvent(name, detail) {
document.dispatchEvent(new CustomEvent(name, { detail }));
}
/* Status Message Handling */
getStatusMessage(data) {
// Helper function to format an array into a human-readable list without a comma before "and".
function formatList(items) {
if (!items || items.length === 0) return '';
if (items.length === 1) return items[0];
if (items.length === 2) return `${items[0]} and ${items[1]}`;
// For three or more items: join all but the last with commas, then " and " the last item.
return items.slice(0, -1).join(', ') + ' and ' + items[items.length - 1];
}
// Helper function for a simple pluralization:
function pluralize(word) {
return word.endsWith('s') ? word : word + 's';
}
switch (data.status) {
case 'downloading':
if (data.type === 'track') {
return `Downloading track "${data.song}" by ${data.artist}...`;
}
return `Downloading ${data.type}...`;
case 'initializing':
if (data.type === 'playlist') {
return `Initializing playlist download "${data.name}" with ${data.total_tracks} tracks...`;
} else if (data.type === 'album') {
return `Initializing album download "${data.album}" by ${data.artist}...`;
} else if (data.type === 'artist') {
let subsets = [];
if (data.subsets && Array.isArray(data.subsets) && data.subsets.length > 0) {
subsets = data.subsets;
} else if (data.album_type) {
subsets = data.album_type
.split(',')
.map(item => item.trim())
.map(item => pluralize(item));
}
if (subsets.length > 0) {
const subsetsMessage = formatList(subsets);
return `Initializing download for ${data.artist}'s ${subsetsMessage}`;
}
return `Initializing download for ${data.artist} with ${data.total_albums} album(s) [${data.album_type}]...`;
}
return `Initializing ${data.type} download...`;
case 'progress':
if (data.track && data.current_track) {
const parts = data.current_track.split('/');
const current = parts[0];
const total = parts[1] || '?';
if (data.type === 'playlist') {
return `Downloading playlist: Track ${current} of ${total} - ${data.track}`;
} else if (data.type === 'album') {
if (data.album && data.artist) {
return `Downloading album "${data.album}" by ${data.artist}: track ${current} of ${total} - ${data.track}`;
} else {
return `Downloading track ${current} of ${total}: ${data.track} from ${data.album}`;
}
}
}
return `Progress: ${data.status}...`;
case 'done':
if (data.type === 'track') {
return `Finished track "${data.song}" by ${data.artist}`;
} else if (data.type === 'playlist') {
return `Finished playlist "${data.name}" with ${data.total_tracks} tracks`;
} else if (data.type === 'album') {
return `Finished album "${data.album}" by ${data.artist}`;
} else if (data.type === 'artist') {
return `Finished artist "${data.artist}" (${data.album_type})`;
}
return `Finished ${data.type}`;
case 'retrying':
return `Track "${data.song}" by ${data.artist}" failed, retrying (${data.retry_count}/10) in ${data.seconds_left}s`;
case 'error':
return `Error: ${data.message || 'Unknown error'}`;
case 'complete':
return 'Download completed successfully';
case 'skipped':
return `Track "${data.song}" skipped, it already exists!`;
case 'real_time': {
const totalMs = data.time_elapsed;
const minutes = Math.floor(totalMs / 60000);
const seconds = Math.floor((totalMs % 60000) / 1000);
const paddedSeconds = seconds < 10 ? '0' + seconds : seconds;
return `Real-time downloading track "${data.song}" by ${data.artist} (${(data.percentage * 100).toFixed(1)}%). Time elapsed: ${minutes}:${paddedSeconds}`;
}
default:
return data.status;
}
}
/* New Methods to Handle Terminal State and Inactivity */
handleTerminalState(entry, queueId, progress) {
// Mark the entry as ended and clear its monitoring interval.
entry.hasEnded = true;
clearInterval(entry.intervalId);
const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`);
if (!logElement) return;
// If the terminal state is an error, hide the cancel button and add error buttons.
if (progress.status === 'error') {
// Hide the cancel button.
const cancelBtn = entry.element.querySelector('.cancel-btn');
if (cancelBtn) {
cancelBtn.style.display = 'none';
}
logElement.innerHTML = `
<div class="error-message">${this.getStatusMessage(progress)}</div>
<div class="error-buttons">
<button class="close-error-btn" title="Close">&times;</button>
<button class="retry-btn" title="Retry">Retry</button>
</div>
`;
// Close (X) button: immediately remove the queue entry.
logElement.querySelector('.close-error-btn').addEventListener('click', () => {
this.cleanupEntry(queueId);
});
// Retry button: re-send the original API request.
logElement.querySelector('.retry-btn').addEventListener('click', async () => {
logElement.textContent = 'Retrying download...';
if (!entry.requestUrl) {
logElement.textContent = 'Retry not available: missing original request information.';
return;
}
try {
const retryResponse = await fetch(entry.requestUrl);
const retryData = await retryResponse.json();
if (retryData.prg_file) {
// Delete the failed prg file before updating to the new one.
const oldPrgFile = entry.prgFile;
await fetch(`/api/prgs/delete/${encodeURIComponent(oldPrgFile)}`, { method: 'DELETE' });
// Update the log element's id to reflect the new prg_file.
const logEl = entry.element.querySelector('.log');
logEl.id = `log-${entry.uniqueId}-${retryData.prg_file}`;
// Update the entry with the new prg_file and reset its state.
entry.prgFile = retryData.prg_file;
entry.lastStatus = null;
entry.hasEnded = false;
entry.lastUpdated = Date.now();
logEl.textContent = 'Retry initiated...';
// Restart monitoring using the new prg_file.
this.startEntryMonitoring(queueId);
} else {
logElement.textContent = 'Retry failed: invalid response from server';
}
} catch (error) {
logElement.textContent = 'Retry failed: ' + error.message;
}
});
// Do not automatically clean up if an error occurred.
return;
} else {
// For non-error terminal states, update the message and then clean up after 5 seconds.
logElement.textContent = this.getStatusMessage(progress);
setTimeout(() => this.cleanupEntry(queueId), 5000);
}
}
handleInactivity(entry, queueId, logElement) {
// If no update in 10 seconds, treat as an error.
const now = Date.now();
if (now - entry.lastUpdated > 300000) {
const progress = { status: 'error', message: 'Inactivity timeout' };
this.handleTerminalState(entry, queueId, progress);
} else {
if (logElement) {
logElement.textContent = this.getStatusMessage(entry.lastStatus)
}
}
}
}
// Singleton instance
export const downloadQueue = new DownloadQueue();

175
static/js/track.js Normal file
View File

@@ -0,0 +1,175 @@
// Import the downloadQueue singleton from your working queue.js implementation.
import { downloadQueue } from './queue.js';
document.addEventListener('DOMContentLoaded', () => {
// Parse track ID from URL. Expecting URL in the form /track/{id}
const pathSegments = window.location.pathname.split('/');
const trackId = pathSegments[pathSegments.indexOf('track') + 1];
if (!trackId) {
showError('No track ID provided.');
return;
}
// Fetch track info and render it
fetch(`/api/track/info?id=${encodeURIComponent(trackId)}`)
.then(response => {
if (!response.ok) throw new Error('Network response was not ok');
return response.json();
})
.then(data => renderTrack(data))
.catch(error => {
console.error('Error:', error);
showError('Error loading track');
});
// Attach event listener to the queue icon to toggle the download queue
const queueIcon = document.getElementById('queueIcon');
if (queueIcon) {
queueIcon.addEventListener('click', () => {
downloadQueue.toggleVisibility();
});
}
});
/**
* Renders the track header information.
*/
function renderTrack(track) {
// Hide the loading and error messages.
document.getElementById('loading').classList.add('hidden');
document.getElementById('error').classList.add('hidden');
// Update track information fields.
document.getElementById('track-name').innerHTML =
`<a href="/track/${track.id}" title="View track details">${track.name}</a>`;
document.getElementById('track-artist').innerHTML =
`By ${track.artists.map(a =>
`<a href="/artist/${a.id}" title="View artist details">${a.name}</a>`
).join(', ')}`;
document.getElementById('track-album').innerHTML =
`Album: <a href="/album/${track.album.id}" title="View album details">${track.album.name}</a> (${track.album.album_type})`;
document.getElementById('track-duration').textContent =
`Duration: ${msToTime(track.duration_ms)}`;
document.getElementById('track-explicit').textContent =
track.explicit ? 'Explicit' : 'Clean';
const imageUrl = (track.album.images && track.album.images[0])
? track.album.images[0].url
: 'placeholder.jpg';
document.getElementById('track-album-image').src = imageUrl;
// --- Insert Home Button (if not already present) ---
let homeButton = document.getElementById('homeButton');
if (!homeButton) {
homeButton = document.createElement('button');
homeButton.id = 'homeButton';
homeButton.className = 'home-btn';
homeButton.innerHTML = `<img src="/static/images/home.svg" alt="Home" />`;
// Prepend the home button into the header.
document.getElementById('track-header').insertBefore(homeButton, document.getElementById('track-header').firstChild);
}
homeButton.addEventListener('click', () => {
window.location.href = window.location.origin;
});
// --- Move the Download Button from #actions into #track-header ---
let downloadBtn = document.getElementById('downloadTrackBtn');
if (downloadBtn) {
// Remove the parent container (#actions) if needed.
const actionsContainer = document.getElementById('actions');
if (actionsContainer) {
actionsContainer.parentNode.removeChild(actionsContainer);
}
// Set the inner HTML to use the download.svg icon.
downloadBtn.innerHTML = `<img src="/static/images/download.svg" alt="Download">`;
// Append the download button to the track header so it appears at the right.
document.getElementById('track-header').appendChild(downloadBtn);
}
// Attach a click listener to the download button.
downloadBtn.addEventListener('click', () => {
downloadBtn.disabled = true;
// Save the original icon markup in case we need to revert.
downloadBtn.dataset.originalHtml = `<img src="/static/images/download.svg" alt="Download">`;
downloadBtn.innerHTML = `<span>Queueing...</span>`;
// Start the download for this track.
startDownload(track.external_urls.spotify, 'track', { name: track.name })
.then(() => {
downloadBtn.innerHTML = `<span>Queued!</span>`;
})
.catch(err => {
showError('Failed to queue track download: ' + err.message);
downloadBtn.disabled = false;
downloadBtn.innerHTML = downloadBtn.dataset.originalHtml;
});
});
// Reveal the header now that track info is loaded.
document.getElementById('track-header').classList.remove('hidden');
}
/**
* Converts milliseconds to minutes:seconds.
*/
function msToTime(duration) {
const minutes = Math.floor(duration / 60000);
const seconds = Math.floor((duration % 60000) / 1000);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
/**
* Displays an error message in the UI.
*/
function showError(message) {
const errorEl = document.getElementById('error');
if (errorEl) {
errorEl.textContent = message;
errorEl.classList.remove('hidden');
}
}
/**
* Starts the download process by building the API URL,
* fetching download details, and then adding the download to the queue.
*/
async function startDownload(url, type, item) {
const config = JSON.parse(localStorage.getItem('activeConfig')) || {};
const {
fallback = false,
spotify = '',
deezer = '',
spotifyQuality = 'NORMAL',
deezerQuality = 'MP3_128',
realTime = false
} = config;
const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer';
let apiUrl = `/api/${type}/download?service=${service}&url=${encodeURIComponent(url)}`;
if (fallback && service === 'spotify') {
apiUrl += `&main=${deezer}&fallback=${spotify}`;
apiUrl += `&quality=${deezerQuality}&fall_quality=${spotifyQuality}`;
} else {
const mainAccount = service === 'spotify' ? spotify : deezer;
apiUrl += `&main=${mainAccount}&quality=${service === 'spotify' ? spotifyQuality : deezerQuality}`;
}
if (realTime) {
apiUrl += '&real_time=true';
}
try {
const response = await fetch(apiUrl);
const data = await response.json();
downloadQueue.addDownload(item, type, data.prg_file);
} catch (error) {
showError('Download failed: ' + error.message);
throw error;
}
}

37
templates/album.html Normal file
View File

@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Album Viewer</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/queue/queue.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/icons.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/album/album.css') }}" />
</head>
<body>
<div id="app">
<div id="album-header" class="hidden">
<img id="album-image" alt="Album cover">
<div id="album-info">
<h1 id="album-name"></h1>
<p id="album-artist"></p>
<p id="album-stats"></p>
<p id="album-copyright"></p>
</div>
<button id="queueIcon" class="queue-icon" aria-label="Download queue" aria-controls="downloadQueue" aria-expanded="false">
<img src="{{ url_for('static', filename='images/queue.svg') }}" alt="Queue Icon">
</button>
</div>
<div id="tracks-container" class="hidden">
<h2>Tracks</h2>
<div id="tracks-list"></div>
</div>
<div id="loading">Loading...</div>
<div id="error" class="hidden">Error loading album</div>
</div>
<script type="module" src="{{ url_for('static', filename='js/album.js') }}"></script>
</body>
</html>

42
templates/artist.html Normal file
View File

@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Artist Viewer</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/queue/queue.css') }}">
<!-- Optionally include the icons CSS if not already merged into your artist.css -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/icons.css') }}">
<!-- Reusing the playlist CSS (or duplicate/modify as needed) -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/artist/artist.css') }}" />
</head>
<body>
<div id="app">
<!-- Artist header container -->
<div id="artist-header" class="hidden">
<img id="artist-image" alt="Artist image">
<div id="artist-info">
<h1 id="artist-name"></h1>
<!-- For example, show the total number of albums -->
<p id="artist-stats"></p>
</div>
<!-- Queue Icon Button -->
<button id="queueIcon" class="queue-icon" aria-label="Download queue" aria-controls="downloadQueue" aria-expanded="false">
<img src="{{ url_for('static', filename='images/queue.svg') }}" alt="Queue Icon">
</button>
</div>
<!-- Albums container -->
<div id="albums-container" class="hidden">
<!-- This container will hold one section per album type -->
<div id="album-groups"></div>
</div>
<div id="loading">Loading...</div>
<div id="error" class="hidden">Error loading artist info</div>
</div>
<!-- The download queue container will be inserted by queue.js -->
<script type="module" src="{{ url_for('static', filename='js/artist.js') }}"></script>
</body>
</html>

83
templates/index.html → templates/config.html Executable file → Normal file
View File

@@ -1,20 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Spotizerr</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Configuration - Spotizerr</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/config/config.css') }}" />
<link rel="stylesheet" href="{{ url_for('static', filename='css/queue/queue.css') }}">
</head>
<body>
<div id="settingsSidebar" class="sidebar">
<div class="sidebar-header">
<h2>Settings</h2>
<button id="closeSidebar" class="close-btn">&times;</button>
</div>
<!-- Account Configuration Section -->
<div class="config-container">
<header class="config-header">
<h1>Configuration</h1>
<a href="/" class="back-button">&larr; Back to App</a>
<!-- Added queue icon button to toggle the download queue -->
<button
id="queueIcon"
class="queue-icon"
aria-label="Download queue"
aria-controls="downloadQueue"
aria-expanded="false"
>
<img src="{{ url_for('static', filename='images/queue.svg') }}" alt="Queue" />
</button>
</header>
<div class="account-config">
<!-- Your account config section remains unchanged -->
<div class="config-item">
<label>Active Spotify Account:</label>
<select id="spotifyAccountSelect"></select>
@@ -42,72 +53,46 @@
<div class="config-item">
<label>Download Fallback:</label>
<label class="switch">
<input type="checkbox" id="fallbackToggle">
<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">
<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>
<h2>Credentials Management</h2>
<form id="credentialForm">
<div class="form-group">
<label>Name:</label>
<input type="text" id="credentialName" required>
<input type="text" id="credentialName" required />
</div>
<div id="serviceFields"></div>
<button type="submit" class="save-btn">Save</button>
<button type="submit" class="save-btn">Save Credentials</button>
</form>
<div id="configError" class="error"></div>
</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... (Or paste in a spotify url)" 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>
<!--
Do not include any hard-coded download queue markup here.
The shared queue.js module will create the queue sidebar dynamically.
-->
<!-- Download Queue Sidebar -->
<div id="downloadQueue" class="sidebar right">
<div class="sidebar-header">
<h2>Download Queue</h2>
<button class="close-btn" onclick="toggleDownloadQueue()">&times;</button>
</div>
<div id="queueItems"></div>
</div>
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
<!-- Load config.js as a module so you can import queue.js -->
<script type="module" src="{{ url_for('static', filename='js/config.js') }}"></script>
</body>
</html>

48
templates/main.html Executable file
View File

@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spotizerr</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/main.css') }}" />
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/icons.css') }}" />
<link rel="stylesheet" href="{{ url_for('static', filename='css/queue/queue.css') }}">
</head>
<body>
<div class="container">
<div class="search-header">
<!-- Settings icon linking to the config page -->
<a href="/config" class="settings-icon">
<img src="{{ url_for('static', filename='images/settings.svg') }}" alt="Settings" />
</a>
<input
type="text"
class="search-input"
placeholder="Search tracks, albums, playlists or artists... (Or paste in a spotify url)"
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" aria-label="Search">
Search
</button>
<!-- Queue icon button; event is attached in main.js -->
<button
id="queueIcon"
class="queue-icon"
aria-label="Download queue"
aria-controls="downloadQueue"
aria-expanded="false"
>
<img src="{{ url_for('static', filename='images/queue.svg') }}" alt="" />
</button>
</div>
<div id="resultsContainer" class="results-grid"></div>
</div>
<script type="module" src="{{ url_for('static', filename='js/main.js') }}"></script>
</body>
</html>

40
templates/playlist.html Normal file
View File

@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Playlist Viewer</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/queue/queue.css') }}">
<!-- Optionally include the icons CSS if not already merged into your playlist.css -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/icons.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/playlist/playlist.css') }}" />
</head>
<body>
<div id="app">
<div id="playlist-header" class="hidden">
<img id="playlist-image" alt="Playlist cover">
<div id="playlist-info">
<h1 id="playlist-name"></h1>
<p id="playlist-owner"></p>
<p id="playlist-stats"></p>
<p id="playlist-description"></p>
</div>
<!-- Queue Icon Button -->
<button id="queueIcon" class="queue-icon" aria-label="Download queue" aria-controls="downloadQueue" aria-expanded="false">
<img src="{{ url_for('static', filename='images/queue.svg') }}" alt="Queue Icon">
</button>
</div>
<div id="tracks-container" class="hidden">
<h2>Tracks</h2>
<div id="tracks-list"></div>
</div>
<div id="loading">Loading...</div>
<div id="error" class="hidden">Error loading playlist</div>
</div>
<!-- The download queue container will be inserted by queue.js -->
<script type="module" src="{{ url_for('static', filename='js/playlist.js') }}"></script>
</body>
</html>

44
templates/track.html Normal file
View File

@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Track Viewer</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/queue/queue.css') }}">
<!-- Optionally include the icons CSS if not already merged into your track.css -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/icons.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/track/track.css') }}">
</head>
<body>
<div id="app">
<div id="track-header" class="hidden">
<!-- Back Button will be inserted here via JavaScript -->
<img id="track-album-image" alt="Album cover">
<div id="track-info">
<h1 id="track-name"></h1>
<p id="track-artist"></p>
<p id="track-album"></p>
<p id="track-duration"></p>
<p id="track-explicit"></p>
</div>
<!-- Queue Icon Button -->
<button id="queueIcon" class="queue-icon" aria-label="Download queue" aria-controls="downloadQueue" aria-expanded="false">
<img src="{{ url_for('static', filename='images/queue.svg') }}" alt="Queue Icon">
</button>
</div>
<div id="actions" class="hidden">
<!-- Download Button for this track -->
<button id="downloadTrackBtn" class="download-btn download-btn--main">
Download Track
</button>
</div>
<div id="loading">Loading...</div>
<div id="error" class="hidden">Error loading track</div>
</div>
<!-- The download queue container will be inserted by queue.js -->
<script type="module" src="{{ url_for('static', filename='js/track.js') }}"></script>
</body>
</html>