Quick start#

Installation#

uv add binary-tidal-py        # from the Forgejo registry
# or, from source:
pip install .

Requires Python 3.12+. The ffmpeg/ffprobe binaries are on PATH only if you use the remux/verification options (Post-processing).

First login (device flow)#

The most common path: an operator approves a browser login, and the token is saved to disk for the next runs.

from tidalpy import Config, TidalClient

client = TidalClient(Config(token_path="~/.tidalpy/token.json"))
if not client.auth_status()["logged_in"]:
    auth = client.start_device_login()
    print("Approve the login at:", auth.verification_url)
    client.finish_device_login(auth, timeout=300)

print(client.auth_status())
# {'logged_in': True, 'userId': '42', 'subscription': {...}, ...}

You can also adopt a token pasted from a Tidal web session:

client.login("PASTE_ACCESS_TOKEN")   # probed before persisting

Downloading an album#

from tidalpy import Config, Quality, TidalClient

client = TidalClient(Config(token_path="~/.tidalpy/token.json",
                            output="~/Music/Tidal"))
report = client.download(
    "https://tidal.com/album/155243",
    quality=Quality.LOSSLESS,
    on_progress=lambda e: print(e["stage"], e.get("bytes", "")),
)
print(report.ok, len(report.items), "items,",
      len(report.failures), "failures")
for item in report.failures:
    print(item.index, item.code, item.reason)

# ...or the whole run state as one JSON-safe dict (a status line
# for an admin UI or a log — counts, bytes, the failures' ids):
print(report.summary())

The run is resumable: kill it at any time and call download again with the same settings — finished files are skipped, interrupted files resume from their .part.

Streaming a track (no disk I/O)#

with client.stream("https://tidal.com/track/12345678") as media:
    for chunk in media.chunks():
        upload_chunk_to_s3(chunk)

# ...and a player that plays a whole collection in order without
# saving anything: iter_streams yields one stream per audio track
# (broken items skipped by default, each independently closable):
for media in client.iter_streams("https://tidal.com/album/58990510"):
    play(media)
    media.close()

# A free-tier (or pre-release) account: 'play what you can' —
# preview=True fills the preview-only slots with the 30-second
# stream (media.info.preview flags the badge) instead of
# dropping them as broken items:
for media in client.iter_streams(album_url, preview=True):
    play(media, badge=media.info.preview)
    media.close()

# ...and a search UI that plays *something* for every row:
# preview=True accepts the ~30-second preview the catalog serves
# for free-tier / pre-release / exclusive tracks (media.info.preview
# is True when you got a preview — show a badge, never a badge
# on a full track):
with client.stream(row_link, preview=True) as media:
    play_preview(media, badge=media.info.preview)

# ...and a player that wants to show the quality badge BEFORE
# pressing play: playback resolves the stream without downloading
# a byte (delivered quality, codec, rate/depth, preview flag, and
# the expiring CDN link for hosts that fetch the bytes
# themselves) — and the cached resolution feeds a subsequent
# stream() of the same track at no extra cost:
info = client.playback("https://tidal.com/track/12345678")
badge = quality_label(info["deliveredQuality"])  # "Lossless"
if info["sampleRate"]:
    badge += f" {info['sampleRate'] // 1000} kHz"

See Streaming (host-owned file I/O) and Django / Celery integration.

Artists, search, lyrics#

download accepts a full artist link too — the discography lands in per-album folders, and list_items/search cover the catalog:

# whole discography, EPs and singles included (TMD parity):
client.download("https://tidal.com/artist/64518")
# or full albums only:
client.artist_albums("https://tidal.com/artist/64518", include_ep=False)

# catalog search: a wrapper dict whose items carry kind/id/title
# (plus artist for albums and tracks) — the ids build links. For
# a real-time search UI: rows additionally carry the garnish the
# catalog has — version, duration, quality, and the artwork id
# (thumbnail via artwork_url, pure URL construction, no auth):
result = client.search("radiohead", item_type="album", limit=5)
for hit in result["items"]:
    print(hit["id"], hit["title"], hit["artist"])
    if "artwork" in hit:
        thumb = client.artwork_url(hit["artwork"], 120, 120)
# ...and the catalog's single best match for the query (its
# topHit — a "best match" banner above the results, the same
# row shape as a hit; absent when the catalog names none):
if "top_hit" in result:
    print("best match:", result["top_hit"]["title"])

# Track rows also carry pre-attempt availability badges when the
# catalog has them: a pay-to-stream row is marked *before* you
# attempt a resolve (and a non-stereo audioModes row gets a
# 5.1/ATMOS badge):
for hit in client.search("radiohead", item_type="track")["items"]:
    if hit.get("payToStream"):
        print(hit["title"], "- purchase required")

# A real-time search box calls autocomplete as the user types
# (the catalog's suggest endpoint — the same uniform rows as
# search, so one row renderer covers both):
def on_keystroke(text):
    return client.autocomplete(text)

# and a track page gets its credits tab (one {name, role} row
# per credit: Producer, Composer, Lyricist, ...):
for credit in client.track_contributors("https://tidal.com/track/58990511"):
    print(credit["role"], "-", credit["name"])

# ...and for an indexing client that wants *all* matches (not
# one page): search_all pages the catalog for you and merges the
# rows (capped, reference-fork defaults: 50/page, 200 rows):
everything = client.search_all("radiohead", item_type="album")

# ...and search_albums fixes the "artist name" case: album search
# ranks by title, so searching an artist's name hides their
# discography. When the query is a catalog artist's name,
# search_albums returns that discography first, then the
# title-matching albums (deduplicated); otherwise it falls back
# to the paged album search:
albums = client.search_albums("radiohead")

# ...and a browse UI gets the catalog's "Discover" grid: genres
# with capability flags (which tabs a genre card offers) and the
# card artwork; genre_items pages one genre's catalog — the rows
# are the same uniform search rows:
for genre in client.genres():
    if genre["has_albums"]:
        page = client.genre_items(genre["path"], item_type="albums")
        print(genre["name"], "has", page["total"], "albums")

# ...and a library UI gets the account's own playlists (the
# "My Playlists" tab — pass another user id for their public
# ones; the rows build playlist links straight away):
for row in client.user_playlists()["items"]:
    print("https://tidal.com/playlist/" + str(row["id"]), row["title"])

# and the search UI can serve the thumbnails it just rendered:
# artwork fetches the bytes (id, URL, or link), SSRF-validated,
# best-effort (None when the CDN is down):
cover = client.artwork(hit["artwork"], 120)

# an artist page gets its 'top tracks' tab from the catalog:
top = client.artist_top_tracks("https://tidal.com/artist/64518", 10)

# ...and its 'listen in a mix' tab: the catalog's editorial mix
# id builds a play.tidal.com/mc/ link that resolves like any mix:
artist_meta = client.metadata("https://tidal.com/artist/64518")
for mix_id in artist_meta["data"]["mixes"].values():
    print(client.resolve(f"https://play.tidal.com/mc/{mix_id}").title)

# ...and any page gets its track listing (album, playlist, artist
# discography, single track): Track models in catalog order, no
# stream resolved — cheap enough for a real-time UI:
for row in client.tracks("https://tidal.com/album/58990510"):
    # the models carry their own display labels ("01", "3:38"):
    print(row.number_label, row.title, row.duration_label)

# ...and for an indexer that has the ids (not the links) from
# elsewhere: tracks_by_ids resolves them in one catalog round
# trip per 50 (the v1 catalog has no bulk endpoint). Request
# order is kept; ids the catalog doesn't know are skipped, not
# fabricated — diff to find the gaps:
tracks = client.tracks_by_ids([12345678, 87654321])
missing = [i for i in (12345678, 87654321) if i not in {t.id for t in tracks}]

# albums_by_ids / artists_by_ids / playlists_by_ids are the same
# contract for the other entity types (bulk artists are also the
# only v2 artist resolution — web-app ids the v1 catalog
# doesn't know work here; playlists are the linkable UUIDs;
# bulk albums and playlists carry no cover art, the single
# fetch does):
albums = client.albums_by_ids([58990510])
artists = client.artists_by_ids([64518])
playlists = client.playlists_by_ids(["2bf56688-c693-47a2-839f-aa18bd333c24"])

# ...and for an indexer that holds ISRCs or UPCs from elsewhere
# (CDDB, a licensing feed, the back of a CD case) instead of
# Tidal ids: track_by_isrc / album_by_upc query the catalog's
# own indexes — a key the catalog doesn't know returns None
# (not an exception), so diffing a library is a plain loop:
track = client.track_by_isrc("USUM71801211")
if track:
    # the models carry their own canonical URLs (tracks, albums,
    # artists, playlists/mixes; .link on a resolved Resource too):
    print(track.title, track.artists[0].name, track.link)
album = client.album_by_upc("634904078164")

# ...and the bulk versions for a whole library of keys: the
# catalog's key filters take an OR list, so tracks_by_isrcs /
# albums_by_upcs spend one catalog call per 50 keys (request
# order kept, unknown keys skipped — diff to find the gaps):
tracks = client.tracks_by_isrcs([row.isrc for row in cd_db_rows])
albums = client.albums_by_upcs([row.upc for row in shelf_rows])

# ...and for an indexer with a list of links to import:
# parse_link_list expands a pasted blob (or a links file — a line
# that names a file expands to its contents, nested) into an
# ordered, deduplicated link list. Pre-flight, resolve_many walks
# the list with per-URL failure isolation (a dead id or a video
# link is a failed row with the machine code, not an exception):
links = tidalpy.parse_link_list(pasted_text)
preflight = client.resolve_many(links)  # one JSON-safe row each
to_download = [r["url"] for r in preflight if r["ok"]]
# ...and download_many gives one report per URL (in order), with
# the same isolation contract:
reports = client.download_many(to_download)

# lyrics: plain text + timed (LRC) subtitles, each "" when absent
# (never an error):
data = client.lyrics("https://tidal.com/track/68883384")
text = data["lyrics"] or data["subtitles"]

With Config(write_lyrics=True) downloads embed the lyrics into the tags, and Config(lyric_file=True) additionally writes them next to the media file (.lrc when timed, else .txt); the tags always carry the composer credit, ISRC and copyright (see Post-processing).

Logging#

The library logs through logging.getLogger("tidalpy.<module>") and never prints. Configure handlers yourself:

import logging
logging.basicConfig(level=logging.INFO)
logging.getLogger("tidalpy").setLevel(logging.DEBUG)