Improved frontend

This commit is contained in:
cool.gitter.choco
2025-01-26 17:27:08 -06:00
parent bf0f50c440
commit 5531d5eefd
9 changed files with 1156 additions and 41 deletions

1
.gitignore vendored
View File

@@ -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
View File

@@ -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
View File

@@ -1,6 +0,0 @@
{
"name": "spotizer",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

24
routes/prgs.py Normal file
View 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}")

View File

@@ -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:

View File

@@ -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:

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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">&times;</button> <button id="closeSidebar" class="close-btn">&times;</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()">&times;</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>