Architecture#

How the pieces fit together, and what each module owns.

TidalClient                     the facade: one per operation set
  |-- Config                    all settings, injected (no globals)
  |-- Transport                 HTTP: retries, 429/Retry-After,
  |                             rate-limit waits, SSRF media guard
  |-- OAuth                     device flow / pasted token / refresh
  |   \-- TokenStore            where the session lives (file, DB)
  |-- TidalApi                  catalog v1+v2, search, resolve,
  |                             OpenAPI stream ladder, quality chain
  |-- Pipeline                  resolve -> fetch -> post-process,
  |   |-- Downloader            per-item isolation, .part resume,
  |   |                         parallel ranges, atomic promotion
  |   |-- media / tags          remux, ffprobe verify, mutagen tags
  |   \-- naming                safe, template-driven file paths
  \-- MediaStream               plaintext bytes to the host (stream)

Module reference#

HTTP transport with Tidal’s request policy.

One Transport per client instance — no shared sessions, no globals. The policy implemented here (and only here) is what Tidal’s APIs actually demand, learned from the reference clients:

  • catalog/auth calls send a bare Authorization: Bearer header (extra device fingerprint headers are treated as suspect);

  • HTTP 429 honours Retry-After (delta-seconds and HTTP-date) and never consumes the transient-retry budget; the total time spent waiting is bounded per logical request (short waits by Config.rate_limit_max_wait, long server-requested pauses by Config.rate_limit_max_pause), then RateLimitError. A 429 is an account-level signal, so it also sets a client-wide pause: every catalog request on this transport waits out the server-requested cooldown instead of firing straight into its own 429 (reactive — a healthy run never pauses);

  • HTTP 401 with subStatus 4005 means “asset not ready” (bounded backoff); any other 401 triggers exactly one token refresh;

  • HTTP 404 subStatus 4022 means the issuing OAuth client was retired (catalog/auth endpoints) — the saved session is cleared and the host must log in again; on playback endpoints the same subStatus is a playback-client rejection (this client/account/quality may be unavailable for playback) — the session is kept and the error stays actionable;

  • HTTP 403 with a “not available in your country” message is a region lock (distinct, non-retryable error);

  • 5xx responses (gateway 502/503 during Tidal deploys) are retried within the transient-retry budget, with half-to-full jitter.

Media URLs handed to us by remote manifests are additionally validated (validate_media_url()) against SSRF: no credentials, no local hosts, no private address space.

class tidalpy.transport.AuthProvider(*args, **kwargs)[source]#

Bases: Protocol

What the transport needs from the OAuth layer (implemented by tidalpy.oauth.OAuth).

__init__(*args, **kwargs)#
access_token()[source]#
Return type:

str | None

clear_saved_session()[source]#
Return type:

None

country_code()[source]#
Return type:

str | None

handle_401()[source]#

Attempt a one-shot token refresh; True when the token changed.

Return type:

bool

user_agent()[source]#
Return type:

str

class tidalpy.transport.Transport(config, stop_event=None, clock=None, sleeper=None, media_resolver=None)[source]#

Bases: object

A private HTTP session with the Tidal request policy applied.

Parameters:
__init__(config, stop_event=None, clock=None, sleeper=None, media_resolver=None)[source]#
Parameters:
Return type:

None

bind_auth(auth)[source]#
Parameters:

auth (AuthProvider)

Return type:

None

close()[source]#
Return type:

None

fetch(url, headers=None)[source]#

Open a media URL (already SSRF-validated by the caller).

Connect-phase failures (DNS blip, dropped idle connection, CDN reset before the first byte) are retried with backoff — an exception from get(stream=True) means no body was consumed, so a retry cannot corrupt a partial write. Mid-stream failures surface later in iter_content and are the caller’s policy.

Parameters:
Return type:

Response

get_bytes(url, headers=None)[source]#

Download a small binary (cover art) in full.

Redirects are followed manually (requests runs with allow_redirects=False) and every hop is re-validated: the Location header is remote-supplied input, and following it without the SSRF guard would let a public image URL redirect to an internal host (reference-fork pin: validated redirects).

Parameters:
Return type:

bytes

get_json(path, params=None, base=None, accept=None)[source]#

GET a JSON API endpoint with the full Tidal policy applied.

base defaults to the v1 API base (Config-overridable).

Parameters:
Return type:

dict

post_json(path, data, base=None)[source]#

POST form data to the OAuth endpoint (bounded attempts).

Parameters:
Return type:

dict

probe_size(url)[source]#

Remote size of a media URL, or -1 when it cannot be determined.

A failed probe must never be interpreted as “file changed” — callers fall back to trusting the local file (legacy bug fix: one dead HEAD used to re-download whole playlists). The probe requests identity encoding and distrusts encoded lengths (reference-fork parity: a compressed length would mis-size the file).

Parameters:

url (str)

Return type:

int

revoke_session(access_token)[source]#

Best-effort remote session revocation (Tidal’s /v1/logout).

Never raises — a failed revocation reports False (and logs); the caller still forgets the token locally. 401 counts as success: the token no longer works, which is the goal.

Parameters:

access_token (str)

Return type:

bool

set_stop_event(stop_event)[source]#

Bind the cancellation event for the in-flight operation.

One operation at a time per transport (i.e. per client); the client rebinds this at the start of every download/stream.

Parameters:

stop_event (Event)

Return type:

None

property api_base: str#
property auth_base: str#
property legacy_api_base: str#
property openapi_base: str#
property stop_event: Event#
tidalpy.transport.response_error_codes(body)[source]#

Machine codes a Tidal error body carries (or []).

JSON:API-style errors[].code entries plus a top-level code. Callers use these to tell an account-capability rejection (e.g. CLIENT_NOT_ENTITLED — true for every track) from a track-specific block (must not taint other tracks).

Parameters:

body (Any)

Return type:

list[str]

tidalpy.transport.response_facts(body)[source]#

(status, subStatus, userMessage) from a Tidal error envelope.

Parameters:

body (Any)

Return type:

tuple[Any, Any, str | None]

tidalpy.transport.retry_after_seconds(response)[source]#

Wait requested by a Retry-After header, in seconds (or None).

Supports both the delta-seconds and HTTP-date forms.

Parameters:

response (Response | None)

Return type:

float | None

tidalpy.transport.validate_media_url(url, resolver=None)[source]#

Reject media URLs that are malformed, credentialed, or internal.

Manifest URLs are remote-supplied; this is the last line of defence before we open a socket to them. Raises DownloadError when the URL is unsafe.

Parameters:
Return type:

str

OAuth2 device flow + token lifecycle.

Flow for a headless server (the Django case):

auth = client.start_device_login()
# operator opens auth.verification_url, enters auth.user_code
session = client.finish_device_login(auth, timeout=300)

The library never touches a browser: the host displays the user code (terminal, admin screen, e-mail) and polls. Polling honours the slow_down instruction (interval grows) and the overall deadline.

Saved sessions stay bound to the OAuth client that issued them: Tidal only honours a Bearer token for its own client, so switching clients silently invalidates the login. OAuth enforces that binding at load time instead of failing on every request with opaque 401s.

class tidalpy.oauth.DeviceAuth(user_code, verification_uri, device_code, interval, expires_in)[source]#

Bases: object

A pending device-flow login; show verification_url to a human.

Parameters:
  • user_code (str)

  • verification_uri (str)

  • device_code (str)

  • interval (int)

  • expires_in (int)

__init__(user_code, verification_uri, device_code, interval, expires_in)#
Parameters:
  • user_code (str)

  • verification_uri (str)

  • device_code (str)

  • interval (int)

  • expires_in (int)

Return type:

None

device_code: str#
expires_in: int#
interval: int#
user_code: str#
verification_uri: str#
property verification_url: str#
class tidalpy.oauth.OAuth(config, transport, store, clock=None, sleeper=None)[source]#

Bases: object

Token lifecycle: load, verify, refresh, device flow, re-login.

Parameters:
  • config (Config)

  • transport (transport_mod.Transport)

  • store (TokenStore)

  • clock (Callable[[], float] | None)

  • sleeper (Callable[[float], None] | None)

__init__(config, transport, store, clock=None, sleeper=None)[source]#
Parameters:
Return type:

None

access_token()[source]#
Return type:

str | None

clear_saved_session()[source]#
Return type:

None

country_code()[source]#
Return type:

str | None

ensure_logged_in()[source]#

A usable session, or AuthError.

Order: fresh token -> expired token + refresh -> no refresh token -> AuthError.

Return type:

TokenRecord

finish_device_login(auth, timeout=None, user_id=None)[source]#

Poll the token endpoint until the operator approves (or not).

Parameters:
Return type:

TokenRecord

handle_401()[source]#

One-shot refresh; True when a new access token is in place.

Return type:

bool

login_with_token(access_token, user_id=None)[source]#

Adopt a token the operator pasted in (browser session / other app).

Parameters:
  • access_token (str)

  • user_id (int | str | None)

Return type:

TokenRecord

logout()[source]#

End the session: best-effort remote revocation, then forget the saved session locally.

Returns whether the remote revocation succeeded (False when there was no token to revoke or the request failed — the local session is cleared either way; a token Tidal no longer accepts is already as good as revoked).

Return type:

bool

refresh()[source]#

Refresh the access token using the stored refresh token.

Return type:

bool

start_device_login()[source]#

Begin the device flow; returns the code to show to a human.

Return type:

DeviceAuth

status()[source]#

auth_status() payload for hosts.

Return type:

dict

user_agent()[source]#
Return type:

str

verify()[source]#

Network check that the current access token works.

Return type:

bool

property record: TokenRecord | None#
tidalpy.oauth.decode_jwt_payload(access_token)[source]#

Best-effort JWT payload decode (no signature check).

Parameters:

access_token (str | None)

Return type:

dict

Catalog and playback API (v1 gust + v2 OpenAPI fallback).

Tidal runs two catalog APIs in parallel:

  • v1 (api.tidal.com/v1) resolves the legacy entity ids and answers playback info;

  • v2 (openapi.tidal.com/v2, JSON:API) resolves both legacy and the new entity ids used by the current web app.

So a fetch goes v1 first and, on 404, retries v2 (top-level entities only — sub-resources like playback info accept new ids directly). v2 has a hard 20-item cap on album/playlist items with no working pagination; when the declared item count exceeds what came back we raise instead of silently enumerating a truncated collection.

class tidalpy.api.TidalApi(config, oauth, transport)[source]#

Bases: object

Stateless catalog/playback access on top of Transport.

Parameters:
__init__(config, oauth, transport)[source]#
Parameters:
Return type:

None

autocomplete(query, limit=10, country=None)[source]#

Catalog autocomplete (the web app’s search-box suggest).

The keystroke endpoint for real-time search: search/ autocomplete answers as the user types, across artists, albums, tracks, and playlists, with a small result budget. Each row is the uniform search row (kind/id/ title plus the catalog garnish when present), so a suggest box and a search result render with the same row code.

Live API notes (probed against Tidal, 2026): the response rows carry a type discriminator ("ARTIST", "ALBUM", "TRACK", "PLAYLIST") plus the full v1 object under item; countryCode is optional (an account in an unknown market gets an empty result, not an error), so the session’s country is passed by default, like search().

Raises TidalError on transport failures; a query with no matches returns [] (not an error).

Parameters:
  • query (str)

  • limit (int)

  • country (str | None)

Return type:

list[dict[str, Any]]

get_album(album_id, with_items=True)[source]#
Parameters:
Return type:

tuple[Album, list[Track | None]]

get_album_by_upc(upc)[source]#

Resolve an album by its UPC (live API verified, v2 only): albums?filter[barcodeId]= is the catalog’s barcode index — one call, the matching album’s full row.

The album counterpart of get_track_by_isrc() for hosts that hold UPCs from elsewhere (the back of a CD case, a licensing feed) instead of Tidal ids: the v1 catalog cannot answer this (live verified: a UPC typed into search returns nothing), so the call goes straight to v2, where the UPC is the barcodeId attribute — the same value the v1 album’s upc carries.

A lookup, not a fetch: the barcode the catalog does not know returns None (the live contract: 200 with an empty row list; a defensive catch of the unexpected 404 degrades the same way), so a host diffing a library of barcodes pays no exception per miss. Blank input returns None without a request. The input is stripped and otherwise passed through (UPCs run 12-14 digits, no reformatting); a malformed value is the catalog’s to reject (TidalApiError), which propagates.

The match is exact on the stored barcode (live verified: a prefix matches nothing), but placeholder barcodes are shared by real catalog entries (a barcode of zeros and the classic 1234567890128 each match several junk albums, live verified) — when the catalog lists more than one, the first is returned; a host that cares diffs the title.

Parameters:

upc (str)

Return type:

Album | None

get_albums_by_ids(album_ids)[source]#

Bulk album fetch (live API verified, v2 only): N ids in, one catalog call per 50.

The album counterpart of get_tracks_by_ids() (an indexer importing a discography from ids). Same contract: request order, duplicates collapse, unknown ids skipped not fabricated. v2-only for the same reason (the v1 catalog has no bulk endpoint).

The v2 bulk rows carry no coverArt relationship (live verified: the single-album resource does), so bulk albums have no artwork — a host that needs the cover for a card resolves that album singly, or uses the row artwork of the search/browse that surfaced the id.

Parameters:

album_ids (Sequence[int | str])

Return type:

list[Album]

get_albums_by_upcs(upcs)[source]#

Bulk UPC lookup (live API verified, v2 only): N UPCs in, one catalog call per 50 — filter[barcodeId] accepts a comma-separated OR list.

The bulk sibling of get_album_by_upc() for hosts that hold a library of barcodes (the back of a shelf of CD cases, a licensing feed): N keys in ceil(N/50) rate-limited calls instead of N single lookups. Same contract as get_albums_by_ids(): request order, duplicates collapse, barcodes the catalog does not know are skipped, not fabricated — diff the result against the request to find the gaps.

Each UPC is stripped and otherwise passed through (no reformatting); blank entries are skipped. Rows are matched back to the request by their stored barcodeId — when the catalog’s matching normalises a placeholder barcode differently than the stored value (live verified: twelve zeros also answer with rows whose barcodeId carries thirteen), such a row cannot be attributed to a key and is dropped, the same as a miss.

Parameters:

upcs (Sequence[str])

Return type:

list[Album]

get_artist(artist_id)[source]#

Artist metadata (v1).

Parameters:

artist_id (int | str)

Return type:

Artist

get_artist_albums(artist_id, include_ep=False)[source]#

The artist’s discography (full albums; EPs/singles opt-in).

Mirrors the reference client: the normal album listing plus, when include_ep is set, a second listing with the EPSANDSINGLES filter.

Parameters:
Return type:

list[Album]

get_artist_top_tracks(artist_id, limit=20)[source]#

The artist’s top tracks, ranked by the catalog (v1 artists/{id}/toptracks; live API verified: paged, full v1 track payloads, none of the reference clients expose it).

A single ranked page: a real-time “top tracks” tab wants the top N, and the endpoint already orders them — the host pages with repeated calls if it needs more.

Parameters:
Return type:

list[Track]

get_artists_by_ids(artist_ids)[source]#

Bulk artist fetch (live API verified, v2 only): N ids in, one catalog call per 50.

The artist counterpart of get_tracks_by_ids() — also the only v2 artist resolution path (the single artist fetch is v1), so web-app ids that the v1 catalog does not know are resolvable here. Same contract: request order, duplicates collapse, unknown ids skipped not fabricated.

The v2 bulk rows carry no picture/handle (live verified: the attributes block is name/popularity/numberOfFollowers), so those stay None on bulk artists.

Parameters:

artist_ids (Sequence[int | str])

Return type:

list[Artist]

get_genre_items(genre, item_type='tracks', limit=25, offset=0)[source]#

Browse one genre’s catalog items ((rows, total)).

genre is the catalog’s genre path (from get_genres()); item_type one of "tracks", "albums", "playlists", "videos" — the plural endpoint segment, which also matches the genre row’s has_<item_type> flag (browse what the card offers). The items are full v1 objects and come back as the uniform search rows (_search_row()): a browse tab and a search result render with the same row code, and the rows’ id/kind build links straight away.

Raises ConfigError for an unknown item_type, NotFoundError for an unknown genre.

Parameters:
Return type:

tuple[list[dict[str, Any]], int]

get_genres()[source]#

The catalog’s browse genres (live API verified: 20 fixed genres — the web app’s “Discover” grid).

Each row is {"name", "path", "has_playlists", "has_artists", "has_albums", "has_tracks", "has_videos"} plus artwork (the card thumbnail id, consumable by artwork_url()) when the catalog names one. path is the catalog’s own URL segment for the genre (equal to name on every probed genre) — pass it back to get_genre_items(). The has_* flags tell a browse UI which tabs a genre card offers (no genre currently has artists; one has no playlists).

Return type:

list[dict[str, Any]]

get_items(resource)[source]#

The tracks of a resolved collection (cached per instance).

Albums/playlists/mixes return their own tracks; an artist returns the tracks of every album in the discography, each with its album back-reference set (the pipeline needs it for the per-album destination folders).

Parameters:

resource (Resource)

Return type:

list[Track | None]

get_lyrics(track_id, track=None)[source]#

The track’s lyrics: {"lyrics", "subtitles"}, each "" when Tidal has none.

lyrics is the plain text, subtitles the timed (LRC) version. Lyrics are garnish: the provider service rejects tracks without lyrics (a 400, not a 404) and any other rejection — rate limit, stale session — degrades to the empty strings instead of an error (a cancellation is not a rejection: it propagates).

When track is given (a Track with title/artists) and Tidal has no timed subtitles for the track itself, a fallback searches the catalog for matching track versions (remaster, live, radio edit — same title and artists, scored by ISRC/duration/album) and adopts the first candidate’s timed lyrics, keeping the primary plain text (reference-fork parity: the plain text of a version can drift, the timing is what a display client needs). The fallback only runs when it can be scored — a title-less track_id call costs no extra requests.

Parameters:
Return type:

dict[str, str]

get_playlist(playlist_id, is_mix=False)[source]#
Parameters:
Return type:

tuple[Playlist, list[Track | None]]

get_playlists_by_ids(playlist_ids)[source]#

Bulk playlist fetch (live API verified, v2 only): N playlist UUIDs in, one catalog call per 50.

The playlist counterpart of get_tracks_by_ids() — the ids are the linkable playlist UUIDs (the v2 playlist resource is uuid-keyed; editorial mixes are a v1 concept and not reachable here). Same contract: request order, duplicates collapse, unknown UUIDs skipped not fabricated — diff the result against the request to find the gaps.

The rows carry the playlist card facts (the v2 title key is name, the duration is ISO-8601, the track count is numberOfTrackItems) but no artwork: resolving the row’s v2 artwork id costs one extra catalog call per row, which would defeat the bulk fetch — a host that needs a cover resolves that playlist singly (the same contract as bulk albums).

Parameters:

playlist_ids (Sequence[int | str])

Return type:

list[Playlist]

get_track(track_id)[source]#
Parameters:

track_id (int | str)

Return type:

Track

get_track_by_isrc(isrc)[source]#

Resolve a track by its ISRC (live API verified, v2 only): tracks?filter[isrc]= is the catalog’s ISRC index — one call, the matching track’s full row.

The indexer surface for hosts that hold ISRCs from elsewhere (CDDB, a licensing feed, another catalog’s export) instead of Tidal ids: the v1 catalog cannot answer this (live verified: an ISRC typed into search returns nothing), so the call goes straight to v2.

A lookup, not a fetch: the ISRC the catalog does not know returns None (the live contract: 200 with an empty row list, never a 404 — a defensive catch of the unexpected 404 degrades the same way), so a host diffing a library of ISRCs pays no exception per miss. Blank input returns None without a request. The input is normalised before the call (whitespace removed, upper-cased — the catalog stores ISRCs upper-case and rejects any other case with a 400, live verified); a malformed ISRC is the catalog’s to reject (TidalApiError), which propagates.

When the catalog lists more than one track for the ISRC (the same recording on several releases), the first is returned.

Parameters:

isrc (str)

Return type:

Track | None

get_track_contributors(track_id)[source]#

The track’s credited contributors, or [] when unavailable.

Garnish for tagging (TMD parity: the Composer credit lands in the audio tags): any failure degrades to an empty list. Each item is a {"name": str, "role": str} dict.

Parameters:

track_id (int | str)

Return type:

list[dict[str, Any]]

get_tracks_by_ids(track_ids)[source]#

Bulk track fetch (live API verified, v2 only): N ids in, one catalog call per 50 — the indexer surface.

A sync job that has track ids from elsewhere (a playlist export, a library audit, a web-app deep link) resolves them in one round trip per page, instead of N single-track fetches (each of which starts as a rate-limited v1 attempt). The v1 catalog has no bulk endpoint (live 404), so this goes straight to v2 — which knows both the legacy numeric ids and the new web-app ids.

Result order is the request order (duplicates collapse to the first occurrence); ids the catalog does not know are skipped, not fabricated, so a host diffs the result against the request to find the gaps. No per-track position follow-up (the single-track fetch does that one; the bulk fetch stays one call per page).

Parameters:

track_ids (Sequence[int | str])

Return type:

list[Track]

get_tracks_by_isrcs(isrcs)[source]#

Bulk ISRC lookup (live API verified, v2 only): N ISRCs in, one catalog call per 50 — filter[isrc] accepts a comma-separated OR list (live verified with two real ISRCs).

The bulk sibling of get_track_by_isrc() for hosts that hold a library of ISRCs (a CDDB export, a licensing feed): N keys in ceil(N/50) rate-limited calls instead of N single lookups. Same contract as get_tracks_by_ids(): request order, duplicates collapse, keys the catalog does not know are skipped, not fabricated — diff the result against the request to find the gaps.

Each ISRC is normalised as in get_track_by_isrc() (whitespace removed, upper-cased); blank entries are skipped. When the catalog lists more than one track for an ISRC (the same recording on several releases), the first is returned for that key.

Parameters:

isrcs (Sequence[str])

Return type:

list[Track]

get_user_playlists(user_id=None, limit=25, offset=0)[source]#

A user’s playlist links ((rows, total)) — the “My Playlists” tab (reference parity: TMD’s getUserPlaylists).

user_id defaults to the logged-in session’s user (the account’s own playlists); pass another user id for that user’s public playlists. Each row is the uniform search row for a playlist (kind/id/title — the id is the linkable uuid), so a library UI renders it with the same row code as search/browse and builds https://tidal.com/playlist/<uuid> links straight away.

Live API verified (2026): the endpoint is the plural users/{id}/playlists (the singular user/... family is gone — 404); the response is the standard items envelope.

Parameters:
Return type:

tuple[list[dict[str, Any]], int]

resolve(url)[source]#

Resolve any Tidal link (or bare id) to a catalog resource.

Parameters:

url (str)

Return type:

Resource

resolve_stream(track_id, profile, track=None, allow_preview=False)[source]#

Resolve a playable stream for track_id at profile.

Strategy (order matters, it encodes Tidal’s real behaviour):

  1. lossless profiles try the OpenAPI v2 FLAC manifest first (full fidelity, no legacy caps);

  2. then the v1 playback ladder (per-track attempts);

  3. if nothing meets the profile but something playable was resolved, the best available stream is returned and the host reports the downgrade — silent quality loss, loud reporting.

Successful resolutions are cached briefly per (account, track, profile, allow_preview) — a duplicate track in a playlist, or a host that previews the same track twice, must not spend a second rate-limited playback request. Failures are never cached (a transient miss may clear).

allow_preview=True accepts the ~30-second preview the catalog serves instead of the full track (free-tier accounts, pre-release/exclusive tracks): the returned StreamInfo has preview=True set, and a preview answer short-circuits the ladder (every other rung would return the same preview). Full streams are still preferred: a track that has one gets it, preview or not.

Raises PreviewOnlyError (when allow_preview is false) / QualityUnavailableError when nothing acceptable can be delivered.

Parameters:
Return type:

StreamInfo

search(query, types=('TRACK',), limit=20, offset=0, country=None)[source]#

Catalog search; returns (items, total, top_hit).

top_hit is the catalog’s single best match for the query (the topHit field, live API verified: a full v1 object of its named group) built as the same uniform search row — or None when the catalog doesn’t name one. The reference clients ignore the field; a real-time search UI renders it as the “best match” banner above the results.

Each item is the reference client’s search row shape ({"kind", "id", "title"} plus artist where sensible) — the same rows its search RPC/CLI emit, which the host contract reads: lowercase kind (https://tidal.com/ <kind>/<id> links build from it), playlist ids are the uuid, artist titles normalise from the name field. Rows additionally carry the catalog’s garnish when present (for result-rendering clients): version (track/album editions), duration in seconds (track/video length, album total), quality (track/album, the raw API value — map it with tidalpy.quality.quality_label() for display). total is the sum of the catalog’s per-group totalNumberOfItems (may exceed len(items)).

Live API notes (probed against Tidal, 2026):

  • types must be the plural names (TRACKS, ALBUMS, …); singular names are rejected;

  • countryCode is mandatory (400/subStatus 1002 without it) and is taken from the logged-in session;

  • the response is grouped (albums.items, tracks.items, …), not a flat items array — items are flattened here.

Parameters:
Return type:

tuple[list[dict], int, dict | None]

subscription()[source]#
Return type:

dict

tidalpy.api.artwork_url(artwork_id, width=1280, height=1280)[source]#

The static-CDN URL for a five-segment artwork id.

The ids carried by search rows (artwork), Album.artwork_id and Artist pictures are the legacy image ids: five dash- separated segments. The image path is the same id with the dashes turned into slashes — the exact pattern the reference tool uses (tagHelper). Pure URL construction: no network, no auth, safe for any client to render thumbnails from search rows.

Raises ConfigError for ids that are not five-segment image ids (a paste mistake or a v2 artwork uuid that needs the artworks/{id} lookup first).

Parameters:
Return type:

str

tidalpy.api.parse_tidal_url(url)[source]#

(kind, id) from a Tidal link; (None, url) when it is not a Tidal link (bare ids, foreign links).

Host matching is exact — tidal.com, www., listen. — http(s) only, no embedded credentials, ports 80/443 only, any locale prefix and nesting, query strings and fragments ignored. A link to not-tidal.com therefore never resolves to a Tidal resource (reference-fork parity). Identifiers are validated per kind before any network call: numeric for track/album/video/ artist, opaque (UUID-ish) for playlist/mix. Nested paths resolve to the innermost known kind. play.tidal.com/mc/<id> yields a mix.

Parameters:

url (str)

Return type:

tuple[str | None, str | None]

Download pipeline: resolve -> fetch -> post-process -> report.

The pipeline is the only place that turns a resolved Resource into a DownloadReport. It implements the two contracts that make a collection download safe in a shared environment:

  • per-item isolation: every track is resolved, downloaded and post-processed inside its own try/except; one bad item cannot kill the run. skip_broken=False stops after the first unrecoverable item instead of continuing;

  • resume & skip-existing: streams land in <path>.part and are promoted atomically, so a non-empty file at the final path is complete and skipped (the downloader owns the byte-level details).

Post-processing per item, in order: FLAC-in-MP4 remux (when configured and ffmpeg is present), ffprobe verification, tag writing (best-effort by contract — never fails the item).

class tidalpy.pipeline.Pipeline(api, config, transport, media_resolver=None)[source]#

Bases: object

Orchestrates a whole download run (see module docstring).

Parameters:
__init__(api, config, transport, media_resolver=None)[source]#
Parameters:
Return type:

None

dest_for(resource, track, stream, index=0, dest=None)[source]#

The canonical destination path for one item.

The exact computation download uses, exposed so hosts can get the path without downloading (pre-flight checks, resume planning). index is the 1-based position inside the collection (matters only for playlist/mix numbering).

Parameters:
Return type:

Path

download(resource, *, dest=None, quality=None, skip_broken=None, on_progress=None, stop_event)[source]#

Download one resolved resource; always returns a report.

Parameters:
Return type:

DownloadReport

Resumable media download with parallel ranges and atomic promotion.

One Downloader per client. Fetches a logical file — either a single bento URL or a sequence of DASH segment URLs — into dest:

  • bytes always land in dest + ".part" first and are promoted with os.replace only after size verification, so a file at the final path is always complete (the skip-existing contract);

  • a failed HEAD/size probe is never treated as “file changed”: when the remote size cannot be determined the local .part is trusted, (legacy bug: one dead HEAD re-downloaded whole playlists);

  • a 416 or a 206 short read that contradicts the probe means the object changed mid-run (re-encode, stale CDN edge): the true size (carried by the response) re-evaluates the local prefix — matching promotes it complete, mismatching restarts the transfer;

  • single-URL files download in parallel 1 MiB range parts (thread pool); DASH segments download sequentially and are concatenated in manifest order;

  • interruption leaves the .part in place for the next run — trusted only when it was written for the same source: the source identity (track id, delivered quality/codec, stable media URLs) is recorded in <dest>.part.source.json and a mismatch restarts the transfer (a same-size different rendition would otherwise slip past every size check; tidekeeper parity);

  • progress is reported as plain callback events (thread-safe);

  • cancellation raises CancelledError and keeps the .part;

  • a zero-byte result is never promoted (a remote that answers with no data would otherwise poison the skip-existing contract);

  • progress never double-counts: a resumed run reports the on-disk prefix as its baseline and each byte as written at most once (a re-fetched DASH segment overlapping the prefix re-claims no progress).

class tidalpy.downloader.Downloader(transport, config, stop_event, on_progress=None, resolver=None, sleeper=None)[source]#

Bases: object

Fetch media URLs into a destination path (see module docstring).

Parameters:
__init__(transport, config, stop_event, on_progress=None, resolver=None, sleeper=None)[source]#
Parameters:
Return type:

None

download(urls, dest, encryption_key=None, media_identity=None)[source]#

Fetch the logical file described by urls into dest.

media_identity (the track id, delivered quality and codec the URLs describe) enables the source guard: the leftover .part is only resumed when it was written for the same source (see source_identity()). Omitted (a host using the downloader directly for a bare URL), the guard is off and the size checks are the only resume protection, as before.

Parameters:
Return type:

Path

remove_partial(dest)[source]#

Drop a leftover .part (used after non-resumable failures).

Parameters:

dest (Path)

Return type:

None

tidalpy.downloader.source_identity(urls, media)[source]#

The identity of a transfer’s source: sha256 over the media identity (track id, delivered quality, codec) and the stable form of the URLs.

The resume contract’s guard: a leftover .part is only trusted when it was written for this source. A renewed CDN token keeps the same identity (the representation is immutable); a different rendition — quality, codec, or URL — does not, and a same-size different rendition would otherwise slip past every size check (a prefix that is fully inside a skipped segment is never re-verified).

Parameters:
Return type:

str

tidalpy.downloader.stable_media_url(url)[source]#

The media URL with the expiring authorization removed.

Scheme/host case and port are normalised, the query is re-sorted; the media-selecting query fields (the ones that pick the representation) are kept, so two signed variants of the same object hash to the same stable form while different objects do not.

Parameters:

url (str)

Return type:

str

Media streaming: hand the plaintext audio bytes to the caller.

TidalClient.stream returns a MediaStream that wraps a resolved StreamInfo and yields decrypted audio chunks over the wire — no disk I/O anywhere in the library. This is the integration path for Django/Celery hosts that store media in S3, PostgreSQL, or any other backend the library knows nothing about:

with client.stream(url) as media:
    for chunk in media.chunks():
        s3_upload_part(chunk)

MediaStream is safe to read from one thread at a time; it is not an unbounded buffer — each call to chunks()/ read() blocks on the network until the next chunk arrives.

class tidalpy.streaming.MediaStream(track, info, transport, stop_event=None, chunk_size=262144, on_close=None)[source]#

Bases: object

A resolved track’s audio bytes, readable as a plaintext stream.

Parameters:
track#

The Track this stream belongs to.

info#

The StreamInfo (codec, quality, urls).

The object is a context manager and an iterator of bytes chunks.
__init__(track, info, transport, stop_event=None, chunk_size=262144, on_close=None)[source]#
Parameters:
Return type:

None

chunks(size=None)[source]#

Yield non-empty plaintext chunks until the stream is exhausted.

Parameters:

size (int | None)

Return type:

Iterator[bytes]

close()[source]#

Close the open segment and release host resources (idempotent).

Return type:

None

read(n=-1)[source]#

Read up to n bytes (all remaining when n is negative).

Parameters:

n (int)

Return type:

bytes

readinto(buf)[source]#

Fill a writable buffer; returns the number of bytes written.

Parameters:

buf (bytearray)

Return type:

int

property codec: str#
property delivered_quality: str#
property size: int#

Total bytes of the logical file, or -1 when unknown.

Probes each segment once (HEAD, then ranged GET) and caches the result. A failed probe keeps the value at -1 — it is not an error, callers may just not know the length ahead of time.

property track_id: int | str | None#

The Tidal track id (shorthand for track.id) — hosts use it to name the stored object.

Stream manifest parsing: bento (JSON) and DASH (XML).

Manifests are produced by Tidal’s CDN and embedded (base64) in API responses, but they are still remote-supplied input: XML is parsed with defusedxml (entity-expansion bombs) and every parser enforces hard bounds (bytes, nodes, segments, expanded URL size). A manifest that asks for DRM (ContentProtection) is rejected outright — returning those URLs would save undecryptable bytes as a “successful” download.

Both parsers return plain dicts:

{"codec": "FLAC", "urls": [...], "bandwidth": 900000,
 "sample_rate": 192000, "bit_depth": 24, "channels": 2,
 "representation_id": "FLAC_HIRES,192000,24"}

urls is [initialization, segment1, segment2, ...] for DASH and [single_file_url] for bento.

tidalpy.manifests.decode_data_uri(uri)[source]#

Decode a data:application/dash+xml;base64,... URI (OpenAPI v2).

Parameters:

uri (str | None)

Return type:

str

tidalpy.manifests.decode_manifest(manifest_b64, mime_type='')[source]#

base64-decode an API manifest payload with size bounds.

Parameters:
  • manifest_b64 (str | None)

  • mime_type (str | None)

Return type:

str

tidalpy.manifests.parse_bento(manifest_b64)[source]#

Parse a application/vnd.tidal.bts* (bento) manifest.

Result includes codec (normalised), urls, and encryption_key (the raw keyId security token or None).

Parameters:

manifest_b64 (str)

Return type:

dict[str, Any]

tidalpy.manifests.parse_dash(manifest)[source]#

Parse a DASH MPD into audio representations (see module docstring).

Parameters:

manifest (str | bytes)

Return type:

list[dict[str, Any]]

Decryption of Tidal’s encrypted (bento) audio streams.

Tidal delivers HIGH-quality AAC (and some lossless renditions) AES-CTR encrypted. The bento manifest carries a base64 security token; decrypted with a fixed public master key it yields the real 16-byte AES key and an 8-byte nonce. The counter block is nonce || 8-byte big-endian counter, so a file offset maps directly to a counter start — decryption can run in bounded chunks and resume from any 16-byte-aligned offset.

class tidalpy.decrypt.StreamDecryptor(security_token)[source]#

Bases: object

Incremental AES-CTR decryption for a running media stream.

The counter block is nonce || 8-byte big-endian counter, so the file offset maps directly to the counter start: chunks may be any size (they are decrypted in place), and the state is just the byte offset. This is what tidalpy.streaming uses to hand plaintext bytes to a host without ever touching the disk.

Parameters:

security_token (str)

__init__(security_token)[source]#
Parameters:

security_token (str)

Return type:

None

decrypt(data)[source]#
Parameters:

data (bytes)

Return type:

bytes

tidalpy.decrypt.decrypt_in_place(path, security_token)[source]#

Decrypt path (encrypted) to path (plain), atomically.

Returns the (unchanged) path. The original bytes are replaced via a temporary file so an interruption never leaves a half-decrypted file at the final name.

Parameters:
Return type:

Path

tidalpy.decrypt.decrypt_security_token(security_token)[source]#

Decode a bento keyId into (aes_key, nonce).

Raises ValueError on malformed tokens.

Parameters:

security_token (str)

Return type:

tuple[bytes, bytes]

tidalpy.decrypt.stream_decrypt(src, dst, key, nonce, chunk_size=1048576, should_stop=None)[source]#

Decrypt src into dst in bounded chunks; returns bytes written.

should_stop is called before every chunk; when it returns True a CancelledError is raised (dst is left incomplete on purpose — callers manage cleanup).

Parameters:
Return type:

int

Post-download media handling: FLAC-in-MP4 remux and ffprobe verification.

Tidal serves hi-res FLAC inside an MP4/DASH container. Saving those bytes as .flac would be a lie; the two honest outcomes are:

  • remux to true FLAC with ffmpeg -c copy (no re-encode) when the binary is available — the DJ/CDJ default;

  • otherwise keep the container and name it .m4a.

Verification (when ffprobe is available) probes the finished file and compares codec/sample-rate/bit-depth against the manifest facts. A mismatch means the CDN delivered something other than what the manifest promised — the item fails with a diagnostic instead of silently shipping degraded audio.

class tidalpy.media.MediaFacts(codec=None, sample_rate=None, bit_depth=None, channels=None, bandwidth=None, verified=False)[source]#

Bases: object

What we know about the finished file (manifest + verified probe).

Parameters:
  • codec (str | None)

  • sample_rate (int | None)

  • bit_depth (int | None)

  • channels (int | None)

  • bandwidth (int | None)

  • verified (bool)

__init__(codec=None, sample_rate=None, bit_depth=None, channels=None, bandwidth=None, verified=False)#
Parameters:
  • codec (str | None)

  • sample_rate (int | None)

  • bit_depth (int | None)

  • channels (int | None)

  • bandwidth (int | None)

  • verified (bool)

Return type:

None

bandwidth: int | None = None#
bit_depth: int | None = None#
channels: int | None = None#
codec: str | None = None#
sample_rate: int | None = None#
verified: bool = False#
tidalpy.media.export_flac_from_container(path, stop_event=None)[source]#

Remux FLAC-in-MP4 to true FLAC (-c copy).

Returns the final path: <stem>.flac on success, <stem>.m4a when ffmpeg is missing or the remux fails (the bytes stay valid, just container-named). With a stop_event, a cancelled run kills the running ffmpeg and propagates the cancellation (reference-fork pin) instead of waiting out the remux.

Parameters:
Return type:

Path

tidalpy.media.flac_in_mp4(stream)[source]#

Pre-download prediction: True when the delivered FLAC is expected to live in an MP4/DASH container.

This is only a prediction (it is what picks the file extension before the bytes exist). The remux decision itself is made from the bytes on disk — see is_flac_file() (reference-client parity: the legacy tool sniffs the same header before converting).

Parameters:

stream (StreamInfo)

Return type:

bool

tidalpy.media.is_flac_file(path)[source]#

True when the bytes at path are a genuine FLAC stream (fLaC magic). A missing or unreadable file is not FLAC.

The remux decision is made from the bytes, not the manifest: a missed prediction (an unusual bento URL shape, a CDN that changed its naming) must never ship MP4 bytes under a .flac name.

Parameters:

path (Path)

Return type:

bool

tidalpy.media.manifest_facts(stream)[source]#
Parameters:

stream (StreamInfo)

Return type:

MediaFacts

tidalpy.media.probe_media(path, stop_event=None)[source]#

ffprobe the finished file; raises DownloadError on unreadable media.

Parameters:
Return type:

MediaFacts

tidalpy.media.verify_media(path, stream, stop_event=None)[source]#

Probe and compare against manifest facts (only when ffprobe exists).

Checks that are applied (each only when the manifest knows the fact): - FLAC was promised -> the file must actually be FLAC; - sample rate must match; - bit depth must not be lower than promised.

Parameters:
Return type:

MediaFacts

Audio metadata writing (best-effort, never fatal).

Tagging uses mutagen and is best-effort by contract: a failed tag write logs and returns False but never fails the download — the audio bytes are the product, the tags are the garnish. The opened mutagen file object is what gets mutated, which keeps the mapping unit-testable with fakes.

Supported containers: MP3 (ID3v2), AAC/MP4 (.m4a), FLAC. ReplayGain from Tidal’s OpenAPI loudness data is written for all three (FLAC REPLAYGAIN_* tags, MP3 RVA2 frames, MP4 Apple freeform atoms).

tidalpy.tags.apply_tags(audio, track, album, cover_bytes=None, replaygain=None, composer=None, lyrics=None)[source]#

Write standard tags (plus cover/ReplayGain) into an open mutagen file.

The tag set mirrors the reference client: title (with version), artist/album artist, album, date, track/disc numbers, composer credit, ISRC, copyright, ReplayGain (all containers), and optionally embedded lyrics. MP4 has no standard ISRC atom, so ISRC is written for ID3/FLAC only.

Returns False when the container type is unknown.

Parameters:
Return type:

bool

tidalpy.tags.write_tags(path, track, album, cover_bytes=None, replaygain=None, composer=None, lyrics=None)[source]#

Open path, write tags, save. Never raises.

Parameters:
Return type:

bool

Filename sanitisation, path templates, and container extensions.

The two failure modes here are data loss (two different tracks landing in the same file) and unreadable files (illegal characters, 255-byte paths, control bytes). Sanitisation is conservative: characters that are illegal on any common OS are replaced, unicode is normalised (NFKC) but otherwise preserved, and names are length-capped before the extension is attached.

tidalpy.naming.album_dir(album, template)[source]#
Parameters:
Return type:

str

tidalpy.naming.cd_folder(album, track)[source]#

CD2-style subfolder for multi-disc albums (TMD parity).

Single-disc (or unknown) albums keep a flat layout: None.

Parameters:
Return type:

str | None

tidalpy.naming.playlist_dir(playlist, template)[source]#
Parameters:
Return type:

str

tidalpy.naming.quality_flag(album)[source]#

The TMD-style [M]/[E] marker letters for a collection.

M when the catalog advertises hi-res, E when explicit, joined without a separator ("ME"). Empty when neither — the {flag} placeholder substitutes nothing in that case.

Parameters:

album (Album)

Return type:

str

tidalpy.naming.sanitize_name(name, max_length=120)[source]#

Make an arbitrary title safe for use as a single path component.

Parameters:
  • name (str | None)

  • max_length (int)

Return type:

str

tidalpy.naming.track_extension(codec, stream_url='')[source]#

Container extension from the delivered codec (not the request).

Tidal serves FLAC both in native FLAC and inside an MP4/DASH container; lossless-in-MP4 keeps .m4a unless the host remuxes (see tidalpy.media). Dolby/MQA renditions land in .mp4.

Parameters:
  • codec (str | None)

  • stream_url (str)

Return type:

str

tidalpy.naming.track_path(track, extension, dest_dir, track_format, album=None, position=None, quality=None)[source]#

Full destination path for one track inside dest_dir.

extension is the final container extension (decided by the caller, which knows whether a FLAC-in-MP4 stream will be remuxed). The path is canonical (no disambiguation suffixes); if two different tracks would collide, the pipeline assigns the later one a deterministic `` (index)`` suffix — the skip-existing/resume logic relies on stable, reproducible paths. Multi-disc albums land in CD{n} subfolders (TMD parity).

Parameters:
  • track (Track)

  • extension (str)

  • dest_dir (Path)

  • track_format (str)

  • album (Album | None)

  • position (int | None)

  • quality (str | None)

Return type:

Path

tidalpy.naming.track_stem(track, track_format, album=None, position=None, quality=None)[source]#

The final filename without extension.

quality is the delivered quality (the stream’s delivered_quality); when absent, {quality} falls back to the catalog’s track.audio_quality (reference-client parity: the label reflects what is actually on disk, downgrades included).

Parameters:
  • track (Track)

  • track_format (str)

  • album (Album | None)

  • position (int | None)

  • quality (str | None)

Return type:

str

tidalpy.naming.unique_path(path)[source]#

a.flac -> a (1).flac … when the target already exists.

A host-side utility for layouts the library does not manage. The pipeline itself uses deterministic in-run disambiguation (see tidalpy.pipeline) instead, so re-runs map to the same paths — a disk-scan like this one would not.

Parameters:

path (Path)

Return type:

Path

Notes#

  • Transport owns the Tidal HTTP policy: per-request retry with exponential backoff (half-to-full jitter), 429 handling that honours Retry-After within a time budget per logical request (short waits bounded by rate_limit_max_wait, long server-requested pauses by rate_limit_max_pause) and holds back every request on the client for the server-requested cooldown (a 429 is an account-level signal — sibling requests of a concurrent run would each earn their own 429 otherwise), 5xx retries within the same budget (gateway 502/503 during Tidal deploys), asset-not-ready waits for freshly published tracks, and the one-shot 401 refresh handshake with tidalpy.oauth. A 404/subStatus 4022 on a catalog/auth endpoint means the issuing OAuth client was retired or the token went stale for it: one refresh with the active client is attempted first (a fresh token can repair it) and the request retried — 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 — the login is kept and the error stays actionable (this client/account/quality may be unavailable for playback).

  • Downloader applies the same 5xx tolerance to media fetches: a CDN 5xx on a range part or DASH segment is refetched within the per-part budget (each retry restarts at the part/segment offset), so a transient CDN blip degrades to a pause, not a failed item. A zero-byte result is never promoted (a remote answering with no data would poison the skip-existing contract), and progress never double-counts: a resumed run reports the on-disk prefix as its baseline, and a re-fetched segment overlapping it re-claims no progress. With save_receipts on, each finished file also gets a completion receipt (<final>.tidalpy.json) and both skip checks consult it: the file must prove it is this track (identity), intact (size + mtime + a sampled content fingerprint, no full-file digest), fully post-processed, and at least the requested quality — so a file delivered at a lower quality than the current request re-downloads (the upgrade path the plain contract cannot express), while a receipt-less file (a receipts-off run) costs one re-download to establish its receipt.

  • TidalApi is stateless apart from three per-client caches, all keyed on the account identity: a collection-items cache (collection contents are per-account — a private playlist or a region-specific catalog must not leak across a re-login), a short-lived (90 s, LRU-bounded) resolved-stream cache keyed by (account, track, profile) — a duplicate track in a playlist, or a host previewing the same track twice, must not spend a second rate-limited playback request — and a TTL-bounded (300 s) block list of playback qualities the account is not entitled to (a 403 CLIENT_NOT_ENTITLED is an account property, not a track quirk; later resolutions skip the doomed probe instead of spending one rate-limited request per track). It tries the v1 catalogue first and falls back to the v2 (JSON:API) catalogue on 404, and resolves streams through the OpenAPI playback endpoints with the quality downgrade chain (Lossless → HiRes → High → Low) and format preference (FLAC first); a quality that already answered a resolution is never requested again (a lossy-only track answers every lossless quality with the same rendition — re-asking only burns request budget).

    item listings surface non-audio slots (music videos in playlists and albums) as None entries: the reports count them as skipped video items, so the catalog’s item count always matches the report — a host comparing the two can trust the download processed everything, and audio tracks keep their dense numbering (a video slot takes a report position but no file number).

  • Manifests decode bento JSON and DASH MPD (defusedxml against the untrusted CDN input) into StreamInfo; encrypted renditions carry the base64 security token which tidalpy.decrypt turns into the AES key + nonce (public master key, CTR mode with an 8-byte nonce — chunk- and offset-addressable, which is what makes streaming decryption possible).

  • Downloader and MediaStream share the byte-level rules (validation, ranges, decryption) but differ on who writes the bytes: the former owns the disk (.part + os.replace), the latter hands chunks to the host. The post-download media steps (the ffmpeg remux, the ffprobe verification) run under the same cancellation event: a cancelled run kills the running process and propagates CancelledError instead of waiting out a multi-minute remux — and a cancelled remux is never swallowed by the container fallback. Cover-art fetches follow redirects manually (the session runs with allow_redirects=False) and re-validate every hop against the SSRF guard: the Location header is remote input, and a public image URL must not be able to redirect into the internal address space.