Configuration#

Config is the only configuration object. It is constructed once and injected into a client — there is no global settings module, no environment read on import, no singletons.

from tidalpy import Config

config = Config(
    token_path="~/.tidalpy/token.json",
    output="~/Music/Tidal",
    quality="LOSSLESS",
    download_workers=8,
)

Config is a pydantic model: every field is validated at construction (constraints like download_workers >= 1 and proxy URL checks fail fast with ConfigError), the instance is immutable (with_updates returns a re-validated copy), and quality accepts the usual labels and aliases ("LOSSLESS", "hifi", "master", "24/192", …). Path values accept ~ — it is expanded wherever the library resolves them (token_file, output_dir, and per-call dest=).

From a dict (JSON/TOML config files)#

import json
config = Config.from_dict(json.load(open("tidal.json")))

Keys may be the snake_case field names or the legacy camelCase names (tokenPath, outputDir, albumFormat, …). Unknown keys raise ConfigError — a typo in a config file fails loudly instead of silently using a default.

config.as_dict() serialises back (snake_case), and config.with_updates(output=...) returns a copy with overrides — handy for per-task settings in Celery.

From the environment#

config_from_env() reads TIDALPY_* variables as overrides on top of defaults (useful in containers). Every configurable field has a variable — e.g. TIDALPY_TOKEN_PATH, TIDALPY_OUTPUT, TIDALPY_QUALITY, TIDALPY_PROXY, TIDALPY_WRITE_LYRICS, TIDALPY_INCLUDE_EP, TIDALPY_DOWNLOAD_WORKERS, TIDALPY_PART_SIZE, TIDALPY_TIMEOUT_CONNECT, TIDALPY_TIMEOUT_READ — the full table is _ENV_OVERRIDES in tidalpy.config. Unset or empty variables keep the default; an unparseable one fails fast with a ConfigError (a typo in TIDALPY_WRITE_TAGS is a deploy bug, not a silent False).

OAuth client (client_id)#

Tidal’s public device login is issued by client credentials (platform personas: “Tidal HiRes”, “Tidal TV”, “Android Auto”, …). The registry — list_clients(), seeded from the reference clients’ key lists — carries the public id/secret pairs, each client’s advertised quality support, its user-agent, and a provenance note. client_id selects the persona a client presents; None means DEFAULT_CLIENT_ID (“Tidekeeper OAuth” — full quality range, the reference default).

Helpers for hosts that need to pick:

from tidalpy import list_clients, get_client, clients_for

list_clients()          # every registered OAuthClient
get_client("4N3n6Q1x95LL5K7p")   # by id (ConfigError if unknown)
clients_for("24/192")    # clients that can serve that quality,
                         # best candidate (the default) first

clients_for accepts the same quality labels as the rest of the library and is how a login should target the right client from the start when the quality is known up front. Registry entries carry stable ids: they are never renumbered, because a saved login is bound to the client that minted its token — switching client_id deliberately logs the host out of the saved session rather than presenting a foreign client.

When the catalog answers 404/subStatus 4022 (the issuing client was retired or the token is stale for that client), the transport first attempts one refresh with the active client and retries; only a failed refresh — or a second 4022 after a successful one — clears the saved session. The same subStatus on a playback endpoint is a playback-client rejection and never touches the login.

auth_status() reports the persona: the "client" field is the registry entry’s platform ("Tidekeeper OAuth", “Tidal TV”, …), "clientId" the id that minted the saved token.

HTTP proxies and TLS verification#

proxies takes a plain URL applied to both schemes, or the requests-style mapping:

Config(proxies="http://10.0.0.5:3128")
Config(proxies={"http": "http://10.0.0.5:3128",
                "https": "http://10.0.0.5:3129"})

Proxy URLs are validated at construction. http(s) always works; socks4(a)/socks5(h) URLs are accepted when the optional ``PySocks`` dependency is installed (uv add 'binary-tidal-py[socks]') and rejected with a clear ConfigError pointing at the extra when it is not — a socks URL without PySocks would otherwise fail mid-download with an opaque urllib3 error. Credentials in the URL (http://user:pass@host:port) are passed through to requests. Proxies apply to API and media traffic.

Deliberately, the client honours no implicit environment state: HTTP_PROXY/HTTPS_PROXY/no_proxy environment variables, ~/.netrc credentials, and REQUESTS_CA_BUNDLE are not read (trust_env is off). In a Django/Celery deployment a stray env var or a developer’s .netrc entry must not leak credentials into Tidal or CDN traffic — pass proxies (and any custom CA bundle via verify_tls) explicitly in the Config.

verify_tls overrides TLS verification for the whole client (API and media): False disables it, a path to a CA bundle enables a custom trust store — the usual escape hatch for corporate MITM proxies. None (default) keeps requests’ default (certifi).

Base-URL overrides (replay proxies and test servers)#

api_base, legacy_api_base, openapi_base and auth_base point the four endpoint families at alternate hosts. Production code leaves them None; they exist for replay proxies, VPN-less test environments, and the offline e2e suite. When a host owns the media URLs itself, pair them with validate_media_urls=False (the SSRF guard is on by default and production should keep it on).

Naming templates#

File and folder names are format strings:

  • track_format (default "{number} {artist} - {title}{explicit}")

  • album_format (default "{artist} - {album} {year}")

  • playlist_format (default "Playlist - {title}")

Track-file placeholders: {number} (zero-padded track number), {artist} (primary artist), {artists} (full credit list), {title}, {explicit} (`` [Explicit]`` or empty), {album}, {year}, {quality} and {id} (the Tidal track id). Unsafe path characters are sanitised. For playlist and mix downloads {number} is the track’s position within the collection (dense, video entries never enter the download — reference-client parity); for albums and single tracks it is the track’s own album number. Multi-disc albums are laid out in CD1/CD2/… subfolders automatically (TMD parity) — the volume is never part of the file name.

{quality} is the delivered quality as a short label (Max, HiFi, High, Low), falling back to the catalog’s quality when no stream is known — a silent downgrade is visible in the file name, e.g. track_format = "{number} {title} [{quality}]".

{flag} is the reference client’s quality/explicit marker: it substitutes [M] `` (hi-res catalog), ``[E] `` (explicit), ``[ME] `` (both) or nothing, e.g. ``album_format = "{artist} - {flag}{album} {year}" gives Band - [ME] Great Album 2024. It is opt-in (the default template has no flag) and, like in the reference client, only applies to album folders.

Collection-folder placeholders: albums additionally support {id} (the Tidal album id), {quality} (the catalog’s quality label — what the album offers, in contrast to the track file’s delivered label) and {type} (ALBUM/EP/SINGLE — the reference client’s {RecordType}); playlists support {id} (the UUID) and {type} (playlist/mix). All opt-in.

Defaults vs. the reference client#

The defaults above are deliberately tidier than the reference client’s raw defaults (which embed the album id and the {Flag} marker in the default folder name, and leave a leading space when the flag is empty). To reproduce the reference client’s layout exactly:

  • track_format = "{number} - {artist} - {title}{explicit}"

  • album_format = "{artist}/{flag}{album} [{id}] [{year}]"

  • playlist_format = "Playlist/{title} [{id}]"

Quality selection differs in one place: for tracks whose catalog quality is unknown (playlist tracks are not album-bound), the reference client conservatively requests LOSSLESS and therefore never delivers hi-res for hi-res playlist tracks. tidalpy requests HI_RES_LOSSLESS instead and lets the downgrade chain (and the v2-first FLAC manifest) settle the answer — a silent downgrade that the reference client performs is a loud delivery here.

include_ep (default True) scopes artist downloads and listings: True adds the EPs-and-singles listing to the discography (the reference client’s effective default), False restricts to full albums. Per-call overrides on client.artist_albums win over the config.

The reference client has three side-effect file outputs tidalpy deliberately does not reproduce by default: a separate cover image file (saveCovers, on by default there), a separate .lrc lyrics file (lyricFile), and an album-info file (saveAlbumInfo). The library’s equivalents are first-class data: covers and lyrics are embedded in the tags (download_cover / write_lyrics), and album info is a structured result (client.metadata(url)). The lyrics sidecar is available opt-in as lyric_file (below) — some players and DJ tools read it — but covers and album info stay data-only: the host owns where non-audio bytes go, and the download tree only ever contains audio (and .part files), which keeps the resume/skip-existing contract simple.

The reference client also writes the delivered quality into a comment tag (e.g. "Delivered at LOSSLESS"). tidalpy keeps that fact out of the tags: the delivered quality is the {quality} placeholder in the file name and ItemReport.delivered_quality in the report, both machine-readable.

Post-processing switches#

All off-able, all best-effort (see Post-processing):

  • write_tags (default True) — ID3/MP4/FLAC tags: title (with version), artists, album, date, track/disc numbers, composer credit (from Tidal’s contributor data), ISRC, copyright, ReplayGain (FLAC tags, MP3 RVA2 frames, MP4 Apple freeform atoms), cover art.

  • write_lyrics (default False) — additionally fetch the lyrics per track (one extra API call per item) and embed them in the tags (plain text preferred, timed LRC when that is all Tidal has).

  • lyric_file (default False) — additionally write the lyrics next to the media file: .lrc when timed subtitles exist, else .txt (reference-client lyricFile parity). Shares the write_lyrics fetch, so enabling both costs no extra call. Written on the download path only — a skipped (already complete) file gets no sidecar, keeping skip-existing request-free — and best-effort: a write failure never fails the item.

  • download_cover (default True) — attach album art.

  • save_as_flac (default True) — remux FLAC-in-MP4 to true FLAC (needs ffmpeg).

  • verify_media (default True) — ffprobe the finished file and compare it against the manifest.

  • save_receipts (default False) — write a completion receipt (<final>.tidalpy.json) next to each finished file and make the skip-existing checks consult it: a file is skipped only when its receipt proves it is this track, intact (size + mtime + a sampled content fingerprint), fully post-processed, and at least the requested quality. Off by default — the documented skip contract (a non-empty file at the final path is complete) stays the behaviour. Turn it on when you re-run collections at a higher quality ceiling (the plain contract would keep the older, lower-quality file forever) or run long, crash-prone transfers (a file promoted but crashed before tagging is then re-finished, not skipped). Receiving a file from a receipts-off run (no receipt) costs one re-download to establish the receipt, then it skips.