diff --git a/routes/album.py b/routes/album.py index a47f572..e27c7d5 100755 --- a/routes/album.py +++ b/routes/album.py @@ -14,16 +14,26 @@ download_processes = {} def generate_random_filename(length=6): chars = string.ascii_lowercase + string.digits - return ''.join(random.choice(chars) for _ in range(length)) + '.prg' + return ''.join(random.choice(chars) for _ in range(length)) + '.album.prg' class FlushingFileWrapper: def __init__(self, file): self.file = file def write(self, text): - # Filter lines to only write JSON objects + # Process each line separately. for line in text.split('\n'): - if line.startswith('{'): + line = line.strip() + # Only process non-empty lines that look like JSON objects. + if line and line.startswith('{'): + try: + obj = json.loads(line) + # Skip writing if the JSON object has a "type" of "track" + if obj.get("type") == "track": + continue + except ValueError: + # If not valid JSON, write the line as is. + pass self.file.write(line + '\n') self.file.flush() diff --git a/routes/artist.py b/routes/artist.py index 0251abc..ea5dac0 100644 --- a/routes/artist.py +++ b/routes/artist.py @@ -19,7 +19,7 @@ download_processes = {} def generate_random_filename(length=6): chars = string.ascii_lowercase + string.digits - return ''.join(random.choice(chars) for _ in range(length)) + '.prg' + return ''.join(random.choice(chars) for _ in range(length)) + '.artist.prg' class FlushingFileWrapper: def __init__(self, file): @@ -28,7 +28,16 @@ class FlushingFileWrapper: def write(self, text): # Only write lines that start with '{' for line in text.split('\n'): - if line.startswith('{'): + line = line.strip() + if line and line.startswith('{'): + try: + obj = json.loads(line) + # Skip writing if the JSON object has type "track" + if obj.get("type") == "track": + continue + except ValueError: + # If not valid JSON, write the line as is. + pass self.file.write(line + '\n') self.file.flush() diff --git a/routes/playlist.py b/routes/playlist.py index ff4e720..4ebbb95 100755 --- a/routes/playlist.py +++ b/routes/playlist.py @@ -14,7 +14,7 @@ playlist_processes = {} def generate_random_filename(length=6): chars = string.ascii_lowercase + string.digits - return ''.join(random.choice(chars) for _ in range(length)) + '.prg' + return ''.join(random.choice(chars) for _ in range(length)) + '.playlist.prg' class FlushingFileWrapper: def __init__(self, file): @@ -22,7 +22,18 @@ class FlushingFileWrapper: def write(self, text): for line in text.split('\n'): - if line.startswith('{'): + line = line.strip() + # Only process non-empty lines that start with '{' + if line and line.startswith('{'): + try: + # Try to parse the line as JSON + obj = json.loads(line) + # If the object has a "type" key with the value "track", skip writing it. + if obj.get("type") == "track": + continue + except ValueError: + # If the line isn't valid JSON, we don't filter it. + pass self.file.write(line + '\n') self.file.flush() diff --git a/routes/prgs.py b/routes/prgs.py index c015551..71929e0 100755 --- a/routes/prgs.py +++ b/routes/prgs.py @@ -1,5 +1,6 @@ -from flask import Blueprint, abort +from flask import Blueprint, abort, jsonify import os +import json prgs_bp = Blueprint('prgs', __name__, url_prefix='/api/prgs') @@ -9,23 +10,104 @@ PRGS_DIR = os.path.join(os.getcwd(), 'prgs') @prgs_bp.route('/', methods=['GET']) def get_prg_file(filename): """ - Return the last line of the specified file from the prgs directory. + Return a JSON object with the resource type, its name (title) and the last line (progress update) of the PRG file. + If the file is empty, return default values. """ try: - # Security check to prevent path traversal attacks + # Security check to prevent path traversal attacks. if '..' in filename or '/' in filename: abort(400, "Invalid file request") 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 + # If the file is empty, return default values. + if not lines: + return jsonify({ + "type": "", + "name": "", + "last_line": None + }) + + # Process the initialization line (first line) to extract type and name. + try: + init_data = json.loads(lines[0]) + except Exception as e: + # If parsing fails, use defaults. + init_data = {} + + resource_type = init_data.get("type", "") + # Determine the name based on type. + if resource_type == "track": + resource_name = init_data.get("song", "") + elif resource_type == "album": + resource_name = init_data.get("album", "") + elif resource_type == "playlist": + resource_name = init_data.get("name", "") + elif resource_type == "artist": + resource_name = init_data.get("artist", "") + else: + resource_name = "" + + # Get the last line from the file. + last_line_raw = lines[-1] + # Try to parse the last line as JSON. + try: + last_line_parsed = json.loads(last_line_raw) + except Exception: + last_line_parsed = last_line_raw # Fallback to returning raw string if JSON parsing fails. + + return jsonify({ + "type": resource_type, + "name": resource_name, + "last_line": last_line_parsed + }) except FileNotFoundError: abort(404, "File not found") except Exception as e: - abort(500, f"An error occurred: {e}") \ No newline at end of file + abort(500, f"An error occurred: {e}") + + +@prgs_bp.route('/delete/', methods=['DELETE']) +def delete_prg_file(filename): + """ + Delete the specified .prg file from the prgs directory. + """ + try: + # Security checks to prevent path traversal and ensure correct file type. + if '..' in filename or '/' in filename: + abort(400, "Invalid file request") + if not filename.endswith('.prg'): + abort(400, "Only .prg files can be deleted") + + filepath = os.path.join(PRGS_DIR, filename) + + if not os.path.isfile(filepath): + abort(404, "File not found") + + os.remove(filepath) + return {'message': f'File {filename} deleted successfully'}, 200 + except FileNotFoundError: + abort(404, "File not found") + except Exception as e: + abort(500, f"An error occurred: {e}") + + +@prgs_bp.route('/list', methods=['GET']) +def list_prg_files(): + """ + Retrieve a list of all .prg files in the prgs directory. + """ + try: + prg_files = [] + if os.path.isdir(PRGS_DIR): + with os.scandir(PRGS_DIR) as entries: + for entry in entries: + if entry.is_file() and entry.name.endswith('.prg'): + prg_files.append(entry.name) + return jsonify(prg_files) + except Exception as e: + abort(500, f"An error occurred: {e}") diff --git a/routes/track.py b/routes/track.py index 70c5848..80c8f59 100755 --- a/routes/track.py +++ b/routes/track.py @@ -14,7 +14,7 @@ track_processes = {} def generate_random_filename(length=6): chars = string.ascii_lowercase + string.digits - return ''.join(random.choice(chars) for _ in range(length)) + '.prg' + return ''.join(random.choice(chars) for _ in range(length)) + '.track.prg' class FlushingFileWrapper: def __init__(self, file): diff --git a/routes/utils/artist.py b/routes/utils/artist.py index 89c14c8..4c376aa 100644 --- a/routes/utils/artist.py +++ b/routes/utils/artist.py @@ -49,10 +49,14 @@ def download_artist_albums(service, artist_url, main, fallback=None, quality=Non except Exception as e: log_json({"status": "error", "message": f"Error retrieving artist discography: {e}"}) raise - albums = discography.get('items', []) - # Attempt to extract the artist name from the discography; fallback to artist_url if not available. - artist_name = discography.get("name", artist_url) + # Extract artist name from the first album's artists + artist_name = artist_url # default fallback + if albums: + first_album = albums[0] + artists = first_album.get('artists', []) + if artists: + artist_name = artists[0].get('name', artist_url) if not albums: log_json({ @@ -64,7 +68,7 @@ def download_artist_albums(service, artist_url, main, fallback=None, quality=Non }) return - log_json({"status": "initializing", "type": "artist", "total_albums": len(albums)}) + log_json({"status": "initializing", "type": "artist", "artist": artist_name, "total_albums": len(albums)}) for album in albums: try: @@ -109,4 +113,4 @@ def download_artist_albums(service, artist_url, main, fallback=None, quality=Non "type": "artist", "artist": artist_name, "album_type": album_type - }) + }) \ No newline at end of file diff --git a/static/js/app.js b/static/js/app.js index d64bade..99718f4 100755 --- a/static/js/app.js +++ b/static/js/app.js @@ -98,7 +98,6 @@ async function initConfig() { } }); - // Add quality select listeners with null checks const spotifyQuality = document.getElementById('spotifyQualitySelect'); if (spotifyQuality) { spotifyQuality.addEventListener('change', saveConfig); @@ -108,8 +107,10 @@ async function initConfig() { if (deezerQuality) { deezerQuality.addEventListener('change', saveConfig); } -} - + + // Load existing PRG files after initial setup + await loadExistingPrgFiles(); +} async function updateAccountSelectors() { try { @@ -496,43 +497,35 @@ async function startEntryMonitoring(queueId) { try { const response = await fetch(`/api/prgs/${entry.prgFile}`); - const lastLine = (await response.text()).trim(); + const data = await response.json(); + // data contains: { type, name, last_line } + const progress = data.last_line; - // Handle empty response - if (!lastLine) { + if (entry.type !== 'track' && progress?.type === 'track') { + return; // Skip track-type messages for non-track downloads + } + // If there is no progress data, handle as inactivity. + if (!progress) { handleInactivity(entry, queueId, logElement); return; } - 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' - }); + // Check for unchanged status to handle inactivity. + if (JSON.stringify(entry.lastStatus) === JSON.stringify(progress)) { + handleInactivity(entry, queueId, logElement); + return; } + // Update entry state and log. + entry.lastStatus = progress; + entry.lastUpdated = Date.now(); + entry.status = progress.status; + logElement.textContent = getStatusMessage(progress); + + // Handle terminal states. + if (progress.status === 'error' || progress.status === 'complete') { + handleTerminalState(entry, queueId, progress); + } } catch (error) { console.error('Status check failed:', error); handleTerminalState(entry, queueId, { @@ -543,6 +536,8 @@ async function startEntryMonitoring(queueId) { }, 2000); } + + function handleInactivity(entry, queueId, logElement) { // Check if real time downloading is enabled const realTimeEnabled = document.getElementById('realTimeToggle')?.checked; @@ -595,10 +590,43 @@ function cleanupEntry(queueId) { if (entry) { clearInterval(entry.intervalId); entry.element.remove(); + const prgFile = entry.prgFile; delete downloadQueue[queueId]; + // Send delete request for the PRG file + fetch(`/api/prgs/delete/${encodeURIComponent(prgFile)}`, { method: 'DELETE' }) + .catch(err => console.error('Error deleting PRG file:', err)); } } +async function loadExistingPrgFiles() { + try { + const response = await fetch('/api/prgs/list'); + if (!response.ok) throw new Error('Failed to fetch PRG files'); + const prgFiles = await response.json(); + for (const prgFile of prgFiles) { + try { + const prgResponse = await fetch(`/api/prgs/${prgFile}`); + const prgData = await prgResponse.json(); + // If name is empty, fallback to using the prgFile as title. + const title = prgData.name || prgFile; + const type = prgData.type || "unknown"; + const dummyItem = { + name: title, + external_urls: {} // You can expand this if needed. + }; + addToQueue(dummyItem, type, prgFile); + } catch (innerError) { + console.error('Error processing PRG file', prgFile, ':', innerError); + } + } + } catch (error) { + console.error('Error loading existing PRG files:', error); + } +} + + + + function createQueueItem(item, type, prgFile, queueId) { const div = document.createElement('div'); div.className = 'queue-item'; @@ -827,48 +855,84 @@ function showSidebarError(message) { 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} from ${data.album}`; - } else { - return `${data.percentage.toFixed(1)}% complete`; - } - case 'done': - if (data.type === 'track') { - return `Finished: ${data.song} by ${data.artist}`; - } else if (data.type === 'album'){ - return `Finished: ${data.album} by ${data.artist}`; - } - else if (data.type === 'artist'){ - return `Finished: Artist's ${data.album_type}s`; - } - case 'initializing': - if (data.type === 'artist') { - return `Initializing artist download, ${data.total_albums} albums to process...`; - } - return `Initializing ${data.type} download for ${data.album || data.artist}...`; - case 'retrying': - return `Track ${data.song} by ${data.artist} failed, retrying (${data.retries}/${data.max_retries}) in ${data.seconds_left}s`; - case 'error': - return `Error: ${data.message || 'Unknown error'}`; - case 'complete': - return 'Download completed successfully'; - case 'skipped': - return `Track ${data.song} skipped, it already exists!`; - case 'real_time': { - // Convert milliseconds to minutes and seconds. - const totalMs = data.time_elapsed; - const minutes = Math.floor(totalMs / 60000); - const seconds = Math.floor((totalMs % 60000) / 1000); - const paddedSeconds = seconds < 10 ? '0' + seconds : seconds; - return `Real-time downloading track ${data.song} by ${data.artist} (${(data.percentage * 100).toFixed(1)}%). Time elapsed: ${minutes}:${paddedSeconds}`; + case 'downloading': + // For track downloads only. + if (data.type === 'track') { + return `Downloading track "${data.song}" by ${data.artist}...`; } - default: - return data.status; + return `Downloading ${data.type}...`; + + case 'initializing': + if (data.type === 'playlist') { + return `Initializing playlist download "${data.name}" with ${data.total_tracks} tracks...`; + } else if (data.type === 'album') { + return `Initializing album download "${data.album}" by ${data.artist}...`; + } else if (data.type === 'artist') { + return `Initializing artist download for ${data.artist} with ${data.total_albums} album(s) [${data.album_type}]...`; + } + return `Initializing ${data.type} download...`; + + case 'progress': + // Expect progress messages for playlists, albums (or artist’s albums) to include a "track" and "current_track". + if (data.track && data.current_track) { + // current_track is a string in the format "current/total" + const parts = data.current_track.split('/'); + const current = parts[0]; + const total = parts[1] || '?'; + + if (data.type === 'playlist') { + return `Downloading playlist: Track ${current} of ${total} - ${data.track}`; + } else if (data.type === 'album') { + // For album progress, the "album" and "artist" fields may be available on a done message. + // In some cases (like artist downloads) only track info is passed. + if (data.album && data.artist) { + return `Downloading album "${data.album}" by ${data.artist}: track ${current} of ${total} - ${data.track}`; + } else { + return `Downloading track ${current} of ${total}: ${data.track} from ${data.album}`; + } + } + } + // Fallback if fields are missing: + return `Progress: ${data.status}...`; + + case 'done': + if (data.type === 'track') { + return `Finished track "${data.song}" by ${data.artist}`; + } else if (data.type === 'playlist') { + return `Finished playlist "${data.name}" with ${data.total_tracks} tracks`; + } else if (data.type === 'album') { + return `Finished album "${data.album}" by ${data.artist}`; + } else if (data.type === 'artist') { + return `Finished artist "${data.artist}" (${data.album_type})`; + } + return `Finished ${data.type}`; + + case 'retrying': + return `Track "${data.song}" by ${data.artist}" failed, retrying (${data.retry_count}/10) in ${data.seconds_left}s`; + + case 'error': + return `Error: ${data.message || 'Unknown error'}`; + + case 'complete': + return 'Download completed successfully'; + + case 'skipped': + return `Track "${data.song}" skipped, it already exists!`; + + case 'real_time': { + // Convert milliseconds to minutes and seconds. + const totalMs = data.time_elapsed; + const minutes = Math.floor(totalMs / 60000); + const seconds = Math.floor((totalMs % 60000) / 1000); + const paddedSeconds = seconds < 10 ? '0' + seconds : seconds; + return `Real-time downloading track "${data.song}" by ${data.artist} (${(data.percentage * 100).toFixed(1)}%). Time elapsed: ${minutes}:${paddedSeconds}`; + } + + default: + return data.status; } -} + } + function saveConfig() { const config = {