diff --git a/.gitignore b/.gitignore index d50dc38..b39477a 100755 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ routes/utils/__pycache__/search.cpython-312.pyc routes/utils/__pycache__/__init__.cpython-312.pyc routes/utils/__pycache__/credentials.cpython-312.pyc routes/utils/__pycache__/search.cpython-312.pyc +search_test.py diff --git a/app.py b/app.py index 4643def..dfcc7c4 100755 --- a/app.py +++ b/app.py @@ -5,6 +5,7 @@ from routes.credentials import credentials_bp from routes.album import album_bp from routes.track import track_bp from routes.playlist import playlist_bp +from routes.artist import artist_bp from routes.prgs import prgs_bp import logging import time @@ -39,6 +40,7 @@ def create_app(): app.register_blueprint(album_bp, url_prefix='/api/album') app.register_blueprint(track_bp, url_prefix='/api/track') app.register_blueprint(playlist_bp, url_prefix='/api/playlist') + app.register_blueprint(artist_bp, url_prefix='/api/artist') app.register_blueprint(prgs_bp, url_prefix='/api/prgs') # Serve frontend diff --git a/routes/artist.py b/routes/artist.py new file mode 100644 index 0000000..0251abc --- /dev/null +++ b/routes/artist.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +""" +Artist endpoint blueprint. +""" + +from flask import Blueprint, Response, request +import json +import os +import random +import string +import sys +import traceback +from multiprocessing import Process + +artist_bp = Blueprint('artist', __name__) + +# Global dictionary to keep track of running download processes. +download_processes = {} + +def generate_random_filename(length=6): + chars = string.ascii_lowercase + string.digits + return ''.join(random.choice(chars) for _ in range(length)) + '.prg' + +class FlushingFileWrapper: + def __init__(self, file): + self.file = file + + def write(self, text): + # Only write lines that start with '{' + for line in text.split('\n'): + if line.startswith('{'): + self.file.write(line + '\n') + self.file.flush() + + def flush(self): + self.file.flush() + +def download_artist_task(service, artist_url, main, fallback, quality, fall_quality, real_time, album_type, prg_path): + """ + This function wraps the call to download_artist_albums and writes JSON status to the prg file. + """ + try: + from routes.utils.artist import download_artist_albums + with open(prg_path, 'w') as f: + flushing_file = FlushingFileWrapper(f) + original_stdout = sys.stdout + sys.stdout = flushing_file # Redirect stdout to our flushing file wrapper + + try: + download_artist_albums( + service=service, + artist_url=artist_url, + main=main, + fallback=fallback, + quality=quality, + fall_quality=fall_quality, + real_time=real_time, + album_type=album_type, + ) + 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 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") + +@artist_bp.route('/download', methods=['GET']) +def handle_artist_download(): + """ + Starts the artist album download process. + Expected query parameters: + - artist_url: string (e.g., a Spotify artist URL) + - service: string (e.g., "deezer" or "spotify") + - main: string (e.g., "MX") + - fallback: string (optional, e.g., "JP") + - quality: string (e.g., "MP3_128") + - fall_quality: string (optional, e.g., "HIGH") + - real_time: bool (e.g., "true" or "false") + - album_type: string(s); one or more of "album", "single", "appears_on", "compilation" (if multiple, comma-separated) + """ + service = request.args.get('service') + artist_url = request.args.get('artist_url') + main = request.args.get('main') + fallback = request.args.get('fallback') + quality = request.args.get('quality') + fall_quality = request.args.get('fall_quality') + album_type = request.args.get('album_type') + real_time_arg = request.args.get('real_time', 'false') + real_time = real_time_arg.lower() in ['true', '1', 'yes'] + + # Sanitize main and fallback to prevent directory traversal + if main: + main = os.path.basename(main) + if fallback: + fallback = os.path.basename(fallback) + + # Check for required parameters. + if not all([service, artist_url, main, quality, album_type]): + return Response( + json.dumps({"error": "Missing parameters"}), + status=400, + mimetype='application/json' + ) + + # Validate credentials based on the selected service. + try: + if service == 'spotify': + if fallback: + # When using Spotify as the main service with a fallback, assume main credentials for Deezer and fallback for Spotify. + deezer_creds_path = os.path.abspath(os.path.join('./creds/deezer', main, 'credentials.json')) + if not os.path.isfile(deezer_creds_path): + return Response( + json.dumps({"error": "Invalid Deezer credentials directory"}), + status=400, + mimetype='application/json' + ) + spotify_fallback_path = os.path.abspath(os.path.join('./creds/spotify', fallback, 'credentials.json')) + if not os.path.isfile(spotify_fallback_path): + return Response( + json.dumps({"error": "Invalid Spotify fallback credentials directory"}), + status=400, + mimetype='application/json' + ) + else: + # Validate Spotify main credentials. + spotify_creds_path = os.path.abspath(os.path.join('./creds/spotify', main, 'credentials.json')) + if not os.path.isfile(spotify_creds_path): + return Response( + json.dumps({"error": "Invalid Spotify credentials directory"}), + status=400, + mimetype='application/json' + ) + elif service == 'deezer': + # Validate Deezer main credentials. + deezer_creds_path = os.path.abspath(os.path.join('./creds/deezer', main, 'credentials.json')) + if not os.path.isfile(deezer_creds_path): + return Response( + json.dumps({"error": "Invalid Deezer credentials directory"}), + status=400, + mimetype='application/json' + ) + else: + return Response( + json.dumps({"error": "Unsupported service"}), + status=400, + mimetype='application/json' + ) + except Exception as e: + return Response( + json.dumps({"error": f"Credential validation failed: {str(e)}"}), + status=500, + mimetype='application/json' + ) + + # Create a random filename for the progress file. + filename = generate_random_filename() + prg_dir = './prgs' + os.makedirs(prg_dir, exist_ok=True) + prg_path = os.path.join(prg_dir, filename) + + # Create and start the download process. + process = Process( + target=download_artist_task, + args=(service, artist_url, main, fallback, quality, fall_quality, real_time, album_type, prg_path) + ) + process.start() + download_processes[filename] = process + + return Response( + json.dumps({"prg_file": filename}), + status=202, + mimetype='application/json' + ) + +@artist_bp.route('/download/cancel', methods=['GET']) +def cancel_artist_download(): + """ + Cancel a running artist download process by its prg file name. + """ + prg_file = request.args.get('prg_file') + if not prg_file: + return Response( + json.dumps({"error": "Missing process id (prg_file) parameter"}), + status=400, + mimetype='application/json' + ) + + process = download_processes.get(prg_file) + prg_dir = './prgs' + prg_path = os.path.join(prg_dir, prg_file) + + if process and process.is_alive(): + process.terminate() + process.join() # Wait for termination + del download_processes[prg_file] + + try: + with open(prg_path, 'a') as f: + f.write(json.dumps({"status": "cancel"}) + "\n") + except Exception as e: + return Response( + json.dumps({"error": f"Failed to write cancel status to file: {str(e)}"}), + status=500, + mimetype='application/json' + ) + + return Response( + json.dumps({"status": "cancel"}), + status=200, + mimetype='application/json' + ) + else: + return Response( + json.dumps({"error": "Process not found or already terminated"}), + status=404, + mimetype='application/json' + ) diff --git a/routes/utils/artist.py b/routes/utils/artist.py new file mode 100644 index 0000000..89c14c8 --- /dev/null +++ b/routes/utils/artist.py @@ -0,0 +1,112 @@ +import json +import traceback + +from deezspot.easy_spoty import Spo +from deezspot.libutils.utils import get_ids, link_is_valid +from routes.utils.album import download_album # Assumes album.py is in routes/utils/ + +def log_json(message_dict): + """Helper function to output a JSON-formatted log message.""" + print(json.dumps(message_dict)) + + +def get_artist_discography(url, album_type='album,single,compilation,appears_on'): + if not url: + message = "No artist URL provided." + log_json({"status": "error", "message": message}) + raise ValueError(message) + + try: + # Validate the URL (this function should raise an error if invalid). + link_is_valid(link=url) + except Exception as validation_error: + message = f"Link validation failed: {validation_error}" + log_json({"status": "error", "message": message}) + raise ValueError(message) + + try: + # Extract the artist ID from the URL. + artist_id = get_ids(url) + except Exception as id_error: + message = f"Failed to extract artist ID from URL: {id_error}" + log_json({"status": "error", "message": message}) + raise ValueError(message) + + try: + # Retrieve the discography using the artist ID. + discography = Spo.get_artist(artist_id, album_type=album_type) + return discography + except Exception as fetch_error: + message = f"An error occurred while fetching the discography: {fetch_error}" + log_json({"status": "error", "message": message}) + raise + + +def download_artist_albums(service, artist_url, main, fallback=None, quality=None, + fall_quality=None, real_time=False, album_type='album,single,compilation,appears_on'): + try: + discography = get_artist_discography(artist_url, album_type=album_type) + 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) + + if not albums: + log_json({ + "status": "done", + "type": "artist", + "artist": artist_name, + "album_type": album_type, + "message": "No albums found for the artist." + }) + return + + log_json({"status": "initializing", "type": "artist", "total_albums": len(albums)}) + + for album in albums: + try: + album_url = album.get('external_urls', {}).get('spotify') + album_name = album.get('name', 'Unknown Album') + # Extract artist names if available. + artists = [] + if "artists" in album: + artists = [artist.get("name", "Unknown") for artist in album["artists"]] + if not album_url: + log_json({ + "status": "warning", + "type": "album", + "album": album_name, + "artist": artists, + "message": "No Spotify URL found; skipping." + }) + continue + + download_album( + service=service, + url=album_url, + main=main, + fallback=fallback, + quality=quality, + fall_quality=fall_quality, + real_time=real_time + ) + + except Exception as album_error: + log_json({ + "status": "error", + "type": "album", + "album": album.get('name', 'Unknown'), + "error": str(album_error) + }) + traceback.print_exc() + + # When everything has been processed, print the final status. + log_json({ + "status": "done", + "type": "artist", + "artist": artist_name, + "album_type": album_type + }) diff --git a/static/css/style.css b/static/css/style.css index 33e0642..a25d904 100755 --- a/static/css/style.css +++ b/static/css/style.css @@ -995,3 +995,174 @@ html { .cancel-btn:active img { transform: scale(0.9); } + +/* --- ICON SIZING FOR ARTIST DOWNLOAD OPTION BUTTONS --- */ +/* This ensures that any inside an option-download button is small */ +.option-download img { + width: 16px; + height: 16px; +} +/* --- ARTIST DOWNLOAD OPTIONS SEPARATION --- */ +.artist-download-buttons { + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; +} + +.artist-download-buttons .option-buttons { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +/* Optional: ensure that each option button takes up a reasonable amount of space */ +.artist-download-buttons .option-download { + flex: 1 1 auto; + max-width: 32%; +} +/* Artist Download Buttons */ +.artist-download-buttons { + width: 100%; + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 12px; +} + +/* Main Download Button */ +.main-download { + background: #1DB954; + color: #fff; + border-radius: 8px; + padding: 10px 16px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + transition: background 0.2s ease; +} + +.main-download:hover { + background: #1ed760; +} + +.download-icon { + width: 18px; + height: 18px; + fill: currentColor; +} + +/* Options Container */ +.download-options-container { + width: 100%; + background: #282828; + border-radius: 8px; + overflow: hidden; +} + +/* Options Toggle */ +.options-toggle { + width: 100%; + background: none; + border: none; + color: #b3b3b3; + padding: 8px 16px; + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + transition: color 0.2s ease; +} + +.options-toggle:hover { + color: #fff; + background: rgba(255,255,255,0.05); +} + +.toggle-chevron { + width: 16px; + height: 16px; + fill: currentColor; + transition: transform 0.3s ease; +} + +.options-toggle[aria-expanded="true"] .toggle-chevron { + transform: rotate(180deg); +} + +/* Secondary Options */ +.secondary-options { + display: none; + flex-wrap: wrap; + gap: 6px; + padding: 0 12px 12px; + background: #181818; + border-radius: 0 0 8px 8px; +} + +.secondary-options.expanded { + display: flex; +} + +.option-btn { + flex: 1 1 calc(33.333% - 4px); + min-width: 100px; + background: #2a2a2a; + color: #fff; + border: none; + border-radius: 6px; + padding: 8px 12px; + display: flex; + align-items: center; + gap: 8px; + font-size: 0.85em; + transition: background 0.2s ease; +} + +.option-btn:hover { + background: #333; +} + +.type-icon { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +/* Mobile Adjustments */ +@media (max-width: 768px) { + .option-btn { + flex: 1 1 100%; + } + + .secondary-options { + gap: 4px; + } + + .main-download { + padding: 12px 16px; + font-size: 0.9em; + } + + .options-toggle { + padding: 10px 16px; + } +} +/* Add margin to the options container */ +.download-options-container { + margin-top: 8px; /* Adds separation from main button */ +} + +/* Add padding to top of expanded options */ +.secondary-options.expanded { + padding: 12px 12px 12px 12px; /* Added top padding */ + gap: 8px; /* Ensure gap between buttons */ +} + +/* Add subtle separation between toggle and options */ +.secondary-options { + border-top: 1px solid rgba(255,255,255,0.05); + margin-top: 4px; /* Small separation from toggle */ + padding-top: 8px !important; +} \ No newline at end of file diff --git a/static/js/app.js b/static/js/app.js index ab4478a..dcbb448 100755 --- a/static/js/app.js +++ b/static/js/app.js @@ -183,11 +183,12 @@ function performSearch() { return; } - // Handle direct Spotify URLs + // Handle direct Spotify URLs for tracks, albums, playlists, and artists if (isSpotifyUrl(query)) { try { const type = getResourceTypeFromUrl(query); - if (!['track', 'album', 'playlist'].includes(type)) { + const supportedTypes = ['track', 'album', 'playlist', 'artist']; + if (!supportedTypes.includes(type)) { throw new Error('Unsupported URL type'); } @@ -196,7 +197,9 @@ function performSearch() { external_urls: { spotify: query } }; - startDownload(query, type, item); + // For artist URLs, download all album types by default + const albumType = type === 'artist' ? 'album,single,compilation' : undefined; + startDownload(query, type, item, albumType); document.getElementById('searchInput').value = ''; return; @@ -206,7 +209,7 @@ function performSearch() { } } - // Existing search functionality + // Standard search resultsContainer.innerHTML = '
Searching...
'; fetch(`/api/search?q=${encodeURIComponent(query)}&search_type=${searchType}&limit=50`) @@ -222,14 +225,18 @@ function performSearch() { resultsContainer.innerHTML = items.map(item => createResultCard(item, searchType)).join(''); + // Attach event listeners for every download button in each card const cards = resultsContainer.querySelectorAll('.result-card'); cards.forEach((card, index) => { - card.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(); + card.querySelectorAll('.download-btn').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const url = e.currentTarget.dataset.url; + const type = e.currentTarget.dataset.type; + const albumType = e.currentTarget.dataset.albumType; // for artist downloads + startDownload(url, type, items[index], albumType); + card.remove(); + }); }); }); }) @@ -249,7 +256,19 @@ function createResultCard(item, type) { ${item.album.name} ${msToMinutesSeconds(item.duration_ms)} `; - break; + return ` +
+ ${type} cover +
${title}
+
${subtitle}
+
${details}
+ +
+ `; case 'playlist': imageUrl = item.images[0]?.url || ''; title = item.name; @@ -258,7 +277,19 @@ function createResultCard(item, type) { ${item.tracks.total} tracks ${item.description || 'No description'} `; - break; + return ` +
+ ${type} cover +
${title}
+
${subtitle}
+
${details}
+ +
+ `; case 'album': imageUrl = item.images[0]?.url || ''; title = item.name; @@ -267,25 +298,106 @@ function createResultCard(item, type) { ${item.release_date} ${item.total_tracks} tracks `; - break; - } + return ` +
+ ${type} cover +
${title}
+
${subtitle}
+
${details}
+ +
+ `; + case 'artist': + imageUrl = item.images && item.images.length ? item.images[0].url : ''; + title = item.name; + subtitle = item.genres && item.genres.length ? item.genres.join(', ') : 'Unknown genres'; + details = `Followers: ${item.followers?.total || 'N/A'}`; + return ` +
+ ${type} cover +
${title}
+
${subtitle}
+
${details}
+
+ + - return ` -
- ${type} cover -
${title}
-
${subtitle}
-
${details}
- -
- `; + +
+ + +
+ + + + + +
+
+
+
+ `; + default: + title = item.name || 'Unknown'; + subtitle = ''; + details = ''; + return ` +
+
${title}
+
${subtitle}
+
${details}
+ +
+ `; + } } -async function startDownload(url, type, item) { +async function startDownload(url, type, item, albumType) { const fallbackEnabled = document.getElementById('fallbackToggle').checked; const spotifyAccount = document.getElementById('spotifyAccountSelect').value; const deezerAccount = document.getElementById('deezerAccountSelect').value; @@ -301,7 +413,15 @@ async function startDownload(url, type, item) { return; } - let apiUrl = `/api/${type}/download?service=${service}&url=${encodeURIComponent(url)}`; + let apiUrl = ''; + if (type === 'artist') { + // Build the API URL for artist downloads. + // Use albumType if provided; otherwise, default to "compilation" (or you could default to "album,single,compilation") + const albumParam = albumType || 'compilation'; + apiUrl = `/api/artist/download?service=${service}&artist_url=${encodeURIComponent(url)}&album_type=${encodeURIComponent(albumParam)}`; + } else { + apiUrl = `/api/${type}/download?service=${service}&url=${encodeURIComponent(url)}`; + } // Get quality settings const spotifyQuality = document.getElementById('spotifyQualitySelect').value; @@ -324,7 +444,7 @@ async function startDownload(url, type, item) { if (realTimeEnabled) { apiUrl += `&real_time=true`; } - + try { const response = await fetch(apiUrl); const data = await response.json(); @@ -334,7 +454,6 @@ async function startDownload(url, type, item) { } } - function addToQueue(item, type, prgFile) { const queueId = Date.now().toString() + Math.random().toString(36).substr(2, 9); const entry = { @@ -493,7 +612,7 @@ function createQueueItem(item, type, prgFile, queueId) { const type = e.target.closest('button').dataset.type; const queueId = e.target.closest('button').dataset.queueid; // Determine the correct cancel endpoint based on the type. - // For example: `/api/album/download/cancel`, `/api/playlist/download/cancel`, `/api/track/download/cancel` + // For example: `/api/album/download/cancel`, `/api/playlist/download/cancel`, `/api/track/download/cancel`, or `/api/artist/download/cancel` const cancelEndpoint = `/api/${type}/download/cancel?prg_file=${encodeURIComponent(prg)}`; try { const response = await fetch(cancelEndpoint); @@ -702,13 +821,23 @@ function getStatusMessage(data) { 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}`; + 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': - return `Finished: ${data.song} by ${data.artist}`; + 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`; @@ -723,12 +852,9 @@ function getStatusMessage(data) { const totalMs = data.time_elapsed; const minutes = Math.floor(totalMs / 60000); const seconds = Math.floor((totalMs % 60000) / 1000); - // Optionally pad seconds with a leading zero if needed: 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}`; + return `Real-time downloading track ${data.song} by ${data.artist} (${(data.percentage * 100).toFixed(1)}%). Time elapsed: ${minutes}:${paddedSeconds}`; } - default: return data.status; } @@ -778,5 +904,5 @@ function isSpotifyUrl(url) { function getResourceTypeFromUrl(url) { const pathParts = new URL(url).pathname.split('/'); - return pathParts[1]; // Returns 'track', 'album', or 'playlist' + return pathParts[1]; // Returns 'track', 'album', 'playlist', or 'artist' } diff --git a/templates/index.html b/templates/index.html index e3f4f70..3f7f3d5 100755 --- a/templates/index.html +++ b/templates/index.html @@ -1,112 +1,113 @@ - - - Spotizerr - + + + Spotizerr + -