(fixes #981) HI_RES_LOSSLESS downloads via MPEG-DASH manifest support - #998
(fixes #981) HI_RES_LOSSLESS downloads via MPEG-DASH manifest support#998joshtheclipper wants to merge 5 commits into
Conversation
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.
|
for some reason it doesn't work for me |
Not working for me either |
|
One thing I noticed is that is does not work 100% of the time. I am suspecting that TIDAL is not always sending true flac audio file will only send you AAC files. Are you seeing some come through as flac at all? |
|
@flying-fox-1 you are on the wrong version of rip i assume. you need to switch to github's version 2.2.0. |
|
Hey, I'm sorry for a late reply. So i install from the beta branch? pip3 install git+https://github.com/nathom/streamrip.git@dev |
|
@flying-fox-1 please, see the comment: |
|
Hello, it now works for me i had to change the main.py in \AppData\Local\Programs\Python\Python311\Lib\site-packages\streamrip\rip line 31 from: asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) to: asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) |
|
you are on windows? rly |
|
For those who are struggling with this: After merging this PR: |
|
Thanks for this — I've been running it locally and it's the only thing that actually gets true hi-res off Tidal. Confirmed working on a Tidal "Max" subscription: Two things I hit while using it, with the fixes I'm running. Both are yours to take or leave. 1. The progress bar shows every track as instantly complete
Every track starts at 100% with no rate and no time remaining. Summing the segments fixes it. One caveat that cost me a while: issuing one HEAD per segment concurrently got roughly half of them refused, and counting a refusal as zero reported 17.0 MB for a track that downloaded 34.6 MB — so the bar was then wrong in the other direction. Bounding the concurrency and estimating any stragglers from the average of the rest gives an exact match: async def size(self) -> int:
if self._size is not None:
return self._size
urls = [self.init_url, *self.segment_urls]
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._sizeVerified by counting the bytes handed to the progress callback: 34.59 MB reported against 34.59 MB delivered. 2. A failed remux is silently treated as a successful downloadproc = await asyncio.create_subprocess_exec(
"ffmpeg", "-i", tmp_path, "-c", "copy", "-y", path, ...
)
await proc.communicate()
os.remove(tmp_path)If ffmpeg fails, the temp file is removed, no output is written, and Checking the exit status and that the output exists turns it into a normal failure: proc = await asyncio.create_subprocess_exec(
"ffmpeg", "-i", tmp_path, "-c", "copy", "-y", path,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await proc.communicate()
if proc.returncode != 0 or not os.path.isfile(path):
os.remove(tmp_path)
raise NonStreamableError(
f"ffmpeg failed to remux Tidal DASH stream "
f"(exit {proc.returncode}): {stderr.decode(errors='replace')[-300:]}"
)
os.remove(tmp_path)Minor
Happy to open a PR against your branch with any of this if that's easier than patching it in. |
|
@sjbrownrigg Thanks for the comment. I am by no means an experienced coder. I had used streamrip in the past and it worked great until I started having trouble getting FLACs to download. Without finding an easy solution, I took to Claude Code to find this solution. By all means if you would like to open a PR that would be great! |
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 <noreply@anthropic.com>
|
Tidal FLAC download still doesn't work |
…x-check Fix DASH progress bar and check that the remux succeeded
Removed detailed change log and notes about Tidal integration.
for me this does not work. i get error: |


Summary
This PR fixes a issue (#981) where Tidal HI_RES_LOSSLESS tracks would silently fall back to lower quality (HIGH/AAC 320kbps) even on accounts with a HiFi subscription.
Root Cause
Tidal serves HI_RES_LOSSLESS tracks using MPEG-DASH manifests (application/dash+xml) rather than the standard JSON manifest format (application/vnd.tidal.bts) used for lower quality tiers. The existing code in get_downloadable() attempts to json.loads() every manifest and catches JSONDecodeError by falling back to a lower quality — meaning hi-res tracks were never downloaded at hi-res, silently and without a meaningful error.
The warning Failed to get manifest for {track_id}. Retrying with lower quality was the only indication something was wrong.
Changes
streamrip/client/tidal.pyget_downloadable() now checks manifestMimeType on JSONDecodeError and routes application/dash+xml manifests to a new handler instead of falling back
New _get_downloadable_from_dash() method parses the MPEG-DASH XML using xml.etree.ElementTree, extracts the initialization URL, all numbered media segment URLs from the , codec, and sample rate
streamrip/client/downloadable.pyNew TidalDASHDownloadable class inheriting from TidalDownloadable
Downloads initialization segment + all media segments sequentially, concatenating into a temporary .mp4 file
Remuxes from MP4 container to FLAC using ffmpeg -c copy (lossless, no re-encoding) — necessary because Tidal wraps FLAC audio in an MP4/ISOBMFF container even when the codec is FLAC
Fully compatible with existing progress tracking, size reporting, and download pipeline
Requirements
ffmpeg must be installed and available on PATH. This is already a soft dependency of streamrip for other features.
Testing
All HI_RES_LOSSLESS tracks now correctly download as 24-bit FLAC. Tracks that Tidal's API reports as HIGH (AAC) on a per-track basis continue to download correctly as .m4a — this is a Tidal-side licensing limitation and not something that can be addressed client-side.
This fork and pull request were researched, debugged, and written with the assistance of Claude AI (Anthropic). The root cause analysis, code changes, and testing approach were developed collaboratively through an iterative debugging session. The human contributor verified and tested all changes against a live Tidal HiFi account.