From a64c5bcb9f8b3b78dd400cd92a1196d961b54e6a Mon Sep 17 00:00:00 2001 From: joshtheclipper Date: Mon, 15 Jun 2026 07:07:12 -0400 Subject: [PATCH 1/2] Made changes to fix not downloading highest avaliable quality on tidal --- streamrip/client/downloadable.py | 53 ++++++++++++++++++++++++++ streamrip/client/tidal.py | 65 +++++++++++++++++++++++++++++++- 2 files changed, 116 insertions(+), 2 deletions(-) diff --git a/streamrip/client/downloadable.py b/streamrip/client/downloadable.py index 3b29b504..45f00e8e 100644 --- a/streamrip/client/downloadable.py +++ b/streamrip/client/downloadable.py @@ -305,6 +305,59 @@ async def _decrypt_mqa_file(in_path, encryption_key): dec_bytes = decryptor.decrypt(await enc_file.read()) return dec_bytes +class TidalDASHDownloadable(TidalDownloadable): + """Handles Tidal HI_RES_LOSSLESS tracks served as MPEG-DASH manifests.""" + + def __init__( + self, + session: aiohttp.ClientSession, + init_url: str, + segment_urls: list[str], + codec: str, + encryption_key: str | None = None, + ): + self.session = session + self.source = "tidal" + self.url = init_url + self.init_url = init_url + self.segment_urls = segment_urls + codec = codec.lower() + if codec in ("flac", "mqa"): + self.extension = "flac" + else: + self.extension = "m4a" + # Use a BasicDownloadable for the init segment so the base class + # size() and other machinery works correctly + self.downloadable = BasicDownloadable(session, init_url, self.extension, "tidal") + + async def _download(self, path: str, callback): + # Download all segments into a temporary .mp4 file first + tmp_path = path + ".tmp.mp4" + async with aiofiles.open(tmp_path, "wb") as f: + async with self.session.get(self.init_url) as resp: + resp.raise_for_status() + chunk = await resp.read() + await f.write(chunk) + callback(len(chunk)) + for url in self.segment_urls: + async with self.session.get(url) as resp: + resp.raise_for_status() + chunk = await resp.read() + await f.write(chunk) + callback(len(chunk)) + + # Remux from MP4 container to raw FLAC using ffmpeg + import asyncio + proc = await asyncio.create_subprocess_exec( + "ffmpeg", "-i", tmp_path, "-c", "copy", "-y", path, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + await proc.communicate() + + # Clean up the temp file + import os + os.remove(tmp_path) class SoundcloudDownloadable(Downloadable): def __init__(self, session, info: dict): diff --git a/streamrip/client/tidal.py b/streamrip/client/tidal.py index 92455d20..b6c37ff5 100644 --- a/streamrip/client/tidal.py +++ b/streamrip/client/tidal.py @@ -11,7 +11,7 @@ from ..config import Config from ..exceptions import NonStreamableError from .client import Client -from .downloadable import TidalDownloadable +from .downloadable import TidalDASHDownloadable, TidalDownloadable logger = logging.getLogger("streamrip") @@ -166,11 +166,14 @@ async def get_downloadable(self, track_id: str, quality: int): except KeyError: raise Exception(resp["userMessage"]) except JSONDecodeError: + # Check if this is a DASH manifest (HI_RES_LOSSLESS uses application/dash+xml) + mime_type = resp.get("manifestMimeType", "") + if mime_type == "application/dash+xml": + return await self._get_downloadable_from_dash(track_id, resp) logger.warning( f"Failed to get manifest for {track_id}. Retrying with lower quality." ) return await self.get_downloadable(track_id, quality - 1) - logger.debug(manifest) enc_key = manifest.get("keyId") if manifest.get("encryptionType") == "NONE": @@ -183,6 +186,64 @@ async def get_downloadable(self, track_id: str, quality: int): restrictions=manifest.get("restrictions"), ) + async def _get_downloadable_from_dash(self, track_id: str, resp: dict): + import xml.etree.ElementTree as ET + + dash_xml = base64.b64decode(resp["manifest"]).decode("utf-8") + logger.debug(f"Parsing DASH manifest for track {track_id}") + + ns = {"mpd": "urn:mpeg:dash:schema:mpd:2011"} + root = ET.fromstring(dash_xml) + + representation = root.find(".//mpd:Representation", ns) + if representation is None: + raise Exception(f"No Representation found in DASH manifest for {track_id}") + + codecs = representation.get("codecs") + sample_rate = representation.get("audioSamplingRate") + logger.debug(f"DASH manifest: codecs={codecs}, sampleRate={sample_rate}") + + seg_template = representation.find("mpd:SegmentTemplate", ns) + if seg_template is None: + raise Exception(f"No SegmentTemplate in DASH manifest for {track_id}") + + init_url = seg_template.get("initialization") + media_template = seg_template.get("media") + start_number = int(seg_template.get("startNumber", "1")) + + if not init_url or not media_template: + raise Exception( + f"Missing initialization or media URL in DASH manifest for {track_id}" + ) + + # Parse segment timeline to get total segment count + timeline = seg_template.find("mpd:SegmentTimeline", ns) + if timeline is None: + raise Exception(f"No SegmentTimeline in DASH manifest for {track_id}") + + # Build full segment URL list from the timeline + segment_numbers = [] + seg_num = start_number + for s in timeline.findall("mpd:S", ns): + repeat = int(s.get("r", "0")) + for _ in range(repeat + 1): + segment_numbers.append(seg_num) + seg_num += 1 + + segment_urls = [ + media_template.replace("$Number$", str(n)) for n in segment_numbers + ] + + logger.debug(f"DASH: {len(segment_urls)} segments for track {track_id}") + + return TidalDASHDownloadable( + self.session, + init_url=init_url, + segment_urls=segment_urls, + codec=codecs, + encryption_key=None, + ) + async def get_video_file_url(self, video_id: str) -> str: """Get the HLS video stream url. From fc3f930c5c4d1f750b8cb87a4af62e25478fb5ac Mon Sep 17 00:00:00 2001 From: joshtheclipper <166189819+joshtheclipper@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:22:36 -0400 Subject: [PATCH 2/2] updated README.md for fork This fork adds support for Tidal's HI_RES_LOSSLESS quality tier, improving the handling of MPEG-DASH manifests and enhancing the download process for hi-res tracks. --- README.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/README.md b/README.md index 0df40b69..790f1bef 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,51 @@ A scriptable stream downloader for Qobuz, Tidal, Deezer and SoundCloud. ![downloading an album](https://github.com/nathom/streamrip/blob/dev/demo/download_album.png?raw=true) +## Changes from upstream + +This fork adds support for Tidal's `HI_RES_LOSSLESS` quality tier, which the +original streamrip does not handle correctly. Tidal serves hi-res lossless +tracks as MPEG-DASH manifests (`application/dash+xml`) rather than the standard +JSON manifest format (`application/vnd.tidal.bts`) used for lower quality tiers. +The original code attempts to JSON-parse all manifests and silently falls back to +a lower quality when parsing fails, meaning hi-res tracks were never actually +downloaded at hi-res even with a Tidal HiFi subscription. + +### 🚨 What was changed 🚨 + +**`streamrip/client/tidal.py`** +- `get_downloadable()` now detects `application/dash+xml` manifests instead of + silently falling back to lower quality on `JSONDecodeError` +- New `_get_downloadable_from_dash()` method parses the MPEG-DASH XML, extracts + the initialization URL, media segment URLs, codec, and sample rate, and returns + a `TidalDASHDownloadable` instance + +**`streamrip/client/downloadable.py`** +- New `TidalDASHDownloadable` class handles downloading DASH-segmented audio +- Downloads the initialization segment followed by all numbered media segments + in order, concatenating them into a single MP4 container +- Remuxes the result from MP4 to FLAC using `ffmpeg -c copy` (lossless, no + re-encoding) since Tidal wraps FLAC audio inside an MP4/ISOBMFF container + even when the codec is FLAC +- Inherits from `TidalDownloadable` to stay compatible with the existing + download pipeline, progress tracking, and size reporting + +### Requirements + +This fork requires `ffmpeg` to be installed and available on your `PATH`. +On Debian/Ubuntu: `apt install ffmpeg` + +### Notes + +Some tracks on an album may still download as `.m4a` (AAC) rather than `.flac`. +This is expected — Tidal's API returns different quality levels per track based +on per-track licensing. Tracks the API reports as `HI_RES_LOSSLESS` will be +downloaded as 24-bit FLAC; tracks reported as `HIGH` will be downloaded as AAC +320kbps. This is a Tidal-side limitation and cannot be worked around from the +client. + +### 🚨 End Fork Changes 🚨 + ## Features - Fast, concurrent downloads powered by `aiohttp`