Downloading#

client.download(url, ...) resolves any Tidal link (track, album, playlist, mix, artist, or bare id) and downloads it, always returning a DownloadReport — even when cancelled.

Artists#

An artist link (https://tidal.com/artist/<id> or a bare id) resolves to the artist’s discography: every track of every album, each with its album back-reference set.

  • client.download(artist_url) — each album lands in its own folder directly under the output root, named by album_format (no artist wrapper folder; TMD parity). Two albums containing identically numbered tracks never collide because the album folder is part of the path.

  • client.artist_albums(artist_url, include_ep=None) — the discography as a list of album dicts (id, title, release_date, …). EPs and singles are included by default (the reference client’s effective default); include_ep=False restricts to full albums. download/list_items of an artist link follow include_ep.

  • client.list_items(artist_url) — the shallow per-track report across the whole discography, as for albums and playlists.

Lyrics#

data = client.lyrics("https://tidal.com/track/68883384")
data["lyrics"]     # plain text: "The club isn't the best..."
data["subtitles"]  # timed LRC, when Tidal has it

lyrics() accepts a single track link and returns both forms: lyrics (plain text) and subtitles (the timed LRC version), each "" when Tidal has none. Lyrics are garnish by contract: the provider service rejects tracks without lyrics (it answers 400, not 404) and any rejection — rate limit, stale session — degrades to the empty strings instead of an error. For a single usable text, prefer the plain form: data["lyrics"] or data["subtitles"].

Timed-lyrics fallback. When Tidal has plain lyrics but no timed subtitles for the exact track version (common for remasters and live editions), the call searches the catalog for matching versions — same title and artists, scored by identical ISRC (same master recording), near-identical duration, and same album — and adopts the best candidate’s timed subtitles, keeping this track’s plain text. Display clients (KTV, car head units) therefore get synced lyrics for versions Tidal tags up as well; the fallback only runs when it can be scored, and it is garnish: a failed search keeps the plain lyrics.

Downloads: write_lyrics=True embeds the lyrics in the tags (plain form preferred), and lyric_file=True additionally writes a sidecar next to the media file — .lrc when timed subtitles exist, else .txt. The two options share one fetch, so enabling both costs no extra API call. The sidecar is written on the download path only: a skipped (already complete) file gets no sidecar, keeping skip-existing request-free.

Progress events#

on_progress receives plain JSON-safe dicts (never I/O, safe to forward to Celery’s update_state):

{"stage": "start",    "kind": "album", "title": "...", "total": 12}
{"stage": "item",     "index": 3, "total": 12, "status": "start"}
{"stage": "bytes",    "index": 3, "bytes": 4194304, "total_bytes": 8912896}
{"stage": "item",     "index": 3, "status": "ok", "path": "/.../03 Artist - Song.flac"}
{"stage": "complete", "ok": true, "failed": 0, "skipped": 1,
 "total_bytes": 108230096, "cancelled": false}

stage is one of start, item, bytes, complete. Callback exceptions are logged and swallowed — a broken host callback never kills the download.

Reports and per-item isolation#

report = client.download(url, skip_broken=True)
report.ok          # True when every item is ok and not cancelled
report.items       # list[ItemReport], one per track, in order
report.failures    # failed items only
report.total_bytes # sum of ok items' sizes
report.to_dict()   # JSON-safe dump (the legacy --diagnose shape)

Each ItemReport carries index, id, title, status (ok/failed/skipped), a machine code (not-found, stream, download, cancelled, …), reason (human-readable), retryable, path, delivered_quality and codec.

client.list_items(url, deep=True) is the pre-flight variant: besides the resolved delivered_quality/codec it reports the expected path (the one a download would use) and size — the bytes already on disk at that path, 0 when absent. Counting size > 0 over the report answers “how much of this is already downloaded?” without touching the files.

  • skip_broken=True (default): one dead track doesn’t kill the run.

  • skip_broken=False: stop after the first failure; the remaining items are recorded as skipped with reason aborted: previous item failed — the report is still returned.

Stale-snapshot recovery. Tidal’s catalog migration leaves item snapshots streamReady: false: the id may still resolve (with fresher metadata), or the release may have been replaced — the old entry deleted, the same recording re-issued under a brand-new id (both API versions 404 the snapshot id). Before such an item is marked broken, the library applies a deterministic recovery ladder — exact predicates only, never fuzzy matching: re-resolve the id (v1 → v2), then the snapshot’s ISRC (a primary key in the catalog), then the exact composite predicate over v1 search candidates (title + primary artist + duration within 1 s, and unique — an exact album-title tie-break when the snapshot names its album, otherwise refuse). Each recovery is logged with the anchor used and the id transition, so every recovered item is auditable; when every anchor misses the snapshot is kept and the item fails (or is skipped) with a clear report as usual.

Resume and skip-existing#

  • Bytes always land in <final path>.part first and are promoted with os.replace after size verification.

  • A non-empty file at the final path is therefore complete and is skipped (status="skipped"), never re-downloaded.

  • The skip check happens before the playback request: the destination candidates are computed from catalog data alone (the name template, the playback attempt ladder’s qualities, and the possible containers), and a non-empty file at any of them skips. Resuming a large playlist therefore spends no rate-limited manifest requests on tracks that are already on disk. The trade: that check cannot compare against the remote size (the probe would need the very request being saved), so a truncated file at a candidate path is not noticed until its destination stops being a candidate (or the host deletes it).

  • When the stream is resolved (a new file, or a destination that is not a candidate — e.g. the delivered quality lies outside the catalog ladder, or a config change moved the layout), a remote-size truncation check applies: all probes succeed and the local file is smaller than the remote → re-download. A failed probe never forces a re-download — one dead HEAD never re-downloads a whole playlist.

  • A .part file resumes: complete 1 MiB-aligned parts are not re-fetched, an unaligned tail is refetched from its start, and when the remote size is unknown (HEAD failed) the local state is trusted.

  • A .part is trusted only for the same source: the transfer records the source identity (track id, delivered quality and codec, the media URLs with their expiring CDN signature stripped) in <final path>.part.source.json. A renewed CDN token keeps the same identity (the representation is immutable); a different rendition changes it, and the stale .part is discarded — a same-size different rendition would otherwise slip past every size check.

  • If the remote file shrank (re-encode), the stale .part is discarded and the file is re-fetched.

  • If two different tracks sanitise to the identical file name (duplicate titles with the same number), the later one is written to a deterministic `` (position)`` variant instead of being skipped over the earlier one’s file — re-runs map to the same paths.

Cancellation#

Pass a cancel_event or call client.cancel() (cancels every active operation). The in-flight item is recorded (status="failed", code="cancelled", retryable=False) and the run ends with report.cancelled=True — the complete event is still emitted. .part files are left in place for the next run.

Post-processing#

Per item, in order:

  1. FLAC-in-MP4 remux — Tidal serves hi-res FLAC inside an MP4 container. With save_as_flac=True (default) and ffmpeg on PATH the file is remuxed to true FLAC with -c copy (no re-encode); without ffmpeg it is kept and named .m4a.

  2. Verification — with verify_media=True (default) and ffprobe available, the finished file is probed and its codec/sample-rate/bit-depth are compared against the manifest. A CDN that delivered something other than the manifest promised fails the item with a diagnostic instead of silently shipping degraded audio.

  3. Tagging — best-effort by contract: a failed tag write logs and continues; the audio bytes are the product, tags are garnish. MP3/M4A/FLAC are supported. The tag set mirrors the reference client: title (with version), artists, album, date, track/disc numbers, composer credit (from Tidal’s contributor data), ISRC, copyright, cover art, and ReplayGain from Tidal’s OpenAPI normalization data — written for every container (FLAC REPLAYGAIN_* tags, MP3 RVA2 frames, MP4 Apple freeform atoms). With write_lyrics=True the lyrics are fetched per track and embedded as well (one extra API call per item, off by default).

All of it is switchable via Config (save_as_flac, verify_media, write_tags, write_lyrics, download_cover).