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(defaultTrue) — 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, MP3RVA2frames, MP4 Apple freeform atoms), cover art.write_lyrics(defaultFalse) — 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(defaultFalse) — additionally write the lyrics next to the media file:.lrcwhen timed subtitles exist, else.txt(reference-clientlyricFileparity). Shares thewrite_lyricsfetch, 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(defaultTrue) — attach album art.save_as_flac(defaultTrue) — remux FLAC-in-MP4 to true FLAC (needsffmpeg).verify_media(defaultTrue) — ffprobe the finished file and compare it against the manifest.save_receipts(defaultFalse) — 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.