From: Kirill A. Korinsky Subject: www/qobuz-dl: update to 0.9.9.10pl20250719 To: OpenBSD ports Date: Sun, 05 Apr 2026 20:58:37 +0200 ports@, I'd like to update www/qobuz-dl to last commit from it's github with backport nessesary patches to make it work again. Tested on -current/amd64, work again. Not sure that mix of MODPY_DISTV and GH_COMMIT is good idea, but MODPY_DISTV is used in PLIST already. Ok? Index: Makefile =================================================================== RCS file: /home/cvs/ports/www/qobuz-dl/Makefile,v diff -u -p -r1.3 Makefile --- Makefile 29 Apr 2025 10:40:31 -0000 1.3 +++ Makefile 5 Apr 2026 18:53:08 -0000 @@ -1,9 +1,11 @@ COMMENT = music downloader for Qobuz -MODPY_DISTV = 0.9.9.10 +MODPY_DISTV = 0.9.9.10 -DISTNAME = qobuz-dl-${MODPY_DISTV} -REVISION = 1 +GH_ACCOUNT = vitiko98 +GH_PROJECT = qobuz-dl +GH_COMMIT = 9c8901dc2f161bb93866b073d6855d3be3ab1ad1 +DISTNAME = qobuz-dl-${MODPY_DISTV}pl20250719 CATEGORIES = www audio @@ -14,7 +16,6 @@ PERMIT_PACKAGE = Yes MODULES = lang/python -MODPY_PI = Yes MODPY_PYBUILD = setuptools RUN_DEPENDS = audio/py-mutagen \ @@ -22,6 +23,8 @@ RUN_DEPENDS = audio/py-mutagen \ devel/py-pathvalidate \ devel/py-pick \ devel/py-tqdm \ + graphics/ffmpeg \ + security/py-cryptography \ www/py-beautifulsoup4 \ www/py-requests Index: distinfo =================================================================== RCS file: /home/cvs/ports/www/qobuz-dl/distinfo,v diff -u -p -r1.1.1.1 distinfo --- distinfo 22 Nov 2024 19:17:36 -0000 1.1.1.1 +++ distinfo 5 Apr 2026 18:46:21 -0000 @@ -1,2 +1,2 @@ -SHA256 (qobuz-dl-0.9.9.10.tar.gz) = q7TUl3scg+isoLB0xJvJLCtvJU7O+ogMlftt0O73qb4= -SIZE (qobuz-dl-0.9.9.10.tar.gz) = 35976 +SHA256 (qobuz-dl-0.9.9.10pl20250719-9c8901dc.tar.gz) = M9un+u8J5y0Ia9e3Q1HwOMTAXZVAYP/ZxXsR+GCS69Q= +SIZE (qobuz-dl-0.9.9.10pl20250719-9c8901dc.tar.gz) = 32952 Index: patches/patch-qobuz_dl_core_py =================================================================== RCS file: /home/cvs/ports/www/qobuz-dl/patches/patch-qobuz_dl_core_py,v diff -u -p -r1.1.1.1 patch-qobuz_dl_core_py --- patches/patch-qobuz_dl_core_py 22 Nov 2024 19:17:36 -0000 1.1.1.1 +++ patches/patch-qobuz_dl_core_py 5 Apr 2026 18:49:52 -0000 @@ -1,4 +1,5 @@ https://github.com/vitiko98/qobuz-dl/pull/179 +https://github.com/vitiko98/qobuz-dl/issues/328 Index: qobuz_dl/core.py --- qobuz_dl/core.py.orig Index: patches/patch-qobuz_dl_downloader_py =================================================================== RCS file: patches/patch-qobuz_dl_downloader_py diff -N patches/patch-qobuz_dl_downloader_py --- /dev/null 1 Jan 1970 00:00:00 -0000 +++ patches/patch-qobuz_dl_downloader_py 5 Apr 2026 18:50:01 -0000 @@ -0,0 +1,170 @@ +https://github.com/vitiko98/qobuz-dl/issues/328 + +Index: qobuz_dl/downloader.py +--- qobuz_dl/downloader.py.orig ++++ qobuz_dl/downloader.py +@@ -1,8 +1,10 @@ + import logging + import os ++import subprocess + from typing import Tuple + + import requests ++from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + from pathvalidate import sanitize_filename, sanitize_filepath + from tqdm import tqdm + +@@ -196,9 +198,7 @@ class Download: + ): + extension = ".mp3" if is_mp3 else ".flac" + +- try: +- url = track_url_dict["url"] +- except KeyError: ++ if "url" not in track_url_dict and "url_template" not in track_url_dict: + logger.info(f"{OFF}Track not available for download") + return + +@@ -222,7 +222,10 @@ class Download: + logger.info(f"{OFF}{track_title} was already downloaded") + return + +- tqdm_download(url, filename, filename) ++ if "url" in track_url_dict: ++ tqdm_download(track_url_dict["url"], filename, filename) ++ else: ++ tqdm_download_segments(track_url_dict, filename, filename) + tag_function = metadata.tag_mp3 if is_mp3 else metadata.tag_flac + try: + tag_function( +@@ -325,6 +328,130 @@ def tqdm_download(url, fname, desc): + if total != download_size: + # https://stackoverflow.com/questions/69919912/requests-iter-content-thinks-file-is-complete-but-its-not + raise ConnectionError("File download was interrupted for " + fname) ++ ++ ++def tqdm_download_segments(track_url_dict, fname, desc): ++ tmp_fname = fname + ".mp4" ++ segment_uuid = None ++ total = 0 ++ for segment in range(track_url_dict["n_segments"] + 1): ++ r = requests.head( ++ track_url_dict["url_template"].replace("$SEGMENT$", str(segment)), ++ allow_redirects=True, ++ ) ++ r.raise_for_status() ++ total += int(r.headers.get("content-length", 0)) ++ ++ try: ++ with open(tmp_fname, "wb") as file, tqdm( ++ total=total, ++ unit="iB", ++ unit_scale=True, ++ unit_divisor=1024, ++ desc=desc, ++ bar_format=CYAN + "{n_fmt}/{total_fmt} /// {desc}", ++ ) as bar: ++ for segment in range(track_url_dict["n_segments"] + 1): ++ r = requests.get( ++ track_url_dict["url_template"].replace("$SEGMENT$", str(segment)), ++ allow_redirects=True, ++ stream=True, ++ ) ++ r.raise_for_status() ++ segment_total = int(r.headers.get("content-length", 0)) ++ segment_size = 0 ++ segment_data = bytearray() ++ for data in r.iter_content(chunk_size=1024): ++ segment_data.extend(data) ++ size = len(data) ++ bar.update(size) ++ segment_size += size ++ r.close() ++ ++ if segment_total and segment_total != segment_size: ++ raise ConnectionError("File download was interrupted for " + fname) ++ if segment == 1: ++ segment_uuid = _get_qobuz_segment_uuid(segment_data) ++ if segment_uuid is None: ++ raise requests.exceptions.ConnectionError( ++ "Cannot find Qobuz segment UUID for " + fname ++ ) ++ file.write( ++ _decrypt_qobuz_segment( ++ segment_data, track_url_dict["raw_key"], segment_uuid ++ ) ++ ) ++ ++ remux = subprocess.run(["ffmpeg", "-nostdin", "-v", "error", "-y", "-i", tmp_fname, "-c:a", "copy", "-f", "flac", fname], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True) ++ if remux.returncode != 0: ++ raise requests.exceptions.ConnectionError( ++ "File remux failed for {}: {}".format( ++ fname, remux.stderr.strip() or "ffmpeg exited with an error" ++ ) ++ ) ++ finally: ++ if os.path.isfile(tmp_fname): ++ os.remove(tmp_fname) ++ ++ ++def _get_qobuz_segment_uuid(segment_data): ++ pos = 0 ++ while pos + 24 <= len(segment_data): ++ size = int.from_bytes(segment_data[pos : pos + 4], "big") ++ if size <= 0 or pos + size > len(segment_data): ++ break ++ ++ if bytes(segment_data[pos + 4 : pos + 8]) == b"uuid": ++ return bytes(segment_data[pos + 8 : pos + 24]) ++ pos += size ++ return None ++ ++ ++def _decrypt_qobuz_segment(segment_data, raw_key, segment_uuid): ++ if segment_uuid is None: ++ return bytes(segment_data) ++ ++ buf = bytearray(segment_data) ++ pos = 0 ++ while pos + 8 <= len(buf): ++ size = int.from_bytes(buf[pos : pos + 4], "big") ++ if size <= 0 or pos + size > len(buf): ++ break ++ ++ if ( ++ bytes(buf[pos + 4 : pos + 8]) == b"uuid" ++ and bytes(buf[pos + 8 : pos + 24]) == segment_uuid ++ ): ++ pointer = pos + 28 ++ data_end = pos + int.from_bytes(buf[pointer : pointer + 4], "big") ++ pointer += 4 ++ counter_len = buf[pointer] ++ pointer += 1 ++ frame_count = int.from_bytes(buf[pointer : pointer + 3], "big") ++ pointer += 3 ++ ++ for _ in range(frame_count): ++ frame_len = int.from_bytes(buf[pointer : pointer + 4], "big") ++ pointer += 6 ++ flags = int.from_bytes(buf[pointer : pointer + 2], "big") ++ pointer += 2 ++ frame_start = data_end ++ frame_end = frame_start + frame_len ++ data_end = frame_end ++ ++ if flags: ++ counter = bytes(buf[pointer : pointer + counter_len]) + ( ++ b"\x00" * (16 - counter_len) ++ ) ++ decryptor = Cipher( ++ algorithms.AES(raw_key), modes.CTR(counter) ++ ).decryptor() ++ buf[frame_start:frame_end] = decryptor.update( ++ bytes(buf[frame_start:frame_end]) ++ ) + decryptor.finalize() ++ pointer += counter_len ++ pos += size ++ return bytes(buf) + + + def _get_description(item: dict, track_title, multiple=None): Index: patches/patch-qobuz_dl_qopy_py =================================================================== RCS file: /home/cvs/ports/www/qobuz-dl/patches/patch-qobuz_dl_qopy_py,v diff -u -p -r1.1.1.1 patch-qobuz_dl_qopy_py --- patches/patch-qobuz_dl_qopy_py 22 Nov 2024 19:17:36 -0000 1.1.1.1 +++ patches/patch-qobuz_dl_qopy_py 5 Apr 2026 18:50:17 -0000 @@ -1,17 +1,187 @@ https://github.com/vitiko98/qobuz-dl/issues/261 +https://github.com/vitiko98/qobuz-dl/issues/329 +https://github.com/vitiko98/qobuz-dl/issues/328 + Index: qobuz_dl/qopy.py --- qobuz_dl/qopy.py.orig +++ qobuz_dl/qopy.py -@@ -122,12 +122,8 @@ class Client: +@@ -2,11 +2,15 @@ + # of qopy, originally written by Sorrow446. All credits to the + # original author. ++import base64 + import hashlib + import logging + import time + + import requests ++from cryptography.hazmat.primitives import hashes, padding ++from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes ++from cryptography.hazmat.primitives.kdf.hkdf import HKDF + + from qobuz_dl.exceptions import ( + AuthenticationError, +@@ -38,6 +42,9 @@ class Client: + ) + self.base = "https://www.qobuz.com/api.json/0.2/" + self.sec = None ++ self.session_id = None ++ self.session_infos = None ++ self.session_key = None + self.auth(email, pwd) + self.cfg_setup() + +@@ -103,10 +110,43 @@ class Client: + "format_id": fmt_id, + "intent": "stream", + } ++ elif epoint == "session/start": ++ params = {"profile": "qbz-1"} ++ params["request_ts"] = int(time.time()) ++ params["request_sig"] = self._modern_sig( ++ epoint, params, kwargs.get("sec", self.sec) ++ ) ++ elif epoint == "file/url": ++ track_id = kwargs["id"] ++ fmt_id = kwargs["fmt_id"] ++ if int(fmt_id) not in (6, 7, 27): ++ raise InvalidQuality("Invalid quality id: choose between 6, 7 or 27") ++ params = { ++ "track_id": track_id, ++ "format_id": fmt_id, ++ "intent": "import", ++ } ++ params["request_ts"] = int(time.time()) ++ params["request_sig"] = self._modern_sig( ++ epoint, params, kwargs.get("sec", self.sec) ++ ) + else: + params = kwargs +- r = self.session.get(self.base + epoint, params=params) ++ + if epoint == "user/login": ++ r = self.session.post(self.base + epoint, data=params) ++ print("DEBUG params:", params) ++ print("DEBUG:", r.status_code, r.text) ++ elif epoint == "session/start": ++ r = self.session.post( ++ self.base + epoint, ++ data=params, ++ headers={"Content-Type": "application/x-www-form-urlencoded"}, ++ ) ++ else: ++ r = self.session.get(self.base + epoint, params=params) ++ ++ if epoint == "user/login": + if r.status_code == 401: + raise AuthenticationError("Invalid credentials.\n" + RESET) + elif r.status_code == 400: +@@ -114,7 +154,7 @@ class Client: + else: + logger.info(f"{GREEN}Logged: OK") + elif ( +- epoint in ["track/getFileUrl", "favorite/getUserFavorites"] ++ epoint in ["track/getFileUrl", "favorite/getUserFavorites", "file/url"] + and r.status_code == 400 + ): + raise InvalidAppSecretError(f"Invalid app secret: {r.json()}.\n" + RESET) +@@ -122,14 +162,67 @@ class Client: + r.raise_for_status() + return r.json() + ++ def _modern_sig(self, epoint, params, sec): ++ object_, method = epoint.split("/") ++ r_sig = [object_, method] ++ for key in sorted(params): ++ value = params[key] ++ if key not in ("request_ts", "request_sig") and isinstance( ++ value, (str, int, float) ++ ): ++ r_sig.extend((key, str(value))) ++ r_sig.extend((str(params["request_ts"]), sec)) ++ return hashlib.md5("".join(r_sig).encode("utf-8")).hexdigest() ++ ++ @staticmethod ++ def _b64url_decode(value): ++ return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) ++ ++ def _derive_session_key(self): ++ salt, info = self.session_infos.split(".") ++ return HKDF( ++ algorithm=hashes.SHA256(), ++ length=16, ++ salt=self._b64url_decode(salt), ++ info=self._b64url_decode(info), ++ ).derive(bytes.fromhex(self.sec)) ++ ++ def _unwrap_track_key(self, key_token): ++ _, wrapped, iv = key_token.split(".") ++ decryptor = Cipher( ++ algorithms.AES(self.session_key), ++ modes.CBC(self._b64url_decode(iv)), ++ ).decryptor() ++ padded = decryptor.update(self._b64url_decode(wrapped)) + decryptor.finalize() ++ unpadder = padding.PKCS7(128).unpadder() ++ return unpadder.update(padded) + unpadder.finalize() ++ def auth(self, email, pwd): - usr_info = self.api_call("user/login", email=email, pwd=pwd) +- usr_info = self.api_call("user/login", email=email, pwd=pwd) - if not usr_info["user"]["credential"]["parameters"]: - raise IneligibleError("Free accounts are not eligible to download tracks.") - self.uat = usr_info["user_auth_token"] +- self.uat = usr_info["user_auth_token"] ++ # Direct API login replaced by OAuth+reCAPTCHA. ++ # pwd holds the current user_auth_token; we refresh it via extra=partner. ++ self.session.headers.update({"X-User-Auth-Token": pwd}) ++ r = self.session.post(self.base + "user/login", data={"extra": "partner"}) ++ if r.status_code == 401: ++ raise AuthenticationError( ++ "Token expired or invalid. Get a fresh token from your browser:\n" ++ " DevTools -> Network -> user/login POST -> Response -> user_auth_token\n" ++ " Then run: qobuz-dl -r (paste the token as the password)" ++ ) ++ r.raise_for_status() ++ data = r.json() ++ self.uat = data["user_auth_token"] self.session.headers.update({"X-User-Auth-Token": self.uat}) - self.label = usr_info["user"]["credential"]["parameters"]["short_label"] - logger.info(f"{GREEN}Membership: {self.label}") ++ # Persist refreshed token back to config ++ import configparser, os ++ config_file = os.path.join(os.environ.get("HOME", ""), ".config", "qobuz-dl", "config.ini") ++ if os.path.exists(config_file): ++ c = configparser.ConfigParser() ++ c.read(config_file) ++ if c["DEFAULT"].get("password") != self.uat: ++ c["DEFAULT"]["password"] = self.uat ++ with open(config_file, "w") as f: ++ c.write(f) ++ logger.info(f"{GREEN}Token refreshed and saved.") def multi_meta(self, epoint, key, id, type): total = 1 +@@ -154,7 +247,24 @@ class Client: + return self.api_call("track/get", id=id) + + def get_track_url(self, id, fmt_id): +- return self.api_call("track/getFileUrl", id=id, fmt_id=fmt_id) ++ if int(fmt_id) == 5: ++ return self.api_call("track/getFileUrl", id=id, fmt_id=fmt_id) ++ ++ if self.session_id is None: ++ session = self.api_call("session/start") ++ self.session_id = session["session_id"] ++ self.session_infos = session["infos"] ++ self.session_key = self._derive_session_key() ++ self.session.headers.update({"X-Session-Id": self.session_id}) ++ ++ track = self.api_call("file/url", id=id, fmt_id=fmt_id) ++ if "bits_depth" in track and "bit_depth" not in track: ++ track["bit_depth"] = track["bits_depth"] ++ if track.get("sampling_rate", 0) > 1000: ++ track["sampling_rate"] = track["sampling_rate"] / 1000 ++ if "key" in track: ++ track["raw_key"] = self._unwrap_track_key(track["key"]) ++ return track + + def get_artist_meta(self, id): + return self.multi_meta("artist/get", "albums_count", id, None) -- wbr, Kirill