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 byalbum_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=Falserestricts to full albums.download/list_itemsof an artist link followinclude_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 reasonaborted: 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>.partfirst and are promoted withos.replaceafter 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
.partfile 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
.partis 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.partis discarded — a same-size different rendition would otherwise slip past every size check.If the remote file shrank (re-encode), the stale
.partis 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:
FLAC-in-MP4 remux — Tidal serves hi-res FLAC inside an MP4 container. With
save_as_flac=True(default) andffmpegon PATH the file is remuxed to true FLAC with-c copy(no re-encode); without ffmpeg it is kept and named.m4a.Verification — with
verify_media=True(default) andffprobeavailable, 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.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, MP3RVA2frames, MP4 Apple freeform atoms). Withwrite_lyrics=Truethe 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).