From 2bb0dbada2b893fadee176e4234fbb4754219a3d Mon Sep 17 00:00:00 2001 From: Rob Stanfield Date: Wed, 17 Dec 2025 18:18:14 -0800 Subject: [PATCH] Qobuz: Fix authentication and add automatic credential fetching Qobuz API now requires intent=stream parameter for stream URL requests, and the app_secret must be extracted using the Spoofbuz decoding method from bundle.js rather than plain-text values. Changes: - Add intent=stream parameter to stream URL requests - Add QobuzCredentialFetcher class to extract credentials from web player - Add "Fetch Credentials" button to Qobuz settings page - Decode obfuscated app secrets using seed/timezone/info/extras method This fixes "Invalid Request Signature" errors that prevented playback. --- CMakeLists.txt | 2 + src/qobuz/qobuzcredentialfetcher.cpp | 277 +++++++++++++++++++++++++++ src/qobuz/qobuzcredentialfetcher.h | 59 ++++++ src/qobuz/qobuzservice.h | 2 + src/qobuz/qobuzstreamurlrequest.cpp | 1 + src/settings/qobuzsettingspage.cpp | 42 +++- src/settings/qobuzsettingspage.h | 5 + src/settings/qobuzsettingspage.ui | 10 + 8 files changed, 397 insertions(+), 1 deletion(-) create mode 100644 src/qobuz/qobuzcredentialfetcher.cpp create mode 100644 src/qobuz/qobuzcredentialfetcher.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 4fbef4873..9db5a974a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1463,6 +1463,7 @@ optional_source(HAVE_QOBUZ src/qobuz/qobuzrequest.cpp src/qobuz/qobuzstreamurlrequest.cpp src/qobuz/qobuzfavoriterequest.cpp + src/qobuz/qobuzcredentialfetcher.cpp src/settings/qobuzsettingspage.cpp src/covermanager/qobuzcoverprovider.cpp HEADERS @@ -1472,6 +1473,7 @@ optional_source(HAVE_QOBUZ src/qobuz/qobuzrequest.h src/qobuz/qobuzstreamurlrequest.h src/qobuz/qobuzfavoriterequest.h + src/qobuz/qobuzcredentialfetcher.h src/settings/qobuzsettingspage.h src/covermanager/qobuzcoverprovider.h UI diff --git a/src/qobuz/qobuzcredentialfetcher.cpp b/src/qobuz/qobuzcredentialfetcher.cpp new file mode 100644 index 000000000..2b0ac8e5f --- /dev/null +++ b/src/qobuz/qobuzcredentialfetcher.cpp @@ -0,0 +1,277 @@ +/* + * Strawberry Music Player + * Copyright 2019-2025, Jonas Kvinge + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "includes/shared_ptr.h" +#include "core/logging.h" +#include "core/networkaccessmanager.h" +#include "qobuzcredentialfetcher.h" + +using namespace Qt::Literals::StringLiterals; + +namespace { +constexpr char kLoginPageUrl[] = "https://play.qobuz.com/login"; +constexpr char kPlayQobuzUrl[] = "https://play.qobuz.com"; +} // namespace + +QobuzCredentialFetcher::QobuzCredentialFetcher(const SharedPtr network, QObject *parent) + : QObject(parent), + network_(network), + login_page_reply_(nullptr), + bundle_reply_(nullptr) {} + +void QobuzCredentialFetcher::FetchCredentials() { + + qLog(Debug) << "Qobuz: Fetching credentials from web player"; + + QNetworkRequest request(QUrl(QString::fromLatin1(kLoginPageUrl))); + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); + request.setHeader(QNetworkRequest::UserAgentHeader, u"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"_s); + + login_page_reply_ = network_->get(request); + QObject::connect(login_page_reply_, &QNetworkReply::finished, this, &QobuzCredentialFetcher::LoginPageReceived); + +} + +void QobuzCredentialFetcher::LoginPageReceived() { + + if (!login_page_reply_) return; + + QNetworkReply *reply = login_page_reply_; + login_page_reply_ = nullptr; + + if (reply->error() != QNetworkReply::NoError) { + QString error = QStringLiteral("Failed to fetch login page: %1").arg(reply->errorString()); + qLog(Error) << "Qobuz:" << error; + reply->deleteLater(); + Q_EMIT CredentialsFetchError(error); + return; + } + + const QString login_page = QString::fromUtf8(reply->readAll()); + reply->deleteLater(); + + // Extract bundle.js URL from the login page + // Pattern: + static const QRegularExpression bundle_url_regex(u""_s); + const QRegularExpressionMatch bundle_match = bundle_url_regex.match(login_page); + + if (!bundle_match.hasMatch()) { + QString error = u"Failed to find bundle.js URL in login page"_s; + qLog(Error) << "Qobuz:" << error; + Q_EMIT CredentialsFetchError(error); + return; + } + + bundle_url_ = bundle_match.captured(1); + qLog(Debug) << "Qobuz: Found bundle URL:" << bundle_url_; + + // Fetch the bundle.js + QNetworkRequest request(QUrl(QString::fromLatin1(kPlayQobuzUrl) + bundle_url_)); + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); + request.setHeader(QNetworkRequest::UserAgentHeader, u"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"_s); + + bundle_reply_ = network_->get(request); + QObject::connect(bundle_reply_, &QNetworkReply::finished, this, &QobuzCredentialFetcher::BundleReceived); + +} + +void QobuzCredentialFetcher::BundleReceived() { + + if (!bundle_reply_) return; + + QNetworkReply *reply = bundle_reply_; + bundle_reply_ = nullptr; + + if (reply->error() != QNetworkReply::NoError) { + QString error = QStringLiteral("Failed to fetch bundle.js: %1").arg(reply->errorString()); + qLog(Error) << "Qobuz:" << error; + reply->deleteLater(); + Q_EMIT CredentialsFetchError(error); + return; + } + + const QString bundle = QString::fromUtf8(reply->readAll()); + reply->deleteLater(); + + qLog(Debug) << "Qobuz: Bundle size:" << bundle.length(); + + const QString app_id = ExtractAppId(bundle); + if (app_id.isEmpty()) { + QString error = u"Failed to extract app_id from bundle"_s; + qLog(Error) << "Qobuz:" << error; + Q_EMIT CredentialsFetchError(error); + return; + } + + const QString app_secret = ExtractAppSecret(bundle); + if (app_secret.isEmpty()) { + QString error = u"Failed to extract app_secret from bundle"_s; + qLog(Error) << "Qobuz:" << error; + Q_EMIT CredentialsFetchError(error); + return; + } + + qLog(Debug) << "Qobuz: Successfully extracted credentials - app_id:" << app_id; + Q_EMIT CredentialsFetched(app_id, app_secret); + +} + +QString QobuzCredentialFetcher::ExtractAppId(const QString &bundle) { + + // Pattern: production:{api:{appId:"(\d+)" + static const QRegularExpression app_id_regex(u"production:\\{api:\\{appId:\"(\\d+)\""_s); + const QRegularExpressionMatch app_id_match = app_id_regex.match(bundle); + + if (app_id_match.hasMatch()) { + return app_id_match.captured(1); + } + + return QString(); + +} + +QString QobuzCredentialFetcher::ExtractAppSecret(const QString &bundle) { + + // The plain-text appSecret in the bundle doesn't work for API requests. + // We need to use the Spoofbuz method to extract the real secrets: + // 1. Find seed/timezone pairs + // 2. Find info/extras for each timezone + // 3. Combine seed + info + extras, remove last 44 chars, base64 decode + + // Pattern to find seed and timezone: [a-z].initialSeed("seed",window.utimezone.timezone) + static const QRegularExpression seed_regex(u"[a-z]\\.initialSeed\\(\"([\\w=]+)\",window\\.utimezone\\.([a-z]+)\\)"_s); + + QMap seeds; // timezone -> seed + QRegularExpressionMatchIterator seed_iter = seed_regex.globalMatch(bundle); + while (seed_iter.hasNext()) { + const QRegularExpressionMatch seed_match = seed_iter.next(); + const QString seed = seed_match.captured(1); + const QString tz = seed_match.captured(2); + seeds[tz] = seed; + qLog(Debug) << "Qobuz: Found seed for timezone" << tz; + } + + if (seeds.isEmpty()) { + qLog(Error) << "Qobuz: No seed/timezone pairs found in bundle"; + return QString(); + } + + // Try each timezone - Berlin was confirmed working + const QStringList preferred_order = {u"berlin"_s, u"london"_s, u"abidjan"_s}; + + for (const QString &tz : preferred_order) { + if (!seeds.contains(tz)) { + continue; + } + + // Pattern to find info and extras for this timezone + // name:"xxx/Berlin",info:"...",extras:"..." + const QString capitalized_tz = tz.at(0).toUpper() + tz.mid(1); + const QString info_pattern = QStringLiteral("name:\"\\w+/%1\",info:\"([\\w=]+)\",extras:\"([\\w=]+)\"").arg(capitalized_tz); + const QRegularExpression info_regex(info_pattern); + const QRegularExpressionMatch info_match = info_regex.match(bundle); + + if (!info_match.hasMatch()) { + qLog(Debug) << "Qobuz: No info/extras found for timezone" << tz; + continue; + } + + const QString seed = seeds[tz]; + const QString info = info_match.captured(1); + const QString extras = info_match.captured(2); + + qLog(Debug) << "Qobuz: Decoding secret for timezone" << tz; + + // Combine seed + info + extras + const QString combined = seed + info + extras; + + // Remove last 44 characters + if (combined.length() <= 44) { + qLog(Debug) << "Qobuz: Combined string too short for timezone" << tz; + continue; + } + const QString trimmed = combined.left(combined.length() - 44); + + // Base64 decode + const QByteArray decoded = QByteArray::fromBase64(trimmed.toLatin1()); + const QString secret = QString::fromLatin1(decoded); + + // Validate: should be 32 hex characters + static const QRegularExpression hex_regex(u"^[a-f0-9]{32}$"_s); + if (hex_regex.match(secret).hasMatch()) { + qLog(Debug) << "Qobuz: Successfully decoded secret from timezone" << tz; + return secret; + } + + qLog(Debug) << "Qobuz: Decoded secret invalid for timezone" << tz; + } + + // Try any remaining timezones not in preferred order + for (auto it = seeds.constBegin(); it != seeds.constEnd(); ++it) { + const QString &tz = it.key(); + if (preferred_order.contains(tz)) { + continue; // Already tried + } + + const QString capitalized_tz = tz.at(0).toUpper() + tz.mid(1); + const QString info_pattern = QStringLiteral("name:\"\\w+/%1\",info:\"([\\w=]+)\",extras:\"([\\w=]+)\"").arg(capitalized_tz); + const QRegularExpression info_regex(info_pattern); + const QRegularExpressionMatch info_match = info_regex.match(bundle); + + if (!info_match.hasMatch()) { + continue; + } + + const QString seed = it.value(); + const QString info = info_match.captured(1); + const QString extras = info_match.captured(2); + + const QString combined = seed + info + extras; + if (combined.length() <= 44) { + continue; + } + const QString trimmed = combined.left(combined.length() - 44); + + const QByteArray decoded = QByteArray::fromBase64(trimmed.toLatin1()); + const QString secret = QString::fromLatin1(decoded); + + static const QRegularExpression hex_regex(u"^[a-f0-9]{32}$"_s); + if (hex_regex.match(secret).hasMatch()) { + qLog(Debug) << "Qobuz: Successfully decoded secret from timezone" << tz; + return secret; + } + } + + qLog(Error) << "Qobuz: Failed to decode any valid app_secret from bundle"; + return QString(); + +} diff --git a/src/qobuz/qobuzcredentialfetcher.h b/src/qobuz/qobuzcredentialfetcher.h new file mode 100644 index 000000000..0dfd4d293 --- /dev/null +++ b/src/qobuz/qobuzcredentialfetcher.h @@ -0,0 +1,59 @@ +/* + * Strawberry Music Player + * Copyright 2019-2025, Jonas Kvinge + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#ifndef QOBUZCREDENTIALFETCHER_H +#define QOBUZCREDENTIALFETCHER_H + +#include "config.h" + +#include +#include + +#include "includes/shared_ptr.h" + +class QNetworkReply; +class NetworkAccessManager; + +class QobuzCredentialFetcher : public QObject { + Q_OBJECT + + public: + explicit QobuzCredentialFetcher(const SharedPtr network, QObject *parent = nullptr); + + void FetchCredentials(); + + Q_SIGNALS: + void CredentialsFetched(const QString &app_id, const QString &app_secret); + void CredentialsFetchError(const QString &error); + + private Q_SLOTS: + void LoginPageReceived(); + void BundleReceived(); + + private: + QString ExtractAppId(const QString &bundle); + QString ExtractAppSecret(const QString &bundle); + + const SharedPtr network_; + QNetworkReply *login_page_reply_; + QNetworkReply *bundle_reply_; + QString bundle_url_; +}; + +#endif // QOBUZCREDENTIALFETCHER_H diff --git a/src/qobuz/qobuzservice.h b/src/qobuz/qobuzservice.h index 7bd34acb4..0e7882dfd 100644 --- a/src/qobuz/qobuzservice.h +++ b/src/qobuz/qobuzservice.h @@ -106,6 +106,8 @@ class QobuzService : public StreamingService { bool login_sent() const { return login_sent_; } bool login_attempts() const { return login_attempts_; } + SharedPtr network() const { return network_; } + uint GetStreamURL(const QUrl &url, QString &error); SharedPtr artists_collection_backend() override { return artists_collection_backend_; } diff --git a/src/qobuz/qobuzstreamurlrequest.cpp b/src/qobuz/qobuzstreamurlrequest.cpp index d3fe7f74c..9997ec7fa 100644 --- a/src/qobuz/qobuzstreamurlrequest.cpp +++ b/src/qobuz/qobuzstreamurlrequest.cpp @@ -115,6 +115,7 @@ void QobuzStreamURLRequest::GetStreamURL() { const quint64 timestamp = static_cast(QDateTime::currentSecsSinceEpoch()); ParamList params_to_sign = ParamList() << Param(u"format_id"_s, QString::number(service_->format())) + << Param(u"intent"_s, u"stream"_s) << Param(u"track_id"_s, QString::number(song_id_)); std::sort(params_to_sign.begin(), params_to_sign.end()); diff --git a/src/settings/qobuzsettingspage.cpp b/src/settings/qobuzsettingspage.cpp index 4f65e8f96..673b4bc29 100644 --- a/src/settings/qobuzsettingspage.cpp +++ b/src/settings/qobuzsettingspage.cpp @@ -38,6 +38,7 @@ #include "core/settings.h" #include "widgets/loginstatewidget.h" #include "qobuz/qobuzservice.h" +#include "qobuz/qobuzcredentialfetcher.h" #include "constants/qobuzsettings.h" using namespace Qt::Literals::StringLiterals; @@ -46,13 +47,15 @@ using namespace QobuzSettings; QobuzSettingsPage::QobuzSettingsPage(SettingsDialog *dialog, const SharedPtr service, QWidget *parent) : SettingsPage(dialog, parent), ui_(new Ui::QobuzSettingsPage), - service_(service) { + service_(service), + credential_fetcher_(nullptr) { ui_->setupUi(this); setWindowIcon(IconLoader::Load(u"qobuz"_s, true, 0, 32)); QObject::connect(ui_->button_login, &QPushButton::clicked, this, &QobuzSettingsPage::LoginClicked); QObject::connect(ui_->login_state, &LoginStateWidget::LogoutClicked, this, &QobuzSettingsPage::LogoutClicked); + QObject::connect(ui_->button_fetch_credentials, &QPushButton::clicked, this, &QobuzSettingsPage::FetchCredentialsClicked); QObject::connect(this, &QobuzSettingsPage::Login, &*service_, &StreamingService::LoginWithCredentials); @@ -186,3 +189,40 @@ void QobuzSettingsPage::LoginFailure(const QString &failure_reason) { QMessageBox::warning(this, tr("Authentication failed"), failure_reason); } + +void QobuzSettingsPage::FetchCredentialsClicked() { + + ui_->button_fetch_credentials->setEnabled(false); + ui_->button_fetch_credentials->setText(tr("Fetching...")); + + if (!credential_fetcher_) { + credential_fetcher_ = new QobuzCredentialFetcher(service_->network(), this); + QObject::connect(credential_fetcher_, &QobuzCredentialFetcher::CredentialsFetched, this, &QobuzSettingsPage::CredentialsFetched); + QObject::connect(credential_fetcher_, &QobuzCredentialFetcher::CredentialsFetchError, this, &QobuzSettingsPage::CredentialsFetchError); + } + + credential_fetcher_->FetchCredentials(); + +} + +void QobuzSettingsPage::CredentialsFetched(const QString &app_id, const QString &app_secret) { + + ui_->app_id->setText(app_id); + ui_->app_secret->setText(app_secret); + ui_->checkbox_base64_secret->setChecked(false); + + ui_->button_fetch_credentials->setEnabled(true); + ui_->button_fetch_credentials->setText(tr("Fetch Credentials")); + + QMessageBox::information(this, tr("Credentials fetched"), tr("App ID and secret have been successfully fetched from the Qobuz web player.")); + +} + +void QobuzSettingsPage::CredentialsFetchError(const QString &error) { + + ui_->button_fetch_credentials->setEnabled(true); + ui_->button_fetch_credentials->setText(tr("Fetch Credentials")); + + QMessageBox::warning(this, tr("Credential fetch failed"), error); + +} diff --git a/src/settings/qobuzsettingspage.h b/src/settings/qobuzsettingspage.h index 6576e9b4c..d6ffbb422 100644 --- a/src/settings/qobuzsettingspage.h +++ b/src/settings/qobuzsettingspage.h @@ -30,6 +30,7 @@ class QShowEvent; class QEvent; class SettingsDialog; class QobuzService; +class QobuzCredentialFetcher; class Ui_QobuzSettingsPage; class QobuzSettingsPage : public SettingsPage { @@ -55,10 +56,14 @@ class QobuzSettingsPage : public SettingsPage { void LogoutClicked(); void LoginSuccess(); void LoginFailure(const QString &failure_reason); + void FetchCredentialsClicked(); + void CredentialsFetched(const QString &app_id, const QString &app_secret); + void CredentialsFetchError(const QString &error); private: Ui_QobuzSettingsPage *ui_; const SharedPtr service_; + QobuzCredentialFetcher *credential_fetcher_; }; #endif // QOBUZSETTINGSPAGE_H diff --git a/src/settings/qobuzsettingspage.ui b/src/settings/qobuzsettingspage.ui index 8a0d9dd73..725395860 100644 --- a/src/settings/qobuzsettingspage.ui +++ b/src/settings/qobuzsettingspage.ui @@ -115,6 +115,16 @@ + + + + Fetch Credentials + + + Automatically fetch app ID and secret from Qobuz web player + + +