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
No comments:
Post a Comment