diff --git a/routes/album.py b/routes/album.py index f6e5763..31029f8 100644 --- a/routes/album.py +++ b/routes/album.py @@ -4,15 +4,18 @@ import os import random import string import sys -from threading import Thread import traceback +from multiprocessing import Process # Use multiprocessing instead of threading +# Define the Blueprint for album-related routes album_bp = Blueprint('album', __name__) +# Function to generate random filenames def generate_random_filename(length=6): chars = string.ascii_lowercase + string.digits return ''.join(random.choice(chars) for _ in range(length)) + '.prg' +# File wrapper to flush writes immediately class FlushingFileWrapper: def __init__(self, file): self.file = file @@ -24,62 +27,73 @@ class FlushingFileWrapper: def flush(self): self.file.flush() +# Define the download task as a top-level function for picklability +def download_task(service, url, main, fallback, prg_path): + try: + from routes.utils.album import download_album + with open(prg_path, 'w') as f: + flushing_file = FlushingFileWrapper(f) + original_stdout = sys.stdout + sys.stdout = flushing_file # Redirect stdout to the file + try: + # Execute the download process + download_album( + service=service, + url=url, + main=main, + fallback=fallback + ) + flushing_file.write(json.dumps({"status": "complete"}) + "\n") + except Exception as e: + # Capture exceptions and write to file + error_data = json.dumps({ + "status": "error", + "message": str(e), + "traceback": traceback.format_exc() + }) + flushing_file.write(error_data + "\n") + finally: + sys.stdout = original_stdout # Restore original stdout + except Exception as e: + # Handle exceptions that occur outside the main download process + with open(prg_path, 'w') as f: + error_data = json.dumps({ + "status": "error", + "message": str(e), + "traceback": traceback.format_exc() + }) + f.write(error_data + "\n") + +# Define the route to handle album download requests @album_bp.route('/download', methods=['GET']) def handle_download(): + # Extract query parameters service = request.args.get('service') url = request.args.get('url') main = request.args.get('main') - fallback = request.args.get('fallback') # New fallback parameter - + fallback = request.args.get('fallback') # Optional parameter + + # Validate required parameters if not all([service, url, main]): return Response( json.dumps({"error": "Missing parameters"}), status=400, mimetype='application/json' ) - + + # Generate a unique file for storing the download progress filename = generate_random_filename() prg_dir = './prgs' os.makedirs(prg_dir, exist_ok=True) prg_path = os.path.join(prg_dir, filename) - - def download_task(): - try: - from routes.utils.album import download_album - with open(prg_path, 'w') as f: - flushing_file = FlushingFileWrapper(f) - original_stdout = sys.stdout - sys.stdout = flushing_file - - try: - # Pass fallback parameter to download_album - download_album( - service=service, - url=url, - main=main, - fallback=fallback - ) - flushing_file.write(json.dumps({"status": "complete"}) + "\n") - except Exception as e: - error_data = json.dumps({ - "status": "error", - "message": str(e), - "traceback": traceback.format_exc() - }) - flushing_file.write(error_data + "\n") - finally: - sys.stdout = original_stdout - except Exception as e: - with open(prg_path, 'w') as f: - error_data = json.dumps({ - "status": "error", - "message": str(e), - "traceback": traceback.format_exc() - }) - f.write(error_data + "\n") - - Thread(target=download_task).start() - + + # Start a new process for each download task + Process( + target=download_task, + args=(service, url, main, fallback, prg_path) + ).start() + + # Return the filename to the client for progress tracking return Response( json.dumps({"prg_file": filename}), status=202, diff --git a/routes/playlist.py b/routes/playlist.py index 17b0883..7cfa095 100644 --- a/routes/playlist.py +++ b/routes/playlist.py @@ -4,11 +4,13 @@ import os import random import string import sys -from threading import Thread import traceback +from multiprocessing import Process # Changed from Thread to Process playlist_bp = Blueprint('playlist', __name__) +# Removed thread-local stdout setup since we're using process isolation + def generate_random_filename(length=6): chars = string.ascii_lowercase + string.digits return ''.join(random.choice(chars) for _ in range(length)) + '.prg' @@ -24,14 +26,49 @@ class FlushingFileWrapper: def flush(self): self.file.flush() +# Moved download_task to top-level for picklability +def download_task(service, url, main, fallback, prg_path): + 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 + + try: + download_playlist( + service=service, + url=url, + main=main, + fallback=fallback + ) + flushing_file.write(json.dumps({"status": "complete"}) + "\n") + except Exception as e: + error_data = json.dumps({ + "status": "error", + "message": str(e), + "traceback": traceback.format_exc() + }) + flushing_file.write(error_data + "\n") + finally: + sys.stdout = original_stdout # Restore original stdout + except Exception as e: + # Handle exceptions outside the main logic + with open(prg_path, 'w') as f: + error_data = json.dumps({ + "status": "error", + "message": str(e), + "traceback": traceback.format_exc() + }) + f.write(error_data + "\n") + @playlist_bp.route('/download', methods=['GET']) def handle_download(): service = request.args.get('service') url = request.args.get('url') - main = request.args.get('main') # Changed from 'account' - fallback = request.args.get('fallback') # New parameter + main = request.args.get('main') + fallback = request.args.get('fallback') - # Validate required parameters (main instead of account) if not all([service, url, main]): return Response( json.dumps({"error": "Missing parameters"}), @@ -44,42 +81,11 @@ def handle_download(): os.makedirs(prg_dir, exist_ok=True) prg_path = os.path.join(prg_dir, filename) - def download_task(): - 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 - - try: - # Updated call with main/fallback parameters - download_playlist( - service=service, - url=url, - main=main, - fallback=fallback - ) - flushing_file.write(json.dumps({"status": "complete"}) + "\n") - except Exception as e: - error_data = json.dumps({ - "status": "error", - "message": str(e), - "traceback": traceback.format_exc() - }) - flushing_file.write(error_data + "\n") - finally: - sys.stdout = original_stdout - except Exception as e: - with open(prg_path, 'w') as f: - error_data = json.dumps({ - "status": "error", - "message": str(e), - "traceback": traceback.format_exc() - }) - f.write(error_data + "\n") - - Thread(target=download_task).start() + # Start a new process with required arguments + Process( + target=download_task, + args=(service, url, main, fallback, prg_path) + ).start() return Response( json.dumps({"prg_file": filename}), diff --git a/routes/prgs.py b/routes/prgs.py index c7bfa11..c015551 100644 --- a/routes/prgs.py +++ b/routes/prgs.py @@ -1,24 +1,31 @@ -from flask import Blueprint, send_from_directory, abort +from flask import Blueprint, abort import os prgs_bp = Blueprint('prgs', __name__, url_prefix='/api/prgs') -# Base directory for .prg files +# Base directory for files PRGS_DIR = os.path.join(os.getcwd(), 'prgs') @prgs_bp.route('/', methods=['GET']) def get_prg_file(filename): """ - Serve a .prg file from the prgs directory. + Return the last line of the specified file from the prgs directory. """ try: # Security check to prevent path traversal attacks - if not filename.endswith('.prg') or '..' in filename or '/' in filename: + if '..' in filename or '/' in filename: abort(400, "Invalid file request") - # Ensure the file exists in the directory - return send_from_directory(PRGS_DIR, filename) + filepath = os.path.join(PRGS_DIR, filename) + + # Read the last line of the file + with open(filepath, 'r') as f: + content = f.read() + lines = content.splitlines() + last_line = lines[-1] if lines else '' + + return last_line except FileNotFoundError: abort(404, "File not found") except Exception as e: - abort(500, f"An error occurred: {e}") + abort(500, f"An error occurred: {e}") \ No newline at end of file diff --git a/routes/track.py b/routes/track.py index 91e76a2..ab4c6b6 100644 --- a/routes/track.py +++ b/routes/track.py @@ -4,8 +4,8 @@ import os import random import string import sys -from threading import Thread import traceback +from multiprocessing import Process # Changed from threading import Thread track_bp = Blueprint('track', __name__) @@ -24,12 +24,47 @@ class FlushingFileWrapper: def flush(self): self.file.flush() +# Moved download_task to top-level for multiprocessing compatibility +def download_task(service, url, main, fallback, prg_path): + 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 per process + + try: + download_track( + service=service, + url=url, + main=main, + fallback=fallback + ) + flushing_file.write(json.dumps({"status": "complete"}) + "\n") + except Exception as e: + error_data = json.dumps({ + "status": "error", + "message": str(e), + "traceback": traceback.format_exc() + }) + flushing_file.write(error_data + "\n") + finally: + sys.stdout = original_stdout # Restore original stdout + except Exception as e: + with open(prg_path, 'w') as f: + error_data = json.dumps({ + "status": "error", + "message": str(e), + "traceback": traceback.format_exc() + }) + f.write(error_data + "\n") + @track_bp.route('/download', methods=['GET']) def handle_download(): service = request.args.get('service') url = request.args.get('url') main = request.args.get('main') - fallback = request.args.get('fallback') # New fallback parameter + fallback = request.args.get('fallback') if not all([service, url, main]): return Response( @@ -43,42 +78,11 @@ def handle_download(): os.makedirs(prg_dir, exist_ok=True) prg_path = os.path.join(prg_dir, filename) - def download_task(): - 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 - - try: - # Pass all parameters including fallback - download_track( - service=service, - url=url, - main=main, - fallback=fallback - ) - flushing_file.write(json.dumps({"status": "complete"}) + "\n") - except Exception as e: - error_data = json.dumps({ - "status": "error", - "message": str(e), - "traceback": traceback.format_exc() - }) - flushing_file.write(error_data + "\n") - finally: - sys.stdout = original_stdout - except Exception as e: - with open(prg_path, 'w') as f: - error_data = json.dumps({ - "status": "error", - "message": str(e), - "traceback": traceback.format_exc() - }) - f.write(error_data + "\n") - - Thread(target=download_task).start() + # Start a new process with required arguments + Process( + target=download_task, + args=(service, url, main, fallback, prg_path) + ).start() return Response( json.dumps({"prg_file": filename}), diff --git a/routes/utils/__pycache__/credentials.cpython-312.pyc b/routes/utils/__pycache__/credentials.cpython-312.pyc index c8ba950..07431f4 100644 Binary files a/routes/utils/__pycache__/credentials.cpython-312.pyc and b/routes/utils/__pycache__/credentials.cpython-312.pyc differ diff --git a/routes/utils/album.py b/routes/utils/album.py index f0f2aaa..2ae913a 100644 --- a/routes/utils/album.py +++ b/routes/utils/album.py @@ -33,8 +33,6 @@ def download_album(service, url, main, fallback=None): method_save=1 ) except Exception as e: - # If the first attempt fails, use the fallback Spotify main - print(f"Failed to download via Deezer fallback: {e}. Trying Spotify fallback main.") # Load fallback Spotify credentials and attempt download try: spo_creds_dir = os.path.join('./creds/spotify', fallback) diff --git a/static/css/style.css b/static/css/style.css index 6c3606c..4255d4a 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -420,10 +420,6 @@ input:checked + .slider:before { transition: transform 0.2s ease; } -.queue-item:hover { - transform: translateX(5px); -} - .queue-item .title { font-weight: 500; margin-bottom: 4px; @@ -953,17 +949,11 @@ html { /* 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); diff --git a/static/js/app.js b/static/js/app.js index a9027eb..dc06522 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -50,6 +50,7 @@ document.addEventListener('DOMContentLoaded', () => { settingsIcon.addEventListener('click', () => { sidebar.classList.add('active'); loadCredentials(currentService); + updateFormFields(); }); closeSidebar.addEventListener('click', () => { @@ -166,6 +167,30 @@ function performSearch() { return; } + // Handle direct Spotify URLs + if (isSpotifyUrl(query)) { + try { + const type = getResourceTypeFromUrl(query); + if (!['track', 'album', 'playlist'].includes(type)) { + throw new Error('Unsupported URL type'); + } + + const item = { + name: `Direct URL (${type})`, + external_urls: { spotify: query } + }; + + startDownload(query, type, item); + document.getElementById('searchInput').value = ''; + return; + + } catch (error) { + showError(`Invalid Spotify URL: ${error.message}`); + return; + } + } + + // Existing search functionality resultsContainer.innerHTML = '
Searching...
'; fetch(`/api/search?q=${encodeURIComponent(query)}&search_type=${searchType}&limit=30`) @@ -183,7 +208,6 @@ function performSearch() { 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; @@ -196,6 +220,7 @@ function performSearch() { .catch(error => showError(error.message)); } + function createResultCard(item, type) { let imageUrl, title, subtitle, details; @@ -262,151 +287,149 @@ async function startDownload(url, type, item) { 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] = { + const queueId = Date.now().toString() + Math.random().toString(36).substr(2, 9); + const entry = { item, type, prgFile, - element: createQueueItem(item, type, prgFile), - lastLineCount: 0, + element: createQueueItem(item, type, prgFile, queueId), + lastStatus: null, lastUpdated: Date.now(), - hasEnded: false + hasEnded: false, + intervalId: null, + uniqueId: queueId // Add unique identifier }; - document.getElementById('queueItems').appendChild(downloadQueue[queueId].element); + + downloadQueue[queueId] = entry; + document.getElementById('queueItems').appendChild(entry.element); + startEntryMonitoring(queueId); } +async function startEntryMonitoring(queueId) { + const entry = downloadQueue[queueId]; + if (!entry || entry.hasEnded) return; -function createQueueItem(item, type, prgFile) { + 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 lastLine = (await response.text()).trim(); + + // Handle empty response + if (!lastLine) { + handleInactivity(entry, queueId, logElement); + return; + } + + try { + const data = JSON.parse(lastLine); + + // Check for status changes + if (JSON.stringify(entry.lastStatus) === JSON.stringify(data)) { + handleInactivity(entry, queueId, logElement); + return; + } + + // Update entry state + entry.lastStatus = data; + entry.lastUpdated = Date.now(); + entry.status = data.status; + logElement.textContent = getStatusMessage(data); + + // Handle terminal states + if (data.status === 'error' || data.status === 'complete') { + handleTerminalState(entry, queueId, data); + } + + } catch (e) { + console.error('Invalid PRG line:', lastLine); + logElement.textContent = 'Error parsing status update'; + handleTerminalState(entry, queueId, { + status: 'error', + message: 'Invalid status format' + }); + } + + } catch (error) { + console.error('Status check failed:', error); + handleTerminalState(entry, queueId, { + status: 'error', + message: 'Status check error' + }); + } + }, 2000); +} + +function handleInactivity(entry, queueId, logElement) { + if (Date.now() - entry.lastUpdated > 180000) { + logElement.textContent = 'Download timed out (3 minutes inactivity)'; + handleTerminalState(entry, queueId, { status: 'timeout' }); + } +} + +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 = ` + ${getStatusMessage(data)} + + + `; + + 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'); + } + + 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(); + delete downloadQueue[queueId]; + } +} + +function createQueueItem(item, type, prgFile, queueId) { const div = document.createElement('div'); div.className = 'queue-item'; div.innerHTML = `
${item.name}
${type.charAt(0).toUpperCase() + type.slice(1)}
-
Initializing download...
+
Initializing download...
`; 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 = ` - ${getStatusMessage(data)} - - - `; - - // 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) { try { @@ -627,3 +650,12 @@ function loadConfig() { const fallbackToggle = document.getElementById('fallbackToggle'); if (fallbackToggle) fallbackToggle.checked = !!saved.fallback; } + +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', or 'playlist' +}