""" Download management endpoints — list, cancel active downloads. """ from flask import request, current_app from .auth import require_api_key from .helpers import api_success, api_error from core.runtime_state import download_tasks, tasks_lock def _serialize_download(task_id, task): """Serialize a download task with all available fields.""" track_info = task.get("track_info") or {} # Track names can be top-level or inside track_info track_name = task.get("track_name") or track_info.get("title") or track_info.get("track_name") artist_name = task.get("artist_name") or track_info.get("artist") or track_info.get("artist_name") album_name = task.get("album_name") or track_info.get("album") or track_info.get("album_name") return { "id": task_id, "status": task.get("status"), "track_name": track_name, "artist_name": artist_name, "album_name": album_name, "username": task.get("username"), "filename": task.get("filename"), "progress": task.get("progress", 0), "size": task.get("size"), "error": task.get("error") or task.get("error_message"), "batch_id": task.get("batch_id"), "track_index": task.get("track_index"), "retry_count": task.get("retry_count", 0), "metadata_enhanced": task.get("metadata_enhanced", False), "status_change_time": task.get("status_change_time"), } def register_routes(bp): @bp.route("/downloads", methods=["GET"]) @require_api_key def list_downloads(): """List download tasks with optional filtering and pagination. Query params: status: comma-separated statuses to include (e.g. "downloading,queued"). Default includes all. limit: max tasks to return (default 100, max 500). offset: skip the first N tasks (default 0). Response includes `total` (post-filter count) so clients can paginate without fetching everything. Tasks are sorted by `status_change_time` descending so newest/in-flight tasks appear first. """ try: # Parse pagination params try: limit = int(request.args.get("limit", 100)) except (TypeError, ValueError): limit = 100 try: offset = int(request.args.get("offset", 0)) except (TypeError, ValueError): offset = 0 # Clamp to sensible bounds limit = max(1, min(limit, 500)) offset = max(0, offset) status_param = request.args.get("status", "").strip() status_filter = ( {s.strip() for s in status_param.split(",") if s.strip()} if status_param else None ) # Snapshot under the lock, then sort/slice outside. with tasks_lock: snapshot = list(download_tasks.items()) if status_filter: snapshot = [ (tid, t) for tid, t in snapshot if (t.get("status") or "") in status_filter ] # Sort newest-first by status_change_time; fall back to string id # so ordering is stable when timestamps are missing or tied. snapshot.sort( key=lambda item: (item[1].get("status_change_time") or "", item[0]), reverse=True, ) total = len(snapshot) page = snapshot[offset:offset + limit] tasks = [_serialize_download(tid, t) for tid, t in page] return api_success({ "downloads": tasks, "total": total, "limit": limit, "offset": offset, }) except ImportError: return api_error("NOT_AVAILABLE", "Download tracking not available.", 501) except Exception as e: return api_error("DOWNLOAD_ERROR", str(e), 500) @bp.route("/downloads//cancel", methods=["POST"]) @require_api_key def cancel_download(download_id): """Cancel a specific download. Body: {"username": "..."} """ body = request.get_json(silent=True) or {} username = body.get("username") if not username: return api_error("BAD_REQUEST", "Missing 'username' in body.", 400) try: from utils.async_helpers import run_async soulseek = current_app.soulsync.get("download_orchestrator") if not soulseek: return api_error("NOT_AVAILABLE", "Soulseek client not configured.", 503) current_app.logger.info( f"[CancelTrigger:api.public_cancel] download_id={download_id} username={username}" ) ok = run_async(soulseek.cancel_download(download_id, username, remove=True)) if ok: return api_success({"message": "Download cancelled."}) return api_error("CANCEL_FAILED", "Failed to cancel download.", 500) except Exception as e: return api_error("DOWNLOAD_ERROR", str(e), 500) @bp.route("/downloads/cancel-all", methods=["POST"]) @require_api_key def cancel_all_downloads(): """Cancel all active downloads and clear completed ones.""" try: from utils.async_helpers import run_async soulseek = current_app.soulsync.get("download_orchestrator") if not soulseek: return api_error("NOT_AVAILABLE", "Soulseek client not configured.", 503) run_async(soulseek.cancel_all_downloads()) run_async(soulseek.clear_all_completed_downloads()) return api_success({"message": "All downloads cancelled and cleared."}) except Exception as e: return api_error("DOWNLOAD_ERROR", str(e), 500)