Django / Celery integration#

tidalpy is built for the tidal_transfer migration: it replaces the old CLI-subprocess layer with a structured, in-process API. This page shows the idiomatic patterns.

One client per task#

from celery import Celery
from tidalpy import Config, TidalClient
from tidalpy.errors import AuthError, RateLimitError, TidalError

app = Celery("tidal_transfer")

def make_client() -> TidalClient:
    # Token lives in the DB via a custom TokenStore, or on a
    # shared volume via the default FileTokenStore.
    return TidalClient(Config.from_dict(settings.TIDALPY_CONFIG))


@app.task(bind=True, max_retries=3)
def download_collection(task, url, quality="LOSSLESS"):
    client = make_client()
    try:
        if not client.auth_status()["logged_in"]:
            raise AuthError("no valid Tidal login in the DB")
        report = client.download(
            url,
            quality=quality,
            on_progress=lambda e: _progress(task, e),
        )
        return report.to_dict()
    except RateLimitError as exc:
        # Tidal's 429: retry with a backoff sized to the task.
        raise task.retry(exc=exc, countdown=120)
    finally:
        client.close()


def _progress(task, event: dict) -> None:
    # Events are JSON-safe dicts — straight into update_state.
    if event["stage"] == "bytes":
        task.update_state(
            state="PROGRESS",
            meta={
                "current": event["index"],
                "total": event["total"],
                "bytes": event.get("bytes"),
            },
        )
    elif event["stage"] == "complete":
        task.update_state(state="SUCCESS", meta=event)

Cancellation#

Map Celery’s on_revoke (or your UI’s “cancel” button) to a shared event:

import threading

_active: dict[str, threading.Event] = {}

def cancel_download(task_id: str) -> None:
    _active.get(task_id, threading.Event()).set()

# inside the task:
stop = threading.Event()
_active[task.request.id] = stop
try:
    report = client.download(url, cancel_event=stop)
finally:
    _active.pop(task.request.id, None)

The task then ends with report.cancelled is True and the partial result — no dangling process, and the next run resumes the .part files.

Host-owned storage (streaming)#

When tidal_transfer stores media in S3 (or a DB), skip the disk entirely:

def stream_track(track_url: str, s3_client, bucket: str) -> str:
    client = make_client()
    try:
        with client.stream(track_url) as media:
            return _s3_multipart_upload(
                s3_client, bucket, f"tidal/{media.track_id}.flac",
                media.chunks(),
            )
    finally:
        client.close()

See Streaming (host-owned file I/O) for the full multipart skeleton.

Run status for an admin UI#

DownloadReport.summary() is the one-shot status line for a run — counts, bytes, and the failures’ identity, JSON-safe for a task result or an admin column (no item walk on the host):

result = report.summary()
# {"ok": False, "total": 12, "okCount": 10, "skippedCount": 1,
#  "failedCount": 1, "totalBytes": 482133760,
#  "failures": [{"id": 555, "title": "…", "code": "stream",
#                "reason": "…"}], …}

Real-time search endpoint#

The search/browse facades back a live search box: the rows are uniform JSON-safe dicts (kind/id/title plus the catalog garnish), so one row serializer covers the suggest box (autocomplete), full results (search), and the genre browse tabs (genres + genre_items):

@app.route("/api/tidal/search")
def tidal_search(q: str):
    client = make_client()
    try:
        if len(q.strip()) < 3:
            return jsonify(client.autocomplete(q))
        page = client.search(q)
        return jsonify(page["items"])
    finally:
        client.close()

A track page’s credits tab is client.track_contributors(url), and the account’s own playlist links are client.user_playlists().

Prefork safety#

  • Build the client inside the task, never at module import time (a forked worker would inherit the parent’s HTTP session and stop events).

  • Two clients in one process are fully independent (no shared credentials, no shared token state).

  • The only shared resources are the token store (use the DB-backed store under multi-worker) and, if you keep files, the output directory (safe: promotion is os.replace).