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 / ItemReport result types.

  • metadata(url)["type"] now carries the reference client’s capitalised Type names ("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 the Type enum names verbatim, so a host keying on "Track" would have seen "track". The download reports keep their lowercase kind (a different consumer, pinned separately).

  • client.search now 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"} (lowercase kind, so https://tidal.com/<kind>/<id> links build straight away), plus artist for albums and tracks (", "-joined). Playlist ids are the uuid (the linkable form), artist titles normalise from the name field, and total sums the catalog’s per-group totalNumberOfItems (it may exceed the row count). The wrapper type is the reference client’s Type name for the requested group ("Null" for the default all-groups search) — the same values its search RPC 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"), duration in seconds (track/video length, album total), and quality (track/album, the raw API value — map it with tidalpy.quality.quality_label for 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) (default False, TIDALPY_LYRIC_FILE) writes the lyrics next to the media file — .lrc when timed subtitles exist, else .txt (reference-client lyricFile parity; some players and DJ tools read sidecars). It shares the write_lyrics fetch (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/logout endpoint) 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. With write_lyrics=True downloads 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, MP3 RVA2 frames, 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_type accepts 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 of 01, 02, …) for playlists that contain videos; Track.number_label also 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 LOSSLESS and therefore never delivers hi-res for hi-res playlist tracks. tidalpy requests HI_RES_LOSSLESS instead 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’s codec, its expected path (the exact path a download would use) and size (bytes already on disk there). Counting size > 0 answers “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 as download) instead of being stuck at config.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_items on 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_albums accepts the same flag per call.

Configuration

  • Config is now a validated, immutable pydantic model: bad values fail fast with ConfigError at construction (constraints, proxy URLs, quality parsing), with_updates returns a re-validated copy, as_dict/from_dict are JSON-safe and accept both snake_case and the legacy camelCase keys.

  • HTTP proxies: Config.proxies accepts a plain URL (both schemes) or the requests-style mapping. http(s) always works; socks4(a)/socks5(h) are accepted when the optional PySocks dependency (the socks extra) is installed and get a clear ConfigError pointing at the extra when it is missing.

  • write_lyrics config option (default off): fetch and embed lyrics per track during downloads.

  • Config.verify_tls overrides TLS verification for the whole client (False to disable, or a CA bundle path for corporate MITM proxies).

  • Config(quality=...) coerces aliases ("master", "24/192", "hifi") to the Quality profile at construction.

  • client.stream now defers its default chunk size to streaming.DEFAULT_CHUNK_SIZE (256 KiB, what the docs and MediaStream promise) instead of carrying its own stale 64 KiB default: hosts that read the docs and did not pass chunk_size now 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 with ConfigError instead of silently becoming False/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) and session.default_store() (FileTokenStore() is the same construction).

Features

  • Typed ``Subscription`` model with tier semantics (reference-client parity, and more). The account’s users/{id}/subscription was 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. Now client.subscription() returns a Subscription (also embedded in auth_status() as a curated view with two computed fields, maxQuality and lossless): max_quality maps the highestSoundQuality ceiling to the highest deliverable profile, allows_lossless is the reference parity, and allows_quality(q) answers “can this plan deliver this quality?” The ceiling is finer than the host profiles — an HI_RES ceiling delivers LOSSLESS (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. New api_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-catalogued duration; 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 kind and id, 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 (None on a row without an id, e.g. a collection-level failure). Not added to the legacy-shape to_dict() output.

  • Human-readable ``repr`` on the two non-dataclass types. TidalClient and MediaStream were 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 call logged_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 (link the canonical model URL), {"url", "ok": False, "code", "reason"} on failure (the machine code for library errors). Sequential (resolutions are catalog-light; parallel would fight the rate-limit budget), cancel_event supported like download_many. An importer can now diff a link export against its store and hand the survivors straight to download_many.

  • Card and DJ ergonomics on the models. Album.year (the release year as an int — the card’s year label from release_date’s leading digits, None when the catalog has none) and Track.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 .part was 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 by remove_partial).

  • Stale-snapshot recovery ladder (TMD findings §5 parity). Item snapshots the catalog migration leaves streamReady: false were 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 (and Resource.link on a resolved entry) build the canonical https://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 through parse_tidal_url to 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). None without an id; not part of the legacy-shape to_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 raises ConfigError when 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 is name, the track count is numberOfTrackItems, the duration is ISO-8601) now has client.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 v2 mixes resource) 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) and client.albums_by_upcs(upcs) next to the single track_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 for filter[id], the row’s own isrc / barcodeId for 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’s tracks?filter[isrc]=) and client.album_by_upc(upc) (the v2 catalog’s albums?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 returns None (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 returns None without 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 of lyrics() (previously it could be swallowed by the “lyrics never error” catch-all).

  • Search rows for tracks now carry the catalog’s isrc and album title when present (alongside the existing version/ duration/quality garnish): 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 has client.albums_by_ids(ids) and client.artists_by_ids(ids) next to tracks_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 as barcodeId (live: the same value v1’s upc carries, 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 of tracks(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. And client.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 subsequent stream() 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 plural users/{id}/playlists (the singular user/... family is gone — 404, as is the rest of the old library family: user/{id}/albums/tracks/ artists/favorites all 404 too). user_id defaults 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 linkable uuid) — a library UI renders them with the same row code as search/browse and builds https://tidal.com/playlist/<uuid> links straight away; the wrapper carries the catalog’s total for 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’s search/autocomplete endpoint 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’s type discriminator picks the kind; the session’s country is threaded like search, with a per-call override; a no-match query is [], not an error). And client.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"} plus artwork (the card thumbnail id, for artwork_url) when named — a browse UI builds the grid and, per card, the has_* flags say which tabs to offer. client.genre_items( genre, item_type) ("tracks" / "albums" / "playlists" / "videos" — the same plural names as the flags, paginated limit/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 the hasArtists: False flags).

  • 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 as promoted_artists (Artist rows, the card’s deep-link source) and in metadata() as promotedArtists; plus lastItemAddedAt (the “updated” fact) as last_item_added. Also a latent bug: the v1 album’s copyright is a PLAIN STRING (“XL Recordings Ltd”), but the mapping read it as a dict — every live album’s copyright_label was silently None (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 album version (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), and streamStartDate (the pre-release fact) — now on the Album model and in metadata() as duration/payToStream/ streamStartDate. Also a latent bug: v2 reports durations as ISO-8601 strings (PT1H56M35S), but the v2 playlist mapping did int(...) on it — a ValueError on any real v2 playlist that carries one (mixes escaped only because the v2 mix object omits the key). parse_iso8601_duration now 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/numberOfItems are both null), so every resolved mix reported 0 tracks in metadata()/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 as mixes and in metadata() as mixes. The ids build https://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 True for the badge, in order, never downgrading a track that has a full stream. stream() and api.resolve_stream already 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 failures list of id/title/code/reason) — a Django admin column, a batch UI, or a log line gets the run state without walking items; to_dict remains 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), and stream_start_date (the pre-release fact). On the Track model, in metadata() as payToStream/audioModes/ streamStartDate, and on track search rows where they discriminate (payToStream only when true, audioModes only 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 PreviewOnlyError in that case; now client.stream(url, preview=True) (and api.resolve_stream(..., allow_preview=True)) accepts the preview: the returned StreamInfo has preview=True set (in info and to_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) and publicPlaylist (the public/private flag) - both were dropped. On the model now as popularity and is_public (None when the catalog doesn’t say - the v2 playlist object carries neither, and we don’t guess), in metadata() as popularity/isPublic.

  • ReplayGain tags on the primary (bento) path. The v1 track payload carries the track’s own loudness facts (replayGain dB + true peak, 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 to Track.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 uses albumType) - reading albumType left Album.album_type (and the legacy metadata’s type) 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), and vibrant_color (a card background for rendering clients). Album search rows now carry the type badge when the catalog has it.

  • Search market override — search and search_all accept a per-call country (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 Track models 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’s album back-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 in resolve.

  • ``download_many`` events carry their URL — every progress event of a batch run is annotated with the run’s url and 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 in download_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 with download_many for 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 readable MediaStream per 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-resolved skipped) progress events now carry delivered_quality and codec alongside the DownloadReport items: 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_RECEIPTS to enable).

  • Search ``top_hit`` banner — the catalog’s topHit field (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 as top_hit, wearing the same uniform row shape as the results, present only when the catalog names one (garnish convention). search and search_all both 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}/toptracks endpoint, live API verified: paged, full track payloads; neither reference client exposes it). The real-time ‘top tracks’ tab for an artist page: Track models in catalog rank, 1..50 per call, stale snapshots refreshed like the discography.

  • Artist garnish — Artist now carries the catalog’s popularity (v1 int 0..100 and v2 float 0..1 normalised to float), the handle (the linkable name in tidal.com/artist/<handle>), and the follower_count (v2). In metadata() and the search rows’ artist facts alike.

  • Album search rows now carry the catalog releaseDate (1997-07-01 shape, 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 what artwork_url built). 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 returns None and never raises; a bad size (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 failed report carrying the machine code and retryable flag, 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 image field was never mapped: playlist covers were always null in metadata()), 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 — on Playlist and in the legacy --json dict (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) — on Track, in the legacy --json dict (bpm/key/keyScale), and as tags on every container: ID3 TBPM/TKEY, MP4 tmpo + the Apple freeform key, FLAC BPM/KEY. The word spelling becomes conventional notation (FSharp becomes F#) 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-group offset/limit semantics) 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’s searchAll: 50 per page, 200 rows — both overridable). Same result shape as search; total stays 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_albums returns 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 uniform search album shape, and include_ep defaults to config.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 (also client.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 (download and list_items) count them as skipped items with kind="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 .part is dropped (reference-fork pin).

  • Download progress no longer double-counts on a resumed run: the on-disk .part is the baseline, and a re-fetched DASH segment that overlaps the existing prefix no longer re-claims those bytes (the final report was previously size + overlap instead of size — 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), relative Location headers 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_env is off): HTTP_PROXY/ HTTPS_PROXY/no_proxy environment variables, ~/.netrc credentials, and REQUESTS_CA_BUNDLE are not read. A stray env var or a developer’s .netrc entry on a shared host can no longer leak credentials into Tidal or CDN traffic — proxies and CA bundles are explicit Config fields (matching the no-global-state contract).

  • Link parsing is now strict, matching the reference fork: the host must be exactly tidal.com / www. / listen. (plus play.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 turned not-tidal.com/track/123 into Tidal track 123), nested paths resolve to the innermost known kind, and resolve() 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 raised TidalApiError (.codes).

  • 429 handling is now bounded by time instead of a count (reference-fork parity, replacing rate_limit_max_waits with rate_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-After beyond 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-Range total (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.codec carries the canonical name (normalize_codec maps eac3/ac4/mha1 → "DOLBY"), and needs_stereo_fallback only recognised the raw spellings — so a lossless request answered with a Dolby rendition never triggered the retry that exists precisely for that answer. SPATIAL_CODECS now 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 LOSSLESS rung’s HI_RES_LOSSLESS variant could trigger a second, identical LOSSLESS request). 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 answering HI_RES_LOSSLESS spatially, where the LOSSLESS retry can yield a stereo FLAC).

  • The remote-size probe now requests Accept-Encoding: identity and distrusts any Content-Encoding-encoded length (Content-Length and the Content-Range total): 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, auth post_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 .part before 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 /items listing (empty items page while totalNumberOfItems claims 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 report ok=False with zero items — indistinguishable from a genuinely empty playlist (the v2 catalogue already had the twin truncation guard; a genuinely empty collection — totalNumberOfItems of 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 _throttle knob (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/Downloader constructors and a handful of helpers are fully annotated (no untyped Any seams in the download path). The package ships a py.typed marker (PEP 561) so hosts get the type hints under mypy.

  • A failed decrypt no longer leaves an orphan .dec.tmp file.

  • Fixed a silent mutagen bug: the MP4 cover kwarg is imageformat= (format= raised TypeError, swallowed by the best-effort tag pass, so M4A covers never landed).

  • Live-API contract fixes: v1 search uses plural types plus a mandatory countryCode and returns grouped results; v1 /items payloads nest the track under item; bare-id resolution tries track, 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=STEREO up 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 passes token_path="~/.tidalpy/token.json" and output="~/Music/Tidal", but nothing expanded the tilde, so a host copying the example verbatim created a literal ~ directory. config.token_file, config.output_dir and the per-call download(dest=...) override all expand ~ now.

  • Fixed a live-API bug found by probing: the v1 envelope check treated any non-200 status in a 200 body as an application error, but the subscription endpoint answers {"status": "ACTIVE", ...} — so client.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 .flac name. The pipeline now sniffs the fLaC magic 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_dict treats an explicit null as “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 as None; a naive None would have clobbered working defaults or raised on every field. Unknown keys are still rejected, even with a None value.

  • 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, so get_track resolves it from the album’s item meta (reference-client parity: the legacy client performs the same follow-up call). Previously such a track always numbered 00. 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.