Skip to content
Merged
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
65 changes: 57 additions & 8 deletions streamrip/client/downloadable.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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):
Expand Down