Skip to content

Fix CDN download resilience & rewrite Qobuz authentication#326

Open
Zachery2008 wants to merge 3 commits intovitiko98:masterfrom
Zachery2008:add-retry-and-skip-failed
Open

Fix CDN download resilience & rewrite Qobuz authentication#326
Zachery2008 wants to merge 3 commits intovitiko98:masterfrom
Zachery2008:add-retry-and-skip-failed

Conversation

@Zachery2008
Copy link
Copy Markdown

@Zachery2008 Zachery2008 commented Mar 15, 2026

When downloading large Hi-Res FLAC files (e.g. 24-bit/96kHz), the Akamai CDN
occasionally drops the connection after delivering only 1 byte. This causes
IncompleteRead / ChunkedEncodingError exceptions that abort the entire
album download with no recovery. Additionally, Qobuz deprecated the old
GET-based user/login endpoint and the browser-based OAuth redirect flow
requires Qobuz to whitelist redirect URLs, which doesn't work for local CLI
apps — making it impossible to log in without manually copying a token from
DevTools.

The CDN issue is server-side — confirmed via curl that specific files can be
persistently broken on a regional edge node while other tracks from the same
album download fine.

Changes

qobuz_dl/downloader.py

  • Retry with exponential backoff: Track downloads now retry up to 5 times
    with increasing delays (2s, 4s, 8s, 16s) before giving up.
  • Fresh URL on retry: Each retry re-fetches the download URL via
    get_track_url(), since Akamai rejects reused/stale signed URLs.
  • Graceful skip: If all retries fail, the track is skipped with an error
    log instead of aborting the entire album. The partial .tmp file is cleaned up.
  • Connection timeout: Added timeout=(10, 60) to the download request
    (10s connect, 60s read) to prevent indefinite hangs.
  • Inter-track delay: Added a 1-second pause between consecutive track
    downloads to reduce CDN throttling on large albums.

qobuz_dl/qopy.py — Authentication rewrite

Qobuz deprecated the old GET-based user/login endpoint. Auth has been
rewritten to support two flows:

  • POST form-encoded login: New _login_with_password() method sends a
    standard POST to user/login with email and password (matching the web
    player's own login mechanism). Returns a user_auth_token on success.
  • Auto-detect password vs token: auth() checks if the stored credential
    is a short password (< 60 chars) or a long token, and tries POST login first
    when it looks like a password. Existing token-based configs continue to work
    unchanged.
  • Token validation via user/get: After obtaining a token (either from
    POST login or config), validates it against the user/get endpoint. Handles
    the flat response structure (credential at top level, not nested under
    "user").
  • Auto-refresh: If the API returns a new user_auth_token, it is
    persisted back to config.ini automatically.

qobuz_dl/cli.py — Config wizard update

  • Password prompt: qobuz-dl -r now asks for your actual Qobuz password
    and attempts POST login to obtain a token automatically.
  • Fallback to manual token: If POST login fails (e.g. Qobuz adds
    reCAPTCHA in the future), falls back to manual user_auth_token paste with
    instructions.
  • Removed OAuth browser flow: The browser-based OAuth redirect flow
    (oauth.py) has been removed — it required Qobuz to whitelist the redirect
    URL, which doesn't work for local CLI apps.

Refactor tqdm_download to use requests instead of urllib for downloading files. Remove unused imports and handle download in chunks with requests.
@eismith
Copy link
Copy Markdown

eismith commented Mar 17, 2026

Yes, please!

Copy link
Copy Markdown

@mzilinski mzilinski left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review PR #326

Guter Ansatz — CDN-Probleme bei großen Hi-Res Downloads sind ein reales Problem. Hier mein detailliertes Feedback:

✅ Positiv

  • Exponentieller Backoff: Sinnvolle Retry-Strategie mit steigenden Wartezeiten (2s, 4s, 8s, 16s).
  • Frische URL bei Retry: Wichtig, da Akamai signierte URLs nach einer gewissen Zeit ablehnt. Guter Ansatz mit get_track_url().
  • Graceful Skip: Album-Download bricht nicht mehr komplett ab wenn ein einzelner Track fehlschlägt — deutlich bessere UX.
  • Timeout hinzugefügt: timeout=(10, 60) in tqdm_download verhindert endloses Hängen. Gute Ergänzung.
  • Incomplete-Download-Check verbessert: download_size < total statt total != download_size ist semantisch richtiger und robuster (z.B. bei content-length: 0).

⚠️ Verbesserungsvorschläge

  1. Partielle Datei-Bereinigung: Bei fehlgeschlagenen Retries wird filename (die .tmp-Datei) gelöscht — gut. Aber wenn der letzte Versuch in tqdm_download mit einer unerwarteten Exception (nicht in der catch-Liste) fehlschlägt, bleibt eine teilweise .tmp-Datei zurück. Ein finally-Block oder ein generellerer Cleanup wäre robuster.

  2. time.sleep(1) zwischen Tracks: Der Delay ist nur in download_release, nicht in anderen Download-Pfaden. Außerdem wird der Sleep auch bei Demo-/nicht-streamable Tracks ausgeführt — man könnte den Sleep hinter den erfolgreichen Download verschieben.

  3. Redundanter Exception-Typ: ConnectionError (built-in) und requests.exceptions.ConnectionError sind in Python identisch — requests re-exportiert den built-in. Kein Bug, aber redundant in der catch-Liste.

💬 Frage

  • Wurde das mit einem ganzen Album getestet, bei dem ein Track reproduzierbar fehlschlägt? Das Retry + Skip-Verhalten wäre interessant im echten Betrieb zu validieren.

Fazit

Solider PR, der ein echtes Problem löst. Die Kernlogik ist korrekt. Die obigen Punkte sind Verfeinerungen, keine Blocker.

@mzilinski
Copy link
Copy Markdown

mzilinski commented Mar 31, 2026

Update zu meinem Review — kritische Bugs gefunden

Bei genauerer Analyse des Codes sind drei kritische Probleme aufgefallen, die ich im ursprünglichen Review übersehen habe:

1. Retry wird nie ausgelöst — fehlender HTTP-Statuscheck

tqdm_download() ruft kein r.raise_for_status() auf. Wenn das CDN 403/500 zurückgibt:

  • requests.get() wirft keine Exception
  • Die HTML-Fehlerseite wird als Datei geschrieben
  • download_size == total → keine ConnectionError
  • Download gilt als "erfolgreich" → kein Retry
  • Tagging schlägt fehl (korrupte Datei) → Track geht verloren

Fix: r.raise_for_status() nach requests.get() + HTTPError zur Retry-Exception-Liste hinzufügen.

2. Stale URL bei URL-Refresh-Fehler → Retry schlägt immer fehl

Wenn get_track_url() beim Refresh eine Exception wirft (Zeile 258-259), wird nur gewarnt und der abgelaufene URL weiterverwendet. Qobuz-CDN-URLs sind signiert und zeitlich begrenzt — ein staler URL wird immer abgelehnt. Ergebnis: alle 5 Retry-Versuche scheitern garantiert.

Fix: Bei Refresh-Fehler continue statt Download mit kaputtem URL versuchen.

3. Memory Leak — Response wird nie geschlossen

requests.get(..., stream=True) in tqdm_download() öffnet eine Streaming-Verbindung, die nie geschlossen wird (kein r.close(), kein Context-Manager). Bei großen Downloads mit vielen Tracks und Retries können sich offene Sockets akkumulieren und den Speicherverbrauch erheblich steigern.

Fix: try/finally mit r.close() um die Response.


Falls gewünscht, kann ich einen separaten PR mit den Korrekturen erstellen.

@Zachery2008 Zachery2008 changed the title Add retry with backoff and graceful skip for failed track downloads Update Oauth and Add retry with backoff and graceful skip for failed track downloads Apr 4, 2026
@Zachery2008 Zachery2008 changed the title Update Oauth and Add retry with backoff and graceful skip for failed track downloads Fix CDN download resilience & rewrite Qobuz authentication Apr 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants