From a64c5bcb9f8b3b78dd400cd92a1196d961b54e6a Mon Sep 17 00:00:00 2001 From: joshtheclipper Date: Mon, 15 Jun 2026 07:07:12 -0400 Subject: [PATCH 1/4] 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/4] 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` From 96e1d218b0bed7013d7c805048b6670e20695b65 Mon Sep 17 00:00:00 2001 From: ghostdanser <5584730+sjbrownrigg@users.noreply.github.com> Date: Fri, 14 Aug 2026 10:48:28 +0100 Subject: [PATCH 3/4] Fix progress bar and check the remux succeeded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes for the DASH download path. TidalDASHDownloadable inherits size(), which HEADs self.url -- for a DASH track that is only the init segment, a few hundred bytes against a track of tens of MB. The progress bar's total was therefore tiny, so the first chunk took it past 100% and every track appeared to start finished: Track 5 ━━━━━━━━━━━━━━━━━━━━━━━━━ 100.0% • 3.9 MB/s • 0:00:00 Track 6 ━━━━━━━━━━━━━━━━━━━━━━━━━ 100.0% • 5.5 MB/s • 0:00:00 Sum the init segment and every media segment instead. The requests are bounded to 8 at a time: issuing one per segment at once got about half of them refused, and counting a refusal as zero put the total at half the real size (17.0 MB reported against 34.6 MB downloaded). Segments that still do not answer are estimated at the average of those that did. Verified against the progress callback: 34.59 MB reported, 34.59 MB delivered. Separately, the remux discarded ffmpeg's exit status. If ffmpeg failed the temp file was removed, no output was written, and _download returned normally -- so the caller counted the track as downloaded with nothing on disk, and on current dev recorded it in the downloads database, which means it is skipped on every later run. Check the exit status and that the output exists, and raise NonStreamableError otherwise. Co-Authored-By: Claude Opus 5 --- streamrip/client/downloadable.py | 65 ++++++++++++++++++++++++++++---- 1 file changed, 57 insertions(+), 8 deletions(-) diff --git a/streamrip/client/downloadable.py b/streamrip/client/downloadable.py index 45f00e8e..100ecf2c 100644 --- a/streamrip/client/downloadable.py +++ b/streamrip/client/downloadable.py @@ -326,10 +326,50 @@ def __init__( self.extension = "flac" else: self.extension = "m4a" - # Use a BasicDownloadable for the init segment so the base class - # size() and other machinery works correctly + # Used for the init segment; size() below accounts for the rest. self.downloadable = BasicDownloadable(session, init_url, self.extension, "tidal") + async def size(self) -> int: + """Total size of the init segment plus every media segment. + + The inherited size() HEADs self.url, which for a DASH track is only + the init segment -- a few hundred bytes against a track of tens of + MB. The progress bar's total was therefore tiny, so the first chunk + took it past 100% and every track appeared to start finished, with + no rate and no time remaining. + """ + if self._size is not None: + return self._size + + urls = [self.init_url, *self.segment_urls] + # Bounded on purpose: issuing one request per segment at once gets a + # large share of them refused, and counting a refusal as zero bytes + # reports roughly half the real size. + sem = asyncio.Semaphore(8) + + async def content_length(url: str) -> int | None: + async with sem: + try: + async with self.session.head(url) as resp: + resp.raise_for_status() + return int(resp.headers.get("Content-Length", 0)) + except Exception: + return None + + sizes = await asyncio.gather(*(content_length(u) for u in urls)) + + known = [s for s in sizes if s] + if not known: + self._size = await super().size() + return self._size + + # Segments are near-uniform, so stand in for any that did not answer + # rather than dropping them and under-reporting the total. + average = sum(known) // len(known) + missing = len(sizes) - len(known) + self._size = sum(known) + missing * average + return self._size + async def _download(self, path: str, callback): # Download all segments into a temporary .mp4 file first tmp_path = path + ".tmp.mp4" @@ -346,17 +386,26 @@ async def _download(self, path: str, callback): await f.write(chunk) callback(len(chunk)) - # Remux from MP4 container to raw FLAC using ffmpeg - import asyncio + # Remux the MP4 container to FLAC. "-c copy" is a stream copy, so the + # audio is bit-identical -- only the container changes. proc = await asyncio.create_subprocess_exec( "ffmpeg", "-i", tmp_path, "-c", "copy", "-y", path, stdout=asyncio.subprocess.DEVNULL, - stderr=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.PIPE, ) - await proc.communicate() + _, stderr = await proc.communicate() + + if proc.returncode != 0 or not os.path.isfile(path): + # Without this a failed remux is silent: the temp file is removed, + # no output is written, and the caller treats the download as + # successful -- on current dev that records the track as + # downloaded, so it is skipped on every future run. + os.remove(tmp_path) + raise NonStreamableError( + f"ffmpeg failed to remux Tidal DASH stream " + f"(exit {proc.returncode}): {stderr.decode(errors='replace')[-300:]}" + ) - # Clean up the temp file - import os os.remove(tmp_path) class SoundcloudDownloadable(Downloadable): From 207431eff3d3348edab3f1f5a6b42af0c2aae737 Mon Sep 17 00:00:00 2001 From: joshtheclipper <166189819+joshtheclipper@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:08:47 -0400 Subject: [PATCH 4/4] Clean up README by removing change details Removed detailed change log and notes about Tidal integration. --- README.md | 35 ----------------------------------- 1 file changed, 35 deletions(-) diff --git a/README.md b/README.md index 790f1bef..796ab135 100644 --- a/README.md +++ b/README.md @@ -17,41 +17,6 @@ The original code attempts to JSON-parse all manifests and silently falls back t 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`