From 035aff5454cec23ff071404bdc2c6b331daab9f8 Mon Sep 17 00:00:00 2001 From: Jonas Kvinge Date: Sun, 24 Mar 2024 05:23:35 +0100 Subject: [PATCH] Add Open Tidal cover provider --- src/CMakeLists.txt | 2 + src/core/application.cpp | 2 + src/covermanager/opentidalcoverprovider.cpp | 437 ++++++++++++++++++++ src/covermanager/opentidalcoverprovider.h | 92 +++++ 4 files changed, 533 insertions(+) create mode 100644 src/covermanager/opentidalcoverprovider.cpp create mode 100644 src/covermanager/opentidalcoverprovider.h diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 629f1fd7..ff638d63 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -171,6 +171,7 @@ set(SOURCES covermanager/qobuzcoverprovider.cpp covermanager/musixmatchcoverprovider.cpp covermanager/spotifycoverprovider.cpp + covermanager/opentidalcoverprovider.cpp lyrics/lyricsproviders.cpp lyrics/lyricsprovider.cpp @@ -416,6 +417,7 @@ set(HEADERS covermanager/qobuzcoverprovider.h covermanager/musixmatchcoverprovider.h covermanager/spotifycoverprovider.h + covermanager/opentidalcoverprovider.h lyrics/lyricsproviders.h lyrics/lyricsprovider.h diff --git a/src/core/application.cpp b/src/core/application.cpp index 22802e41..0a813135 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -57,6 +57,7 @@ #include "covermanager/deezercoverprovider.h" #include "covermanager/musixmatchcoverprovider.h" #include "covermanager/spotifycoverprovider.h" +#include "covermanager/opentidalcoverprovider.h" #include "lyrics/lyricsproviders.h" #include "lyrics/geniuslyricsprovider.h" @@ -143,6 +144,7 @@ class ApplicationImpl { cover_providers->AddProvider(new DeezerCoverProvider(app, app->network())); cover_providers->AddProvider(new MusixmatchCoverProvider(app, app->network())); cover_providers->AddProvider(new SpotifyCoverProvider(app, app->network())); + cover_providers->AddProvider(new OpenTidalCoverProvider(app, app->network())); #ifdef HAVE_TIDAL cover_providers->AddProvider(new TidalCoverProvider(app, app->network())); #endif diff --git a/src/covermanager/opentidalcoverprovider.cpp b/src/covermanager/opentidalcoverprovider.cpp new file mode 100644 index 00000000..06bd0f29 --- /dev/null +++ b/src/covermanager/opentidalcoverprovider.cpp @@ -0,0 +1,437 @@ +/* + * Strawberry Music Player + * Copyright 2024, 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 +#include +#include +#include +#include + +#include "core/shared_ptr.h" +#include "core/application.h" +#include "core/networkaccessmanager.h" +#include "core/logging.h" +#include "utilities/timeconstants.h" +#include "albumcoverfetcher.h" +#include "jsoncoverprovider.h" +#include "opentidalcoverprovider.h" + +namespace { +constexpr char kSettingsGroup[] = "OpenTidal"; +constexpr char kAuthUrl[] = "https://auth.tidal.com/v1/oauth2/token"; +constexpr char kApiUrl[] = "https://openapi.tidal.com"; +constexpr char kApiClientIdB64[] = "RHBwV3FpTEM4ZFJSV1RJaQ=="; +constexpr char kApiClientSecretB64[] = "cGk0QmxpclZXQWlteWpBc0RnWmZ5RmVlRzA2b3E1blVBVTljUW1IdFhDST0="; +constexpr int kLimit = 10; +constexpr const int kRequestsDelay = 1000; +} // namespace + +using std::make_shared; + +OpenTidalCoverProvider::OpenTidalCoverProvider(Application *app, SharedPtr network, QObject *parent) + : JsonCoverProvider("OpenTidal", true, true, 2.5, true, true, app, network, parent), + login_timer_(new QTimer(this)), + timer_flush_requests_(new QTimer(this)), + login_in_progress_(false), + have_login_(false), + login_time_(0), + expires_in_(0) { + + login_timer_->setSingleShot(true); + QObject::connect(login_timer_, &QTimer::timeout, this, &OpenTidalCoverProvider::Login); + + timer_flush_requests_->setInterval(kRequestsDelay); + timer_flush_requests_->setSingleShot(false); + QObject::connect(timer_flush_requests_, &QTimer::timeout, this, &OpenTidalCoverProvider::FlushRequests); + + LoadSession(); + +} + +OpenTidalCoverProvider::~OpenTidalCoverProvider() { + + while (!replies_.isEmpty()) { + QNetworkReply *reply = replies_.takeFirst(); + QObject::disconnect(reply, nullptr, this, nullptr); + reply->abort(); + reply->deleteLater(); + } + +} + +bool OpenTidalCoverProvider::StartSearch(const QString &artist, const QString &album, const QString &title, const int id) { + + if (artist.isEmpty() || album.isEmpty()) return false; + + if (!have_login_ && !login_in_progress_ && QDateTime::currentDateTime().toSecsSinceEpoch() - last_login_attempt_.toSecsSinceEpoch() < 120) { + return false; + } + + SearchRequestPtr search_request = make_shared(id, artist, album, title); + search_requests_queue_ << search_request; + + if (!timer_flush_requests_->isActive()) { + timer_flush_requests_->start(); + } + + return true; + +} + +void OpenTidalCoverProvider::CancelSearch(const int id) { + Q_UNUSED(id); +} + +void OpenTidalCoverProvider::LoadSession() { + + QSettings s; + s.beginGroup(kSettingsGroup); + token_type_ = s.value("token_type").toString(); + access_token_ = s.value("access_token").toString(); + expires_in_ = s.value("expires_in", 0).toLongLong(); + login_time_ = s.value("login_time", 0).toLongLong(); + s.endGroup(); + + if (!token_type_.isEmpty() && !access_token_.isEmpty() && login_time_ > 0 && expires_in_ > 0) { + have_login_ = true; + } + + qint64 time = expires_in_ - (QDateTime::currentDateTime().toSecsSinceEpoch() - login_time_) - 30; + if (time < 2) time = 2000; + login_timer_->setInterval(static_cast(time * kMsecPerSec)); + login_timer_->start(); + +} + +void OpenTidalCoverProvider::FlushRequests() { + + if (!have_login_) { + if (!login_in_progress_) { + Login(); + } + return; + } + + if (!search_requests_queue_.isEmpty()) { + SendSearchRequest(search_requests_queue_.dequeue()); + return; + } + + timer_flush_requests_->stop(); + +} + +void OpenTidalCoverProvider::Login() { + + have_login_ = false; + login_in_progress_ = true; + last_login_attempt_ = QDateTime::currentDateTime(); + + QUrl url(kAuthUrl); + QNetworkRequest req(url); + req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); + req.setRawHeader("Authorization", "Basic " + QByteArray(QByteArray::fromBase64(kApiClientIdB64) + ":" + QByteArray::fromBase64(kApiClientSecretB64)).toBase64()); + QUrlQuery url_query; + url_query.addQueryItem("grant_type", "client_credentials"); + QNetworkReply *reply = network_->post(req, url_query.toString(QUrl::FullyEncoded).toUtf8()); + replies_ << reply; + QObject::connect(reply, &QNetworkReply::sslErrors, this, &OpenTidalCoverProvider::HandleLoginSSLErrors); + QObject::connect(reply, &QNetworkReply::finished, this, [this, reply]() { LoginFinished(reply); }); + +} + +void OpenTidalCoverProvider::HandleLoginSSLErrors(const QList &ssl_errors) { + + for (const QSslError &ssl_error : ssl_errors) { + qLog(Error) << "OpenTidal:" << ssl_error.errorString(); + } + +} + +void OpenTidalCoverProvider::LoginFinished(QNetworkReply *reply) { + + if (!replies_.contains(reply)) return; + replies_.removeAll(reply); + QObject::disconnect(reply, nullptr, this, nullptr); + reply->deleteLater(); + + login_in_progress_ = false; + last_login_attempt_ = QDateTime(); + + QJsonObject json_obj = GetJsonObject(reply); + if (json_obj.isEmpty()) { + FinishAllSearches(); + return; + } + + if (!json_obj.contains("access_token") || + !json_obj.contains("token_type") || + !json_obj.contains("expires_in") || + !json_obj["access_token"].isString() || + !json_obj["token_type"].isString()) { + qLog(Error) << "OpenTidal: Invalid login reply."; + FinishAllSearches(); + return; + } + + have_login_ = true; + token_type_ = json_obj["token_type"].toString(); + access_token_ = json_obj["access_token"].toString(); + login_time_ = QDateTime::currentDateTime().toSecsSinceEpoch(); + expires_in_ = json_obj["expires_in"].toInt(); + + QSettings s; + s.beginGroup(kSettingsGroup); + s.setValue("token_type", token_type_); + s.setValue("access_token", access_token_); + s.setValue("expires_in", expires_in_); + s.setValue("login_time", login_time_); + s.endGroup(); + + if (expires_in_ <= 300) { + expires_in_ = 300; + } + + expires_in_ -= 30; + + login_timer_->setInterval(static_cast(expires_in_ * kMsecPerSec)); + login_timer_->start(); + + if (!timer_flush_requests_->isActive()) { + timer_flush_requests_->start(); + } + +} + +QJsonObject OpenTidalCoverProvider::ExtractJsonObj(const QByteArray &data) { + + QJsonParseError json_parse_error; + const QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_parse_error); + if (json_parse_error.error != QJsonParseError::NoError) { + qLog(Error) << "OpenTidal:" << json_parse_error.errorString(); + return QJsonObject(); + } + if (!json_doc.isObject()) { + return QJsonObject(); + } + return json_doc.object(); + +} + +QJsonObject OpenTidalCoverProvider::GetJsonObject(QNetworkReply *reply) { + + if (reply->error() != QNetworkReply::NoError) { + qLog(Error) << "OpenTidal:" << reply->errorString() << reply->error(); + return QJsonObject(); + } + + const int http_code = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (http_code != 200 && http_code != 207) { + qLog(Error) << "OpenTidal: Received HTTP code" << http_code; + const QByteArray data = reply->readAll(); + if (data.isEmpty()) { + return QJsonObject(); + } + QJsonObject json_obj = ExtractJsonObj(data); + if (json_obj.contains("errors") && json_obj["errors"].isArray()) { + QJsonArray array = json_obj["errors"].toArray(); + for (const QJsonValue &value : array) { + if (!value.isObject()) continue; + QJsonObject obj = value.toObject(); + if (!obj.contains("category") || + !obj.contains("code") || + !obj.contains("detail")) { + continue; + } + QString category = obj["category"].toString(); + QString code = obj["code"].toString(); + QString detail = obj["detail"].toString(); + qLog(Error) << "OpenTidal:" << category << code << detail; + } + } + return QJsonObject(); + } + + const QByteArray data = reply->readAll(); + if (data.isEmpty()) { + return QJsonObject(); + } + + return ExtractJsonObj(data); + +} + +void OpenTidalCoverProvider::SendSearchRequest(SearchRequestPtr search_request) { + + QString query = search_request->artist; + if (!search_request->album.isEmpty()) { + if (!query.isEmpty()) query.append(" "); + query.append(search_request->album); + } + else if (!search_request->title.isEmpty()) { + if (!query.isEmpty()) query.append(" "); + query.append(search_request->title); + } + + QUrlQuery url_query; + url_query.addQueryItem("query", QUrl::toPercentEncoding(query)); + url_query.addQueryItem("limit", QString::number(kLimit)); + url_query.addQueryItem("countryCode", "US"); + QUrl url(QString(kApiUrl) + QString("/search")); + url.setQuery(url_query); + QNetworkRequest req(url); + req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); + req.setHeader(QNetworkRequest::ContentTypeHeader, "application/vnd.tidal.v1+json"); + req.setRawHeader("Authorization", token_type_.toUtf8() + " " + access_token_.toUtf8()); + + QNetworkReply *reply = network_->get(req); + replies_ << reply; + QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, search_request]() { HandleSearchReply(reply, search_request); }); + +} + +void OpenTidalCoverProvider::HandleSearchReply(QNetworkReply *reply, SearchRequestPtr search_request) { + + if (!replies_.contains(reply)) return; + replies_.removeAll(reply); + QObject::disconnect(reply, nullptr, this, nullptr); + reply->deleteLater(); + + QJsonObject json_obj = GetJsonObject(reply); + if (json_obj.isEmpty()) { + emit SearchFinished(search_request->id, CoverProviderSearchResults()); + return; + } + + if (!json_obj.contains("albums") || !json_obj["albums"].isArray()) { + qLog(Debug) << "OpenTidal: Json object is missing albums."; + emit SearchFinished(search_request->id, CoverProviderSearchResults()); + return; + } + + QJsonArray array_albums = json_obj["albums"].toArray(); + if (array_albums.isEmpty()) { + emit SearchFinished(search_request->id, CoverProviderSearchResults()); + return; + } + + CoverProviderSearchResults results; + int i = 0; + for (const QJsonValueRef value_album : array_albums) { + + if (!value_album.isObject()) { + qLog(Debug) << "OpenTidal: Invalid Json reply: Albums array value is not a object."; + continue; + } + QJsonObject obj_album = value_album.toObject(); + + if (!obj_album.contains("resource") || !obj_album["resource"].isObject()) { + qLog(Debug) << "OpenTidal: Invalid Json reply: Albums array album is missing resource object."; + continue; + } + QJsonObject obj_resource = obj_album["resource"].toObject(); + + if (!obj_resource.contains("artists") || !obj_resource["artists"].isArray()) { + qLog(Debug) << "OpenTidal: Invalid Json reply: Resource is missing artists array."; + continue; + } + + if (!obj_resource.contains("title") || !obj_resource["title"].isString()) { + qLog(Debug) << "OpenTidal: Invalid Json reply: Resource is missing title."; + continue; + } + + if (!obj_resource.contains("imageCover") || !obj_resource["imageCover"].isArray()) { + qLog(Debug) << "OpenTidal: Invalid Json reply: Resource is missing imageCover array."; + continue; + } + + QString artist; + const QString album = obj_resource["title"].toString(); + + QJsonArray array_artists = obj_resource["artists"].toArray(); + for (const QJsonValueRef value_artist : array_artists) { + if (!value_artist.isObject()) { + continue; + } + QJsonObject obj_artist = value_artist.toObject(); + if (!obj_artist.contains("name")) { + continue; + } + artist = obj_artist["name"].toString(); + break; + } + + QJsonArray array_covers = obj_resource["imageCover"].toArray(); + for (const QJsonValueRef value_cover : array_covers) { + if (!value_cover.isObject()) { + continue; + } + QJsonObject obj_cover = value_cover.toObject(); + if (!obj_cover.contains("url") || !obj_cover.contains("width") || !obj_cover.contains("height")) { + continue; + } + const QUrl url(obj_cover["url"].toString()); + const int width = obj_cover["width"].toInt(); + const int height = obj_cover["height"].toInt(); + if (!url.isValid()) continue; + if (width < 640 || height < 640) continue; + CoverProviderSearchResult cover_result; + cover_result.artist = artist; + cover_result.album = Song::AlbumRemoveDiscMisc(album); + cover_result.image_url = url; + cover_result.image_size = QSize(width, height); + cover_result.number = ++i; + results << cover_result; + } + } + + emit SearchFinished(search_request->id, results); + +} + +void OpenTidalCoverProvider::FinishAllSearches() { + + timer_flush_requests_->stop(); + + while (!search_requests_queue_.isEmpty()) { + SearchRequestPtr search_request = search_requests_queue_.dequeue(); + emit SearchFinished(search_request->id, CoverProviderSearchResults()); + } + +} + +void OpenTidalCoverProvider::Error(const QString &error, const QVariant &debug) { + + qLog(Error) << "Tidal:" << error; + if (debug.isValid()) qLog(Debug) << debug; + +} diff --git a/src/covermanager/opentidalcoverprovider.h b/src/covermanager/opentidalcoverprovider.h new file mode 100644 index 00000000..82db0d1f --- /dev/null +++ b/src/covermanager/opentidalcoverprovider.h @@ -0,0 +1,92 @@ +/* + * Strawberry Music Player + * Copyright 2024, 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 OPENTIDALCOVERPROVIDER_H +#define OPENTIDALCOVERPROVIDER_H + +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/shared_ptr.h" +#include "jsoncoverprovider.h" + +class QNetworkReply; +class Application; +class NetworkAccessManager; +class QTimer; + +class OpenTidalCoverProvider : public JsonCoverProvider { + Q_OBJECT + + public: + explicit OpenTidalCoverProvider(Application *app, SharedPtr network, QObject *parent = nullptr); + ~OpenTidalCoverProvider() override; + + bool StartSearch(const QString &artist, const QString &album, const QString &title, const int id) override; + void CancelSearch(const int id) override; + + private: + struct SearchRequest { + explicit SearchRequest(const int _id, const QString &_artist, const QString &_album, const QString &_title) : id(_id), artist(_artist), album(_album), title(_title) {} + int id; + QString artist; + QString album; + QString title; + }; + using SearchRequestPtr = SharedPtr; + + private: + void LoadSession(); + void Login(); + QJsonObject GetJsonObject(QNetworkReply *reply); + QJsonObject ExtractJsonObj(const QByteArray &data); + void SendSearchRequest(SearchRequestPtr request); + void FinishAllSearches(); + void Error(const QString &error, const QVariant &debug = QVariant()) override; + + private slots: + void FlushRequests(); + void LoginFinished(QNetworkReply *reply); + void HandleLoginSSLErrors(const QList &ssl_errors); + void HandleSearchReply(QNetworkReply *reply, SearchRequestPtr search_request); + + private: + QTimer *login_timer_; + QTimer *timer_flush_requests_; + bool login_in_progress_; + QDateTime last_login_attempt_; + bool have_login_; + QString token_type_; + QString access_token_; + qint64 login_time_; + qint64 expires_in_; + QQueue search_requests_queue_; + QList replies_; +}; + +#endif // OPENTIDALCOVERPROVIDER_H