Skip to content

[BUG] Tidal Hi-Res FLAC silently downgrades to AAC 320 — root cause is DASH-MPD manifest not handled by get_downloadable #974

@andrewle8

Description

@andrewle8

Describe the bug

Tidal now serves HI_RES_LOSSLESS (and in some regions/albums also LOSSLESS) as manifestMimeType: application/dash+xml — a multi-segment DASH-MPD manifest pointing at fragmented MP4 (fMP4) segments containing the FLAC stream. Streamrip 2.2.0 and the current dev branch only parse the older application/vnd.tidal.bts (single-URL BTS-JSON) format. The XML manifest raises JSONDecodeError inside get_downloadable, the except block calls itself recursively at quality n-1, and Tidal's API then server-side downgrades LOSSLESSHIGH (AAC 320 BTS-JSON), which parses successfully — so the user gets a silent fallback to a .m4a file with no error.

This is the underlying cause of #970, #966, #892, and the long thread in #801. None of those reports identified the DASH transition; they all attribute it to client_id/client_secret rotation. The credentials are not the problem — the manifest format is.

Root cause

streamrip/client/tidal.py (line ~165 on dev and 2.2.0):

async def get_downloadable(self, track_id: str, quality: int):
    ...
    resp = await self._api_request(
        f"tracks/{track_id}/playbackinfopostpaywall", params
    )
    try:
        manifest = json.loads(base64.b64decode(resp["manifest"]).decode("utf-8"))
    except KeyError:
        raise Exception(resp["userMessage"])
    except JSONDecodeError:
        logger.warning(
            f"Failed to get manifest for {track_id}. Retrying with lower quality."
        )
        return await self.get_downloadable(track_id, quality - 1)
    ...
    return TidalDownloadable(
        ...
        url=manifest["urls"][0],
        codec=manifest["codecs"],
        ...
    )

The code assumes resp["manifest"] base64-decodes to JSON. For Hi-Res, it now decodes to:

<?xml version='1.0' encoding='UTF-8'?>
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" ...>
  <Period id="0">
    <AdaptationSet id="0" contentType="audio" mimeType="audio/mp4" ...>
      <Representation id="FLAC_HIRES,96000,24" codecs="flac" bandwidth="2773973" audioSamplingRate="96000">
        <SegmentTemplate timescale="96000"
          initialization="https://sp-ad-fa.audio.tidal.com/mediatracks/.../0.mp4?token=..."
          media="https://sp-ad-fa.audio.tidal.com/mediatracks/.../$Number$.mp4?token=..."
          startNumber="1">
          <SegmentTimeline>
            <S d="380928" r="69"/>
            <S d="128210"/>
          </SegmentTimeline>
        </SegmentTemplate>
      </Representation>
    </AdaptationSet>
  </Period>
</MPD>

The response also carries manifestMimeType: application/dash+xml which the current code never inspects.

Reproduce

# Use an album you know is Hi-Res in the Tidal app:
rip --no-db --quality 3 url "https://tidal.com/album/505811027"

Result: 10 .m4a files at AAC 320, no warning about quality loss beyond the (silent-on-the-surface) "Failed to get manifest. Retrying with lower quality." log line.

Verify with curl that Tidal does have FLAC for the same tracks:

TOKEN=$(grep '^access_token' "$HOME/Library/Application Support/streamrip/config.toml" | sed 's/access_token = "//;s/"$//')
curl -sS "https://api.tidal.com/v1/tracks/505811029/playbackinfopostpaywall?audioquality=HI_RES_LOSSLESS&playbackmode=STREAM&assetpresentation=FULL&countryCode=US" \
  -H "Authorization: Bearer $TOKEN" | python3 -c "
import sys, json, base64
d = json.load(sys.stdin)
print('audioQuality:', d['audioQuality'])
print('manifestMimeType:', d['manifestMimeType'])
print('bitDepth:', d.get('bitDepth'), 'sampleRate:', d.get('sampleRate'))
print(base64.b64decode(d['manifest']).decode()[:300])
"

Expected output: audioQuality: HI_RES_LOSSLESS, manifestMimeType: application/dash+xml, bitDepth: 24, sampleRate: 96000, followed by the MPD XML.

Proposed fix

get_downloadable should dispatch on resp.get("manifestMimeType"):

  • application/vnd.tidal.bts → existing JSON path (unchanged)
  • application/dash+xml → parse MPD, build a downloader that:
    1. Fetches SegmentTemplate/@initialization (the init segment)
    2. Fetches each numbered media segment (media URL with $Number$ substituted), startNumber through startNumber + sum((S.r or 0) + 1)
    3. Concatenates init + segments into a single fMP4 stream
    4. Optionally remuxes to native FLAC via ffmpeg -i in.mp4 -map 0:a -c:a copy out.flac (lossless, no re-encode)

No DRM is in play for the FLAC AdaptationSet (no <ContentProtection> element), so this is a plain HTTP segmented download. fMP4-FLAC is a standard container; ffmpeg copies the FLAC frames out without modification.

A working reference implementation (~250 LOC of stdlib + ffmpeg + mutagen) — happy to attach inline or open a PR. The MPD parser is just xml.etree.ElementTree against the namespace urn:mpeg:dash:schema:mpd:2011, and segment downloads are plain urllib.request (no special headers needed beyond the signed URL).

Workaround

Until this lands, use the standalone script above or any DASH-aware downloader (e.g. yt-dlp with --allow-unplayable-formats, though that re-muxes differently).

Environment

  • streamrip 2.2.0 (from nathom/streamrip@v2.2.0 via pipx)
  • dev branch HEAD as of 2026-05-20: same broken code path
  • Tested on macOS (Apple Silicon), but the bug is in protocol handling — OS-independent
  • Tidal HiFi Plus account, US region, account confirmed to deliver FLAC to the Tidal app for the same albums

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions