soulsync/api/downloads.py
Broque Thomas a685f9ca4a diag: log every cancel_download caller with a trigger label
Diagnostic-only change for issue Technodude reported: Tidal sync-playlist
downloads getting mass-cancelled mid-flight with no clear cause in the
logs. App.log shows ~91 second gaps between Tidal download start and
cancel — matches the monitor's 90s queue-timeout exactly — but none of
the monitor's WARNING log lines fire, so the trigger is ambiguous
between five `_should_retry_task` paths, three web_server cancel paths,
and the API endpoints.

Added a single `[CancelTrigger:<label>]` INFO log line immediately
before every `download_orchestrator.cancel_download(...)` call so the
next log dump pins down which path is firing.

Labels (grep-able, prefix tells the file, suffix tells the trigger):

  monitor.not_in_live_transfers_90s
  monitor.errored_state_retry
  monitor.queued_state_timeout
  monitor.stuck_at_0pct_timeout
  monitor.unknown_state_no_progress_timeout
  candidates.worker_cancelled_during_download
  web.orphan_cleanup
  web.cancel_download_task
  web.atomic_cancel_v2
  api.manual_cancel_single
  api.public_cancel

The monitor's `deferred_ops` tuple grew from 3 elements to 4 (added
trigger label as last element). The dispatch loop unpacks both legacy
and new shapes so the change is backward-compatible for any in-flight
ops mid-deploy.

Zero behavior change. 367 download tests still green. WHATS_NEW left
untouched — diagnostic only, not user-facing.

After ship: ask Technodude to re-run the same sync playlist scenario,
attach the new app.log, grep `[CancelTrigger:` lines for the trigger
context, then write the actual fix.
2026-05-19 22:31:29 -07:00

152 lines
5.8 KiB
Python

"""
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/<download_id>/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)