Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
53 changes: 53 additions & 0 deletions streamrip/client/downloadable.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
65 changes: 63 additions & 2 deletions streamrip/client/tidal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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":
Expand All @@ -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.

Expand Down