Changelog#
0.1.0 (unreleased)#
Features
Clean-room port of the Tidal-Media-Downloader core as a library: OAuth (device flow + pasted-token), catalog resolution, stream resolution with the quality-downgrade chain, resumable downloads, FLAC-in-MP4 remuxing, metadata tagging, and the structured
DownloadReport/ItemReportresult types.metadata(url)["type"]now carries the reference client’s capitalisedTypenames ("Track","Album","Playlist","Mix","Artist") instead of lowercase kinds: the host contract (TidalTask.info’s JSONField) was written against the legacy--json/RPC output, which emits theTypeenum names verbatim, so a host keying on"Track"would have seen"track". The download reports keep their lowercasekind(a different consumer, pinned separately).client.searchnow returns the reference client’s search shape —{"text", "type", "total", "items"}— instead of a flat list of raw v1 dicts: each row is curated to{"kind", "id", "title"}(lowercasekind, sohttps://tidal.com/<kind>/<id>links build straight away), plusartistfor albums and tracks (", "-joined). Playlist ids are theuuid(the linkable form), artist titles normalise from thenamefield, andtotalsums the catalog’s per-grouptotalNumberOfItems(it may exceed the row count). The wrappertypeis the reference client’sTypename for the requested group ("Null"for the default all-groups search) — the same values itssearchRPC emits, which the interop contract documents.Search rows additionally carry the catalog’s garnish when present (verified against the live API):
version(track/album editions such as"Extended Mix"),durationin seconds (track/video length, album total), andquality(track/album, the raw API value — map it withtidalpy.quality.quality_labelfor display). Result-rendering clients (search UIs) can now show the edition, the length, and the quality from one call, without a metadata lookup per row; the keys are absent rather than empty when the catalog row has no value.Structured lyrics.
client.lyrics(url)now returns{"lyrics", "subtitles"}instead of a single string: the plain text and the timed (LRC) version as separate fields, each""when Tidal has none (lyrics never error). Hosts that render lyrics can now pick the form they want instead of receiving whichever the library happened to prefer. Breaking, pre-1.0: no host consumed the old shape.Lyrics sidecar.
Config(lyric_file)(defaultFalse,TIDALPY_LYRIC_FILE) writes the lyrics next to the media file —.lrcwhen timed subtitles exist, else.txt(reference-clientlyricFileparity; some players and DJ tools read sidecars). It shares thewrite_lyricsfetch (no extra API call), is written atomically (temp +os.replace), and is best-effort by our post-processing contract: a write failure never fails the item. Deliberate deviation from the reference client: the sidecar is written on the download path only — a skipped (already complete) file gets no sidecar, which keeps skip-existing request-free.client.logout()now revokes the session remotely (a best-effort POST of the access token to Tidal’s/v1/logoutendpoint) before clearing it locally, and returns whether the remote revocation succeeded. The revocation never blocks the logout — a network failure still clears the local session (the return value lets a host report “signed out” vs. “the server-side session may linger”). The legacy reference client had no remote revocation at all; the maintained reference fork added it, and hosts (a web app’s “log out” button) need it so a forgotten token is actually invalidated.Resolved streams are cached briefly per (account, track, profile) — 90 s TTL, LRU-bounded at 256, per client instance (no global state). A track repeated in a playlist, or a host that previews the same track twice, no longer spends a second rate-limited playback request. The key carries the account identity, so adopting a different login on the same client cannot serve the previous account’s streams; hits and stored entries are copies, so no caller can corrupt a shared resolution; failures are never cached (a transient miss may clear).
Artist support. Artist links resolve and download: the whole discography lands in per-album folders (TMD parity).
client.artist_albums(url, include_ep=...)queries the discography. EPs and singles are included by default — the reference client’s effective default (SETTINGS.includeEP), which the raw API method’s own default hid;Config(include_ep=False)and the per-call flag restore full-albums-only scoping.Lyrics.
client.lyrics(track_url)returns the plain lyrics text (LRC fallback) or""when Tidal has none — never an error. Withwrite_lyrics=Truedownloads embed the lyrics in the tags.Tag parity with the reference client. Tags now carry the composer credit (from Tidal’s contributor data), ISRC, copyright, the track version in the title, and the correct disc total for MP4 (MP4 has no standard ISRC atom, so ISRC is written for ID3/FLAC). ReplayGain is written for every container, as in the reference client: FLAC
REPLAYGAIN_*tags, MP3RVA2frames, MP4 Apple freeform atoms (it used to be FLAC-only).Search paging.
client.search(..., offset=...)pages through the catalog (TMD parity; the API always receives an explicit offset, so paged results are deterministic). The default search now covers all five groups (artists, albums, tracks, videos, playlists, in the reference client’s order) — the old default silently dropped artists and videos;item_typeaccepts all five kinds.Stale-snapshot recovery (reference-client parity). Catalog listings whose items carry
streamReady: false(a mid-catalog- migration state Tidal leaves behind) are re-resolved against the live catalog: when the id still resolves, the fresh record replaces the stale snapshot so file names and tags use the current title and credits. When the id is dead, the snapshot is kept and the item fails later with the usual per-item report — a re-run sees the refreshed listing. (The reference client’s deeper ISRC/composite recovery is intentionally not ported: dead items degrade cleanly, and the migration is a moving target.)Playlist track numbering (reference-client parity). Tracks in playlists and mixes are now numbered by their position within the collection, dense over the audio items — video entries take playlist slots but never enter the download, and the reference client numbers by download position. Previously the API’s playlist slot (which counts video entries) won, leaving gaps in the file numbers (
01, 03, …instead of01, 02, …) for playlists that contain videos;Track.number_labelalso now prefers the collection position over the track’s own album number, as the reference client does for playlist-bound tracks.Quality selection for unknown-catalog tracks (documented deviation). For tracks whose catalog quality is unknown (playlist tracks are not album-bound), the reference client conservatively requests
LOSSLESSand therefore never delivers hi-res for hi-res playlist tracks. tidalpy requestsHI_RES_LOSSLESSinstead and lets the downgrade chain settle the answer — the reference client’s silent downgrade is a loud delivery here. Documented in the configuration guide.Deep item reports are now real pre-flight diagnostics.
client.list_items(url, deep=True)additionally reports each item’scodec, its expectedpath(the exact path a download would use) andsize(bytes already on disk there). Countingsize > 0answers “how much is already downloaded?” — the library equivalent of the reference client’s diagnose output.client.list_items(url, deep=True, quality=...)resolves at a per-call quality (same label coercion asdownload) instead of being stuck atconfig.quality— “can this account play HiRes on this album?” no longer needs a second client.Naming templates gain the reference client’s remaining placeholders. Track files now support
{artists}(full credit list),{album},{year},{id}, and{quality}— the delivered quality as a short label (Max/HiFi/High/Low), so a silent downgrade is visible in the file name. Album directories support the{flag}marker ([M]/[E]/[ME], TMD’s{Flag}),{id},{quality}(the catalog’s label) and{type}(ALBUM/EP/SINGLE, TMD’s{RecordType}); playlists support{id}and{type}(playlist/mix). All opt-in; the default templates are unchanged.Artist scope parity.
download/list_itemson an artist link now include EPs and singles by default — the reference client’s effective default (SETTINGS.includeEP), which the raw API method’s own default hid.Config(include_ep=False)restores full-albums-only;client.artist_albumsaccepts the same flag per call.
Configuration
Configis now a validated, immutable pydantic model: bad values fail fast withConfigErrorat construction (constraints, proxy URLs, quality parsing),with_updatesreturns a re-validated copy,as_dict/from_dictare JSON-safe and accept bothsnake_caseand the legacy camelCase keys.HTTP proxies:
Config.proxiesaccepts a plain URL (both schemes) or the requests-style mapping.http(s)always works;socks4(a)/socks5(h)are accepted when the optionalPySocksdependency (thesocksextra) is installed and get a clearConfigErrorpointing at the extra when it is missing.write_lyricsconfig option (default off): fetch and embed lyrics per track during downloads.Config.verify_tlsoverrides TLS verification for the whole client (Falseto disable, or a CA bundle path for corporate MITM proxies).Config(quality=...)coerces aliases ("master","24/192","hifi") to theQualityprofile at construction.client.streamnow defers its default chunk size tostreaming.DEFAULT_CHUNK_SIZE(256 KiB, what the docs andMediaStreampromise) instead of carrying its own stale 64 KiB default: hosts that read the docs and did not passchunk_sizenow get the documented size.config_from_env()is now table-driven and covers every configurable field (TIDALPY_WRITE_LYRICS,TIDALPY_INCLUDE_EP,TIDALPY_PART_SIZE,TIDALPY_TIMEOUT_CONNECT, …) — previously the hand-maintained list had drifted behind the model. Parsing is strict: an unparseable value fails fast withConfigErrorinstead of silently becomingFalse/raising mid-parse, and empty values keep the default.Dropped two redundant one-line wrappers from the module surface:
config.default_client()(get_client()without arguments is the same call) andsession.default_store()(FileTokenStore()is the same construction).
Features
Typed ``Subscription`` model with tier semantics (reference-client parity, and more). The account’s
users/{id}/subscriptionwas passed through as a raw dict; the reference repos’ tier helpers (subscription_allows_lossless,describe_subscription) were the only behavior worth keeping, and nothing exposed the fact hosts actually need — what the plan can deliver. Nowclient.subscription()returns aSubscription(also embedded inauth_status()as a curated view with two computed fields,maxQualityandlossless):max_qualitymaps thehighestSoundQualityceiling to the highest deliverable profile,allows_losslessis the reference parity, andallows_quality(q)answers “can this plan deliver this quality?” The ceiling is finer than the host profiles — anHI_RESceiling deliversLOSSLESS(16/44) but not the Master (24/192) profile, so a host can pre-check deliverability before spending a rate-limited resolve a plan cannot answer. Newapi_quality_rank()carries the raw-name ordering. A missing login / failed lookup degrades to an empty instance, never an error.``Track.duration_label`` — the display length next to ``number_label`` / ``key_label``. The player/search UI’s duration column as
"3:38"(""when the catalog has no duration, as on search rows; the rare hour-plus track formats"H:MM:SS"). Pure over the already-cataloguedduration;to_dict()unchanged.``ItemReport.link`` — the “view on Tidal” URL on a report row. Report rows are the workhorse of a host’s broken-item UI (a Django admin retry queue, an indexer’s import log). They already carried
kindandid, so the canonical link was always hand-derivable — now it is a property, in the same id-form, round-trip-stable shape as the catalog models’.link(Noneon a row without an id, e.g. a collection-level failure). Not added to the legacy-shapeto_dict()output.Human-readable ``repr`` on the two non-dataclass types.
TidalClientandMediaStreamwere the only public types with the default<object at 0x…>repr (the models are dataclasses and already render their fields). Now a shell or log line shows the essentials: the client’s output dir, quality, and login state (user 42/logged-out/token expired), and a stream’s track id, codec, and delivered quality. Both are built from local state only — the client repr deliberately does not calllogged_in(), which would verify the token over the network.``client.resolve_many(urls)`` — the indexer’s pre-flight with per-item isolation. A pasted/exports blob (
parse_link_list) carries the odd dead id, video link, or typo; the library’s isolation contract now covers the pre-flight, not just downloads: the walk resolves each URL and returns one JSON-safe row per URL in input order —{"url", "ok", "kind", "id", "title", "link"}on success (linkthe canonical model URL),{"url", "ok": False, "code", "reason"}on failure (the machinecodefor library errors). Sequential (resolutions are catalog-light; parallel would fight the rate-limit budget),cancel_eventsupported likedownload_many. An importer can now diff a link export against its store and hand the survivors straight todownload_many.Card and DJ ergonomics on the models.
Album.year(the release year as an int — the card’s year label fromrelease_date’s leading digits,Nonewhen the catalog has none) andTrack.key_label(a short pitch label for players/DJ tools — the pitch class in display form,"m"suffix when minor:"F#m";""when the catalog carries no key facts). Both are pure views over already-catalogued data (no extra request);to_dict()is unchanged.Transfer source identity guards the ``.part`` resume (tidekeeper parity). A leftover
.partwas trusted on size alone. That is safe except in one case: a same-size different rendition (run 1 delivered LOSSLESS, run 2 the ladder delivers HI_RES_LOSSLESS and the re-encode happens to total the same bytes — or a DASH layout whose segment boundaries moved) would resume over a stale prefix and promote it. The transfer now records its 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) and resumes; a different rendition invalidates the prefix. The marker follows the part’s lifecycle (written with it, consumed at promotion, dropped byremove_partial).Stale-snapshot recovery ladder (TMD findings §5 parity). Item snapshots the catalog migration leaves
streamReady: falsewere only recovered at tier 1 (id re-resolution, v1 → v2). The deterministic ladder is now complete — exact predicates only, never fuzzy matching: (2) the snapshot’s ISRC is a primary-key lookup in the catalog’s index (multiple hits are parallel entries of the same audio, first is safe) — recovers replaced releases whose recording kept its ISRC; (3) the exact composite predicate for recordings re-issued under a new ISRC — v1 search as candidate source only (verbatim title, limit 20, relevance ranking ignored); candidates must match title exactly, primary artist exactly, and duration within 1 s, and be unique (an exact album-title tie-break when the snapshot names its album, otherwise refuse — “refusing to guess” is the contract). Every recovery is logged with the anchor used and the id transition (auditable); when all anchors miss the snapshot is kept and the item fails per-item with a clear report as before. Cancellation propagates through the ladder (it is never swallowed as a miss).Linkable URLs on every catalog model.
Track.link,Album.link,Artist.link,Playlist.link(andResource.linkon a resolved entry) build the canonicalhttps://tidal.com/<kind>/<id>URL hosts used to hand-assemble — mixes use the player host (https://play.tidal.com/mc/<id>). The contract is pinned by test: the built form always parses back throughparse_tidal_urlto the same kind and id (which is why the artist link is the id form, not the handle form, and why the playlist link is the id, not the title).Nonewithout an id; not part of the legacy-shapeto_dict()output.OAuth client handling to reference parity. The client-id machinery closed three gaps against the reference-fork behavior: (1) a 404/subStatus 4022 on a catalog endpoint now attempts one refresh with the active client before clearing the saved session — the reference-fork 4022 ladder (refresh once, retry once, only then clear + “log in again”): a stale-client token can be repaired by a fresh one, and only a failed refresh or a second 4022 after a successful one proves the client is dead; the 4022 distinction on playback endpoints (login kept) is unchanged. (2)
clients_for(quality)— the registry documented per-client quality support but nothing used it; a host that wants “a client that can serve 24/192” (a login that should target the right client from the start) now gets the list, best candidate (the default client) first, then the rest in stable registry order; it takes the same quality labels as the rest of the library and raisesConfigErrorwhen no registered client claims the quality. (3)auth_status()now reports the active client’s persona in an additive"client"field (the registry platform, e.g. “Tidekeeper OAuth”), next to the minting"clientId"— a UI can show which client a login is / would be minted for.Bulk resolution of playlists by UUID (completing the ids-first surface). The v2 catalog’s
playlists?filter[id]=(live API verified on the row shape: the rows are keyed by the linkable playlist UUID, the title key isname, the track count isnumberOfTrackItems, the duration is ISO-8601) now hasclient.playlists_by_ids(uuids)next to the other bulk fetches — same contract (one catalog call per 50, request order, unknown UUIDs skipped, not fabricated). Bulk playlists carry the card facts (title, description, track count, duration, playlist type, follower count, created/modified) but no artwork: the row’s coverArt relationship carries a v2 artwork id, and resolving it costs one extra catalog call per row — which would defeat the bulk fetch, so a host that needs a cover resolves that playlist singly (the same contract as bulk albums). Editorial mixes are a v1 concept (there is no v2mixesresource) and are not reachable here.Bulk resolution by ISRC and UPC (completing the key-based surface). The v2 catalog’s key filters accept a comma-separated OR list (live verified with two real ISRCs and two real UPCs), so a library of keys resolves in one catalog call per 50 instead of one per key:
client.tracks_by_isrcs(isrcs)andclient.albums_by_upcs(upcs)next to the singletrack_by_isrc/album_by_upc— same contract as the ids- first family (request order, duplicates collapse, unknown keys skipped not fabricated). The shared bulk engine was generalised for this: rows are now attributed back to the request by an explicit key (the entity id forfilter[id], the row’s ownisrc/barcodeIdfor the key filters — where several rows can share a key, the first wins). One live-verified edge, now pinned: the catalog’s matching can normalise a placeholder barcode differently than the stored value (twelve zeros answers with rows whose barcodeId carries thirteen), such a row cannot be attributed to a requested key and is dropped, the same as a miss.Direct resolution by ISRC and UPC (the key-based siblings of the ids-first surface). Two live-API-verified catalog indexes for hosts that hold keys from elsewhere (CDDB, a licensing feed, the back of a CD case) instead of Tidal ids:
client.track_by_isrc(isrc)(the v2 catalog’stracks?filter[isrc]=) andclient.album_by_upc(upc)(the v2 catalog’salbums?filter[barcodeId]=— the same attribute the v2 UPC fix now maps). The v1 catalog cannot answer either (live verified: an ISRC or a UPC typed into search returns nothing), so both go straight to v2 and reuse the shared v2 row mapping — artists and the album back-reference resolve from the included block exactly as in the bulk fetches. A lookup, not a fetch: a key the catalog does not know returnsNone(the live contract: 200 with an empty row list, never a 404), so a host diffing a library of ISRCs or barcodes pays no exception per miss; blank input returnsNonewithout a request. Two live-verified filter behaviors: the ISRC filter is case-sensitive and wants exactly 12 characters (lowercase gets a 400), so the input is normalised (whitespace removed, upper-cased) before the call, and a malformed ISRC is the catalog’s 400 (propagated, not a miss); the barcode match is exact (a prefix matches nothing), but placeholder barcodes are shared by real catalog entries (a barcode of zeros and the classic 1234567890128 each match several), so when the catalog lists more than one row the first is returned — a host that cares diffs the title.Timed-lyrics fallback (reference-fork parity): when Tidal has plain lyrics but no timed subtitles for the exact track version (common for remasters and live editions),
lyrics()and the download path now search the catalog for matching versions — same title and artists, scored by identical ISRC (same master recording), near-identical duration, and same album — and adopt the best candidate’s timed subtitles while keeping the track’s own plain text. Display clients (KTV, car head units) get synced lyrics for versions Tidal tags up as well. The fallback only runs when it can be scored (a title-less id costs no extra requests), the queries and candidate fetching are bounded, and it is garnish: a failed search keeps the plain lyrics. Cancellation is not a rejection and propagates out oflyrics()(previously it could be swallowed by the “lyrics never error” catch-all).Search rows for tracks now carry the catalog’s
isrcand album title when present (alongside the existingversion/duration/qualitygarnish): the same fields the timed-lyrics fallback scores by, useful for any search-rendering client.Bulk fetch for albums and artists (completing the ids-first surface) + the v2 UPC fix. The v2 catalog’s
?filter[id]=bulk endpoint (live API verified on all four resource types) now hasclient.albums_by_ids(ids)andclient.artists_by_ids(ids)next totracks_by_ids— same contract (one catalog call per 50, request order, unknown ids skipped not fabricated). Two live-verified shape facts: bulk album rows carry no coverArt relationship (single-album does — a host needing a cover resolves singly), and bulk artist rows are the artist resources themselves (no include needed; picture/handle are not in the bulk attributes block). Also a latent gap found in the shape audit: v2 albums carry the UPC asbarcodeId(live: the same value v1’supccarries, e.g. 634904078164 for OK Computer), but the v2 album mapping discarded it — every v2-resolved album lost its stable physical-release id. Now mapped on both the single and bulk fetches (the shared v2 album field mapping was extracted for this).Bulk track fetch (the indexer surface) + playback facade (the player surface). Two live-API-verified surfaces for the non-download clients:
client.tracks_by_ids(ids)is the ids-first counterpart oftracks(url): a host that has track ids from elsewhere (a playlist export, a library audit, a web-app deep link) gets the full Track models in one catalog round trip per 50 ids, instead of N single-track resolves (each a rate-limited fetch). It is v2-only — the v1 catalog has no bulk endpoint (live 404), and v2’s?filter[id]=knows both legacy and web-app ids. Result order is the request order (duplicates collapse); ids the catalog does not know are skipped, not fabricated, so a host diffs the result against the request to find the gaps. Andclient.playback(url, quality=, preview=)— what will actually be played, resolved WITHOUT downloading a byte: the delivered quality, codec, rate/depth, preview flag, and the expiring CDN link (for hosts that fetch the bytes themselves). A player UI shows the quality badge before pressing play; the resolution is cached per (account, track, quality, preview), so a subsequentstream()of the same track spends no second rate-limited probe.``client.user_playlists()`` — the “My Playlists” tab. Reference parity (TMD’s
getUserPlaylists), live API verified: the endpoint is the pluralusers/{id}/playlists(the singularuser/...family is gone — 404, as is the rest of the old library family:user/{id}/albums/tracks/artists/favoritesall 404 too).user_iddefaults to the logged-in session’s user (the account’s own playlists); pass another id for that user’s public playlists. Rows are the uniform playlist search rows (the id is the linkableuuid) — a library UI renders them with the same row code as search/browse and buildshttps://tidal.com/playlist/<uuid>links straight away; the wrapper carries the catalog’stotalfor paging.Real-time search: autocomplete + track credits. Two live-API-verified surfaces for interactive clients:
client.autocomplete(query)is the search-box suggest — the catalog’ssearch/autocompleteendpoint answers as the user types (a small result budget, across artists/albums/ tracks/playlists); rows are the uniform search rows, so a suggest box and a results list render with the same row code (the row’stypediscriminator picks the kind; the session’s country is threaded likesearch, with a per-call override; a no-match query is[], not an error). Andclient.track_contributors(url)— the track page’s “credits” tab: one{name, role}row per credit (Producer, Composer, Lyricist, Associated Performer, …); a creditless track gets[]. The tagging path already used the API-level method for the composer tag; the client facade was missing.Genre browse (the catalog’s “Discover” tab). New surface (live API verified; neither reference client has it — they are download tools, not browsers):
client.genres()returns the catalog’s fixed genre list (~20) as{"name", "path", "has_playlists", "has_artists", "has_albums", "has_tracks", "has_videos"}plusartwork(the card thumbnail id, forartwork_url) when named — a browse UI builds the grid and, per card, thehas_*flags say which tabs to offer.client.genre_items( genre, item_type)("tracks"/"albums"/"playlists"/"videos"— the same plural names as the flags, paginatedlimit/offset) returns{"genre", "type", "total", "items"}where the items are the uniform search rows (browse and search render with the same row code; genre names are URL-quoted — “Hip Hop / Rap”; no genre has an artists browse — the 404 matches thehasArtists: Falseflags).Playlist promoted artists + “updated” fact; the v1 album copyright bug. Live API verified: the v1 playlist object carries
promotedArtists(the featured artist(s) a playlist card attributes it to —[{id, name, handle, picture, type: MAIN}]; the v2 object carries none) — now on the Playlist model aspromoted_artists(Artist rows, the card’s deep-link source) and inmetadata()aspromotedArtists; pluslastItemAddedAt(the “updated” fact) aslast_item_added. Also a latent bug: the v1 album’scopyrightis a PLAIN STRING (“XL Recordings Ltd”), but the mapping read it as a dict — every live album’scopyright_labelwas silentlyNone(the v1 track object is the dict-shaped one). Now mapped on both catalog versions (v2 sends the dict{text}shape, also previously discarded). The albumversion(the edition badge: “2025 Mix” on a remaster, live-verified) was already mapped but now live-shape-pinned.Album-card facts + a latent v2 duration crash. Live API verified: the v1 album payload and album search rows carry
duration(total playtime in seconds — an album card’s “12 tracks, 54 min” without an item walk),payToStream(the purchase-required badge, surfaced only when true), andstreamStartDate(the pre-release fact) — now on the Album model and inmetadata()asduration/payToStream/streamStartDate. Also a latent bug: v2 reports durations as ISO-8601 strings (PT1H56M35S), but the v2 playlist mapping didint(...)on it — aValueErroron any real v2 playlist that carries one (mixes escaped only because the v2 mix object omits the key).parse_iso8601_durationnow accepts plain seconds values as well, and the v2 playlist and album mappings use it (a v1 plain-seconds value is unaffected).Mix fix + editorial-mix links (the ‘listen in a mix’ tab). Real bug, live API verified: a v2 mix carries NO count attribute (
numberOfTrackItems/numberOfItemsare both null), so every resolved mix reported0tracks inmetadata()/the models — the parsed items are now the fallback truth. Also new: the v1 catalog’s editorial-mix maps on track and artist objects ({"TRACK_MIX": "<id>"}/{"ARTIST_MIX": "<id>"}; v2 carries no mixes) were fetched and discarded — they are now on the models asmixesand inmetadata()asmixes. The ids buildhttps://play.tidal.com/mc/<id>links, which resolve through the normal mix path (live-verified end to end), so a UI can offer the mix from a track or artist page.``iter_streams(preview=True)`` — the player’s ‘play what you can’ path: a collection that mixes full tracks with preview-only ones (free-tier accounts, pre-release/exclusive tracks) used to drop the preview slots as broken items; now the flag yields the ~30-second stream instead, flagged
info.preview is Truefor the badge, in order, never downgrading a track that has a full stream.stream()andapi.resolve_streamalready had the flag; the collection facade is now complete with it.``DownloadReport.summary()`` — the host-display contract: a compact JSON-safe status line for a run (kind/title/ok/ cancelled/total/okCount/skippedCount/failedCount/totalBytes plus a compact
failureslist of id/title/code/reason) — a Django admin column, a batch UI, or a log line gets the run state without walkingitems;to_dictremains the full per-item detail.Track availability facts (pre-attempt badges). The v1 track payload carries availability facts the library fetched but discarded (live API verified: every track entry and every search row — v2 carries none of them):
pay_to_stream(a track that must be bought before it can be played — an indexer now badges the row before attempting a resolve instead of learning it from the error),audio_modes(the spatial-audio modes; a 5.1/ATMOS badge), andstream_start_date(the pre-release fact). On the Track model, inmetadata()aspayToStream/audioModes/streamStartDate, and on track search rows where they discriminate (payToStreamonly when true,audioModesonly when non-stereo — STEREO is the norm, not a fact worth a key).Preview streams (the search-UI ‘play something’ path). The catalog serves a ~30-second preview instead of the full track for free-tier accounts and for pre-release/exclusive tracks — even on subscribed accounts. The library used to raise
PreviewOnlyErrorin that case; nowclient.stream(url, preview=True)(andapi.resolve_stream(..., allow_preview=True)) accepts the preview: the returnedStreamInfohaspreview=Trueset (ininfoandto_dict), so a host can show a ‘preview’ badge and never treat the 30 seconds as the full track. A track that HAS a full stream is never downgraded to a preview by the flag, and a preview answer short-circuits the quality ladder (every other rung would return the same 30 seconds, burning rate-limited requests). Preview and full resolutions are cached separately. Downloads never resolve previews: the pipeline passes the default, so a 30-second file is never a valid download result.Playlist popularity + public flag. The v1 playlist object carries the catalog’s
popularity(int 0..100, normalised to float as on Artist/Album - a trending signal for UIs) andpublicPlaylist(the public/private flag) - both were dropped. On the model now aspopularityandis_public(Nonewhen the catalog doesn’t say - the v2 playlist object carries neither, and we don’t guess), inmetadata()aspopularity/isPublic.ReplayGain tags on the primary (bento) path. The v1 track payload carries the track’s own loudness facts (
replayGaindB + truepeak, every items entry — live API verified) which the library fetched but discarded: the bento stream carries no normalization data, so files delivered over the primary path shipped with zero ReplayGain tags. The v1 facts now map toTrack.replaygain(the tag writer’s key set), and the tag write merges them under the stream’s v2 normalization facts (the stream wins on a key it provides; facts only one side has are kept).Album type bug fix + catalog garnish. The v1 album payload’s type key is
type(the v2 JSON:API attributes block is the one that usesalbumType) - readingalbumTypeleftAlbum.album_type(and the legacy metadata’stype) empty for every v1 album: the catalog’s ALBUM/SINGLE/… badge was missing. Also new on the Album model (live API verified, v1; v2 where it has the fact):popularity(normalised to float as on Artist),upc(the barcode - an indexer’s stable physical-release id), andvibrant_color(a card background for rendering clients). Album search rows now carry thetypebadge when the catalog has it.Search market override —
searchandsearch_allaccept a per-callcountry(ISO code): the catalog is per-market, and a search UI serving an account in another region (or an indexer for a specific market) can now search that market’s catalog instead of the account’s.None(default) keeps the session’s country.``client.tracks`` — the content-listing facade for real-time UIs and indexers: any resolvable link (album, playlist, mix, artist, single track) returns its tracks as
Trackmodels in catalog order (album rows with number/duration/explicit/bpm/key, playlist rows in position order, an artist flattened across the discography with each track’salbumback-reference set). The listing is the catalog’s — no stream is resolved (cheap, one items call per collection, cached per client); non-audio slots are skipped; video links raise as inresolve.``download_many`` events carry their URL — every progress event of a batch run is annotated with the run’s
urland its position in the input sequence (url_index): a batch UI correlates events to links without hand-rolling its own bookkeeping. The keys are present only indownload_many(single-URL runs keep the flat event contract).``parse_link_list`` — link-list expansion for hosts that accept a paste box or a links file: one link per line, comma or whitespace separated, Tidal URL-export comment lines (
#/[/{) skipped, and a line that names an existing file expands to that file’s contents (nested lists, relative to the containing file, symlinks and self-including lists read once). Iterative (no recursion) and capped (8 MiB total, 4096 files, 50 000 links) so a hostile paste box cannot OOM the host; tokens come back verbatim in input order, first occurrence of a duplicate wins — pair it withdownload_manyfor the per-URL report.``client.iter_streams`` — the player-client facade: the
stream()equivalent for collections. A host that plays a collection in order (a UI, a gapless player, a live broadcast) without saving anything gets one readableMediaStreamper audio track, in catalog order, resolved lazily one at a time (the rate-limit budget is shared with every other client operation). Non-audio slots are skipped; an unresolvable track is logged and skipped (skip_broken, default on) or raised; cancellation ends the walk and an abandoned generator releases its client registration on close.Progress events report the delivered quality — the
ok(and stream-resolvedskipped) progress events now carrydelivered_qualityandcodecalongside theDownloadReportitems: a downgraded run (LOSSLESS asked, HI_RES delivered) is visible in the real-time channel, not only in the post-hoc report.``save_receipts`` (opt-in) — completion receipts and the quality-aware skip (the tidekeeper-fork goodie). A finished file gets a receipt (
<final>.tidalpy.json: identity, delivered quality/codec, size, mtime, a sampled content fingerprint, post-processed flag), and both skip checks consult it. What this fixes that the plain skip contract (non-empty file = complete) cannot express: a collection re-run at a higher quality ceiling re-downloads the lower-quality files (the upgrade path) instead of keeping them forever; a file promoted but crashed before tagging is re-finished instead of skipped; a file at a higher quality than requested still satisfies; and the stream-free skip now also verifies the track identity from the receipt (a file at the path that belongs to a different track never satisfies). All local — no network, no stream request, no remote probe. Off by default: the documented skip contract stays the default behaviour (TIDALPY_SAVE_RECEIPTSto enable).Search ``top_hit`` banner — the catalog’s
topHitfield (its single best match for the query, a full v1 object of its named group — the reference clients ignore the field entirely) now comes back on the search wrapper astop_hit, wearing the same uniform row shape as the results, present only when the catalog names one (garnish convention).searchandsearch_allboth carry it — the ready-made “best match” banner for a real-time search UI.``client.artist_top_tracks`` — the artist’s top tracks, ranked by the catalog (the v1
artists/{id}/toptracksendpoint, live API verified: paged, full track payloads; neither reference client exposes it). The real-time ‘top tracks’ tab for an artist page:Trackmodels in catalog rank, 1..50 per call, stale snapshots refreshed like the discography.Artist garnish —
Artistnow carries the catalog’spopularity(v1 int 0..100 and v2 float 0..1 normalised to float), thehandle(the linkable name intidal.com/artist/<handle>), and thefollower_count(v2). Inmetadata()and the search rows’ artist facts alike.Album search rows now carry the catalog
releaseDate(1997-07-01shape, live API verified) when present — the year an album card in a real-time search UI shows.``client.artwork`` — the cover bytes for a link, a search-row artwork id, or a
resources.tidal.com/images/...URL (the id is parsed back out, so a host can round-trip whatartwork_urlbuilt). The fetch goes through the SSRF-validated transport (the same validated-redirect-follow the download pipeline uses), so a real-time search UI serves thumbnails without doing its own HTTP. Best effort: a dead CDN or missing artwork returnsNoneand never raises; a badsize(caller error) still raises.``client.download_many(urls)`` — the indexing-client batch facade: one report per URL, in input order (results correlate to links by position), with per-URL failure isolation — a bad link (unresolvable, region locked, rate-limited out of its item listing) becomes a single-item
failedreport carrying the machinecodeandretryableflag, and the walk continues. Runs are sequential: each already manages the server’s rate-limit budget, and parallel collections would only fight over it. Cancellation ends the walk; the processed URLs still report.Playlists/mixes now carry the catalog’s metadata garnish — the artwork id (the v1
imagefield was never mapped: playlist covers were alwaysnullinmetadata()), the creator’s display name (editorial playlists have none), creation and last- update timestamps, total duration in seconds, the catalog’s playlist type (EDITORIAL/COMMUNITY/PERSONAL/…), and the follower count — onPlaylistand in the legacy--jsondict (v1 and v2 names mapped). A host rendering a playlist page gets the facts without extra API calls.Tracks now carry the catalog’s musical-key facts —
bpm,key(pitch class, e.g.FSharp),keyScale(MAJOR/MINOR) — onTrack, in the legacy--jsondict (bpm/key/keyScale), and as tags on every container: ID3TBPM/TKEY, MP4tmpo+ the Apple freeform key, FLACBPM/KEY. The word spelling becomes conventional notation (FSharpbecomesF#) and fractional BPMs stay fractional. Live API verified: the facts are on the track objects (v1 and v2), not on search rows; neither reference client exposes or tags them — DJ/CDJ software reads them.``client.search_all`` — the indexing-client complement to the real-time
search: it pages the catalog (the same per-groupoffset/limitsemantics) and merges the rows, so a host collecting every match of a query doesn’t page the catalog itself. It stops when the groups are exhausted (the per-group totals), a page comes back empty or short, or the row cap is hit (defaults mirror the reference fork’ssearchAll: 50 per page, 200 rows — both overridable). Same result shape assearch;totalstays the catalog’s overall sum, so a capped run still tells the host how much is left.``client.search_albums`` — album search that understands artist-name queries. The catalog ranks album search by title, so searching an artist’s name returns only albums whose titles match and hides the artist’s actual discography (the reference fork documents the exact case: “O.S.T.R.” hides most of that artist’s output). When the query — case- and punctuation-insensitively — is the name of a catalog artist,
search_albumsreturns that artist’s full discography first, then the title-matching albums, deduplicated by id. No name match falls back to the paged album search (search_all). Rows wear the uniformsearchalbum shape, andinclude_epdefaults toconfig.include_ep. A dead discography endpoint is logged and skipped instead of hiding the title hits.Search rows now carry the artwork id when the catalog row has one — albums from their own cover, tracks from the nested album’s cover, artists from their picture — and the new
tidalpy.artwork_url()helper (alsoclient.artwork_url) turns the id into the static-CDN image URL. Pure URL construction, no network, no auth: a real-time search UI renders thumbnails straight from the search rows. The ids are validated as five- segment image ids (a paste mistake fails fast with an actionable error instead of a 404), and the download pipeline builds its cover URLs through the same helper (single source of truth).The artist/album-artist tags now carry every artist of a collaboration (primary first), not just the primary: a music library built from these files sees the featured artists too (reference-fork parity). File names keep the primary-artist convention for tree stability.
Robustness
Playlist and album video slots are no longer silently dropped: the item reports (
downloadandlist_items) count them asskippeditems withkind="video"and the reason “video item (audio-only library)”. The catalog’s item count (numberOfItems) now always matches the report’s — a playlist with 25 entries including 3 music videos reports 25 items (22 downloaded + 3 skipped) instead of pretending 22. Audio tracks keep their dense numbering (the video slot takes a position in the report but no number in the file name).DASH manifests with an open segment timeline (
<S r="-1"/>, the live idiom) are now expanded when the end is knowable: bounded by the following timeline entry’s start time, else by the presentation duration — exactly the segments that start before the end (reference-fork pin). An open repeat with no known end is rejected instead of guessed. Separately fixed: width-specified template variables ($Time%04d$) crashed the parser on the regex group — any such manifest used to be a hard failure.DASH representation selection now pins the fidelity ranking (lossless first, then bit depth, sample rate, bandwidth, channels): the highest-rate FLAC wins over the one Tidal emitted first, and numeric representation ids are never misread as bit depths (reference-fork pins). The total expanded URL size and the base64-decoded manifest size are enforced bounds, not just the encoded input.
A zero-byte download is never promoted: a remote that answers 200 with no data (a dead CDN edge) used to produce a zero-byte file at the final path, poisoning the skip-existing contract for every future run of that item. The item now fails with a clear error and the empty
.partis dropped (reference-fork pin).Download progress no longer double-counts on a resumed run: the on-disk
.partis the baseline, and a re-fetched DASH segment that overlaps the existing prefix no longer re-claims those bytes (the final report was previouslysize + overlapinstead ofsize— reference-fork pin: a restarted transfer reports each byte exactly once).Cover-art fetches now follow redirects, validated: the session runs with
allow_redirects=False, so each hop is re-checked against the SSRF guard (a public image URL redirecting to an internal host is refused, not followed), relativeLocationheaders resolve against the current URL, the hop count is capped, and every response is closed. Cover URLs that redirect therefore work instead of silently dropping the art.The ffmpeg remux and ffprobe verification are cancel-responsive: a cancelled run kills the running media process and propagates the cancellation instead of waiting for a multi-minute remux to finish (reference-fork pin), and a cancelled remux is never swallowed by the container fallback (the source file is left untouched).
Error bodies that are not JSON objects (a bare list, a string, HTML) now degrade to the plain HTTP error instead of being misread: a 401 with a list body is an auth error, a 404 is not found, a 403 carries no machine codes (reference-fork pin).
The client now honours no implicit environment state (the transport session’s
trust_envis off):HTTP_PROXY/HTTPS_PROXY/no_proxyenvironment variables,~/.netrccredentials, andREQUESTS_CA_BUNDLEare not read. A stray env var or a developer’s.netrcentry on a shared host can no longer leak credentials into Tidal or CDN traffic — proxies and CA bundles are explicitConfigfields (matching the no-global-state contract).Link parsing is now strict, matching the reference fork: the host must be exactly
tidal.com/www./listen.(plusplay.tidal.com/mc/for mixes), http(s) only, no embedded credentials, ports 80/443 only, with identifiers validated per kind (numeric for track/album/video/artist, opaque for playlist/mix) before any network call. A link to not-tidal.com can no longer be silently reinterpreted as a Tidal id (the old substring match turnednot-tidal.com/track/123into Tidal track 123), nested paths resolve to the innermost known kind, andresolve()of a foreign URL now fails fast with a clear error instead of burning the whole kind-probe ladder on a string no catalog contains.A 404/subStatus 4022 on a playback endpoint is now understood as a playback-client rejection, not as a stale login: the saved session is kept (reference-fork live case — clearing it would log the host out of a perfectly good account) and the error is actionable (endpoint, country, quality, “login kept”). A 4022 on a catalog/auth endpoint still means the issuing OAuth client was retired and still clears the session.
A playback quality the account is not entitled to (403 with the machine code
CLIENT_NOT_ENTITLED) is now cached per account for 300 s (reference-fork TTL): it is an account property, not a track quirk, so later resolutions skip the doomed probe instead of spending one rate-limited request per track. Only capability rejections are cached — a track-specific 403 never taints other tracks — and a re-login resets the list (entitlements differ per account). The machine codes a 403 body carries are now attached to the raisedTidalApiError(.codes).429 handling is now bounded by time instead of a count (reference-fork parity, replacing
rate_limit_max_waitswithrate_limit_max_wait, default 90 s): an account-level rate limit may need several short waits to clear, so a fixed number of waits could give up while the limit still had a minute to run. The total wait time per logical request is the budget; 429s still never consume the transient-retry budget. A long server-requested pause (Retry-Afterbeyond the short-wait budget) is honoured against a separate, larger pause budget (rate_limit_max_pause, now defaulting to 6 h) instead of being capped and re-tripped: Tidal answers every request 429 until the pause lapses, so failing one request would have failed every following one too.A 416 (range not satisfiable) or a 206 short read that contradicts the probed size is now understood as the object changed after the probe (a re-encode, a stale CDN edge) instead of a hard failure: the response’s
Content-Rangetotal (or a fresh probe) re- evaluates the local.part— a size matching the prefix confirms it complete and promotes it, a mismatching one restarts the transfer (reference-fork parity, pinned for the matching, the overlong, and the size-less 416 forms).The collection-items cache is now keyed on the account identity (like the stream cache already was): collection contents are per-account, so a private playlist or a region-specific catalog under one login could not leak into a second login adopted on the same client (reference-fork parity).
logout()now only forgets the session that was current when it started: if a new login lands while the remote revocation is in flight (a host signing a user out on one tab and back in on another), the newer login survives the logout (reference-fork parity: a delayed revocation cannot clear a new login).login()rejects a purely numeric “token” up front with an actionable message instead of burning a probe: a numeric paste is almost always a user id, never a Tidal OAuth token (reference-fork parity).Fixed the reactive stereo/lossless retry being dead code for spatial renditions:
StreamInfo.codeccarries the canonical name (normalize_codecmapseac3/ac4/mha1→"DOLBY"), andneeds_stereo_fallbackonly recognised the raw spellings — so a lossless request answered with a Dolby rendition never triggered the retry that exists precisely for that answer.SPATIAL_CODECSnow carries the canonical name as well (pinned, together with the retry firing on the first rung for an Atmos-style track).A playback quality that already answered a resolution is never requested again: a lossy-only track answers every lossless quality with the same rendition, so the reactive stereo/lossy retry could re-send a request whose answer was already on hand (and the
LOSSLESSrung’sHI_RES_LOSSLESSvariant could trigger a second, identicalLOSSLESSrequest). Reference-fork issue #32 pinned the same rule; the retry still fires when it asks a quality that has not answered yet (e.g. an Atmos-only track answeringHI_RES_LOSSLESSspatially, where theLOSSLESSretry can yield a stereo FLAC).The remote-size probe now requests
Accept-Encoding: identityand distrusts anyContent-Encoding-encoded length (Content-Lengthand theContent-Rangetotal): an encoded response advertises the compressed size, which is smaller than the object, so trusting it could have made a healthy local file look truncated and triggered a spurious re-download (reference-fork parity, pinned for the encoded HEAD and ranged-GET legs).Every HTTP response is now closed on every exit path (catalog
get_json, authpost_json, and both size-probe legs): the retry loops (429, 5xx, empty body) never read the body at all, so without an explicit close each of them held its pooled connection for the whole wait — a long or rate-limited run would eventually exhaust the connection pool (reference-fork parity, pinned for success, retry, and failure paths).A 429 (rate limit) now holds back every catalog request on the client for the server-requested cooldown, not just the request that earned it: a 429 is an account-level signal, so during a concurrent run the sibling requests used to fire straight into their own 429s (each with its own wait), multiplying the cooldown instead of honouring it once (reference-fork parity). The pause is reactive — it only exists after a 429 — so a healthy run never pays for it, and it lapses of its own.
Resuming a large collection no longer spends a rate-limited playback request per already-downloaded track: the skip-existing check now runs before stream resolution, against destination candidates computed from catalog data alone (name template, the playback attempt ladder’s qualities, possible containers). A non-empty file at any candidate skips with zero Tidal requests — a playlist resume that previously burned the request budget (and could hit the rate limit before reaching new tracks) now gets straight to the missing ones (reference-fork parity). Deliberate trade, documented in Downloading: that check cannot compare against the remote size, so the remote-size truncation guard still applies to the stream path (destinations that are not candidates) as before.
A size mismatch after download (the assembled bytes disagree with the probed remote size — e.g. a mid-run re-encode) now drops the
.partbefore failing: the leftover would have been resumed on the next run, and a resume trusts the local prefix, so the corrupt bytes would have persisted silently (reference-client parity: the legacy tool removes the file on a mismatch).The transport retries connect-phase failures (network resets before any body is streamed) with backoff, on top of the existing HTTP-status retry policy (429/Retry-After, 401 refresh, asset-not-ready).
Two different tracks that sanitise to the identical file name no longer silently skip over each other’s file: the later item gets a deterministic `` (position)`` suffix, stable across re-runs.
A degraded v1
/itemslisting (empty items page whiletotalNumberOfItemsclaims the collection is non-empty) now raises instead of returning zero items: the pager used to stop silently on an empty first page, which would download nothing from a live album and reportok=Falsewith zero items — indistinguishable from a genuinely empty playlist (the v2 catalogue already had the twin truncation guard; a genuinely empty collection —totalNumberOfItemsof 0 or absent — still yields an empty report as before).Retry backoffs (connect-phase and request-level) now use half-to-full jitter so concurrent clients don’t retry in lockstep. The legacy
_throttleknob (which read config fields that no longer existed) was dead code and is removed.5xx responses (gateway 502/503 during Tidal deploys, 500 on hot keys) are retried within the same retry budget as transient request errors — the reference client surfaces them immediately. The same treatment covers media responses: a 5xx from the CDN during a range-part or DASH-segment fetch is refetched within the part budget (a part/segment retry restarts from its own offset, so a partial write can never corrupt the assembly), and the part backoffs now use the same half-to-full jitter.
Internal typing tightened: the
Pipeline/TidalApi/Downloaderconstructors and a handful of helpers are fully annotated (no untypedAnyseams in the download path). The package ships apy.typedmarker (PEP 561) so hosts get the type hints under mypy.A failed decrypt no longer leaves an orphan
.dec.tmpfile.Fixed a silent mutagen bug: the MP4 cover kwarg is
imageformat=(format=raisedTypeError, swallowed by the best-effort tag pass, so M4A covers never landed).Live-API contract fixes: v1 search uses plural types plus a mandatory
countryCodeand returns grouped results; v1/itemspayloads nest the track underitem; bare-id resolution triestrack, album, playlist, mix, artist(artists last — numeric ids collide).TMD-parity file naming (no volume prefix in the file name; multi-disc albums get
CD1/CD2/… subfolders).Playback requests now ask for
audiomode=STEREOup front (the reference client sends it on every playbackinfo call): spatial / proprietary renditions are useless for the transfer use case, and requesting stereo avoids the reactive stereo-fallback round trip. Live-verified against the API (identical 200s with and without, stereo FLAC delivered).~is now expanded in path config: the docs’ own quickstart passestoken_path="~/.tidalpy/token.json"andoutput="~/Music/Tidal", but nothing expanded the tilde, so a host copying the example verbatim created a literal~directory.config.token_file,config.output_dirand the per-calldownload(dest=...)override all expand~now.Fixed a live-API bug found by probing: the v1 envelope check treated any non-200
statusin a 200 body as an application error, but the subscription endpoint answers{"status": "ACTIVE", ...}— soclient.auth_status()silently lost all subscription data (the host saw{}). Only numeric non-200 statuses are errors now.auth_status()also reports the post-refresh token state (it verifies the login through the transport’s 401 handshake first, as the interop contract requires).FLAC-in-MP4 remux is now decided by the bytes, not the manifest. The remux (
save_as_flac) used to be gated on a pre-download prediction of the container shape; a missed prediction (an unusual bento URL shape) would silently ship MP4 bytes under a.flacname. The pipeline now sniffs thefLaCmagic of the finished file (reference-client parity: the legacy tool checks the same header before converting) — a FLAC codec that is not real FLAC on disk is remuxed, and genuine FLAC skips the ffmpeg pass entirely.Skip-existing now applies the truncation check the contract promises: a non-empty file at the final path is complete unless every stream URL’s remote size can be determined and the local file is smaller than the remote total (a re-encode / truncation — re-downloaded). A failed probe never forces a re-download (the legacy bug where one dead HEAD re-downloaded an entire playlist); previously the check was only
exists and size > 0.Config.from_dicttreats an explicitnullas “unspecified” and keeps the model default (reference-client parity: the legacy profile reader applies the same rule). Django form/JSON round-trips serialize unset values asNone; a naiveNonewould have clobbered working defaults or raised on every field. Unknown keys are still rejected, even with aNonevalue.A single-track link that only resolves via the v2 catalog now gets the track’s real album position (
track_number) in the file name: the v2 track resource carries no position, soget_trackresolves it from the album’s item meta (reference-client parity: the legacy client performs the same follow-up call). Previously such a track always numbered00. A dead album id degrades to the default position instead of failing the download.
Docs & CI
Sphinx docs (deployable as a container; see Deploying this documentation).
CI gates: ruff, black, isort, flake8, mypy, and pytest with
--cov-fail-under=90.