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 = '