Index | Thread | Search

From:
Kirill A. Korinsky <kirill@korins.ky>
Subject:
www/qobuz-dl: update to 0.9.9.10pl20250719
To:
OpenBSD ports <ports@openbsd.org>
Date:
Sun, 05 Apr 2026 20:58:37 +0200

Download raw body.

Thread
  • Kirill A. Korinsky:

    www/qobuz-dl: update to 0.9.9.10pl20250719

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