architectural changes, preparing for playlist & artist watching

This commit is contained in:
cool.gitter.not.me.again.duh
2025-05-27 12:01:49 -06:00
parent 59370367bd
commit e822284b88
25 changed files with 844 additions and 174 deletions

View File

@@ -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}),

View File

@@ -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:

View File

@@ -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:

View File

@@ -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

View File

@@ -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'
)

View File

@@ -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'
)

View File

@@ -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)}")

View File

@@ -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

View File

@@ -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():
"""

View File

@@ -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()

View File

@@ -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

View File

@@ -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)}

View File

@@ -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':

View File

@@ -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:

View File

@@ -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)}")

View File

@@ -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():

View File

@@ -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)