architectural changes, preparing for playlist & artist watching
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -35,3 +35,4 @@ celery_worker.log
|
||||
logs/spotizerr.log
|
||||
/.venv
|
||||
static/js
|
||||
data
|
||||
|
||||
@@ -2,7 +2,10 @@ from flask import Blueprint, Response, request
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
import uuid
|
||||
import time
|
||||
from routes.utils.celery_queue_manager import download_queue_manager
|
||||
from routes.utils.celery_tasks import store_task_info, store_task_status, ProgressState
|
||||
|
||||
album_bp = Blueprint('album', __name__)
|
||||
|
||||
@@ -26,13 +29,38 @@ def handle_download():
|
||||
# Include full original request URL in metadata
|
||||
orig_params = request.args.to_dict()
|
||||
orig_params["original_url"] = request.url
|
||||
task_id = download_queue_manager.add_task({
|
||||
"download_type": "album",
|
||||
"url": url,
|
||||
"name": name,
|
||||
"artist": artist,
|
||||
"orig_request": orig_params
|
||||
})
|
||||
try:
|
||||
task_id = download_queue_manager.add_task({
|
||||
"download_type": "album",
|
||||
"url": url,
|
||||
"name": name,
|
||||
"artist": artist,
|
||||
"orig_request": orig_params
|
||||
})
|
||||
except Exception as e:
|
||||
# Generic error handling for other issues during task submission
|
||||
# Create an error task ID if add_task itself fails before returning an ID
|
||||
error_task_id = str(uuid.uuid4())
|
||||
|
||||
store_task_info(error_task_id, {
|
||||
"download_type": "album",
|
||||
"url": url,
|
||||
"name": name,
|
||||
"artist": artist,
|
||||
"original_request": orig_params,
|
||||
"created_at": time.time(),
|
||||
"is_submission_error_task": True
|
||||
})
|
||||
store_task_status(error_task_id, {
|
||||
"status": ProgressState.ERROR,
|
||||
"error": f"Failed to queue album download: {str(e)}",
|
||||
"timestamp": time.time()
|
||||
})
|
||||
return Response(
|
||||
json.dumps({"error": f"Failed to queue album download: {str(e)}", "task_id": error_task_id}),
|
||||
status=500,
|
||||
mimetype='application/json'
|
||||
)
|
||||
|
||||
return Response(
|
||||
json.dumps({"prg_file": task_id}),
|
||||
|
||||
@@ -40,20 +40,25 @@ def handle_artist_download():
|
||||
from routes.utils.artist import download_artist_albums
|
||||
|
||||
# Delegate to the download_artist_albums function which will handle album filtering
|
||||
task_ids = download_artist_albums(
|
||||
successfully_queued_albums, duplicate_albums = download_artist_albums(
|
||||
url=url,
|
||||
album_type=album_type,
|
||||
request_args=request.args.to_dict()
|
||||
)
|
||||
|
||||
# Return the list of album task IDs.
|
||||
response_data = {
|
||||
"status": "complete",
|
||||
"message": f"Artist discography processing initiated. {len(successfully_queued_albums)} albums queued.",
|
||||
"queued_albums": successfully_queued_albums
|
||||
}
|
||||
if duplicate_albums:
|
||||
response_data["duplicate_albums"] = duplicate_albums
|
||||
response_data["message"] += f" {len(duplicate_albums)} albums were already in progress or queued."
|
||||
|
||||
return Response(
|
||||
json.dumps({
|
||||
"status": "complete",
|
||||
"task_ids": task_ids,
|
||||
"message": f"Artist discography queued – {len(task_ids)} album tasks have been queued."
|
||||
}),
|
||||
status=202,
|
||||
json.dumps(response_data),
|
||||
status=202, # Still 202 Accepted as some operations may have succeeded
|
||||
mimetype='application/json'
|
||||
)
|
||||
except Exception as e:
|
||||
|
||||
@@ -7,7 +7,7 @@ import time
|
||||
import os
|
||||
|
||||
config_bp = Blueprint('config_bp', __name__)
|
||||
CONFIG_PATH = Path('./config/main.json')
|
||||
CONFIG_PATH = Path('./data/config/main.json')
|
||||
|
||||
# Flag for config change notifications
|
||||
config_changed = False
|
||||
@@ -70,7 +70,7 @@ def handle_config():
|
||||
return jsonify({"error": "Could not read config file"}), 500
|
||||
|
||||
# Create config/state directory
|
||||
Path('./config/state').mkdir(parents=True, exist_ok=True)
|
||||
Path('./data/config/state').mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Set default values for any missing config options
|
||||
defaults = {
|
||||
@@ -116,7 +116,14 @@ def update_config():
|
||||
if not save_config(new_config):
|
||||
return jsonify({"error": "Failed to save config"}), 500
|
||||
|
||||
return jsonify({"message": "Config updated successfully"})
|
||||
# Return the updated config
|
||||
updated_config_values = get_config()
|
||||
if updated_config_values is None:
|
||||
# This case should ideally not be reached if save_config succeeded
|
||||
# and get_config handles errors by returning a default or None.
|
||||
return jsonify({"error": "Failed to retrieve configuration after saving"}), 500
|
||||
|
||||
return jsonify(updated_config_values)
|
||||
except json.JSONDecodeError:
|
||||
return jsonify({"error": "Invalid JSON data"}), 400
|
||||
except Exception as e:
|
||||
|
||||
@@ -69,7 +69,7 @@ def handle_search_credential(service, name):
|
||||
return jsonify({"error": "Both client_id and client_secret are required"}), 400
|
||||
|
||||
# For POST, first check if the credentials directory exists
|
||||
if request.method == 'POST' and not any(Path(f'./creds/{service}/{name}').glob('*.json')):
|
||||
if request.method == 'POST' and not any(Path(f'./data/{service}/{name}').glob('*.json')):
|
||||
return jsonify({"error": f"Account '{name}' doesn't exist. Create it first."}), 404
|
||||
|
||||
# Create or update search credentials
|
||||
|
||||
@@ -2,7 +2,10 @@ from flask import Blueprint, Response, request
|
||||
import os
|
||||
import json
|
||||
import traceback
|
||||
import uuid # For generating error task IDs
|
||||
import time # For timestamps
|
||||
from routes.utils.celery_queue_manager import download_queue_manager
|
||||
from routes.utils.celery_tasks import store_task_info, store_task_status, ProgressState # For error task creation
|
||||
|
||||
playlist_bp = Blueprint('playlist', __name__)
|
||||
|
||||
@@ -12,6 +15,8 @@ def handle_download():
|
||||
url = request.args.get('url')
|
||||
name = request.args.get('name')
|
||||
artist = request.args.get('artist')
|
||||
orig_params = request.args.to_dict()
|
||||
orig_params["original_url"] = request.url
|
||||
|
||||
# Validate required parameters
|
||||
if not url:
|
||||
@@ -21,21 +26,40 @@ def handle_download():
|
||||
mimetype='application/json'
|
||||
)
|
||||
|
||||
# Add the task to the queue with only essential parameters
|
||||
# The queue manager will now handle all config parameters
|
||||
# Include full original request URL in metadata
|
||||
orig_params = request.args.to_dict()
|
||||
orig_params["original_url"] = request.url
|
||||
task_id = download_queue_manager.add_task({
|
||||
"download_type": "playlist",
|
||||
"url": url,
|
||||
"name": name,
|
||||
"artist": artist,
|
||||
"orig_request": orig_params
|
||||
})
|
||||
try:
|
||||
task_id = download_queue_manager.add_task({
|
||||
"download_type": "playlist",
|
||||
"url": url,
|
||||
"name": name,
|
||||
"artist": artist,
|
||||
"orig_request": orig_params
|
||||
})
|
||||
# Removed DuplicateDownloadError handling, add_task now manages this by creating an error task.
|
||||
except Exception as e:
|
||||
# Generic error handling for other issues during task submission
|
||||
error_task_id = str(uuid.uuid4())
|
||||
store_task_info(error_task_id, {
|
||||
"download_type": "playlist",
|
||||
"url": url,
|
||||
"name": name,
|
||||
"artist": artist,
|
||||
"original_request": orig_params,
|
||||
"created_at": time.time(),
|
||||
"is_submission_error_task": True
|
||||
})
|
||||
store_task_status(error_task_id, {
|
||||
"status": ProgressState.ERROR,
|
||||
"error": f"Failed to queue playlist download: {str(e)}",
|
||||
"timestamp": time.time()
|
||||
})
|
||||
return Response(
|
||||
json.dumps({"error": f"Failed to queue playlist download: {str(e)}", "task_id": error_task_id}),
|
||||
status=500,
|
||||
mimetype='application/json'
|
||||
)
|
||||
|
||||
return Response(
|
||||
json.dumps({"prg_file": task_id}),
|
||||
json.dumps({"prg_file": task_id}), # prg_file is the old name for task_id
|
||||
status=202,
|
||||
mimetype='application/json'
|
||||
)
|
||||
|
||||
@@ -2,7 +2,10 @@ from flask import Blueprint, Response, request
|
||||
import os
|
||||
import json
|
||||
import traceback
|
||||
import uuid # For generating error task IDs
|
||||
import time # For timestamps
|
||||
from routes.utils.celery_queue_manager import download_queue_manager
|
||||
from routes.utils.celery_tasks import store_task_info, store_task_status, ProgressState # For error task creation
|
||||
from urllib.parse import urlparse # for URL validation
|
||||
|
||||
track_bp = Blueprint('track', __name__)
|
||||
@@ -13,6 +16,8 @@ def handle_download():
|
||||
url = request.args.get('url')
|
||||
name = request.args.get('name')
|
||||
artist = request.args.get('artist')
|
||||
orig_params = request.args.to_dict()
|
||||
orig_params["original_url"] = request.url
|
||||
|
||||
# Validate required parameters
|
||||
if not url:
|
||||
@@ -31,20 +36,40 @@ def handle_download():
|
||||
mimetype='application/json'
|
||||
)
|
||||
|
||||
# Add the task to the queue with only essential parameters
|
||||
# The queue manager will now handle all config parameters
|
||||
orig_params = request.args.to_dict()
|
||||
orig_params["original_url"] = request.url
|
||||
task_id = download_queue_manager.add_task({
|
||||
"download_type": "track",
|
||||
"url": url,
|
||||
"name": name,
|
||||
"artist": artist,
|
||||
"orig_request": orig_params
|
||||
})
|
||||
try:
|
||||
task_id = download_queue_manager.add_task({
|
||||
"download_type": "track",
|
||||
"url": url,
|
||||
"name": name,
|
||||
"artist": artist,
|
||||
"orig_request": orig_params
|
||||
})
|
||||
# Removed DuplicateDownloadError handling, add_task now manages this by creating an error task.
|
||||
except Exception as e:
|
||||
# Generic error handling for other issues during task submission
|
||||
error_task_id = str(uuid.uuid4())
|
||||
store_task_info(error_task_id, {
|
||||
"download_type": "track",
|
||||
"url": url,
|
||||
"name": name,
|
||||
"artist": artist,
|
||||
"original_request": orig_params,
|
||||
"created_at": time.time(),
|
||||
"is_submission_error_task": True
|
||||
})
|
||||
store_task_status(error_task_id, {
|
||||
"status": ProgressState.ERROR,
|
||||
"error": f"Failed to queue track download: {str(e)}",
|
||||
"timestamp": time.time()
|
||||
})
|
||||
return Response(
|
||||
json.dumps({"error": f"Failed to queue track download: {str(e)}", "task_id": error_task_id}),
|
||||
status=500,
|
||||
mimetype='application/json'
|
||||
)
|
||||
|
||||
return Response(
|
||||
json.dumps({"prg_file": task_id}),
|
||||
json.dumps({"prg_file": task_id}), # prg_file is the old name for task_id
|
||||
status=202,
|
||||
mimetype='application/json'
|
||||
)
|
||||
|
||||
@@ -47,11 +47,11 @@ def download_album(
|
||||
# Smartly determine where to look for Spotify search credentials
|
||||
if service == 'spotify' and fallback:
|
||||
# If fallback is enabled, use the fallback account for Spotify search credentials
|
||||
search_creds_path = Path(f'./creds/spotify/{fallback}/search.json')
|
||||
search_creds_path = Path(f'./data/creds/spotify/{fallback}/search.json')
|
||||
print(f"DEBUG: Using Spotify search credentials from fallback: {search_creds_path}")
|
||||
else:
|
||||
# Otherwise use the main account for Spotify search credentials
|
||||
search_creds_path = Path(f'./creds/spotify/{main}/search.json')
|
||||
search_creds_path = Path(f'./data/creds/spotify/{main}/search.json')
|
||||
print(f"DEBUG: Using Spotify search credentials from main: {search_creds_path}")
|
||||
|
||||
if search_creds_path.exists():
|
||||
@@ -77,7 +77,7 @@ def download_album(
|
||||
deezer_error = None
|
||||
try:
|
||||
# Load Deezer credentials from 'main' under deezer directory
|
||||
deezer_creds_dir = os.path.join('./creds/deezer', main)
|
||||
deezer_creds_dir = os.path.join('./data/creds/deezer', main)
|
||||
deezer_creds_path = os.path.abspath(os.path.join(deezer_creds_dir, 'credentials.json'))
|
||||
|
||||
# DEBUG: Print Deezer credential paths being used
|
||||
@@ -89,8 +89,8 @@ def download_album(
|
||||
|
||||
# List available directories to compare
|
||||
print(f"DEBUG: Available Deezer credential directories:")
|
||||
for dir_name in os.listdir('./creds/deezer'):
|
||||
print(f"DEBUG: ./creds/deezer/{dir_name}")
|
||||
for dir_name in os.listdir('./data/creds/deezer'):
|
||||
print(f"DEBUG: ./data/creds/deezer/{dir_name}")
|
||||
|
||||
with open(deezer_creds_path, 'r') as f:
|
||||
deezer_creds = json.load(f)
|
||||
@@ -129,7 +129,7 @@ def download_album(
|
||||
|
||||
# Load fallback Spotify credentials and attempt download
|
||||
try:
|
||||
spo_creds_dir = os.path.join('./creds/spotify', fallback)
|
||||
spo_creds_dir = os.path.join('./data/creds/spotify', fallback)
|
||||
spo_creds_path = os.path.abspath(os.path.join(spo_creds_dir, 'credentials.json'))
|
||||
|
||||
print(f"DEBUG: Using Spotify fallback credentials from: {spo_creds_path}")
|
||||
@@ -173,7 +173,7 @@ def download_album(
|
||||
# Original behavior: use Spotify main
|
||||
if quality is None:
|
||||
quality = 'HIGH'
|
||||
creds_dir = os.path.join('./creds/spotify', main)
|
||||
creds_dir = os.path.join('./data/creds/spotify', main)
|
||||
credentials_path = os.path.abspath(os.path.join(creds_dir, 'credentials.json'))
|
||||
print(f"DEBUG: Using Spotify main credentials from: {credentials_path}")
|
||||
print(f"DEBUG: Credentials exist: {os.path.exists(credentials_path)}")
|
||||
@@ -208,7 +208,7 @@ def download_album(
|
||||
if quality is None:
|
||||
quality = 'FLAC'
|
||||
# Existing code remains the same, ignoring fallback
|
||||
creds_dir = os.path.join('./creds/deezer', main)
|
||||
creds_dir = os.path.join('./data/creds/deezer', main)
|
||||
creds_path = os.path.abspath(os.path.join(creds_dir, 'credentials.json'))
|
||||
print(f"DEBUG: Using Deezer credentials from: {creds_path}")
|
||||
print(f"DEBUG: Credentials exist: {os.path.exists(creds_path)}")
|
||||
|
||||
@@ -6,6 +6,7 @@ import logging
|
||||
from flask import Blueprint, Response, request, url_for
|
||||
from routes.utils.celery_queue_manager import download_queue_manager, get_config_params
|
||||
from routes.utils.get_info import get_spotify_info
|
||||
from routes.utils.celery_tasks import get_last_task_status, ProgressState
|
||||
|
||||
from deezspot.easy_spoty import Spo
|
||||
from deezspot.libutils.utils import get_ids, link_is_valid
|
||||
@@ -32,7 +33,7 @@ def get_artist_discography(url, main, album_type='album,single,compilation,appea
|
||||
# Initialize Spotify API with credentials
|
||||
spotify_client_id = None
|
||||
spotify_client_secret = None
|
||||
search_creds_path = Path(f'./creds/spotify/{main}/search.json')
|
||||
search_creds_path = Path(f'./data/creds/spotify/{main}/search.json')
|
||||
if search_creds_path.exists():
|
||||
try:
|
||||
with open(search_creds_path, 'r') as f:
|
||||
@@ -76,7 +77,7 @@ def download_artist_albums(url, album_type="album,single,compilation", request_a
|
||||
request_args (dict): Original request arguments for tracking
|
||||
|
||||
Returns:
|
||||
list: List of task IDs for the queued album downloads
|
||||
tuple: (list of successfully queued albums, list of duplicate albums)
|
||||
"""
|
||||
if not url:
|
||||
raise ValueError("Missing required parameter: url")
|
||||
@@ -133,10 +134,12 @@ def download_artist_albums(url, album_type="album,single,compilation", request_a
|
||||
|
||||
if not filtered_albums:
|
||||
logger.warning(f"No albums match the specified types: {album_type}")
|
||||
return []
|
||||
return [], []
|
||||
|
||||
# Queue each album as a separate download task
|
||||
album_task_ids = []
|
||||
successfully_queued_albums = []
|
||||
duplicate_albums = [] # To store info about albums that were duplicates
|
||||
|
||||
for album in filtered_albums:
|
||||
# Add detailed logging to inspect each album's structure and URLs
|
||||
@@ -185,10 +188,38 @@ def download_artist_albums(url, album_type="album,single,compilation", request_a
|
||||
# Debug log the task data being sent to the queue
|
||||
logger.debug(f"Album task data: url={task_data['url']}, retry_url={task_data['retry_url']}")
|
||||
|
||||
# Add the task to the queue manager
|
||||
task_id = download_queue_manager.add_task(task_data)
|
||||
album_task_ids.append(task_id)
|
||||
logger.info(f"Queued album download: {album_name} ({task_id})")
|
||||
try:
|
||||
task_id = download_queue_manager.add_task(task_data)
|
||||
|
||||
# Check the status of the newly added task to see if it was marked as a duplicate error
|
||||
last_status = get_last_task_status(task_id)
|
||||
|
||||
if last_status and last_status.get("status") == ProgressState.ERROR and last_status.get("existing_task_id"):
|
||||
logger.warning(f"Album {album_name} (URL: {album_url}) is a duplicate. Error task ID: {task_id}. Existing task ID: {last_status.get('existing_task_id')}")
|
||||
duplicate_albums.append({
|
||||
"name": album_name,
|
||||
"artist": album_artist,
|
||||
"url": album_url,
|
||||
"error_task_id": task_id, # This is the ID of the task marked as a duplicate error
|
||||
"existing_task_id": last_status.get("existing_task_id"),
|
||||
"message": last_status.get("message", "Duplicate download attempt.")
|
||||
})
|
||||
else:
|
||||
# If not a duplicate error, it was successfully queued (or failed for other reasons handled by add_task)
|
||||
# We only add to successfully_queued_albums if it wasn't a duplicate error from add_task
|
||||
# Other errors from add_task (like submission failure) would also result in an error status for task_id
|
||||
# but won't have 'existing_task_id'. The client can check the status of this task_id.
|
||||
album_task_ids.append(task_id) # Keep track of all task_ids returned by add_task
|
||||
successfully_queued_albums.append({
|
||||
"name": album_name,
|
||||
"artist": album_artist,
|
||||
"url": album_url,
|
||||
"task_id": task_id
|
||||
})
|
||||
logger.info(f"Queued album download: {album_name} ({task_id})")
|
||||
except Exception as e: # Catch any other unexpected error from add_task itself (though it should be rare now)
|
||||
logger.error(f"Failed to queue album {album_name} due to an unexpected error in add_task: {str(e)}")
|
||||
# Optionally, collect these errors. For now, just logging and continuing.
|
||||
|
||||
logger.info(f"Queued {len(album_task_ids)} album downloads for artist: {artist_name}")
|
||||
return album_task_ids
|
||||
logger.info(f"Artist album processing: {len(successfully_queued_albums)} queued, {len(duplicate_albums)} duplicates found.")
|
||||
return successfully_queued_albums, duplicate_albums
|
||||
|
||||
@@ -22,7 +22,7 @@ REDIS_BACKEND = os.getenv('REDIS_BACKEND', REDIS_URL)
|
||||
logger.info(f"Redis configuration: REDIS_URL={REDIS_URL}, REDIS_BACKEND={REDIS_BACKEND}")
|
||||
|
||||
# Config path
|
||||
CONFIG_PATH = './config/main.json'
|
||||
CONFIG_PATH = './data/config/main.json'
|
||||
|
||||
def get_config_params():
|
||||
"""
|
||||
|
||||
@@ -17,7 +17,8 @@ from .celery_tasks import (
|
||||
get_task_info,
|
||||
get_last_task_status,
|
||||
store_task_status,
|
||||
get_all_tasks as get_all_celery_tasks_info
|
||||
get_all_tasks as get_all_celery_tasks_info,
|
||||
cleanup_stale_errors
|
||||
)
|
||||
from .celery_config import get_config_params
|
||||
|
||||
@@ -25,7 +26,7 @@ from .celery_config import get_config_params
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Configuration
|
||||
CONFIG_PATH = './config/main.json'
|
||||
CONFIG_PATH = './data/config/main.json'
|
||||
CELERY_APP = 'routes.utils.celery_tasks.celery_app'
|
||||
CELERY_PROCESS = None
|
||||
CONFIG_CHECK_INTERVAL = 30 # seconds
|
||||
@@ -39,6 +40,7 @@ class CeleryManager:
|
||||
self.celery_process = None
|
||||
self.current_worker_count = 0
|
||||
self.monitoring_thread = None
|
||||
self.error_cleanup_thread = None
|
||||
self.running = False
|
||||
self.log_queue = queue.Queue()
|
||||
self.output_threads = []
|
||||
@@ -114,6 +116,10 @@ class CeleryManager:
|
||||
# Start monitoring thread for config changes
|
||||
self.monitoring_thread = threading.Thread(target=self._monitor_config, daemon=True)
|
||||
self.monitoring_thread.start()
|
||||
|
||||
# Start periodic error cleanup thread
|
||||
self.error_cleanup_thread = threading.Thread(target=self._run_periodic_error_cleanup, daemon=True)
|
||||
self.error_cleanup_thread.start()
|
||||
|
||||
# Register shutdown handler
|
||||
atexit.register(self.stop)
|
||||
@@ -325,5 +331,24 @@ class CeleryManager:
|
||||
logger.error(f"Error in config monitoring thread: {e}")
|
||||
time.sleep(5) # Wait before retrying
|
||||
|
||||
def _run_periodic_error_cleanup(self):
|
||||
"""Periodically triggers the cleanup_stale_errors Celery task."""
|
||||
cleanup_interval = 60 # Run cleanup task every 60 seconds
|
||||
logger.info(f"Starting periodic error cleanup scheduler (runs every {cleanup_interval}s).")
|
||||
while self.running:
|
||||
try:
|
||||
logger.info("Scheduling cleanup_stale_errors task...")
|
||||
cleanup_stale_errors.delay() # Call the Celery task
|
||||
except Exception as e:
|
||||
logger.error(f"Error scheduling cleanup_stale_errors task: {e}", exc_info=True)
|
||||
|
||||
# Wait for the next interval
|
||||
# Use a loop to check self.running more frequently to allow faster shutdown
|
||||
for _ in range(cleanup_interval):
|
||||
if not self.running:
|
||||
break
|
||||
time.sleep(1)
|
||||
logger.info("Periodic error cleanup scheduler stopped.")
|
||||
|
||||
# Create single instance
|
||||
celery_manager = CeleryManager()
|
||||
@@ -25,7 +25,7 @@ from routes.utils.celery_tasks import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Load configuration
|
||||
CONFIG_PATH = './config/main.json'
|
||||
CONFIG_PATH = './data/config/main.json'
|
||||
try:
|
||||
with open(CONFIG_PATH, 'r') as f:
|
||||
config_data = json.load(f)
|
||||
@@ -96,16 +96,89 @@ class CeleryDownloadQueueManager:
|
||||
|
||||
def add_task(self, task):
|
||||
"""
|
||||
Add a new download task to the Celery queue
|
||||
Add a new download task to the Celery queue.
|
||||
If a duplicate active task is found, a new task ID is created and immediately set to an ERROR state.
|
||||
|
||||
Args:
|
||||
task (dict): Task parameters including download_type, url, etc.
|
||||
|
||||
Returns:
|
||||
str: Task ID
|
||||
str: Task ID (either for a new task or for a new error-state task if duplicate detected).
|
||||
"""
|
||||
try:
|
||||
# Extract essential parameters
|
||||
# Extract essential parameters for duplicate check
|
||||
incoming_url = task.get("url")
|
||||
incoming_type = task.get("download_type", "unknown")
|
||||
|
||||
if not incoming_url:
|
||||
# This should ideally be validated before calling add_task
|
||||
# For now, let it proceed and potentially fail in Celery task if URL is vital and missing.
|
||||
# Or, create an error task immediately if URL is strictly required for any task logging.
|
||||
logger.warning("Task being added with no URL. Duplicate check might be unreliable.")
|
||||
|
||||
# --- Check for Duplicates ---
|
||||
NON_BLOCKING_STATES = [
|
||||
ProgressState.COMPLETE,
|
||||
ProgressState.CANCELLED,
|
||||
ProgressState.ERROR
|
||||
]
|
||||
|
||||
all_existing_tasks_summary = get_all_tasks()
|
||||
if incoming_url: # Only check for duplicates if we have a URL
|
||||
for task_summary in all_existing_tasks_summary:
|
||||
existing_task_id = task_summary.get("task_id")
|
||||
if not existing_task_id:
|
||||
continue
|
||||
|
||||
existing_task_info = get_task_info(existing_task_id)
|
||||
existing_last_status_obj = get_last_task_status(existing_task_id)
|
||||
|
||||
if not existing_task_info or not existing_last_status_obj:
|
||||
continue
|
||||
|
||||
existing_url = existing_task_info.get("url")
|
||||
existing_type = existing_task_info.get("download_type")
|
||||
existing_status = existing_last_status_obj.get("status")
|
||||
|
||||
if (existing_url == incoming_url and
|
||||
existing_type == incoming_type and
|
||||
existing_status not in NON_BLOCKING_STATES):
|
||||
|
||||
message = f"Duplicate download: URL '{incoming_url}' (type: {incoming_type}) is already being processed by task {existing_task_id} (status: {existing_status})."
|
||||
logger.warning(message)
|
||||
|
||||
# Create a new task_id for this duplicate request and mark it as an error
|
||||
error_task_id = str(uuid.uuid4())
|
||||
|
||||
# Store minimal info for this error task
|
||||
error_task_info_payload = {
|
||||
"download_type": incoming_type,
|
||||
"type": task.get("type", incoming_type),
|
||||
"name": task.get("name", "Duplicate Task"),
|
||||
"artist": task.get("artist", ""),
|
||||
"url": incoming_url,
|
||||
"original_request": task.get("orig_request", task.get("original_request", {})),
|
||||
"created_at": time.time(),
|
||||
"is_duplicate_error_task": True
|
||||
}
|
||||
store_task_info(error_task_id, error_task_info_payload)
|
||||
|
||||
# Store error status for this new task_id
|
||||
error_status_payload = {
|
||||
"status": ProgressState.ERROR,
|
||||
"error": message,
|
||||
"existing_task_id": existing_task_id, # So client knows which task it duplicates
|
||||
"timestamp": time.time(),
|
||||
"type": error_task_info_payload["type"],
|
||||
"name": error_task_info_payload["name"],
|
||||
"artist": error_task_info_payload["artist"]
|
||||
}
|
||||
store_task_status(error_task_id, error_status_payload)
|
||||
|
||||
return error_task_id # Return the ID of this new error-state task
|
||||
# --- End Duplicate Check ---
|
||||
|
||||
# Proceed with normal task creation if no duplicate found or no URL to check
|
||||
download_type = task.get("download_type", "unknown")
|
||||
|
||||
# Debug existing task data
|
||||
|
||||
@@ -44,6 +44,8 @@ class ProgressState:
|
||||
REAL_TIME = "real_time"
|
||||
SKIPPED = "skipped"
|
||||
DONE = "done"
|
||||
ERROR_RETRIED = "ERROR_RETRIED" # Status for an error task that has been retried
|
||||
ERROR_AUTO_CLEANED = "ERROR_AUTO_CLEANED" # Status for an error task that was auto-cleaned
|
||||
|
||||
# Reuse the application's logging configuration for Celery workers
|
||||
@setup_logging.connect
|
||||
@@ -146,7 +148,7 @@ def cancel_task(task_id):
|
||||
# Mark the task as cancelled in Redis
|
||||
store_task_status(task_id, {
|
||||
"status": ProgressState.CANCELLED,
|
||||
"message": "Task cancelled by user",
|
||||
"error": "Task cancelled by user",
|
||||
"timestamp": time.time()
|
||||
})
|
||||
|
||||
@@ -165,12 +167,12 @@ def retry_task(task_id):
|
||||
# Get task info
|
||||
task_info = get_task_info(task_id)
|
||||
if not task_info:
|
||||
return {"status": "error", "message": f"Task {task_id} not found"}
|
||||
return {"status": "error", "error": f"Task {task_id} not found"}
|
||||
|
||||
# Check if task has error status
|
||||
last_status = get_last_task_status(task_id)
|
||||
if not last_status or last_status.get("status") != ProgressState.ERROR:
|
||||
return {"status": "error", "message": "Task is not in a failed state"}
|
||||
return {"status": "error", "error": "Task is not in a failed state"}
|
||||
|
||||
# Get current retry count
|
||||
retry_count = last_status.get("retry_count", 0)
|
||||
@@ -185,7 +187,7 @@ def retry_task(task_id):
|
||||
if retry_count >= max_retries:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"Maximum retry attempts ({max_retries}) exceeded"
|
||||
"error": f"Maximum retry attempts ({max_retries}) exceeded"
|
||||
}
|
||||
|
||||
# Calculate retry delay
|
||||
@@ -255,34 +257,51 @@ def retry_task(task_id):
|
||||
|
||||
# Launch the appropriate task based on download_type
|
||||
download_type = task_info.get("download_type", "unknown")
|
||||
task = None
|
||||
new_celery_task_obj = None
|
||||
|
||||
logger.info(f"Retrying task {task_id} as {new_task_id} (retry {retry_count + 1}/{max_retries})")
|
||||
|
||||
if download_type == "track":
|
||||
task = download_track.apply_async(
|
||||
new_celery_task_obj = download_track.apply_async(
|
||||
kwargs=task_info,
|
||||
task_id=new_task_id,
|
||||
queue='downloads'
|
||||
)
|
||||
elif download_type == "album":
|
||||
task = download_album.apply_async(
|
||||
new_celery_task_obj = download_album.apply_async(
|
||||
kwargs=task_info,
|
||||
task_id=new_task_id,
|
||||
queue='downloads'
|
||||
)
|
||||
elif download_type == "playlist":
|
||||
task = download_playlist.apply_async(
|
||||
new_celery_task_obj = download_playlist.apply_async(
|
||||
kwargs=task_info,
|
||||
task_id=new_task_id,
|
||||
queue='downloads'
|
||||
)
|
||||
else:
|
||||
logger.error(f"Unknown download type for retry: {download_type}")
|
||||
store_task_status(new_task_id, {
|
||||
"status": ProgressState.ERROR,
|
||||
"error": f"Cannot retry: Unknown download type '{download_type}' for original task {task_id}",
|
||||
"timestamp": time.time()
|
||||
})
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"Unknown download type: {download_type}"
|
||||
"error": f"Unknown download type: {download_type}"
|
||||
}
|
||||
|
||||
# If retry was successfully submitted, update the original task's status
|
||||
if new_celery_task_obj:
|
||||
store_task_status(task_id, {
|
||||
"status": "ERROR_RETRIED",
|
||||
"error": f"Task superseded by retry: {new_task_id}",
|
||||
"retried_as_task_id": new_task_id,
|
||||
"timestamp": time.time()
|
||||
})
|
||||
logger.info(f"Original task {task_id} status updated to ERROR_RETRIED, superseded by {new_task_id}")
|
||||
else:
|
||||
logger.error(f"Retry submission for task {task_id} (as {new_task_id}) did not return a Celery AsyncResult. Original task not marked as ERROR_RETRIED.")
|
||||
|
||||
return {
|
||||
"status": "requeued",
|
||||
@@ -292,9 +311,8 @@ def retry_task(task_id):
|
||||
"retry_delay": retry_delay
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error retrying task {task_id}: {e}")
|
||||
traceback.print_exc()
|
||||
return {"status": "error", "message": str(e)}
|
||||
logger.error(f"Error retrying task {task_id}: {e}", exc_info=True)
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
def get_all_tasks():
|
||||
"""Get all active task IDs"""
|
||||
@@ -657,8 +675,9 @@ class ProgressTrackingTask(Task):
|
||||
task_info['error_count'] = error_count
|
||||
store_task_info(task_id, task_info)
|
||||
|
||||
# Set status
|
||||
# Set status and error message
|
||||
data['status'] = ProgressState.ERROR
|
||||
data['error'] = message
|
||||
|
||||
def _handle_done(self, task_id, data, task_info):
|
||||
"""Handle done status from deezspot"""
|
||||
@@ -812,22 +831,21 @@ def task_failure_handler(task_id=None, exception=None, traceback=None, *args, **
|
||||
can_retry = retry_count < max_retries
|
||||
|
||||
# Update task status to error
|
||||
error_message = str(exception)
|
||||
error_message_str = str(exception)
|
||||
store_task_status(task_id, {
|
||||
"status": ProgressState.ERROR,
|
||||
"timestamp": time.time(),
|
||||
"type": task_info.get("type", "unknown"),
|
||||
"name": task_info.get("name", "Unknown"),
|
||||
"artist": task_info.get("artist", ""),
|
||||
"error": error_message,
|
||||
"error": error_message_str,
|
||||
"traceback": str(traceback),
|
||||
"can_retry": can_retry,
|
||||
"retry_count": retry_count,
|
||||
"max_retries": max_retries,
|
||||
"message": f"Error: {error_message}"
|
||||
"max_retries": max_retries
|
||||
})
|
||||
|
||||
logger.error(f"Task {task_id} failed: {error_message}")
|
||||
logger.error(f"Task {task_id} failed: {error_message_str}")
|
||||
if can_retry:
|
||||
logger.info(f"Task {task_id} can be retried ({retry_count}/{max_retries})")
|
||||
except Exception as e:
|
||||
@@ -1053,4 +1071,71 @@ def download_playlist(self, **task_data):
|
||||
except Exception as e:
|
||||
logger.error(f"Error in download_playlist task: {e}")
|
||||
traceback.print_exc()
|
||||
raise
|
||||
raise
|
||||
|
||||
# Helper function to fully delete task data from Redis
|
||||
def delete_task_data_and_log(task_id, reason="Task data deleted"):
|
||||
"""
|
||||
Marks a task as cancelled (if not already) and deletes all its data from Redis.
|
||||
"""
|
||||
try:
|
||||
task_info = get_task_info(task_id) # Get info before deleting
|
||||
last_status = get_last_task_status(task_id)
|
||||
|
||||
# Update status to cancelled if it's not already in a terminal state that implies deletion is okay
|
||||
if not last_status or last_status.get("status") not in [ProgressState.CANCELLED, ProgressState.ERROR_RETRIED, ProgressState.ERROR_AUTO_CLEANED]:
|
||||
store_task_status(task_id, {
|
||||
"status": ProgressState.ERROR_AUTO_CLEANED, # Use specific status
|
||||
"error": reason,
|
||||
"timestamp": time.time()
|
||||
})
|
||||
|
||||
# Delete Redis keys associated with the task
|
||||
redis_client.delete(f"task:{task_id}:info")
|
||||
redis_client.delete(f"task:{task_id}:status")
|
||||
redis_client.delete(f"task:{task_id}:status:next_id")
|
||||
|
||||
logger.info(f"Data for task {task_id} ('{task_info.get('name', 'Unknown')}') deleted from Redis. Reason: {reason}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting task data for {task_id}: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
@celery_app.task(name="cleanup_stale_errors", queue="default") # Put on default queue, not downloads
|
||||
def cleanup_stale_errors():
|
||||
"""
|
||||
Periodically checks for tasks in ERROR state for more than 1 minute and cleans them up.
|
||||
"""
|
||||
logger.info("Running cleanup_stale_errors task...")
|
||||
cleaned_count = 0
|
||||
try:
|
||||
task_keys = redis_client.keys("task:*:info")
|
||||
if not task_keys:
|
||||
logger.info("No task keys found for cleanup.")
|
||||
return {"status": "complete", "message": "No tasks to check."}
|
||||
|
||||
current_time = time.time()
|
||||
stale_threshold = 60 # 1 minute
|
||||
|
||||
for key_bytes in task_keys:
|
||||
task_id = key_bytes.decode('utf-8').split(':')[1]
|
||||
last_status = get_last_task_status(task_id)
|
||||
|
||||
if last_status and last_status.get("status") == ProgressState.ERROR:
|
||||
error_timestamp = last_status.get("timestamp", 0)
|
||||
if (current_time - error_timestamp) > stale_threshold:
|
||||
# Check again to ensure it wasn't retried just before cleanup
|
||||
current_last_status_before_delete = get_last_task_status(task_id)
|
||||
if current_last_status_before_delete and current_last_status_before_delete.get("status") == ProgressState.ERROR_RETRIED:
|
||||
logger.info(f"Task {task_id} was retried just before cleanup. Skipping delete.")
|
||||
continue
|
||||
|
||||
logger.info(f"Task {task_id} is in ERROR state for more than {stale_threshold}s. Cleaning up.")
|
||||
if delete_task_data_and_log(task_id, reason=f"Auto-cleaned: Task was in ERROR state for over {stale_threshold} seconds without manual retry."):
|
||||
cleaned_count += 1
|
||||
|
||||
logger.info(f"cleanup_stale_errors task finished. Cleaned up {cleaned_count} stale errored tasks.")
|
||||
return {"status": "complete", "cleaned_count": cleaned_count}
|
||||
except Exception as e:
|
||||
logger.error(f"Error during cleanup_stale_errors: {e}", exc_info=True)
|
||||
return {"status": "error", "error": str(e)}
|
||||
@@ -1,6 +1,85 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from deezspot.spotloader import SpoLogin
|
||||
from deezspot.deezloader import DeeLogin
|
||||
import traceback # For logging detailed error messages
|
||||
import time # For retry delays
|
||||
|
||||
def _get_spotify_search_creds(creds_dir: Path):
|
||||
"""Helper to load client_id and client_secret from search.json for a Spotify account."""
|
||||
search_file = creds_dir / 'search.json'
|
||||
if search_file.exists():
|
||||
try:
|
||||
with open(search_file, 'r') as f:
|
||||
search_data = json.load(f)
|
||||
return search_data.get('client_id'), search_data.get('client_secret')
|
||||
except Exception:
|
||||
# Log error if search.json is malformed or unreadable
|
||||
print(f"Warning: Could not read Spotify search credentials from {search_file}")
|
||||
traceback.print_exc()
|
||||
return None, None
|
||||
|
||||
def _validate_with_retry(service_name, account_name, creds_dir_path, cred_file_path, data_for_validation, is_spotify):
|
||||
"""
|
||||
Attempts to validate credentials with retries for connection errors.
|
||||
- For Spotify, cred_file_path is used.
|
||||
- For Deezer, data_for_validation (which contains the 'arl' key) is used.
|
||||
Returns True if validated, raises ValueError if not.
|
||||
"""
|
||||
max_retries = 5
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
if is_spotify:
|
||||
client_id, client_secret = _get_spotify_search_creds(creds_dir_path)
|
||||
SpoLogin(credentials_path=str(cred_file_path), spotify_client_id=client_id, spotify_client_secret=client_secret)
|
||||
else: # Deezer
|
||||
arl = data_for_validation.get('arl')
|
||||
if not arl:
|
||||
# This should be caught by prior checks, but as a safeguard:
|
||||
raise ValueError("Missing 'arl' for Deezer validation.")
|
||||
DeeLogin(arl=arl)
|
||||
|
||||
print(f"{service_name.capitalize()} credentials for {account_name} validated successfully (attempt {attempt + 1}).")
|
||||
return True # Validation successful
|
||||
except Exception as e:
|
||||
last_exception = e
|
||||
error_str = str(e).lower()
|
||||
# More comprehensive check for connection-related errors
|
||||
is_connection_error = (
|
||||
"connection refused" in error_str or
|
||||
"connection error" in error_str or
|
||||
"timeout" in error_str or
|
||||
"temporary failure in name resolution" in error_str or
|
||||
"dns lookup failed" in error_str or
|
||||
"network is unreachable" in error_str or
|
||||
"ssl handshake failed" in error_str or # Can be network-related
|
||||
"connection reset by peer" in error_str
|
||||
)
|
||||
|
||||
if is_connection_error and attempt < max_retries - 1:
|
||||
retry_delay = 2 + attempt # Increasing delay (2s, 3s, 4s, 5s)
|
||||
print(f"Validation for {account_name} ({service_name}) failed on attempt {attempt + 1}/{max_retries} due to connection issue: {e}. Retrying in {retry_delay}s...")
|
||||
time.sleep(retry_delay)
|
||||
continue # Go to next retry attempt
|
||||
else:
|
||||
# Not a connection error, or it's the last retry for a connection error
|
||||
print(f"Validation for {account_name} ({service_name}) failed on attempt {attempt + 1} with non-retryable error or max retries reached for connection error.")
|
||||
break # Exit retry loop
|
||||
|
||||
# If loop finished without returning True, validation failed
|
||||
print(f"ERROR: Credential validation definitively failed for {service_name} account {account_name} after {attempt + 1} attempt(s).")
|
||||
if last_exception:
|
||||
base_error_message = str(last_exception).splitlines()[-1]
|
||||
detailed_error_message = f"Invalid {service_name} credentials. Verification failed: {base_error_message}"
|
||||
if is_spotify and "incorrect padding" in base_error_message.lower():
|
||||
detailed_error_message += ". Hint: Do not throw your password here, read the docs"
|
||||
# traceback.print_exc() # Already printed in create/edit, avoid duplicate full trace
|
||||
raise ValueError(detailed_error_message)
|
||||
else: # Should not happen if loop runs at least once
|
||||
raise ValueError(f"Invalid {service_name} credentials. Verification failed (unknown reason after retries).")
|
||||
|
||||
def get_credential(service, name, cred_type='credentials'):
|
||||
"""
|
||||
@@ -28,7 +107,7 @@ def get_credential(service, name, cred_type='credentials'):
|
||||
if service == 'deezer' and cred_type == 'search':
|
||||
raise ValueError("Search credentials are only supported for Spotify")
|
||||
|
||||
creds_dir = Path('./creds') / service / name
|
||||
creds_dir = Path('./data/creds') / service / name
|
||||
file_path = creds_dir / f'{cred_type}.json'
|
||||
|
||||
if not file_path.exists():
|
||||
@@ -56,7 +135,7 @@ def list_credentials(service):
|
||||
if service not in ['spotify', 'deezer']:
|
||||
raise ValueError("Service must be 'spotify' or 'deezer'")
|
||||
|
||||
service_dir = Path('./creds') / service
|
||||
service_dir = Path('./data/creds') / service
|
||||
if not service_dir.exists():
|
||||
return []
|
||||
|
||||
@@ -116,21 +195,80 @@ def create_credential(service, name, data, cred_type='credentials'):
|
||||
raise ValueError(f"Missing required field for {cred_type}: {field}")
|
||||
|
||||
# Create directory
|
||||
creds_dir = Path('./creds') / service / name
|
||||
creds_dir = Path('./data/creds') / service / name
|
||||
file_created_now = False
|
||||
dir_created_now = False
|
||||
|
||||
if cred_type == 'credentials':
|
||||
try:
|
||||
creds_dir.mkdir(parents=True, exist_ok=False)
|
||||
dir_created_now = True
|
||||
except FileExistsError:
|
||||
raise FileExistsError(f"Credential '{name}' already exists for {service}")
|
||||
else:
|
||||
# Directory already exists, which is fine for creating credentials.json
|
||||
# if it doesn't exist yet, or if we are overwriting (though POST usually means new)
|
||||
pass
|
||||
except Exception as e:
|
||||
raise ValueError(f"Could not create directory {creds_dir}: {e}")
|
||||
|
||||
file_path = creds_dir / 'credentials.json'
|
||||
if file_path.exists() and request.method == 'POST': # type: ignore
|
||||
# Safety check for POST to not overwrite if file exists unless it's an edit (PUT)
|
||||
raise FileExistsError(f"Credential file {file_path} already exists. Use PUT to modify.")
|
||||
|
||||
# Write the credential file first
|
||||
try:
|
||||
with open(file_path, 'w') as f:
|
||||
json.dump(data, f, indent=4)
|
||||
file_created_now = True # Mark as created for potential cleanup
|
||||
except Exception as e:
|
||||
if dir_created_now: # Cleanup directory if file write failed
|
||||
try:
|
||||
creds_dir.rmdir()
|
||||
except OSError: # rmdir fails if not empty, though it should be
|
||||
pass
|
||||
raise ValueError(f"Could not write credential file {file_path}: {e}")
|
||||
|
||||
# --- Validation Step ---
|
||||
try:
|
||||
_validate_with_retry(
|
||||
service_name=service,
|
||||
account_name=name,
|
||||
creds_dir_path=creds_dir,
|
||||
cred_file_path=file_path,
|
||||
data_for_validation=data, # 'data' contains the arl for Deezer
|
||||
is_spotify=(service == 'spotify')
|
||||
)
|
||||
except ValueError as val_err: # Catch the specific error from our helper
|
||||
print(f"ERROR: Credential validation failed during creation for {service} account {name}: {val_err}")
|
||||
traceback.print_exc() # Print full traceback here for creation failure context
|
||||
# Clean up the created file and directory if validation fails
|
||||
if file_created_now:
|
||||
try:
|
||||
file_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass # Ignore if somehow already gone
|
||||
if dir_created_now and not any(creds_dir.iterdir()): # Only remove if empty
|
||||
try:
|
||||
creds_dir.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
raise # Re-raise the ValueError from validation
|
||||
|
||||
elif cred_type == 'search': # Spotify only
|
||||
# For search.json, ensure the directory exists (it should if credentials.json exists)
|
||||
if not creds_dir.exists():
|
||||
raise FileNotFoundError(f"Credential '{name}' not found for {service}")
|
||||
|
||||
# Write credentials file
|
||||
file_path = creds_dir / f'{cred_type}.json'
|
||||
with open(file_path, 'w') as f:
|
||||
json.dump(data, f, indent=4)
|
||||
# This implies credentials.json was not created first, which is an issue.
|
||||
# However, the form logic might allow adding API creds to an existing empty dir.
|
||||
# For now, let's create it if it's missing, assuming API creds can be standalone.
|
||||
try:
|
||||
creds_dir.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Could not create directory for search credentials {creds_dir}: {e}")
|
||||
|
||||
file_path = creds_dir / 'search.json'
|
||||
# No specific validation for client_id/secret themselves, they are validated in use.
|
||||
with open(file_path, 'w') as f:
|
||||
json.dump(data, f, indent=4)
|
||||
|
||||
def delete_credential(service, name, cred_type=None):
|
||||
"""
|
||||
@@ -145,7 +283,7 @@ def delete_credential(service, name, cred_type=None):
|
||||
Raises:
|
||||
FileNotFoundError: If the credential directory or specified file does not exist
|
||||
"""
|
||||
creds_dir = Path('./creds') / service / name
|
||||
creds_dir = Path('./data/creds') / service / name
|
||||
|
||||
if cred_type:
|
||||
if cred_type not in ['credentials', 'search']:
|
||||
@@ -193,23 +331,29 @@ def edit_credential(service, name, new_data, cred_type='credentials'):
|
||||
raise ValueError("Search credentials are only supported for Spotify")
|
||||
|
||||
# Get file path
|
||||
creds_dir = Path('./creds') / service / name
|
||||
creds_dir = Path('./data/creds') / service / name
|
||||
file_path = creds_dir / f'{cred_type}.json'
|
||||
|
||||
# For search.json, create if it doesn't exist
|
||||
if cred_type == 'search' and not file_path.exists():
|
||||
if not creds_dir.exists():
|
||||
raise FileNotFoundError(f"Credential '{name}' not found for {service}")
|
||||
data = {}
|
||||
else:
|
||||
# Load existing data
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError(f"{cred_type.capitalize()} credential '{name}' not found for {service}")
|
||||
|
||||
original_data_str = None # Store original data as string to revert
|
||||
file_existed_before_edit = file_path.exists()
|
||||
|
||||
if file_existed_before_edit:
|
||||
with open(file_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Validate new_data fields
|
||||
original_data_str = f.read()
|
||||
try:
|
||||
data = json.loads(original_data_str)
|
||||
except json.JSONDecodeError:
|
||||
# If existing file is corrupt, treat as if we are creating it anew for edit
|
||||
data = {}
|
||||
original_data_str = None # Can't revert to corrupt data
|
||||
else:
|
||||
# If file doesn't exist, and we're editing (PUT), it's usually an error
|
||||
# unless it's for search.json which can be created during an edit flow.
|
||||
if cred_type == 'credentials':
|
||||
raise FileNotFoundError(f"Cannot edit non-existent credentials file: {file_path}")
|
||||
data = {} # Start with empty data for search.json creation
|
||||
|
||||
# Validate new_data fields (data to be merged)
|
||||
allowed_fields = []
|
||||
if cred_type == 'credentials':
|
||||
if service == 'spotify':
|
||||
@@ -223,15 +367,66 @@ def edit_credential(service, name, new_data, cred_type='credentials'):
|
||||
if key not in allowed_fields:
|
||||
raise ValueError(f"Invalid field '{key}' for {cred_type} credentials")
|
||||
|
||||
# Update data
|
||||
# Update data (merging new_data into existing or empty data)
|
||||
data.update(new_data)
|
||||
|
||||
# For Deezer: Strip all fields except 'arl'
|
||||
# --- Write and Validate Step for 'credentials' type ---
|
||||
if cred_type == 'credentials':
|
||||
try:
|
||||
# Temporarily write new data for validation
|
||||
with open(file_path, 'w') as f:
|
||||
json.dump(data, f, indent=4)
|
||||
|
||||
_validate_with_retry(
|
||||
service_name=service,
|
||||
account_name=name,
|
||||
creds_dir_path=creds_dir,
|
||||
cred_file_path=file_path,
|
||||
data_for_validation=data, # 'data' is the merged data with 'arl' for Deezer
|
||||
is_spotify=(service == 'spotify')
|
||||
)
|
||||
except ValueError as val_err: # Catch the specific error from our helper
|
||||
print(f"ERROR: Edited credential validation failed for {service} account {name}: {val_err}")
|
||||
traceback.print_exc() # Print full traceback here for edit failure context
|
||||
# Revert or delete the file
|
||||
if original_data_str is not None:
|
||||
with open(file_path, 'w') as f:
|
||||
f.write(original_data_str) # Restore original content
|
||||
elif file_existed_before_edit: # file existed but original_data_str is None (corrupt)
|
||||
pass
|
||||
else: # File didn't exist before this edit attempt, so remove it
|
||||
try:
|
||||
file_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass # Ignore if somehow already gone
|
||||
raise # Re-raise the ValueError from validation
|
||||
except Exception as e: # Catch other potential errors like file IO during temp write
|
||||
print(f"ERROR: Unexpected error during edit/validation for {service} account {name}: {e}")
|
||||
traceback.print_exc()
|
||||
# Attempt revert/delete
|
||||
if original_data_str is not None:
|
||||
with open(file_path, 'w') as f: f.write(original_data_str)
|
||||
elif file_existed_before_edit:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
file_path.unlink(missing_ok=True)
|
||||
except OSError: pass
|
||||
raise ValueError(f"Failed to save edited {service} credentials due to: {str(e).splitlines()[-1]}")
|
||||
|
||||
# For 'search' type, just write, no specific validation here for client_id/secret
|
||||
elif cred_type == 'search':
|
||||
if not creds_dir.exists(): # Should not happen if we're editing
|
||||
raise FileNotFoundError(f"Credential directory {creds_dir} not found for editing search credentials.")
|
||||
with open(file_path, 'w') as f:
|
||||
json.dump(data, f, indent=4) # `data` here is the merged data for search
|
||||
|
||||
# For Deezer: Strip all fields except 'arl' - This should use `data` which is `updated_data`
|
||||
if service == 'deezer' and cred_type == 'credentials':
|
||||
if 'arl' not in data:
|
||||
raise ValueError("Missing 'arl' field for Deezer credential")
|
||||
raise ValueError("Missing 'arl' field for Deezer credential after edit.")
|
||||
data = {'arl': data['arl']}
|
||||
|
||||
|
||||
# Ensure required fields are present
|
||||
required_fields = []
|
||||
if cred_type == 'credentials':
|
||||
|
||||
@@ -29,7 +29,7 @@ def get_spotify_info(spotify_id, spotify_type):
|
||||
raise ValueError("No Spotify account configured in settings")
|
||||
|
||||
if spotify_id:
|
||||
search_creds_path = Path(f'./creds/spotify/{main}/search.json')
|
||||
search_creds_path = Path(f'./data/creds/spotify/{main}/search.json')
|
||||
if search_creds_path.exists():
|
||||
try:
|
||||
with open(search_creds_path, 'r') as f:
|
||||
|
||||
@@ -47,11 +47,11 @@ def download_playlist(
|
||||
# Smartly determine where to look for Spotify search credentials
|
||||
if service == 'spotify' and fallback:
|
||||
# If fallback is enabled, use the fallback account for Spotify search credentials
|
||||
search_creds_path = Path(f'./creds/spotify/{fallback}/search.json')
|
||||
search_creds_path = Path(f'./data/creds/spotify/{fallback}/search.json')
|
||||
print(f"DEBUG: Using Spotify search credentials from fallback: {search_creds_path}")
|
||||
else:
|
||||
# Otherwise use the main account for Spotify search credentials
|
||||
search_creds_path = Path(f'./creds/spotify/{main}/search.json')
|
||||
search_creds_path = Path(f'./data/creds/spotify/{main}/search.json')
|
||||
print(f"DEBUG: Using Spotify search credentials from main: {search_creds_path}")
|
||||
|
||||
if search_creds_path.exists():
|
||||
@@ -77,7 +77,7 @@ def download_playlist(
|
||||
deezer_error = None
|
||||
try:
|
||||
# Load Deezer credentials from 'main' under deezer directory
|
||||
deezer_creds_dir = os.path.join('./creds/deezer', main)
|
||||
deezer_creds_dir = os.path.join('./data/creds/deezer', main)
|
||||
deezer_creds_path = os.path.abspath(os.path.join(deezer_creds_dir, 'credentials.json'))
|
||||
|
||||
# DEBUG: Print Deezer credential paths being used
|
||||
@@ -124,7 +124,7 @@ def download_playlist(
|
||||
|
||||
# Load fallback Spotify credentials and attempt download
|
||||
try:
|
||||
spo_creds_dir = os.path.join('./creds/spotify', fallback)
|
||||
spo_creds_dir = os.path.join('./data/creds/spotify', fallback)
|
||||
spo_creds_path = os.path.abspath(os.path.join(spo_creds_dir, 'credentials.json'))
|
||||
|
||||
print(f"DEBUG: Using Spotify fallback credentials from: {spo_creds_path}")
|
||||
@@ -168,7 +168,7 @@ def download_playlist(
|
||||
# Original behavior: use Spotify main
|
||||
if quality is None:
|
||||
quality = 'HIGH'
|
||||
creds_dir = os.path.join('./creds/spotify', main)
|
||||
creds_dir = os.path.join('./data/creds/spotify', main)
|
||||
credentials_path = os.path.abspath(os.path.join(creds_dir, 'credentials.json'))
|
||||
print(f"DEBUG: Using Spotify main credentials from: {credentials_path}")
|
||||
print(f"DEBUG: Credentials exist: {os.path.exists(credentials_path)}")
|
||||
@@ -203,7 +203,7 @@ def download_playlist(
|
||||
if quality is None:
|
||||
quality = 'FLAC'
|
||||
# Existing code for Deezer, using main as Deezer account.
|
||||
creds_dir = os.path.join('./creds/deezer', main)
|
||||
creds_dir = os.path.join('./data/creds/deezer', main)
|
||||
creds_path = os.path.abspath(os.path.join(creds_dir, 'credentials.json'))
|
||||
print(f"DEBUG: Using Deezer credentials from: {creds_path}")
|
||||
print(f"DEBUG: Credentials exist: {os.path.exists(creds_path)}")
|
||||
|
||||
@@ -19,7 +19,7 @@ def search(
|
||||
client_secret = None
|
||||
|
||||
if main:
|
||||
search_creds_path = Path(f'./creds/spotify/{main}/search.json')
|
||||
search_creds_path = Path(f'./data/creds/spotify/{main}/search.json')
|
||||
logger.debug(f"Looking for credentials at: {search_creds_path}")
|
||||
|
||||
if search_creds_path.exists():
|
||||
|
||||
@@ -47,11 +47,11 @@ def download_track(
|
||||
# Smartly determine where to look for Spotify search credentials
|
||||
if service == 'spotify' and fallback:
|
||||
# If fallback is enabled, use the fallback account for Spotify search credentials
|
||||
search_creds_path = Path(f'./creds/spotify/{fallback}/search.json')
|
||||
search_creds_path = Path(f'./data/creds/spotify/{fallback}/search.json')
|
||||
print(f"DEBUG: Using Spotify search credentials from fallback: {search_creds_path}")
|
||||
else:
|
||||
# Otherwise use the main account for Spotify search credentials
|
||||
search_creds_path = Path(f'./creds/spotify/{main}/search.json')
|
||||
search_creds_path = Path(f'./data/creds/spotify/{main}/search.json')
|
||||
print(f"DEBUG: Using Spotify search credentials from main: {search_creds_path}")
|
||||
|
||||
if search_creds_path.exists():
|
||||
@@ -76,7 +76,7 @@ def download_track(
|
||||
# First attempt: use Deezer's download_trackspo with 'main' (Deezer credentials)
|
||||
deezer_error = None
|
||||
try:
|
||||
deezer_creds_dir = os.path.join('./creds/deezer', main)
|
||||
deezer_creds_dir = os.path.join('./data/creds/deezer', main)
|
||||
deezer_creds_path = os.path.abspath(os.path.join(deezer_creds_dir, 'credentials.json'))
|
||||
|
||||
# DEBUG: Print Deezer credential paths being used
|
||||
@@ -88,8 +88,8 @@ def download_track(
|
||||
|
||||
# List available directories to compare
|
||||
print(f"DEBUG: Available Deezer credential directories:")
|
||||
for dir_name in os.listdir('./creds/deezer'):
|
||||
print(f"DEBUG: ./creds/deezer/{dir_name}")
|
||||
for dir_name in os.listdir('./data/creds/deezer'):
|
||||
print(f"DEBUG: ./data/creds/deezer/{dir_name}")
|
||||
|
||||
with open(deezer_creds_path, 'r') as f:
|
||||
deezer_creds = json.load(f)
|
||||
@@ -122,7 +122,7 @@ def download_track(
|
||||
|
||||
# If the first attempt fails, use the fallback Spotify credentials
|
||||
try:
|
||||
spo_creds_dir = os.path.join('./creds/spotify', fallback)
|
||||
spo_creds_dir = os.path.join('./data/creds/spotify', fallback)
|
||||
spo_creds_path = os.path.abspath(os.path.join(spo_creds_dir, 'credentials.json'))
|
||||
|
||||
# We've already loaded the Spotify client credentials above based on fallback
|
||||
@@ -159,7 +159,7 @@ def download_track(
|
||||
# Directly use Spotify main account
|
||||
if quality is None:
|
||||
quality = 'HIGH'
|
||||
creds_dir = os.path.join('./creds/spotify', main)
|
||||
creds_dir = os.path.join('./data/creds/spotify', main)
|
||||
credentials_path = os.path.abspath(os.path.join(creds_dir, 'credentials.json'))
|
||||
spo = SpoLogin(
|
||||
credentials_path=credentials_path,
|
||||
@@ -188,7 +188,7 @@ def download_track(
|
||||
if quality is None:
|
||||
quality = 'FLAC'
|
||||
# Deezer download logic remains unchanged, with the custom formatting parameters passed along.
|
||||
creds_dir = os.path.join('./creds/deezer', main)
|
||||
creds_dir = os.path.join('./data/creds/deezer', main)
|
||||
creds_path = os.path.abspath(os.path.join(creds_dir, 'credentials.json'))
|
||||
with open(creds_path, 'r') as f:
|
||||
creds = json.load(f)
|
||||
|
||||
131
src/js/config.ts
131
src/js/config.ts
@@ -53,6 +53,24 @@ let isEditingSearch = false;
|
||||
let activeSpotifyAccount = '';
|
||||
let activeDeezerAccount = '';
|
||||
|
||||
// Reference to the credentials form card and add button
|
||||
let credentialsFormCard: HTMLElement | null = null;
|
||||
let showAddAccountFormBtn: HTMLElement | null = null;
|
||||
let cancelAddAccountBtn: HTMLElement | null = null;
|
||||
|
||||
// Helper function to manage visibility of form and add button
|
||||
function setFormVisibility(showForm: boolean) {
|
||||
if (credentialsFormCard && showAddAccountFormBtn) {
|
||||
credentialsFormCard.style.display = showForm ? 'block' : 'none';
|
||||
showAddAccountFormBtn.style.display = showForm ? 'none' : 'flex'; // Assuming flex for styled button
|
||||
if (showForm) {
|
||||
resetForm(); // Reset form to "add new" state when showing for add
|
||||
const credentialNameInput = document.getElementById('credentialName') as HTMLInputElement | null;
|
||||
if(credentialNameInput) credentialNameInput.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const response = await fetch('/api/config');
|
||||
@@ -75,6 +93,8 @@ async function loadConfig() {
|
||||
// but updateAccountSelectors() will rebuild the options and set the proper values.)
|
||||
const spotifySelect = document.getElementById('spotifyAccountSelect') as HTMLSelectElement | null;
|
||||
const deezerSelect = document.getElementById('deezerAccountSelect') as HTMLSelectElement | null;
|
||||
const spotifyMessage = document.getElementById('spotifyAccountMessage') as HTMLElement | null;
|
||||
const deezerMessage = document.getElementById('deezerAccountMessage') as HTMLElement | null;
|
||||
if (spotifySelect) spotifySelect.value = activeSpotifyAccount;
|
||||
if (deezerSelect) deezerSelect.value = activeDeezerAccount;
|
||||
|
||||
@@ -115,6 +135,30 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
setupServiceTabs();
|
||||
setupEventListeners();
|
||||
|
||||
// Setup for the collapsable "Add Account" form
|
||||
credentialsFormCard = document.querySelector('.credentials-form.card');
|
||||
showAddAccountFormBtn = document.getElementById('showAddAccountFormBtn');
|
||||
cancelAddAccountBtn = document.getElementById('cancelAddAccountBtn');
|
||||
|
||||
if (credentialsFormCard && showAddAccountFormBtn) {
|
||||
// Initially hide form, show add button (default state handled by setFormVisibility if called)
|
||||
credentialsFormCard.style.display = 'none';
|
||||
showAddAccountFormBtn.style.display = 'flex'; // Assuming styled button uses flex
|
||||
}
|
||||
|
||||
if (showAddAccountFormBtn) {
|
||||
showAddAccountFormBtn.addEventListener('click', () => {
|
||||
setFormVisibility(true);
|
||||
});
|
||||
}
|
||||
|
||||
if (cancelAddAccountBtn && credentialsFormCard && showAddAccountFormBtn) {
|
||||
cancelAddAccountBtn.addEventListener('click', () => {
|
||||
setFormVisibility(false);
|
||||
resetForm(); // Also reset form state on cancel
|
||||
});
|
||||
}
|
||||
|
||||
const queueIcon = document.getElementById('queueIcon');
|
||||
if (queueIcon) {
|
||||
queueIcon.addEventListener('click', () => {
|
||||
@@ -231,46 +275,65 @@ async function updateAccountSelectors() {
|
||||
// Get the select elements
|
||||
const spotifySelect = document.getElementById('spotifyAccountSelect') as HTMLSelectElement | null;
|
||||
const deezerSelect = document.getElementById('deezerAccountSelect') as HTMLSelectElement | null;
|
||||
const spotifyMessage = document.getElementById('spotifyAccountMessage') as HTMLElement | null;
|
||||
const deezerMessage = document.getElementById('deezerAccountMessage') as HTMLElement | null;
|
||||
|
||||
// Rebuild the Spotify selector options
|
||||
if (spotifySelect) {
|
||||
spotifySelect.innerHTML = spotifyAccounts
|
||||
.map((a: string) => `<option value="${a}">${a}</option>`)
|
||||
.join('');
|
||||
if (spotifySelect && spotifyMessage) {
|
||||
if (spotifyAccounts.length > 0) {
|
||||
spotifySelect.innerHTML = spotifyAccounts
|
||||
.map((a: string) => `<option value="${a}">${a}</option>`)
|
||||
.join('');
|
||||
spotifySelect.style.display = '';
|
||||
spotifyMessage.style.display = 'none';
|
||||
|
||||
// Use the active account loaded from the config (activeSpotifyAccount)
|
||||
if (spotifyAccounts.includes(activeSpotifyAccount)) {
|
||||
spotifySelect.value = activeSpotifyAccount;
|
||||
} else if (spotifyAccounts.length > 0) {
|
||||
spotifySelect.value = spotifyAccounts[0];
|
||||
activeSpotifyAccount = spotifyAccounts[0];
|
||||
await saveConfig();
|
||||
// Use the active account loaded from the config (activeSpotifyAccount)
|
||||
if (activeSpotifyAccount && spotifyAccounts.includes(activeSpotifyAccount)) {
|
||||
spotifySelect.value = activeSpotifyAccount;
|
||||
} else {
|
||||
spotifySelect.value = spotifyAccounts[0];
|
||||
activeSpotifyAccount = spotifyAccounts[0];
|
||||
await saveConfig(); // Save if we defaulted
|
||||
}
|
||||
} else {
|
||||
spotifySelect.innerHTML = '';
|
||||
spotifySelect.style.display = 'none';
|
||||
spotifyMessage.textContent = 'No Spotify accounts available.';
|
||||
spotifyMessage.style.display = '';
|
||||
if (activeSpotifyAccount !== '') { // Clear active account if it was set
|
||||
activeSpotifyAccount = '';
|
||||
await saveConfig();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild the Deezer selector options
|
||||
if (deezerSelect) {
|
||||
deezerSelect.innerHTML = deezerAccounts
|
||||
.map((a: string) => `<option value="${a}">${a}</option>`)
|
||||
.join('');
|
||||
if (deezerSelect && deezerMessage) {
|
||||
if (deezerAccounts.length > 0) {
|
||||
deezerSelect.innerHTML = deezerAccounts
|
||||
.map((a: string) => `<option value="${a}">${a}</option>`)
|
||||
.join('');
|
||||
deezerSelect.style.display = '';
|
||||
deezerMessage.style.display = 'none';
|
||||
|
||||
if (deezerAccounts.includes(activeDeezerAccount)) {
|
||||
deezerSelect.value = activeDeezerAccount;
|
||||
} else if (deezerAccounts.length > 0) {
|
||||
deezerSelect.value = deezerAccounts[0];
|
||||
activeDeezerAccount = deezerAccounts[0];
|
||||
await saveConfig();
|
||||
if (activeDeezerAccount && deezerAccounts.includes(activeDeezerAccount)) {
|
||||
deezerSelect.value = activeDeezerAccount;
|
||||
} else {
|
||||
deezerSelect.value = deezerAccounts[0];
|
||||
activeDeezerAccount = deezerAccounts[0];
|
||||
await saveConfig(); // Save if we defaulted
|
||||
}
|
||||
} else {
|
||||
deezerSelect.innerHTML = '';
|
||||
deezerSelect.style.display = 'none';
|
||||
deezerMessage.textContent = 'No Deezer accounts available.';
|
||||
deezerMessage.style.display = '';
|
||||
if (activeDeezerAccount !== '') { // Clear active account if it was set
|
||||
activeDeezerAccount = '';
|
||||
await saveConfig();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle empty account lists
|
||||
[spotifySelect, deezerSelect].forEach((select, index) => {
|
||||
const accounts = index === 0 ? spotifyAccounts : deezerAccounts;
|
||||
if (select && accounts.length === 0) {
|
||||
select.innerHTML = '<option value="">No accounts available</option>';
|
||||
select.value = '';
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
showConfigError('Error updating accounts: ' + error.message);
|
||||
}
|
||||
@@ -291,7 +354,7 @@ async function loadCredentials(service: string) {
|
||||
}
|
||||
|
||||
function renderCredentialsList(service: string, credentials: any[]) {
|
||||
const list = document.querySelector('.credentials-list') as HTMLElement | null;
|
||||
const list = document.querySelector('.credentials-list-items') as HTMLElement | null;
|
||||
if (!list) return;
|
||||
list.innerHTML = '';
|
||||
|
||||
@@ -396,6 +459,8 @@ async function handleEditCredential(e: MouseEvent) {
|
||||
(document.querySelector(`[data-service="${service}"]`) as HTMLElement | null)?.click();
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
setFormVisibility(true); // Show form for editing, will hide add button
|
||||
|
||||
const response = await fetch(`/api/credentials/${service}/${name}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load credential: ${response.statusText}`);
|
||||
@@ -430,6 +495,8 @@ async function handleEditSearchCredential(e: Event) {
|
||||
throw new Error('Search credentials are only available for Spotify');
|
||||
}
|
||||
|
||||
setFormVisibility(true); // Show form for editing search creds, will hide add button
|
||||
|
||||
(document.querySelector(`[data-service="${service}"]`) as HTMLElement | null)?.click();
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
@@ -662,7 +729,7 @@ async function handleCredentialSubmit(e: Event) {
|
||||
await updateAccountSelectors();
|
||||
await saveConfig();
|
||||
loadCredentials(service!);
|
||||
resetForm();
|
||||
setFormVisibility(false); // Hide form and show add button on successful submission
|
||||
|
||||
// Show success message
|
||||
showConfigSuccess(isEditingSearch ? 'API credentials saved successfully' : 'Account saved successfully');
|
||||
|
||||
@@ -215,7 +215,7 @@ export class DownloadQueue {
|
||||
<h2>Download Queue (<span id="queueTotalCount">0</span> items)</h2>
|
||||
<div class="header-actions">
|
||||
<button id="cancelAllBtn" aria-label="Cancel all downloads">
|
||||
<img src="https://www.svgrepo.com/show/488384/skull-head.svg" alt="Skull" class="skull-icon">
|
||||
<img src="/static/images/skull-head.svg" alt="Cancel All" class="skull-icon">
|
||||
Cancel all
|
||||
</button>
|
||||
</div>
|
||||
@@ -249,7 +249,7 @@ export class DownloadQueue {
|
||||
const queueIcon = document.getElementById('queueIcon');
|
||||
if (queueIcon && this.config) {
|
||||
if (this.config.downloadQueueVisible) {
|
||||
queueIcon.innerHTML = '<span class="queue-x">×</span>';
|
||||
queueIcon.innerHTML = '<img src="/static/images/cross.svg" alt="Close queue" class="queue-x">';
|
||||
queueIcon.setAttribute('aria-expanded', 'true');
|
||||
queueIcon.classList.add('queue-icon-active'); // Add red tint class
|
||||
} else {
|
||||
@@ -329,7 +329,7 @@ export class DownloadQueue {
|
||||
if (queueIcon && this.config) {
|
||||
if (isVisible) {
|
||||
// Replace the image with an X and add red tint
|
||||
queueIcon.innerHTML = '<span class="queue-x">×</span>';
|
||||
queueIcon.innerHTML = '<img src="/static/images/cross.svg" alt="Close queue" class="queue-x">';
|
||||
queueIcon.setAttribute('aria-expanded', 'true');
|
||||
queueIcon.classList.add('queue-icon-active'); // Add red tint class
|
||||
} else {
|
||||
@@ -356,13 +356,13 @@ export class DownloadQueue {
|
||||
// Also revert the icon back
|
||||
if (queueIcon && this.config) {
|
||||
if (!isVisible) {
|
||||
queueIcon.innerHTML = '<span class="queue-x">×</span>';
|
||||
queueIcon.innerHTML = '<img src="/static/images/cross.svg" alt="Close queue" class="queue-x">';
|
||||
queueIcon.setAttribute('aria-expanded', 'true');
|
||||
queueIcon.classList.add('queue-icon-active'); // Add red tint class
|
||||
} else {
|
||||
queueIcon.innerHTML = '<img src="/static/images/queue.svg" alt="Queue Icon">';
|
||||
queueIcon.setAttribute('aria-expanded', 'false');
|
||||
queueIcon.classList.remove('queue-icon-active'); // Remove red tint class
|
||||
queueIcon.innerHTML = '<img src="/static/images/cross.svg" alt="Close queue" class="queue-x">';
|
||||
queueIcon.setAttribute('aria-expanded', 'true');
|
||||
queueIcon.classList.add('queue-icon-active'); // Add red tint class
|
||||
}
|
||||
}
|
||||
this.dispatchEvent('queueVisibilityChanged', { visible: !isVisible });
|
||||
@@ -658,7 +658,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
||||
<div class="type ${type}">${displayType}</div>
|
||||
</div>
|
||||
<button class="cancel-btn" data-prg="${prgFile}" data-type="${type}" data-queueid="${queueId}" title="Cancel Download">
|
||||
<img src="https://www.svgrepo.com/show/488384/skull-head.svg" alt="Cancel Download">
|
||||
<img src="/static/images/skull-head.svg" alt="Cancel Download" style="width: 16px; height: 16px;">
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -2179,7 +2179,9 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
||||
errorLogElement.innerHTML = `
|
||||
<div class="error-message">${errMsg}</div>
|
||||
<div class="error-buttons">
|
||||
<button class="close-error-btn" title="Close">×</button>
|
||||
<button class="close-error-btn" title="Close">
|
||||
<img src="/static/images/cross.svg" alt="Close error" style="width: 12px; height: 12px; vertical-align: middle;">
|
||||
</button>
|
||||
<button class="retry-btn" title="Retry download">Retry</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -928,3 +928,78 @@ input:checked + .slider:before {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Credentials List Wrapper */
|
||||
.credentials-list-wrapper {
|
||||
background: #181818; /* Same as original .credentials-list.card */
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||
padding: 1.5rem; /* Add padding here if you want it around the whole block */
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* Where individual credential items will be rendered */
|
||||
.credentials-list-items {
|
||||
/* No specific styles needed here unless items need separation from the add button */
|
||||
}
|
||||
|
||||
/* Styling for the Add New Account button to make it look like a list item */
|
||||
.add-account-item {
|
||||
margin-top: 0.75rem; /* Space above the add button if there are items */
|
||||
}
|
||||
|
||||
.btn-add-account-styled {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 1.25rem;
|
||||
background-color: #1db954; /* Green background */
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
border-radius: 8px; /* Same as credential-item */
|
||||
font-size: 1.1rem; /* Similar to credential-name */
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s ease, transform 0.2s ease;
|
||||
text-align: center;
|
||||
opacity: 1; /* Ensure it's not transparent by default */
|
||||
}
|
||||
|
||||
.btn-add-account-styled img {
|
||||
width: 20px; /* Adjust as needed */
|
||||
height: 20px; /* Adjust as needed */
|
||||
margin-right: 10px;
|
||||
filter: brightness(0) invert(1); /* Make icon white if it's not already */
|
||||
}
|
||||
|
||||
.btn-add-account-styled:hover {
|
||||
background-color: #1aa34a; /* Darker green on hover */
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* New styles for the icon-based cancel button */
|
||||
.btn-cancel-icon {
|
||||
background-color: #c0392b !important; /* Red background */
|
||||
padding: 0.6rem !important; /* Adjust padding for icon */
|
||||
width: auto; /* Allow button to size to icon */
|
||||
min-width: 40px; /* Ensure a minimum touch target size */
|
||||
height: 40px; /* Ensure a minimum touch target size */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50% !important; /* Make it circular */
|
||||
opacity: 1 !important; /* Ensure it's always visible when its container is */
|
||||
visibility: visible !important; /* Ensure it's not hidden by visibility property */
|
||||
}
|
||||
|
||||
.btn-cancel-icon img {
|
||||
width: 16px; /* Adjust icon size as needed */
|
||||
height: 16px;
|
||||
filter: brightness(0) invert(1); /* Make icon white */
|
||||
}
|
||||
|
||||
.btn-cancel-icon:hover {
|
||||
background-color: #e74c3c !important; /* Lighter red on hover */
|
||||
transform: translateY(-2px) scale(1.05);
|
||||
}
|
||||
|
||||
4
static/images/cross.svg
Normal file
4
static/images/cross.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 5L4.99998 19M5.00001 5L19 19" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 358 B |
4
static/images/plus-circle.svg
Normal file
4
static/images/plus-circle.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2ZM12 7C12.5523 7 13 7.44772 13 8V11H16C16.5523 11 17 11.4477 17 12C17 12.5523 16.5523 13 16 13H13V16C13 16.5523 12.5523 17 12 17C11.4477 17 11 16.5523 11 16V13H8C7.44772 13 7 12.5523 7 12C7 11.4477 7.44772 11 8 11H11V8C11 7.44772 11.4477 7 12 7Z" fill="#000000"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 653 B |
3
static/images/skull-head.svg
Normal file
3
static/images/skull-head.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12,2a8.945,8.945,0,0,0-9,8.889,8.826,8.826,0,0,0,3.375,6.933v1.956A2.236,2.236,0,0,0,8.625,22h6.75a2.236,2.236,0,0,0,2.25-2.222V17.822A8.826,8.826,0,0,0,21,10.889,8.945,8.945,0,0,0,12,2ZM11,20H9V18a1,1,0,0,1,2,0ZM9,15a2,2,0,1,1,2-2A2,2,0,0,1,9,15Zm6,5H13V18a1,1,0,0,1,2,0Zm0-5a2,2,0,1,1,2-2A2,2,0,0,1,15,15Z"/></svg>
|
||||
|
After Width: | Height: | Size: 550 B |
@@ -32,6 +32,7 @@
|
||||
<div class="config-item spotify-specific">
|
||||
<label>Active Spotify Account:</label>
|
||||
<select id="spotifyAccountSelect" class="form-select"></select>
|
||||
<div id="spotifyAccountMessage" style="display: none; color: #888; margin-top: 5px;"></div>
|
||||
</div>
|
||||
<div class="config-item spotify-specific">
|
||||
<label>Spotify Quality:</label>
|
||||
@@ -44,6 +45,7 @@
|
||||
<div class="config-item deezer-specific">
|
||||
<label>Active Deezer Account:</label>
|
||||
<select id="deezerAccountSelect" class="form-select"></select>
|
||||
<div id="deezerAccountMessage" style="display: none; color: #888; margin-top: 5px;"></div>
|
||||
</div>
|
||||
<div class="config-item deezer-specific">
|
||||
<label>Deezer Quality:</label>
|
||||
@@ -229,7 +231,18 @@
|
||||
<button class="tab-button" data-service="deezer">Deezer</button>
|
||||
</div>
|
||||
|
||||
<div class="credentials-list card"></div>
|
||||
<!-- Wrapper for the list and the add button -->
|
||||
<div class="credentials-list-wrapper card">
|
||||
<div class="credentials-list-items">
|
||||
<!-- Dynamic credential items will be rendered here by JavaScript -->
|
||||
<!-- "No credentials" message will also be rendered here -->
|
||||
</div>
|
||||
<div class="add-account-item">
|
||||
<button id="showAddAccountFormBtn" class="btn-add-account-styled" type="button">
|
||||
<img src="{{ url_for('static', filename='images/plus-circle.svg') }}" alt="Add" /> Add New Account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="credentials-form card">
|
||||
<h2 id="formTitle" class="section-title">Add New Spotify Account</h2>
|
||||
@@ -241,6 +254,9 @@
|
||||
<div id="serviceFields"></div>
|
||||
<div id="searchFields" style="display: none;"></div>
|
||||
<button type="submit" id="submitCredentialBtn" class="btn btn-primary save-btn">Save Account</button>
|
||||
<button type="button" id="cancelAddAccountBtn" class="btn btn-secondary cancel-btn btn-cancel-icon" style="margin-left: 10px;" title="Cancel">
|
||||
<img src="{{ url_for('static', filename='images/cross.svg') }}" alt="Cancel" />
|
||||
</button>
|
||||
</form>
|
||||
<div id="configSuccess" class="success"></div>
|
||||
<div id="configError" class="error"></div>
|
||||
|
||||
Reference in New Issue
Block a user