Files
Jonathan 0325467aba fix(worker): smooth Qobuz download progress via streamrip byte hook
streamrip reports progress only per concurrent track, so Job.downloadProgress
came solely from the file-count poller — 0% until every track landed, then a
jump to 100%. Patch streamrip.media.track.get_progress_callback to aggregate
per-track byte deltas over an estimated album total (avg started-track size ×
len(album.tracks)) into one smooth, generally-climbing 0..1 fraction routed
through the pipeline's on_progress. Capped at 0.99; the pipeline sets 1.0 on
completion. No signature changes — track count read from the resolved album.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 02:37:08 +02:00

222 lines
9.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import asyncio
import hashlib
import http.client
import os
from typing import Callable
# Qobuz's CDN returns responses with more than Python's default 100-header limit for some
# tracks, so http.client raises HTTPException("got more than 100 headers"), streamrip skips
# that track, and it lands as a 0-byte file (seen on Bruno Mars "24K Magic" — track 1). Raise
# the limit process-wide so those downloads succeed. (Set on import of the Qobuz adapter.)
http.client._MAXHEADERS = 1000
def _hashed_password(password: str) -> str:
"""streamrip's Qobuz email/password login expects the password as an MD5 hex digest."""
return hashlib.md5(password.encode("utf-8")).hexdigest()
_AUDIO_EXT = {".flac", ".mp3", ".m4a", ".opus", ".ogg", ".aac", ".wav"}
# streamrip holds module-global asyncio primitives (a download-concurrency Semaphore) that
# bind to the running event loop on first use. `asyncio.run` creates a NEW loop per call and
# closes it, orphaning that Semaphore, so the 2nd+ download fails with "Semaphore ... is bound
# to a different event loop" and its tracks silently fail (-> incomplete download). Running every
# streamrip coroutine on ONE persistent loop keeps those globals valid across downloads. The
# worker processes one job at a time, so a single shared loop is safe.
_loop: "asyncio.AbstractEventLoop | None" = None
def _run(coro):
global _loop
if _loop is None or _loop.is_closed():
_loop = asyncio.new_event_loop()
return _loop.run_until_complete(coro)
class _ProgressAggregator:
"""Turn streamrip's per-track byte callbacks into one album-level 0..1 fraction.
streamrip downloads an album's tracks concurrently and reports progress only per track
(a byte delta per chunk), so the file-count poller sees 0 complete files until they all
land at once — the 0%→100% jump. Summing downloaded bytes over an *estimated* album total
(average started-track size × track count) gives a smooth, generally-climbing fraction.
Track sizes are similar, so the estimate is stable once a track or two has started; capped
at 0.99 because the pipeline sets 1.0 once the whole download returns. All streamrip
coroutines run on one event loop on the calling thread, so no locking is needed."""
def __init__(self, track_count: int, on_progress: Callable[[float], None]):
self._n = track_count or 0
self._on_progress = on_progress
self._seen_total = 0 # summed byte-size of tracks that have started downloading
self._seen_count = 0 # number of tracks that have started
self._done_bytes = 0 # summed bytes downloaded across all started tracks
def start_track(self, total) -> None:
self._seen_total += max(int(total or 0), 0)
self._seen_count += 1
def advance(self, delta) -> None:
self._done_bytes += max(int(delta or 0), 0)
if self._seen_count == 0:
return
avg = self._seen_total / self._seen_count
n = self._n if self._n > 0 else self._seen_count
est_total = avg * n
if est_total > 0:
self._on_progress(min(self._done_bytes / est_total, 0.99))
class _ProgressHandle:
"""streamrip's `Track.download` uses `with get_progress_callback(...) as cb:`; `cb` must be
the per-chunk byte callback. This stand-in yields that callback and no-ops on exit."""
def __init__(self, update: Callable[[int], None]):
self.update = update
def __enter__(self):
return self.update
def __exit__(self, *_):
return False
def _flatten_audio(dest: str) -> int:
"""streamrip writes into a nested `Artist - Album [...]/` subfolder. Move any audio
files up into `dest`, remove the now-empty subfolders, and return the count of audio
files directly in `dest` — the REAL number downloaded, so a partial download (streamrip
continues past per-track failures) reports its true short count to the completeness check.
"""
dest = os.path.abspath(dest)
for root, _dirs, files in os.walk(dest):
if os.path.abspath(root) == dest:
continue
for name in files:
if os.path.splitext(name)[1].lower() not in _AUDIO_EXT:
continue
src = os.path.join(root, name)
target = os.path.join(dest, name)
if os.path.exists(target):
base, ext = os.path.splitext(name)
target = os.path.join(dest, f"{base} ({os.path.basename(root)}){ext}")
os.rename(src, target)
for root, _dirs, _files in os.walk(dest, topdown=False):
if os.path.abspath(root) != dest:
try:
os.rmdir(root)
except OSError:
pass # subfolder still has non-audio files (e.g. cover art) — leave it
return sum(1 for f in os.listdir(dest) if os.path.splitext(f)[1].lower() in _AUDIO_EXT)
class StreamripClient:
"""Real Qobuz client backed by streamrip (v2, async → sync via asyncio.run).
NOT unit-tested offline; see test_qobuz_live.py. streamrip is imported lazily
inside the async helpers so importing/constructing this class stays offline.
"""
def __init__(self, config: dict):
self._email = config.get("qobuz.email", "")
self._password = config.get("qobuz.password", "")
self._user_id = config.get("qobuz.user_id", "")
self._token = config.get("qobuz.auth_token", "")
def is_configured(self) -> bool:
# either a user_id + auth token, or an email + password
return bool((self._user_id and self._token) or (self._email and self._password))
def _make_config(self, download_folder: str | None = None):
from streamrip.config import Config
cfg = Config.defaults()
q = cfg.session.qobuz
if self._user_id and self._token:
# token auth: more reliable than email/password, which Qobuz often rejects
q.use_auth_token = True
q.email_or_userid = self._user_id
q.password_or_token = self._token
else:
q.use_auth_token = False
q.email_or_userid = self._email
q.password_or_token = _hashed_password(self._password)
q.quality = 3
if download_folder is not None:
cfg.session.downloads.folder = download_folder
return cfg
def search_album(self, artist: str, album: str) -> list[dict]:
return _run(self._search(f"{artist} {album}"))
async def _search(self, query: str) -> list[dict]:
from streamrip.client import QobuzClient
client = QobuzClient(self._make_config())
try:
try:
await client.login()
except Exception as e: # never let streamrip's credential-bearing error escape
raise RuntimeError(f"Qobuz login failed ({type(e).__name__}) — check credentials") from None
pages = await client.search("album", query, limit=5)
finally:
await client.session.close()
results: list[dict] = []
for page in pages:
for item in page.get("albums", {}).get("items", []):
results.append(
{
"source_ref": str(item.get("id", "")),
"title": item.get("title", ""),
"artist": (item.get("artist") or {}).get("name", ""),
"track_count": item.get("tracks_count", 0) or 0,
"bit_depth": item.get("maximum_bit_depth", 16) or 16,
"sampling_rate": item.get("maximum_sampling_rate", 44.1) or 44.1,
}
)
return results
def download(self, source_ref: str, dest: str, on_progress: Callable[[float], None]) -> dict:
_run(self._download(source_ref, dest, on_progress))
# Count the files actually written and flatten streamrip's nested output into dest
# so the tag stage can organize it. A partial download -> short count -> needs_attention.
return {"track_count": _flatten_audio(dest), "path": dest}
async def _download(self, album_id: str, dest: str, on_progress: Callable[[float], None]) -> None:
os.makedirs(dest, exist_ok=True)
import streamrip.media.track as _track_mod
from streamrip.client import QobuzClient
from streamrip.db import Database, Dummy
from streamrip.media import PendingAlbum
cfg = self._make_config(download_folder=dest)
client = QobuzClient(cfg)
try:
try:
await client.login()
except Exception as e: # never let streamrip's credential-bearing error escape
raise RuntimeError(f"Qobuz login failed ({type(e).__name__}) — check credentials") from None
on_progress(0.0)
db = Database(downloads=Dummy(), failed=Dummy())
pending = PendingAlbum(album_id, client, cfg, db)
album = await pending.resolve()
if album is None:
raise RuntimeError(f"could not resolve Qobuz album {album_id}")
# Hook streamrip's per-track byte progress into a single album-level fraction so the
# Floor bar climbs smoothly instead of jumping 0→100. `Track.download` binds
# get_progress_callback at import, so patch it on the track module (not progress.py).
agg = _ProgressAggregator(len(getattr(album, "tracks", []) or []), on_progress)
_orig_cb = _track_mod.get_progress_callback
def _hook(_enabled, total, _desc):
agg.start_track(total)
return _ProgressHandle(agg.advance)
_track_mod.get_progress_callback = _hook
try:
await album.rip()
finally:
_track_mod.get_progress_callback = _orig_cb
on_progress(1.0)
finally:
await client.session.close()