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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ 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.

## Features

- Fast, concurrent downloads powered by `aiohttp`
Expand Down
102 changes: 102 additions & 0 deletions streamrip/client/downloadable.py
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,108 @@ 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"
# 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"
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 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.PIPE,
)
_, 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:]}"
)

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