Improved frontend
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,5 +8,4 @@
|
|||||||
/flask_server.log
|
/flask_server.log
|
||||||
routes/__pycache__/
|
routes/__pycache__/
|
||||||
routes/utils/__pycache__/
|
routes/utils/__pycache__/
|
||||||
|
|
||||||
test.sh
|
test.sh
|
||||||
|
|||||||
2
app.py
2
app.py
@@ -5,6 +5,7 @@ from routes.credentials import credentials_bp
|
|||||||
from routes.album import album_bp
|
from routes.album import album_bp
|
||||||
from routes.track import track_bp
|
from routes.track import track_bp
|
||||||
from routes.playlist import playlist_bp
|
from routes.playlist import playlist_bp
|
||||||
|
from routes.prgs import prgs_bp
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -34,6 +35,7 @@ def create_app():
|
|||||||
app.register_blueprint(album_bp, url_prefix='/api/album')
|
app.register_blueprint(album_bp, url_prefix='/api/album')
|
||||||
app.register_blueprint(track_bp, url_prefix='/api/track')
|
app.register_blueprint(track_bp, url_prefix='/api/track')
|
||||||
app.register_blueprint(playlist_bp, url_prefix='/api/playlist')
|
app.register_blueprint(playlist_bp, url_prefix='/api/playlist')
|
||||||
|
app.register_blueprint(prgs_bp, url_prefix='/api/prgs')
|
||||||
|
|
||||||
# Serve frontend
|
# Serve frontend
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
|
|||||||
6
package-lock.json
generated
6
package-lock.json
generated
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "spotizer",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {}
|
|
||||||
}
|
|
||||||
24
routes/prgs.py
Normal file
24
routes/prgs.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from flask import Blueprint, send_from_directory, abort
|
||||||
|
import os
|
||||||
|
|
||||||
|
prgs_bp = Blueprint('prgs', __name__, url_prefix='/api/prgs')
|
||||||
|
|
||||||
|
# Base directory for .prg files
|
||||||
|
PRGS_DIR = os.path.join(os.getcwd(), 'prgs')
|
||||||
|
|
||||||
|
@prgs_bp.route('/<filename>', methods=['GET'])
|
||||||
|
def get_prg_file(filename):
|
||||||
|
"""
|
||||||
|
Serve a .prg file from the prgs directory.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Security check to prevent path traversal attacks
|
||||||
|
if not filename.endswith('.prg') or '..' in filename or '/' in filename:
|
||||||
|
abort(400, "Invalid file request")
|
||||||
|
|
||||||
|
# Ensure the file exists in the directory
|
||||||
|
return send_from_directory(PRGS_DIR, filename)
|
||||||
|
except FileNotFoundError:
|
||||||
|
abort(404, "File not found")
|
||||||
|
except Exception as e:
|
||||||
|
abort(500, f"An error occurred: {e}")
|
||||||
@@ -29,7 +29,7 @@ def download_album(service, url, main, fallback=None):
|
|||||||
recursive_quality=True,
|
recursive_quality=True,
|
||||||
recursive_download=False,
|
recursive_download=False,
|
||||||
not_interface=False,
|
not_interface=False,
|
||||||
make_zip=True,
|
make_zip=False,
|
||||||
method_save=1
|
method_save=1
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ def download_playlist(service, url, main, fallback=None):
|
|||||||
recursive_quality=True,
|
recursive_quality=True,
|
||||||
recursive_download=False,
|
recursive_download=False,
|
||||||
not_interface=False,
|
not_interface=False,
|
||||||
make_zip=True,
|
make_zip=False,
|
||||||
method_save=1
|
method_save=1
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -270,3 +270,726 @@ body {
|
|||||||
.service-tabs button.active {
|
.service-tabs button.active {
|
||||||
background: #1DB954;
|
background: #1DB954;
|
||||||
}
|
}
|
||||||
|
/* Add to style.css */
|
||||||
|
.config-bar {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
background: #181818;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 40px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: #ccc;
|
||||||
|
transition: .4s;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider:before {
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
left: 2px;
|
||||||
|
bottom: 2px;
|
||||||
|
background-color: white;
|
||||||
|
transition: .4s;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + .slider {
|
||||||
|
background-color: #1DB954;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + .slider:before {
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-btn {
|
||||||
|
background: #1DB954;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 15px;
|
||||||
|
border-radius: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.right {
|
||||||
|
right: -350px;
|
||||||
|
left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.right.active {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item {
|
||||||
|
background: #2a2a2a;
|
||||||
|
padding: 10px;
|
||||||
|
margin: 10px 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item .log {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #b3b3b3;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
/* Add these styles to your existing CSS */
|
||||||
|
|
||||||
|
/* Download Queue styles */
|
||||||
|
#downloadQueue {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
width: 350px;
|
||||||
|
background: #181818;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 15px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||||
|
transform: translateX(120%);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
z-index: 1002;
|
||||||
|
}
|
||||||
|
|
||||||
|
#downloadQueue.active {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #b3b3b3;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#queueItems {
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item {
|
||||||
|
background: #2a2a2a;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item:hover {
|
||||||
|
transform: translateX(5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item .title {
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item .type {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #1DB954;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item .log {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #b3b3b3;
|
||||||
|
line-height: 1.4;
|
||||||
|
font-family: 'SF Mono', Menlo, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status message colors */
|
||||||
|
.log--success { color: #1DB954 !important; }
|
||||||
|
.log--error { color: #ff5555 !important; }
|
||||||
|
.log--warning { color: #ffaa00 !important; }
|
||||||
|
.log--info { color: #4a90e2 !important; }
|
||||||
|
|
||||||
|
/* Progress animation */
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhanced loading states */
|
||||||
|
.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); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Timeout warning */
|
||||||
|
.timeout-warning {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeout-warning::before {
|
||||||
|
content: "⚠️";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form enhancements */
|
||||||
|
#credentialForm {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#credentialName {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-header {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input,
|
||||||
|
.search-type,
|
||||||
|
.search-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#downloadQueue {
|
||||||
|
width: 90%;
|
||||||
|
right: 5%;
|
||||||
|
top: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 100%;
|
||||||
|
left: -100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.active {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status bar animations */
|
||||||
|
.status-bar {
|
||||||
|
height: 3px;
|
||||||
|
background: #1DB954;
|
||||||
|
width: 0;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error traceback styling */
|
||||||
|
.traceback {
|
||||||
|
background: #2a2a2a;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #ff5555;
|
||||||
|
margin-top: 10px;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Queue item states */
|
||||||
|
.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 percentage styling */
|
||||||
|
.progress-percent {
|
||||||
|
float: right;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #1DB954;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover tooltip for long messages */
|
||||||
|
.queue-item .log {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Download button styling */
|
||||||
|
.download-btn {
|
||||||
|
background-color: #1DB954;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 15px;
|
||||||
|
border-radius: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn:hover {
|
||||||
|
background-color: #1ed760;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 8px rgba(29, 185, 84, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn::before {
|
||||||
|
content: "↓";
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Select dropdown styling */
|
||||||
|
#spotifyAccountSelect,
|
||||||
|
#deezerAccountSelect {
|
||||||
|
background: #2a2a2a;
|
||||||
|
color: white;
|
||||||
|
border: 1px solid #404040;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 15px;
|
||||||
|
font-size: 14px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 5px 0 15px;
|
||||||
|
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 12px center;
|
||||||
|
background-size: 12px;
|
||||||
|
transition: border-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#spotifyAccountSelect:focus,
|
||||||
|
#deezerAccountSelect:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #1DB954;
|
||||||
|
box-shadow: 0 0 0 2px rgba(29, 185, 84, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Style the dropdown options */
|
||||||
|
#spotifyAccountSelect option,
|
||||||
|
#deezerAccountSelect option {
|
||||||
|
background: #181818;
|
||||||
|
color: white;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover state for options (limited browser support) */
|
||||||
|
#spotifyAccountSelect option:hover,
|
||||||
|
#deezerAccountSelect option:hover {
|
||||||
|
background: #1DB954;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disabled state styling */
|
||||||
|
#spotifyAccountSelect:disabled,
|
||||||
|
#deezerAccountSelect:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#spotifyAccountSelect,
|
||||||
|
#deezerAccountSelect {
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add queue icon styling */
|
||||||
|
.queue-icon {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 70px; /* Adjust based on settings icon position */
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 1.5em;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-icon:hover {
|
||||||
|
color: #1DB954;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep the existing queue sidebar styles */
|
||||||
|
#downloadQueue {
|
||||||
|
/* existing styles */
|
||||||
|
right: -350px;
|
||||||
|
transition: right 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#downloadQueue.active {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile-First Enhancements */
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
/* Viewport-friendly base sizing */
|
||||||
|
html {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Container adjustments */
|
||||||
|
.container {
|
||||||
|
padding: 10px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stack search elements vertically */
|
||||||
|
.search-header {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 15px 0;
|
||||||
|
position: relative;
|
||||||
|
top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Improve touch targets */
|
||||||
|
.search-input,
|
||||||
|
.search-type,
|
||||||
|
.search-button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 15px 20px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Adjust grid layout for smaller screens */
|
||||||
|
.results-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Optimize card content spacing */
|
||||||
|
.result-card {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-title {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-artist {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile-friendly sidebar */
|
||||||
|
.sidebar {
|
||||||
|
width: 100%;
|
||||||
|
left: -100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.active {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Queue positioning adjustments */
|
||||||
|
#downloadQueue {
|
||||||
|
width: 95%;
|
||||||
|
right: 2.5%;
|
||||||
|
top: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon positioning */
|
||||||
|
.settings-icon {
|
||||||
|
top: 15px;
|
||||||
|
left: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-icon {
|
||||||
|
top: 15px;
|
||||||
|
right: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form element adjustments */
|
||||||
|
.form-group input,
|
||||||
|
.form-group textarea {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Additional Mobile Optimizations */
|
||||||
|
@media screen and (max-width: 480px) {
|
||||||
|
/* Further reduce grid item size */
|
||||||
|
.results-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Increase body text contrast */
|
||||||
|
body {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhance tap target sizing */
|
||||||
|
button,
|
||||||
|
.result-card {
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevent text overflow */
|
||||||
|
.track-title,
|
||||||
|
.track-artist {
|
||||||
|
white-space: normal;
|
||||||
|
overflow: visible;
|
||||||
|
text-overflow: clip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input element mobile optimization */
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font-size: 16px !important; /* Prevent iOS zoom */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Touch interaction improvements */
|
||||||
|
button {
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-card {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevent layout shift on scrollbar appearance */
|
||||||
|
html {
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modified Button Positioning */
|
||||||
|
.settings-icon {
|
||||||
|
position: static; /* Remove fixed positioning */
|
||||||
|
order: -1; /* Move to start of flex container */
|
||||||
|
margin-right: 15px;
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-icon {
|
||||||
|
position: static; /* Remove fixed positioning */
|
||||||
|
order: 2; /* Place after search button */
|
||||||
|
margin-left: 15px;
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Updated Search Header */
|
||||||
|
.search-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background-color: #121212;
|
||||||
|
padding: 20px 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Responsiveness */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.search-header {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-icon,
|
||||||
|
.queue-icon {
|
||||||
|
margin: 0;
|
||||||
|
order: 0; /* Reset order for mobile */
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input,
|
||||||
|
.search-type {
|
||||||
|
order: 1;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-button {
|
||||||
|
order: 2;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-icon {
|
||||||
|
order: 3;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Existing queue icon styles remain the same */
|
||||||
|
.queue-icon:hover {
|
||||||
|
color: #1DB954;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Updated Sidebar Animations */
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
width: 350px;
|
||||||
|
height: 100vh;
|
||||||
|
background: #181818;
|
||||||
|
padding: 20px;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
z-index: 1001;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 0 15px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Settings Sidebar Specific */
|
||||||
|
#settingsSidebar {
|
||||||
|
left: -350px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#settingsSidebar.active {
|
||||||
|
left: 0;
|
||||||
|
box-shadow: 20px 0 30px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Download Queue Specific */
|
||||||
|
#downloadQueue {
|
||||||
|
right: -350px;
|
||||||
|
left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#downloadQueue.active {
|
||||||
|
right: 0;
|
||||||
|
box-shadow: -20px 0 30px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhanced Transition Effects */
|
||||||
|
.sidebar {
|
||||||
|
transition:
|
||||||
|
left 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
right 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Queue Item Animations */
|
||||||
|
.queue-item {
|
||||||
|
transition:
|
||||||
|
transform 0.2s ease,
|
||||||
|
opacity 0.3s ease,
|
||||||
|
background-color 0.3s ease;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item:not(.active):hover {
|
||||||
|
transform: translateX(5px);
|
||||||
|
background-color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item.entering {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item.exiting {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Responsiveness Adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sidebar {
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#settingsSidebar {
|
||||||
|
left: -100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#downloadQueue {
|
||||||
|
right: -100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.active {
|
||||||
|
box-shadow: 0 0 30px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
395
static/js/app.js
395
static/js/app.js
@@ -1,10 +1,3 @@
|
|||||||
function logRequest(method, url, body = null) {
|
|
||||||
console.log(`Sending ${method} request to: ${url}`);
|
|
||||||
if (body) {
|
|
||||||
console.log('Request payload:', body);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const serviceConfig = {
|
const serviceConfig = {
|
||||||
spotify: {
|
spotify: {
|
||||||
fields: [
|
fields: [
|
||||||
@@ -32,8 +25,11 @@ const serviceConfig = {
|
|||||||
|
|
||||||
let currentService = 'spotify';
|
let currentService = 'spotify';
|
||||||
let currentCredential = null;
|
let currentCredential = null;
|
||||||
|
let downloadQueue = {};
|
||||||
|
let prgInterval = null;
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
|
||||||
const searchButton = document.getElementById('searchButton');
|
const searchButton = document.getElementById('searchButton');
|
||||||
const searchInput = document.getElementById('searchInput');
|
const searchInput = document.getElementById('searchInput');
|
||||||
const settingsIcon = document.getElementById('settingsIcon');
|
const settingsIcon = document.getElementById('settingsIcon');
|
||||||
@@ -41,6 +37,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const closeSidebar = document.getElementById('closeSidebar');
|
const closeSidebar = document.getElementById('closeSidebar');
|
||||||
const serviceTabs = document.querySelectorAll('.tab-button');
|
const serviceTabs = document.querySelectorAll('.tab-button');
|
||||||
|
|
||||||
|
// Initialize configuration
|
||||||
|
initConfig();
|
||||||
|
|
||||||
// Search functionality
|
// Search functionality
|
||||||
searchButton.addEventListener('click', performSearch);
|
searchButton.addEventListener('click', performSearch);
|
||||||
searchInput.addEventListener('keypress', (e) => {
|
searchInput.addEventListener('keypress', (e) => {
|
||||||
@@ -71,7 +70,92 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
document.getElementById('credentialForm').addEventListener('submit', handleCredentialSubmit);
|
document.getElementById('credentialForm').addEventListener('submit', handleCredentialSubmit);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Search functions remain the same
|
async function initConfig() {
|
||||||
|
loadConfig();
|
||||||
|
console.log(loadConfig())
|
||||||
|
await updateAccountSelectors();
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
document.getElementById('fallbackToggle').addEventListener('change', () => {
|
||||||
|
saveConfig();
|
||||||
|
updateAccountSelectors();
|
||||||
|
});
|
||||||
|
|
||||||
|
const accountSelects = ['spotifyAccountSelect', 'deezerAccountSelect'];
|
||||||
|
accountSelects.forEach(id => {
|
||||||
|
document.getElementById(id).addEventListener('change', () => {
|
||||||
|
saveConfig();
|
||||||
|
updateAccountSelectors();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
// Update button state
|
||||||
|
const queueIcon = document.getElementById('queueIcon');
|
||||||
|
queueIcon.textContent = queueSidebar.classList.contains('active') ? '📭' : '📥';
|
||||||
|
}
|
||||||
|
|
||||||
function performSearch() {
|
function performSearch() {
|
||||||
const query = document.getElementById('searchInput').value.trim();
|
const query = document.getElementById('searchInput').value.trim();
|
||||||
const searchType = document.getElementById('searchType').value;
|
const searchType = document.getElementById('searchType').value;
|
||||||
@@ -90,17 +174,29 @@ function performSearch() {
|
|||||||
if (data.error) throw new Error(data.error);
|
if (data.error) throw new Error(data.error);
|
||||||
const items = data.data[`${searchType}s`]?.items;
|
const items = data.data[`${searchType}s`]?.items;
|
||||||
|
|
||||||
resultsContainer.innerHTML = items?.length
|
if (!items || !items.length) {
|
||||||
? items.map(item => createResultCard(item, searchType)).join('')
|
resultsContainer.innerHTML = '<div class="error">No results found</div>';
|
||||||
: '<div class="error">No results found</div>';
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resultsContainer.innerHTML = items.map(item => createResultCard(item, searchType)).join('');
|
||||||
|
|
||||||
|
const cards = resultsContainer.querySelectorAll('.result-card');
|
||||||
|
cards.forEach((card, index) => {
|
||||||
|
// Add download handler
|
||||||
|
card.querySelector('.download-btn').addEventListener('click', async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const url = e.target.dataset.url;
|
||||||
|
const type = e.target.dataset.type;
|
||||||
|
startDownload(url, type, items[index]);
|
||||||
|
card.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.catch(error => showError(error.message));
|
.catch(error => showError(error.message));
|
||||||
}
|
}
|
||||||
|
|
||||||
function createResultCard(item, type) {
|
function createResultCard(item, type) {
|
||||||
const card = document.createElement('div');
|
|
||||||
card.className = 'result-card';
|
|
||||||
|
|
||||||
let imageUrl, title, subtitle, details;
|
let imageUrl, title, subtitle, details;
|
||||||
|
|
||||||
switch(type) {
|
switch(type) {
|
||||||
@@ -133,17 +229,185 @@ function createResultCard(item, type) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
card.innerHTML = `
|
return `
|
||||||
<img src="${imageUrl}" class="album-art" alt="${type} cover">
|
<div class="result-card" data-id="${item.id}">
|
||||||
<div class="track-title">${title}</div>
|
<img src="${imageUrl}" class="album-art" alt="${type} cover">
|
||||||
<div class="track-artist">${subtitle}</div>
|
<div class="track-title">${title}</div>
|
||||||
<div class="track-details">${details}</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>
|
||||||
`;
|
`;
|
||||||
card.addEventListener('click', () => window.open(item.external_urls.spotify, '_blank'));
|
|
||||||
return card;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Credential management functions
|
async function startDownload(url, type, item) {
|
||||||
|
const fallbackEnabled = document.getElementById('fallbackToggle').checked;
|
||||||
|
const spotifyAccount = document.getElementById('spotifyAccountSelect').value;
|
||||||
|
const deezerAccount = document.getElementById('deezerAccountSelect').value;
|
||||||
|
|
||||||
|
let apiUrl = `/api/${type}/download?service=spotify&url=${encodeURIComponent(url)}`;
|
||||||
|
|
||||||
|
if (fallbackEnabled) {
|
||||||
|
apiUrl += `&main=${deezerAccount}&fallback=${spotifyAccount}`;
|
||||||
|
} else {
|
||||||
|
apiUrl += `&main=${spotifyAccount}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(apiUrl);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
addToQueue(item, type, data.prg_file);
|
||||||
|
startMonitoringQueue();
|
||||||
|
} catch (error) {
|
||||||
|
showError('Download failed: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addToQueue(item, type, prgFile) {
|
||||||
|
const queueId = Date.now().toString();
|
||||||
|
downloadQueue[queueId] = {
|
||||||
|
item,
|
||||||
|
type,
|
||||||
|
prgFile,
|
||||||
|
element: createQueueItem(item, type, prgFile),
|
||||||
|
lastLineCount: 0,
|
||||||
|
lastUpdated: Date.now(),
|
||||||
|
hasEnded: false
|
||||||
|
};
|
||||||
|
document.getElementById('queueItems').appendChild(downloadQueue[queueId].element);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function createQueueItem(item, type, prgFile) {
|
||||||
|
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-${prgFile}">Initializing download...</div>
|
||||||
|
`;
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startMonitoringQueue() {
|
||||||
|
if (!prgInterval) {
|
||||||
|
prgInterval = setInterval(async () => {
|
||||||
|
const queueEntries = Object.entries(downloadQueue);
|
||||||
|
if (queueEntries.length === 0) {
|
||||||
|
clearInterval(prgInterval);
|
||||||
|
prgInterval = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let activeEntries = 0;
|
||||||
|
|
||||||
|
for (const [id, entry] of queueEntries) {
|
||||||
|
if (entry.hasEnded) continue;
|
||||||
|
activeEntries++;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/prgs/${entry.prgFile}`);
|
||||||
|
const log = await response.text();
|
||||||
|
const lines = log.split('\n').filter(line => line.trim() !== '');
|
||||||
|
const logElement = document.getElementById(`log-${entry.prgFile}`);
|
||||||
|
|
||||||
|
// Process new lines
|
||||||
|
if (lines.length > entry.lastLineCount) {
|
||||||
|
const newLines = lines.slice(entry.lastLineCount);
|
||||||
|
entry.lastLineCount = lines.length;
|
||||||
|
entry.lastUpdated = Date.now();
|
||||||
|
|
||||||
|
for (const line of newLines) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(line);
|
||||||
|
|
||||||
|
// Store status in queue entry
|
||||||
|
if (data.status === 'error' || data.status === 'complete') {
|
||||||
|
entry.status = data.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle error status with retry button
|
||||||
|
if (data.status === 'error') {
|
||||||
|
logElement.innerHTML = `
|
||||||
|
<span class="error-status">${getStatusMessage(data)}</span>
|
||||||
|
<button class="retry-btn">Retry</button>
|
||||||
|
<button class="close-btn">×</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Retry handler
|
||||||
|
logElement.querySelector('.retry-btn').addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
startDownload(entry.item.external_urls.spotify, entry.type, entry.item);
|
||||||
|
delete downloadQueue[id];
|
||||||
|
entry.element.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close handler
|
||||||
|
logElement.querySelector('.close-btn').addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
delete downloadQueue[id];
|
||||||
|
entry.element.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
entry.element.classList.add('failed');
|
||||||
|
entry.hasEnded = true;
|
||||||
|
} else {
|
||||||
|
logElement.textContent = getStatusMessage(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle terminal statuses
|
||||||
|
if (data.status === 'error' || data.status === 'complete') {
|
||||||
|
entry.hasEnded = true;
|
||||||
|
entry.status = data.status;
|
||||||
|
if (data.status === 'error' && data.traceback) {
|
||||||
|
console.error('Server error:', data.traceback);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Invalid PRG line:', line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle timeout
|
||||||
|
if (Date.now() - entry.lastUpdated > 180000) {
|
||||||
|
logElement.textContent = 'Download timed out (3 minutes inactivity)';
|
||||||
|
entry.hasEnded = true;
|
||||||
|
entry.status = 'timeout';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup completed entries only
|
||||||
|
if (entry.hasEnded && entry.status === 'complete') {
|
||||||
|
setTimeout(() => {
|
||||||
|
delete downloadQueue[id];
|
||||||
|
entry.element.remove();
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Status check failed:', error);
|
||||||
|
entry.hasEnded = true;
|
||||||
|
entry.status = 'error';
|
||||||
|
document.getElementById(`log-${entry.prgFile}`).textContent = 'Status check error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop interval if no active entries
|
||||||
|
if (activeEntries === 0) {
|
||||||
|
clearInterval(prgInterval);
|
||||||
|
prgInterval = null;
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async function loadCredentials(service) {
|
async function loadCredentials(service) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/credentials/${service}`);
|
const response = await fetch(`/api/credentials/${service}`);
|
||||||
@@ -160,7 +424,7 @@ function renderCredentialsList(service, credentials) {
|
|||||||
<span>${name}</span>
|
<span>${name}</span>
|
||||||
<div class="credential-actions">
|
<div class="credential-actions">
|
||||||
<button class="edit-btn" data-name="${name}" data-service="${service}">Edit</button>
|
<button class="edit-btn" data-name="${name}" data-service="${service}">Edit</button>
|
||||||
<button class="delete-btn" data-name="${name}">Delete</button>
|
<button class="delete-btn" data-name="${name}" data-service="${service}">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
@@ -168,10 +432,35 @@ function renderCredentialsList(service, credentials) {
|
|||||||
list.querySelectorAll('.delete-btn').forEach(btn => {
|
list.querySelectorAll('.delete-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', async (e) => {
|
btn.addEventListener('click', async (e) => {
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/credentials/${service}/${e.target.dataset.name}`, { method: 'DELETE' });
|
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);
|
loadCredentials(service);
|
||||||
|
await updateAccountSelectors();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showSidebarError(error.message);
|
showSidebarError(error.message);
|
||||||
|
console.error('Delete error:', error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -182,9 +471,11 @@ function renderCredentialsList(service, credentials) {
|
|||||||
const name = e.target.dataset.name;
|
const name = e.target.dataset.name;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Switch to correct service tab
|
||||||
document.querySelector(`[data-service="${service}"]`).click();
|
document.querySelector(`[data-service="${service}"]`).click();
|
||||||
await new Promise(resolve => setTimeout(resolve, 50));
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
// Load credential data
|
||||||
const response = await fetch(`/api/credentials/${service}/${name}`);
|
const response = await fetch(`/api/credentials/${service}/${name}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
@@ -227,21 +518,16 @@ async function handleCredentialSubmit(e) {
|
|||||||
const name = nameInput.value.trim();
|
const name = nameInput.value.trim();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Validate name exists for new credentials
|
|
||||||
if (!currentCredential && !name) {
|
if (!currentCredential && !name) {
|
||||||
throw new Error('Credential name is required');
|
throw new Error('Credential name is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect form data
|
|
||||||
const formData = {};
|
const formData = {};
|
||||||
serviceConfig[service].fields.forEach(field => {
|
serviceConfig[service].fields.forEach(field => {
|
||||||
formData[field.id] = document.getElementById(field.id).value.trim();
|
formData[field.id] = document.getElementById(field.id).value.trim();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Validate using service config
|
|
||||||
const data = serviceConfig[service].validator(formData);
|
const data = serviceConfig[service].validator(formData);
|
||||||
|
|
||||||
// Use currentCredential for updates, name for new entries
|
|
||||||
const endpointName = currentCredential || name;
|
const endpointName = currentCredential || name;
|
||||||
const method = currentCredential ? 'PUT' : 'POST';
|
const method = currentCredential ? 'PUT' : 'POST';
|
||||||
|
|
||||||
@@ -256,8 +542,12 @@ async function handleCredentialSubmit(e) {
|
|||||||
throw new Error(errorData.error || 'Failed to save credentials');
|
throw new Error(errorData.error || 'Failed to save credentials');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Refresh and persist after credential changes
|
||||||
|
await updateAccountSelectors();
|
||||||
|
saveConfig();
|
||||||
loadCredentials(service);
|
loadCredentials(service);
|
||||||
resetForm();
|
resetForm();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showSidebarError(error.message);
|
showSidebarError(error.message);
|
||||||
console.error('Submission error:', error);
|
console.error('Submission error:', error);
|
||||||
@@ -290,3 +580,50 @@ function showSidebarError(message) {
|
|||||||
errorDiv.textContent = message;
|
errorDiv.textContent = message;
|
||||||
setTimeout(() => errorDiv.textContent = '', 3000);
|
setTimeout(() => errorDiv.textContent = '', 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getStatusMessage(data) {
|
||||||
|
switch (data.status) {
|
||||||
|
case 'downloading':
|
||||||
|
return `Downloading ${data.song || 'track'} by ${data.artist || 'artist'}...`;
|
||||||
|
case 'progress':
|
||||||
|
if (data.type === 'album') {
|
||||||
|
return `Processing track ${data.current_track}/${data.total_tracks} (${data.percentage.toFixed(1)}%): ${data.song}`;
|
||||||
|
} else {
|
||||||
|
return `${data.percentage.toFixed(1)}% complete`;
|
||||||
|
}
|
||||||
|
case 'done':
|
||||||
|
return `Finished: ${data.song} by ${data.artist}`;
|
||||||
|
case 'initializing':
|
||||||
|
return `Initializing ${data.type} download for ${data.album || data.artist}...`;
|
||||||
|
case 'error':
|
||||||
|
return `Error: ${data.message || 'Unknown error'}`;
|
||||||
|
case 'complete':
|
||||||
|
return 'Download completed successfully';
|
||||||
|
default:
|
||||||
|
return data.status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveConfig() {
|
||||||
|
const config = {
|
||||||
|
spotify: document.getElementById('spotifyAccountSelect').value,
|
||||||
|
deezer: document.getElementById('deezerAccountSelect').value,
|
||||||
|
fallback: document.getElementById('fallbackToggle').checked
|
||||||
|
};
|
||||||
|
localStorage.setItem('activeConfig', JSON.stringify(config));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function loadConfig() {
|
||||||
|
const saved = JSON.parse(localStorage.getItem('activeConfig')) || {};
|
||||||
|
|
||||||
|
// Set values only if they exist in the DOM
|
||||||
|
const spotifySelect = document.getElementById('spotifyAccountSelect');
|
||||||
|
const deezerSelect = document.getElementById('deezerAccountSelect');
|
||||||
|
|
||||||
|
if (spotifySelect) spotifySelect.value = saved.spotify || '';
|
||||||
|
if (deezerSelect) deezerSelect.value = saved.deezer || '';
|
||||||
|
|
||||||
|
const fallbackToggle = document.getElementById('fallbackToggle');
|
||||||
|
if (fallbackToggle) fallbackToggle.checked = !!saved.fallback;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,17 +7,41 @@
|
|||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<button id="settingsIcon" class="settings-icon">⚙️</button>
|
|
||||||
<div id="settingsSidebar" class="sidebar">
|
<div id="settingsSidebar" class="sidebar">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<h2>Credentials Management</h2>
|
<h2>Settings</h2>
|
||||||
<button id="closeSidebar" class="close-btn">×</button>
|
<button id="closeSidebar" class="close-btn">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Account Configuration Section -->
|
||||||
|
<div class="account-config">
|
||||||
|
<div class="config-item">
|
||||||
|
<label>Active Spotify Account:</label>
|
||||||
|
<select id="spotifyAccountSelect"></select>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<label>Active Deezer Account:</label>
|
||||||
|
<select id="deezerAccountSelect"></select>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<label>Download Fallback:</label>
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" id="fallbackToggle">
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Service Tabs -->
|
||||||
<div class="service-tabs">
|
<div class="service-tabs">
|
||||||
<button class="tab-button active" data-service="spotify">Spotify</button>
|
<button class="tab-button active" data-service="spotify">Spotify</button>
|
||||||
<button class="tab-button" data-service="deezer">Deezer</button>
|
<button class="tab-button" data-service="deezer">Deezer</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Credentials List -->
|
||||||
<div class="credentials-list"></div>
|
<div class="credentials-list"></div>
|
||||||
|
|
||||||
|
<!-- Credentials Form -->
|
||||||
<div class="credentials-form">
|
<div class="credentials-form">
|
||||||
<h3>Add/Edit Credential</h3>
|
<h3>Add/Edit Credential</h3>
|
||||||
<form id="credentialForm">
|
<form id="credentialForm">
|
||||||
@@ -31,8 +55,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="sidebarError" class="error"></div>
|
<div id="sidebarError" class="error"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="search-header">
|
<div class="search-header">
|
||||||
|
<button id="settingsIcon" class="settings-icon">⚙️</button>
|
||||||
<input type="text" class="search-input" placeholder="Search tracks, albums, or playlists..." id="searchInput">
|
<input type="text" class="search-input" placeholder="Search tracks, albums, or playlists..." id="searchInput">
|
||||||
<select class="search-type" id="searchType">
|
<select class="search-type" id="searchType">
|
||||||
<option value="track">Tracks</option>
|
<option value="track">Tracks</option>
|
||||||
@@ -40,10 +66,20 @@
|
|||||||
<option value="playlist">Playlists</option>
|
<option value="playlist">Playlists</option>
|
||||||
</select>
|
</select>
|
||||||
<button class="search-button" id="searchButton">Search</button>
|
<button class="search-button" id="searchButton">Search</button>
|
||||||
|
<button id="queueIcon" class="queue-icon" onclick="toggleDownloadQueue()">📥</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="resultsContainer" class="results-grid"></div>
|
<div id="resultsContainer" class="results-grid"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Download Queue Sidebar -->
|
||||||
|
<div id="downloadQueue" class="sidebar right">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<h2>Download Queue</h2>
|
||||||
|
<button class="close-btn" onclick="toggleDownloadQueue()">×</button>
|
||||||
|
</div>
|
||||||
|
<div id="queueItems"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Reference in New Issue
Block a user