From d90aecb1643ca7fa1247279ee52394481dbeaf5a Mon Sep 17 00:00:00 2001 From: Jonas Kvinge Date: Mon, 13 Apr 2020 19:04:06 +0200 Subject: [PATCH 1/2] Add back Tidal support --- CMakeLists.txt | 1 + data/data.qrc | 1 + data/icons.qrc | 5 + data/icons/128x128/tidal.png | Bin 0 -> 5892 bytes data/icons/22x22/tidal.png | Bin 0 -> 933 bytes data/icons/32x32/tidal.png | Bin 0 -> 1433 bytes data/icons/48x48/tidal.png | Bin 0 -> 2136 bytes data/icons/64x64/tidal.png | Bin 0 -> 2905 bytes data/icons/full/tidal.png | Bin 0 -> 7322 bytes data/schema/schema-12.sql | 217 +++ data/schema/schema.sql | 218 ++- ...g.strawberrymusicplayer.strawberry.desktop | 2 +- src/CMakeLists.txt | 23 + src/config.h.in | 1 + src/core/application.cpp | 11 + src/core/database.cpp | 4 +- src/core/iconmapper.h | 1 + src/core/mainwindow.cpp | 40 + src/core/mainwindow.h | 1 + src/covermanager/tidalcoverprovider.cpp | 275 ++++ src/covermanager/tidalcoverprovider.h | 73 + src/settings/settingsdialog.cpp | 9 +- src/settings/settingsdialog.h | 3 +- src/settings/tidalsettingspage.cpp | 205 +++ src/settings/tidalsettingspage.h | 71 + src/settings/tidalsettingspage.ui | 357 +++++ src/tidal/tidalbaserequest.cpp | 204 +++ src/tidal/tidalbaserequest.h | 113 ++ src/tidal/tidalfavoriterequest.cpp | 297 ++++ src/tidal/tidalfavoriterequest.h | 88 ++ src/tidal/tidalrequest.cpp | 1253 +++++++++++++++++ src/tidal/tidalrequest.h | 212 +++ src/tidal/tidalservice.cpp | 966 +++++++++++++ src/tidal/tidalservice.h | 256 ++++ src/tidal/tidalstreamurlrequest.cpp | 299 ++++ src/tidal/tidalstreamurlrequest.h | 79 ++ src/tidal/tidalurlhandler.cpp | 68 + src/tidal/tidalurlhandler.h | 57 + 38 files changed, 5404 insertions(+), 6 deletions(-) create mode 100644 data/icons/128x128/tidal.png create mode 100644 data/icons/22x22/tidal.png create mode 100644 data/icons/32x32/tidal.png create mode 100644 data/icons/48x48/tidal.png create mode 100644 data/icons/64x64/tidal.png create mode 100644 data/icons/full/tidal.png create mode 100644 data/schema/schema-12.sql create mode 100644 src/covermanager/tidalcoverprovider.cpp create mode 100644 src/covermanager/tidalcoverprovider.h create mode 100644 src/settings/tidalsettingspage.cpp create mode 100644 src/settings/tidalsettingspage.h create mode 100644 src/settings/tidalsettingspage.ui create mode 100644 src/tidal/tidalbaserequest.cpp create mode 100644 src/tidal/tidalbaserequest.h create mode 100644 src/tidal/tidalfavoriterequest.cpp create mode 100644 src/tidal/tidalfavoriterequest.h create mode 100644 src/tidal/tidalrequest.cpp create mode 100644 src/tidal/tidalrequest.h create mode 100644 src/tidal/tidalservice.cpp create mode 100644 src/tidal/tidalservice.h create mode 100644 src/tidal/tidalstreamurlrequest.cpp create mode 100644 src/tidal/tidalstreamurlrequest.h create mode 100644 src/tidal/tidalurlhandler.cpp create mode 100644 src/tidal/tidalurlhandler.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 67a42031..0ccca7c5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -330,6 +330,7 @@ optional_component(TRANSLATIONS ON "Translations" ) optional_component(SUBSONIC ON "Subsonic support") +optional_component(TIDAL ON "Tidal support") optional_component(MOODBAR ON "Moodbar" DEPENDS "fftw3" FFTW3_FOUND diff --git a/data/data.qrc b/data/data.qrc index 210f09b8..4dc238bb 100644 --- a/data/data.qrc +++ b/data/data.qrc @@ -12,6 +12,7 @@ schema/schema-9.sql schema/schema-10.sql schema/schema-11.sql + schema/schema-12.sql schema/device-schema.sql style/strawberry.css html/playing-tooltip-plain.html diff --git a/data/icons.qrc b/data/icons.qrc index ff491cc7..9993765b 100644 --- a/data/icons.qrc +++ b/data/icons.qrc @@ -89,6 +89,7 @@ icons/128x128/moodbar.png icons/128x128/love.png icons/128x128/subsonic.png + icons/128x128/tidal.png icons/64x64/albums.png icons/64x64/alsa.png icons/64x64/application-exit.png @@ -179,6 +180,7 @@ icons/64x64/moodbar.png icons/64x64/love.png icons/64x64/subsonic.png + icons/64x64/tidal.png icons/48x48/albums.png icons/48x48/alsa.png icons/48x48/application-exit.png @@ -272,6 +274,7 @@ icons/48x48/moodbar.png icons/48x48/love.png icons/48x48/subsonic.png + icons/48x48/tidal.png icons/32x32/albums.png icons/32x32/alsa.png icons/32x32/application-exit.png @@ -365,6 +368,7 @@ icons/32x32/moodbar.png icons/32x32/love.png icons/32x32/subsonic.png + icons/32x32/tidal.png icons/22x22/albums.png icons/22x22/alsa.png icons/22x22/application-exit.png @@ -458,5 +462,6 @@ icons/22x22/moodbar.png icons/22x22/love.png icons/22x22/subsonic.png + icons/22x22/tidal.png diff --git a/data/icons/128x128/tidal.png b/data/icons/128x128/tidal.png new file mode 100644 index 0000000000000000000000000000000000000000..5b7e2f11e143be11e9bdada4a687b1b9adfadd5d GIT binary patch literal 5892 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 WHuG>8GXHNi4Uh*b%2r7m1^f@tF>E0K literal 0 HcmV?d00001 diff --git a/data/icons/22x22/tidal.png b/data/icons/22x22/tidal.png new file mode 100644 index 0000000000000000000000000000000000000000..49610554112233a53feca55142bb787d8d0e7e9c GIT binary patch literal 933 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 DiscogsCoverProvider(app, app)); cover_providers->AddProvider(new MusicbrainzCoverProvider(app, app)); cover_providers->AddProvider(new DeezerCoverProvider(app, app)); +#ifdef HAVE_TIDAL + cover_providers->AddProvider(new TidalCoverProvider(app, app)); +#endif return cover_providers; }), album_cover_loader_([=]() { @@ -130,6 +138,9 @@ class ApplicationImpl { InternetServices *internet_services = new InternetServices(app); #ifdef HAVE_SUBSONIC internet_services->AddService(new SubsonicService(app, internet_services)); +#endif +#ifdef HAVE_TIDAL + internet_services->AddService(new TidalService(app, internet_services)); #endif return internet_services; }), diff --git a/src/core/database.cpp b/src/core/database.cpp index 7ace6388..4964311f 100644 --- a/src/core/database.cpp +++ b/src/core/database.cpp @@ -53,8 +53,8 @@ #include "application.h" #include "scopedtransaction.h" -const char *Database::kDatabaseFilename = "strawberry.db"; -const int Database::kSchemaVersion = 11; +const char *Database::kDatabaseFilename = "strawberry-tidal.db"; +const int Database::kSchemaVersion = 12; const char *Database::kMagicAllSongsTables = "%allsongstables"; int Database::sNextConnectionId = 1; diff --git a/src/core/iconmapper.h b/src/core/iconmapper.h index f111c8df..e1aa8db6 100644 --- a/src/core/iconmapper.h +++ b/src/core/iconmapper.h @@ -115,6 +115,7 @@ static const QMap iconmapper_ = { { "star", { {}, 0, 0 } }, { "strawberry", { {}, 0, 0 } }, { "subsonic", { {}, 0, 0 } }, + { "tidal", { {}, 0, 0 } }, { "tools-wizard", { {}, 0, 0 } }, { "view-choose", { {}, 0, 0 } }, { "view-fullscreen", { {}, 0, 0 } }, diff --git a/src/core/mainwindow.cpp b/src/core/mainwindow.cpp index 9facf935..449b96df 100644 --- a/src/core/mainwindow.cpp +++ b/src/core/mainwindow.cpp @@ -147,6 +147,10 @@ #ifdef HAVE_SUBSONIC # include "settings/subsonicsettingspage.h" #endif +#ifdef HAVE_TIDAL +# include "tidal/tidalservice.h" +# include "settings/tidalsettingspage.h" +#endif #include "internet/internetservices.h" #include "internet/internetservice.h" @@ -229,6 +233,9 @@ MainWindow::MainWindow(Application *app, SystemTrayIcon *tray_icon, OSD *osd, co }), #ifdef HAVE_SUBSONIC subsonic_view_(new InternetSongsView(app_, app->internet_services()->ServiceBySource(Song::Source_Subsonic), SubsonicSettingsPage::kSettingsGroup, SettingsDialog::Page_Subsonic, this)), +#endif +#ifdef HAVE_TIDAL + tidal_view_(new InternetTabsView(app_, app->internet_services()->ServiceBySource(Song::Source_Tidal), TidalSettingsPage::kSettingsGroup, SettingsDialog::Page_Tidal, this)), #endif playlist_menu_(new QMenu(this)), playlist_add_to_another_(nullptr), @@ -282,6 +289,9 @@ MainWindow::MainWindow(Application *app, SystemTrayIcon *tray_icon, OSD *osd, co #ifdef HAVE_SUBSONIC ui_->tabs->AddTab(subsonic_view_, "subsonic", IconLoader::Load("subsonic"), tr("Subsonic")); #endif +#ifdef HAVE_TIDAL + ui_->tabs->AddTab(tidal_view_, "tidal", IconLoader::Load("tidal"), tr("Tidal")); +#endif // Add the playing widget to the fancy tab widget ui_->tabs->addBottomWidget(ui_->widget_playing); @@ -567,6 +577,15 @@ MainWindow::MainWindow(Application *app, SystemTrayIcon *tray_icon, OSD *osd, co connect(subsonic_view_->view(), SIGNAL(AddToPlaylistSignal(QMimeData*)), SLOT(AddToPlaylist(QMimeData*))); #endif +#ifdef HAVE_TIDAL + connect(tidal_view_->artists_collection_view(), SIGNAL(AddToPlaylistSignal(QMimeData*)), SLOT(AddToPlaylist(QMimeData*))); + connect(tidal_view_->albums_collection_view(), SIGNAL(AddToPlaylistSignal(QMimeData*)), SLOT(AddToPlaylist(QMimeData*))); + connect(tidal_view_->songs_collection_view(), SIGNAL(AddToPlaylistSignal(QMimeData*)), SLOT(AddToPlaylist(QMimeData*))); + connect(tidal_view_->search_view(), SIGNAL(AddToPlaylist(QMimeData*)), SLOT(AddToPlaylist(QMimeData*))); + if (TidalService *tidalservice = qobject_cast (app_->internet_services()->ServiceBySource(Song::Source_Tidal))) + connect(this, SIGNAL(AuthorisationUrlReceived(QUrl)), tidalservice, SLOT(AuthorisationUrlReceived(QUrl))); +#endif + // Playlist menu connect(playlist_menu_, SIGNAL(aboutToHide()), SLOT(PlaylistMenuHidden())); playlist_play_pause_ = playlist_menu_->addAction(tr("Play"), this, SLOT(PlaylistPlay())); @@ -896,6 +915,16 @@ void MainWindow::ReloadSettings() { ui_->tabs->DisableTab(subsonic_view_); #endif +#ifdef HAVE_TIDAL + settings.beginGroup(TidalSettingsPage::kSettingsGroup); + bool enable_tidal = settings.value("enabled", false).toBool(); + settings.endGroup(); + if (enable_tidal) + ui_->tabs->EnableTab(tidal_view_); + else + ui_->tabs->DisableTab(tidal_view_); +#endif + ui_->tabs->ReloadSettings(); } @@ -919,6 +948,9 @@ void MainWindow::ReloadAllSettings() { #ifdef HAVE_SUBSONIC subsonic_view_->ReloadSettings(); #endif +#ifdef HAVE_TIDAL + tidal_view_->ReloadSettings(); +#endif } @@ -1992,6 +2024,14 @@ void MainWindow::CommandlineOptionsReceived(const CommandlineOptions &options) { if (!options.urls().empty()) { +#ifdef HAVE_TIDAL + for (const QUrl url : options.urls()) { + if (url.scheme() == "tidal" && url.host() == "login") { + emit AuthorisationUrlReceived(url); + return; + } + } +#endif MimeData *data = new MimeData; data->setUrls(options.urls()); // Behaviour depends on command line options, so set it here diff --git a/src/core/mainwindow.h b/src/core/mainwindow.h index 257b8885..8b68c632 100644 --- a/src/core/mainwindow.h +++ b/src/core/mainwindow.h @@ -316,6 +316,7 @@ class MainWindow : public QMainWindow, public PlatformInterface { PlaylistItemList autocomplete_tag_items_; InternetSongsView *subsonic_view_; + InternetTabsView *tidal_view_; QAction *collection_show_all_; QAction *collection_show_duplicates_; diff --git a/src/covermanager/tidalcoverprovider.cpp b/src/covermanager/tidalcoverprovider.cpp new file mode 100644 index 00000000..7a6658d4 --- /dev/null +++ b/src/covermanager/tidalcoverprovider.cpp @@ -0,0 +1,275 @@ +/* + * Strawberry Music Player + * Copyright 2018, 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 + +#include "core/application.h" +#include "core/closure.h" +#include "core/network.h" +#include "core/logging.h" +#include "core/song.h" +#include "internet/internetservices.h" +#include "tidal/tidalservice.h" +#include "albumcoverfetcher.h" +#include "coverprovider.h" +#include "tidalcoverprovider.h" + +const char *TidalCoverProvider::kApiUrl = "https://api.tidalhifi.com/v1"; +const char *TidalCoverProvider::kResourcesUrl = "https://resources.tidal.com"; +const int TidalCoverProvider::kLimit = 10; + +TidalCoverProvider::TidalCoverProvider(Application *app, QObject *parent) : + CoverProvider("Tidal", 2.0, true, app, parent), + service_(app->internet_services()->Service()), + network_(new NetworkAccessManager(this)) { + +} + +bool TidalCoverProvider::StartSearch(const QString &artist, const QString &album, const int id) { + + if (!service_ || !service_->authenticated()) return false; + + ParamList params = ParamList() << Param("query", QString(artist + " " + album)) + << Param("limit", QString::number(kLimit)); + + QNetworkReply *reply = CreateRequest("search/albums", params); + NewClosure(reply, SIGNAL(finished()), this, SLOT(HandleSearchReply(QNetworkReply*, int)), reply, id); + + return true; + +} + +void TidalCoverProvider::CancelSearch(int id) { Q_UNUSED(id); } + +QNetworkReply *TidalCoverProvider::CreateRequest(const QString &ressource_name, const ParamList ¶ms_supplied) { + + const ParamList params = ParamList() << params_supplied + << Param("countryCode", service_->country_code()); + + QUrlQuery url_query; + for (const Param ¶m : params) { + EncodedParam encoded_param(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second)); + url_query.addQueryItem(encoded_param.first, encoded_param.second); + } + + QUrl url(kApiUrl + QString("/") + ressource_name); + url.setQuery(url_query); + QNetworkRequest req(url); + req.setAttribute(QNetworkRequest::FollowRedirectsAttribute, true); + req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); + if (!service_->access_token().isEmpty()) req.setRawHeader("authorization", "Bearer " + service_->access_token().toUtf8()); + if (!service_->session_id().isEmpty()) req.setRawHeader("X-Tidal-SessionId", service_->session_id().toUtf8()); + QNetworkReply *reply = network_->get(req); + + return reply; + +} + +QByteArray TidalCoverProvider::GetReplyData(QNetworkReply *reply, QString &error) { + + QByteArray data; + + if (reply->error() == QNetworkReply::NoError && reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() == 200) { + data = reply->readAll(); + } + else { + if (reply->error() != QNetworkReply::NoError && reply->error() < 200) { + // This is a network error, there is nothing more to do. + error = Error(QString("%1 (%2)").arg(reply->errorString()).arg(reply->error())); + } + else { + // See if there is Json data containing "status" and "userMessage" - then use that instead. + data = reply->readAll(); + QJsonParseError parse_error; + QJsonDocument json_doc = QJsonDocument::fromJson(data, &parse_error); + int status = 0; + int sub_status = 0; + if (parse_error.error == QJsonParseError::NoError && !json_doc.isNull() && !json_doc.isEmpty() && json_doc.isObject()) { + QJsonObject json_obj = json_doc.object(); + if (!json_obj.isEmpty() && json_obj.contains("status") && json_obj.contains("userMessage")) { + status = json_obj["status"].toInt(); + sub_status = json_obj["subStatus"].toInt(); + QString user_message = json_obj["userMessage"].toString(); + error = QString("%1 (%2) (%3)").arg(user_message).arg(status).arg(sub_status); + } + } + if (error.isEmpty()) { + if (reply->error() != QNetworkReply::NoError) { + error = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()); + } + else { + error = QString("Received HTTP code %1").arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt()); + } + } + if (status == 401 && sub_status == 6001) { // User does not have a valid session + service_->Logout(); + } + error = Error(error); + } + return QByteArray(); + } + + return data; + +} + +QJsonObject TidalCoverProvider::ExtractJsonObj(QByteArray &data, QString &error) { + + QJsonParseError json_error; + QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error); + + if (json_error.error != QJsonParseError::NoError) { + error = Error("Reply from server missing Json data.", data); + return QJsonObject(); + } + + if (json_doc.isEmpty()) { + error = Error("Received empty Json document.", data); + return QJsonObject(); + } + + if (!json_doc.isObject()) { + error = Error("Json document is not an object.", json_doc); + return QJsonObject(); + } + + QJsonObject json_obj = json_doc.object(); + if (json_obj.isEmpty()) { + error = Error("Received empty Json object.", json_doc); + return QJsonObject(); + } + + return json_obj; + +} + +QJsonValue TidalCoverProvider::ExtractItems(QByteArray &data, QString &error) { + + QJsonObject json_obj = ExtractJsonObj(data, error); + if (json_obj.isEmpty()) return QJsonValue(); + return ExtractItems(json_obj, error); + +} + +QJsonValue TidalCoverProvider::ExtractItems(QJsonObject &json_obj, QString &error) { + + if (!json_obj.contains("items")) { + error = Error("Json reply is missing items.", json_obj); + return QJsonArray(); + } + QJsonValue json_items = json_obj["items"]; + return json_items; + +} + +void TidalCoverProvider::HandleSearchReply(QNetworkReply *reply, const int id) { + + reply->deleteLater(); + + CoverSearchResults results; + QString error; + + QByteArray data = GetReplyData(reply, error); + if (data.isEmpty()) { + emit SearchFinished(id, results); + return; + } + + QJsonObject json_obj = ExtractJsonObj(data, error); + if (json_obj.isEmpty()) { + emit SearchFinished(id, results); + return; + } + + QJsonValue json_value = ExtractItems(json_obj, error); + if (!json_value.isArray()) { + emit SearchFinished(id, results); + return; + } + QJsonArray json_items = json_value.toArray(); + if (json_items.isEmpty()) { + emit SearchFinished(id, results); + return; + } + + for (const QJsonValue &value : json_items) { + if (!value.isObject()) { + Error("Invalid Json reply, item not a object.", value); + continue; + } + QJsonObject json_obj = value.toObject(); + + if (!json_obj.contains("artist") || !json_obj.contains("type") || !json_obj.contains("id") || !json_obj.contains("title") || !json_obj.contains("cover")) { + Error("Invalid Json reply, item missing id, type, album or cover.", json_obj); + continue; + } + QString album = json_obj["title"].toString(); + QString cover = json_obj["cover"].toString(); + + QJsonValue json_value_artist = json_obj["artist"]; + if (!json_value_artist.isObject()) { + Error("Invalid Json reply, item artist is not a object.", json_value_artist); + continue; + } + QJsonObject json_artist = json_value_artist.toObject(); + if (!json_artist.contains("name")) { + Error("Invalid Json reply, item artist missing name.", json_artist); + continue; + } + QString artist = json_artist["name"].toString(); + + album.remove(Song::kAlbumRemoveDisc); + album.remove(Song::kAlbumRemoveMisc); + + cover = cover.replace("-", "/"); + QUrl cover_url (QString("%1/images/%2/%3.jpg").arg(kResourcesUrl).arg(cover).arg("1280x1280")); + + CoverSearchResult cover_result; + cover_result.artist = artist; + cover_result.album = album; + cover_result.image_url = cover_url; + results << cover_result; + + } + emit SearchFinished(id, results); + +} + +QString TidalCoverProvider::Error(QString error, QVariant debug) { + qLog(Error) << "Tidal:" << error; + if (debug.isValid()) qLog(Debug) << debug; + return error; +} diff --git a/src/covermanager/tidalcoverprovider.h b/src/covermanager/tidalcoverprovider.h new file mode 100644 index 00000000..9eb3ee2e --- /dev/null +++ b/src/covermanager/tidalcoverprovider.h @@ -0,0 +1,73 @@ +/* + * Strawberry Music Player + * Copyright 2018, 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 TIDALCOVERPROVIDER_H +#define TIDALCOVERPROVIDER_H + +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "coverprovider.h" + +class QNetworkAccessManager; +class QNetworkReply; +class Application; +class TidalService; + +class TidalCoverProvider : public CoverProvider { + Q_OBJECT + + public: + explicit TidalCoverProvider(Application *app, QObject *parent = nullptr); + bool StartSearch(const QString &artist, const QString &album, const int id); + void CancelSearch(int id); + + private slots: + void HandleSearchReply(QNetworkReply *reply, const int id); + + private: + typedef QPair Param; + typedef QList ParamList; + typedef QPair EncodedParam; + static const char *kApiUrl; + static const char *kResourcesUrl; + static const int kLimit; + + QNetworkReply *CreateRequest(const QString &ressource_name, const ParamList ¶ms_supplied); + QByteArray GetReplyData(QNetworkReply *reply, QString &error); + QJsonObject ExtractJsonObj(QByteArray &data, QString &error); + QJsonValue ExtractItems(QByteArray &data, QString &error); + QJsonValue ExtractItems(QJsonObject &json_obj, QString &error); + QString Error(QString error, QVariant debug = QVariant()); + + TidalService *service_; + QNetworkAccessManager *network_; + +}; + +#endif // TIDALCOVERPROVIDER_H diff --git a/src/settings/settingsdialog.cpp b/src/settings/settingsdialog.cpp index 2bf2bb06..33c348d3 100644 --- a/src/settings/settingsdialog.cpp +++ b/src/settings/settingsdialog.cpp @@ -73,6 +73,9 @@ #ifdef HAVE_SUBSONIC # include "subsonicsettingspage.h" #endif +#ifdef HAVE_TIDAL +# include "tidalsettingspage.h" +#endif #include "ui_settingsdialog.h" @@ -145,12 +148,16 @@ SettingsDialog::SettingsDialog(Application *app, QMainWindow *mainwindow, QWidge AddPage(Page_Moodbar, new MoodbarSettingsPage(this), iface); #endif -#if defined(HAVE_SUBSONIC) +#if defined(HAVE_SUBSONIC) || defined(HAVE_TIDAL) QTreeWidgetItem *streaming = AddCategory(tr("Streaming")); #endif + #ifdef HAVE_SUBSONIC AddPage(Page_Subsonic, new SubsonicSettingsPage(this), streaming); #endif +#ifdef HAVE_TIDAL + AddPage(Page_Tidal, new TidalSettingsPage(this), streaming); +#endif // List box connect(ui_->list, SIGNAL(currentItemChanged(QTreeWidgetItem*,QTreeWidgetItem*)), SLOT(CurrentItemChanged(QTreeWidgetItem*))); diff --git a/src/settings/settingsdialog.h b/src/settings/settingsdialog.h index 4d2dc243..0b8575cc 100644 --- a/src/settings/settingsdialog.h +++ b/src/settings/settingsdialog.h @@ -86,7 +86,8 @@ class SettingsDialog : public QDialog { Page_Proxy, Page_Scrobbler, Page_Moodbar, - Page_Subsonic + Page_Subsonic, + Page_Tidal, }; enum Role { diff --git a/src/settings/tidalsettingspage.cpp b/src/settings/tidalsettingspage.cpp new file mode 100644 index 00000000..90df87c4 --- /dev/null +++ b/src/settings/tidalsettingspage.cpp @@ -0,0 +1,205 @@ +/* + * Strawberry Music Player + * Copyright 2018, 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 "settingsdialog.h" +#include "tidalsettingspage.h" +#include "ui_tidalsettingspage.h" +#include "core/application.h" +#include "core/iconloader.h" +#include "internet/internetservices.h" +#include "tidal/tidalservice.h" +#include "widgets/loginstatewidget.h" + +const char *TidalSettingsPage::kSettingsGroup = "Tidal"; + +TidalSettingsPage::TidalSettingsPage(SettingsDialog *parent) + : SettingsPage(parent), + ui_(new Ui::TidalSettingsPage), + service_(dialog()->app()->internet_services()->Service()) { + + ui_->setupUi(this); + setWindowIcon(IconLoader::Load("tidal")); + + connect(ui_->button_login, SIGNAL(clicked()), SLOT(LoginClicked())); + connect(ui_->login_state, SIGNAL(LogoutClicked()), SLOT(LogoutClicked())); + connect(ui_->oauth, SIGNAL(toggled(bool)), SLOT(OAuthClicked(bool))); + + connect(this, SIGNAL(Login()), service_, SLOT(StartAuthorisation())); + connect(this, SIGNAL(Login(QString, QString, QString)), service_, SLOT(SendLogin(QString, QString, QString))); + + connect(service_, SIGNAL(LoginFailure(QString)), SLOT(LoginFailure(QString))); + connect(service_, SIGNAL(LoginSuccess()), SLOT(LoginSuccess())); + + dialog()->installEventFilter(this); + + ui_->quality->addItem("Low", "LOW"); + ui_->quality->addItem("High", "HIGH"); + ui_->quality->addItem("Lossless", "LOSSLESS"); + ui_->quality->addItem("Hi resolution", "HI_RES"); + + ui_->coversize->addItem("160x160", "160x160"); + ui_->coversize->addItem("320x320", "320x320"); + ui_->coversize->addItem("640x640", "640x640"); + ui_->coversize->addItem("750x750", "750x750"); + ui_->coversize->addItem("1280x1280", "1280x1280"); + + ui_->streamurl->addItem("streamurl", StreamUrlMethod_StreamUrl); + ui_->streamurl->addItem("urlpostpaywall", StreamUrlMethod_UrlPostPaywall); + ui_->streamurl->addItem("playbackinfopostpaywall", StreamUrlMethod_PlaybackInfoPostPaywall); + +} + +TidalSettingsPage::~TidalSettingsPage() { delete ui_; } + +void TidalSettingsPage::Load() { + + QSettings s; + + s.beginGroup(kSettingsGroup); + ui_->enable->setChecked(s.value("enabled", false).toBool()); + ui_->oauth->setChecked(s.value("oauth", false).toBool()); + + ui_->client_id->setText(s.value("client_id").toString()); + ui_->api_token->setText(s.value("api_token").toString()); + + ui_->username->setText(s.value("username").toString()); + QByteArray password = s.value("password").toByteArray(); + if (password.isEmpty()) ui_->password->clear(); + else ui_->password->setText(QString::fromUtf8(QByteArray::fromBase64(password))); + + dialog()->ComboBoxLoadFromSettings(s, ui_->quality, "quality", "HIGH"); + ui_->searchdelay->setValue(s.value("searchdelay", 1500).toInt()); + ui_->artistssearchlimit->setValue(s.value("artistssearchlimit", 4).toInt()); + ui_->albumssearchlimit->setValue(s.value("albumssearchlimit", 10).toInt()); + ui_->songssearchlimit->setValue(s.value("songssearchlimit", 10).toInt()); + ui_->checkbox_fetchalbums->setChecked(s.value("fetchalbums", false).toBool()); + ui_->checkbox_download_album_covers->setChecked(s.value("downloadalbumcovers", true).toBool()); + dialog()->ComboBoxLoadFromSettings(s, ui_->coversize, "coversize", "320x320"); + + StreamUrlMethod stream_url = static_cast(s.value("streamurl").toInt()); + int i = ui_->streamurl->findData(stream_url); + if (i == -1) i = ui_->streamurl->findData(StreamUrlMethod_StreamUrl); + ui_->streamurl->setCurrentIndex(i); + + s.endGroup(); + + OAuthClicked(ui_->oauth->isChecked()); + if (service_->authenticated()) ui_->login_state->SetLoggedIn(LoginStateWidget::LoggedIn); + +} + +void TidalSettingsPage::Save() { + + QSettings s; + s.beginGroup(kSettingsGroup); + s.setValue("enabled", ui_->enable->isChecked()); + s.setValue("oauth", ui_->oauth->isChecked()); + s.setValue("client_id", ui_->client_id->text()); + s.setValue("api_token", ui_->api_token->text()); + + s.setValue("username", ui_->username->text()); + s.setValue("password", QString::fromUtf8(ui_->password->text().toUtf8().toBase64())); + + s.setValue("quality", ui_->quality->itemData(ui_->quality->currentIndex())); + s.setValue("searchdelay", ui_->searchdelay->value()); + s.setValue("artistssearchlimit", ui_->artistssearchlimit->value()); + s.setValue("albumssearchlimit", ui_->albumssearchlimit->value()); + s.setValue("songssearchlimit", ui_->songssearchlimit->value()); + s.setValue("fetchalbums", ui_->checkbox_fetchalbums->isChecked()); + s.setValue("downloadalbumcovers", ui_->checkbox_download_album_covers->isChecked()); + s.setValue("coversize", ui_->coversize->itemData(ui_->coversize->currentIndex())); + s.setValue("streamurl", ui_->streamurl->itemData(ui_->streamurl->currentIndex())); + s.endGroup(); + + service_->ReloadSettings(); + +} + +void TidalSettingsPage::LoginClicked() { + + if (ui_->oauth->isChecked()) { + if (ui_->client_id->text().isEmpty()) { + QMessageBox::critical(this, tr("Configuration incomplete"), tr("Missing Tidal client ID.")); + return; + } + emit Login(); + } + else { + if (ui_->api_token->text().isEmpty() || ui_->username->text().isEmpty() || ui_->password->text().isEmpty()) { + QMessageBox::critical(this, tr("Configuration incomplete"), tr("Missing API token, username or password.")); + return; + } + emit Login(ui_->api_token->text(), ui_->username->text(), ui_->password->text()); + } + ui_->button_login->setEnabled(false); + +} + +bool TidalSettingsPage::eventFilter(QObject *object, QEvent *event) { + + if (object == dialog() && event->type() == QEvent::Enter) { + ui_->button_login->setEnabled(true); + return false; + } + + return SettingsPage::eventFilter(object, event); + +} + +void TidalSettingsPage::OAuthClicked(const bool enabled) { + + ui_->client_id->setEnabled(enabled); + ui_->api_token->setEnabled(!enabled); + ui_->username->setEnabled(!enabled); + ui_->password->setEnabled(!enabled); + +} + +void TidalSettingsPage::LogoutClicked() { + service_->Logout(); + ui_->button_login->setEnabled(true); + ui_->login_state->SetLoggedIn(LoginStateWidget::LoggedOut); +} + +void TidalSettingsPage::LoginSuccess() { + if (!this->isVisible()) return; + ui_->login_state->SetLoggedIn(LoginStateWidget::LoggedIn); + ui_->button_login->setEnabled(true); +} + +void TidalSettingsPage::LoginFailure(const QString &failure_reason) { + if (!this->isVisible()) return; + QMessageBox::warning(this, tr("Authentication failed"), failure_reason); + ui_->button_login->setEnabled(true); +} diff --git a/src/settings/tidalsettingspage.h b/src/settings/tidalsettingspage.h new file mode 100644 index 00000000..87cd6a6c --- /dev/null +++ b/src/settings/tidalsettingspage.h @@ -0,0 +1,71 @@ +/* + * Strawberry Music Player + * Copyright 2018, 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 TIDALSETTINGSPAGE_H +#define TIDALSETTINGSPAGE_H + +#include "config.h" + +#include +#include + +#include "settings/settingspage.h" + +class QEvent; +class TidalService; +class SettingsDialog; +class Ui_TidalSettingsPage; + +class TidalSettingsPage : public SettingsPage { + Q_OBJECT + + public: + explicit TidalSettingsPage(SettingsDialog* parent = nullptr); + ~TidalSettingsPage(); + + static const char *kSettingsGroup; + + enum StreamUrlMethod { + StreamUrlMethod_StreamUrl, + StreamUrlMethod_UrlPostPaywall, + StreamUrlMethod_PlaybackInfoPostPaywall, + }; + + void Load(); + void Save(); + + bool eventFilter(QObject *object, QEvent *event); + + signals: + void Login(); + void Login(const QString &api_token, const QString &username, const QString &password); + + private slots: + void OAuthClicked(const bool enabled); + void LoginClicked(); + void LogoutClicked(); + void LoginSuccess(); + void LoginFailure(const QString &failure_reason); + + private: + Ui_TidalSettingsPage* ui_; + TidalService *service_; +}; + +#endif diff --git a/src/settings/tidalsettingspage.ui b/src/settings/tidalsettingspage.ui new file mode 100644 index 00000000..835c2bc7 --- /dev/null +++ b/src/settings/tidalsettingspage.ui @@ -0,0 +1,357 @@ + + + TidalSettingsPage + + + + 0 + 0 + 715 + 836 + + + + Tidal + + + + + + Enable + + + + + + + Tidal support is not official and requires a API token from a registered application to work. We can't help you getting these. + + + true + + + 10 + + + + + + + + 0 + 0 + + + + Authentication + + + + + + Use OAuth + + + + + + + + 150 + 0 + + + + Client ID + + + + + + + + + + + 150 + 0 + + + + API Token + + + + + + + + 200 + 0 + + + + + + + + Username + + + + + + + + + + + + + + Password + + + + + + + QLineEdit::Password + + + + + + + + + + Login + + + + + + + + + + Preferences + + + + + + Audio quality + + + + + + + + + + Search delay + + + + + + + ms + + + 0 + + + 10000 + + + 50 + + + 1500 + + + + + + + Artists search limit + + + + + + + 1 + + + 100 + + + 50 + + + + + + + Albums search limit + + + + + + + 1 + + + 1000 + + + 50 + + + + + + + Songs search limit + + + + + + + Download album covers + + + + + + + Fetch entire albums when searching songs + + + + + + + + + + Album cover size + + + + + + + 1 + + + 1000 + + + 50 + + + + + + + Stream URL method + + + + + + + + + + + + + Qt::Vertical + + + + 20 + 30 + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 64 + 64 + + + + + 64 + 64 + + + + :/icons/64x64/tidal.png + + + + + + + + + + LoginStateWidget + QWidget +
widgets/loginstatewidget.h
+ 1 +
+
+ + enable + oauth + client_id + api_token + username + password + button_login + quality + searchdelay + artistssearchlimit + albumssearchlimit + songssearchlimit + checkbox_download_album_covers + checkbox_fetchalbums + coversize + streamurl + + + + + + +
diff --git a/src/tidal/tidalbaserequest.cpp b/src/tidal/tidalbaserequest.cpp new file mode 100644 index 00000000..1adf5754 --- /dev/null +++ b/src/tidal/tidalbaserequest.cpp @@ -0,0 +1,204 @@ +/* + * Strawberry Music Player + * Copyright 2018, 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/logging.h" +#include "core/network.h" +#include "tidalservice.h" +#include "tidalbaserequest.h" + +const char *TidalBaseRequest::kApiUrl = "https://api.tidalhifi.com/v1"; + +TidalBaseRequest::TidalBaseRequest(TidalService *service, NetworkAccessManager *network, QObject *parent) : + QObject(parent), + service_(service), + network_(network) + {} + +TidalBaseRequest::~TidalBaseRequest() {} + +QNetworkReply *TidalBaseRequest::CreateRequest(const QString &ressource_name, const QList ¶ms_provided) { + + ParamList params = ParamList() << params_provided + << Param("countryCode", country_code()); + + QUrlQuery url_query; + for (const Param& param : params) { + EncodedParam encoded_param(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second)); + url_query.addQueryItem(encoded_param.first, encoded_param.second); + } + + QUrl url(kApiUrl + QString("/") + ressource_name); + url.setQuery(url_query); + QNetworkRequest req(url); + req.setAttribute(QNetworkRequest::FollowRedirectsAttribute, true); + req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); + if (!access_token().isEmpty()) req.setRawHeader("authorization", "Bearer " + access_token().toUtf8()); + if (!session_id().isEmpty()) req.setRawHeader("X-Tidal-SessionId", session_id().toUtf8()); + + QNetworkReply *reply = network_->get(req); + connect(reply, SIGNAL(sslErrors(QList)), this, SLOT(HandleSSLErrors(QList))); + + qLog(Debug) << "Tidal: Sending request" << url; + + return reply; + +} + +void TidalBaseRequest::HandleSSLErrors(QList ssl_errors) { + + for (QSslError &ssl_error : ssl_errors) { + Error(ssl_error.errorString()); + } + +} + +QByteArray TidalBaseRequest::GetReplyData(QNetworkReply *reply, const bool send_login) { + + QByteArray data; + + if (reply->error() == QNetworkReply::NoError && reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() == 200) { + data = reply->readAll(); + } + else { + if (reply->error() != QNetworkReply::NoError && reply->error() < 200) { + // This is a network error, there is nothing more to do. + Error(QString("%1 (%2)").arg(reply->errorString()).arg(reply->error())); + } + else { + // See if there is Json data containing "status" and "userMessage" - then use that instead. + data = reply->readAll(); + QString error; + QJsonParseError json_error; + QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error); + int status = 0; + int sub_status = 0; + if (json_error.error == QJsonParseError::NoError && !json_doc.isNull() && !json_doc.isEmpty() && json_doc.isObject()) { + QJsonObject json_obj = json_doc.object(); + if (!json_obj.isEmpty() && json_obj.contains("status") && json_obj.contains("userMessage")) { + status = json_obj["status"].toInt(); + sub_status = json_obj["subStatus"].toInt(); + QString user_message = json_obj["userMessage"].toString(); + error = QString("%1 (%2) (%3)").arg(user_message).arg(status).arg(sub_status); + } + } + if (reply->error() != QNetworkReply::NoError) { + error = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()); + } + else { + error = QString("Received HTTP code %1").arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt()); + } + if (status == 401 && sub_status == 6001) { // User does not have a valid session + emit service_->Logout(); + if (!oauth() && send_login && login_attempts() < max_login_attempts() && !api_token().isEmpty() && !username().isEmpty() && !password().isEmpty()) { + qLog(Error) << "Tidal:" << error; + qLog(Info) << "Tidal:" << "Attempting to login."; + NeedLogin(); + emit service_->Login(); + } + else { + Error(error); + } + } + else { + Error(error); + } + } + return QByteArray(); + } + + return data; + +} + +QJsonObject TidalBaseRequest::ExtractJsonObj(QByteArray &data) { + + QJsonParseError json_error; + QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error); + + if (json_error.error != QJsonParseError::NoError) { + Error("Reply from server missing Json data.", data); + return QJsonObject(); + } + + if (json_doc.isNull() || json_doc.isEmpty()) { + Error("Received empty Json document.", data); + return QJsonObject(); + } + + if (!json_doc.isObject()) { + Error("Json document is not an object.", json_doc); + return QJsonObject(); + } + + QJsonObject json_obj = json_doc.object(); + if (json_obj.isEmpty()) { + Error("Received empty Json object.", json_doc); + return QJsonObject(); + } + + return json_obj; + +} + +QJsonValue TidalBaseRequest::ExtractItems(QByteArray &data) { + + QJsonObject json_obj = ExtractJsonObj(data); + if (json_obj.isEmpty()) return QJsonValue(); + return ExtractItems(json_obj); + +} + +QJsonValue TidalBaseRequest::ExtractItems(QJsonObject &json_obj) { + + if (!json_obj.contains("items")) { + Error("Json reply is missing items.", json_obj); + return QJsonArray(); + } + QJsonValue json_items = json_obj["items"]; + return json_items; + +} + +QString TidalBaseRequest::ErrorsToHTML(const QStringList &errors) { + + QString error_html; + for (const QString &error : errors) { + error_html += error + "
"; + } + return error_html; + +} diff --git a/src/tidal/tidalbaserequest.h b/src/tidal/tidalbaserequest.h new file mode 100644 index 00000000..c18c8dde --- /dev/null +++ b/src/tidal/tidalbaserequest.h @@ -0,0 +1,113 @@ +/* + * Strawberry Music Player + * Copyright 2018, 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 TIDALBASEREQUEST_H +#define TIDALBASEREQUEST_H + +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "tidalservice.h" + +class QNetworkReply; +class NetworkAccessManager; + +class TidalBaseRequest : public QObject { + Q_OBJECT + + public: + + enum QueryType { + QueryType_None, + QueryType_Artists, + QueryType_Albums, + QueryType_Songs, + QueryType_SearchArtists, + QueryType_SearchAlbums, + QueryType_SearchSongs, + QueryType_StreamURL, + }; + + TidalBaseRequest(TidalService *service, NetworkAccessManager *network, QObject *parent); + ~TidalBaseRequest(); + + typedef QPair Param; + typedef QList ParamList; + + typedef QPair EncodedParam; + typedef QList EncodedParamList; + + QNetworkReply *CreateRequest(const QString &ressource_name, const QList ¶ms_provided); + QByteArray GetReplyData(QNetworkReply *reply, const bool send_login); + QJsonObject ExtractJsonObj(QByteArray &data); + QJsonValue ExtractItems(QByteArray &data); + QJsonValue ExtractItems(QJsonObject &json_obj); + + virtual void Error(const QString &error, const QVariant &debug = QVariant()) = 0; + QString ErrorsToHTML(const QStringList &errors); + + QString api_url() { return QString(kApiUrl); } + bool oauth() { return service_->oauth(); } + QString client_id() { return service_->client_id(); } + QString api_token() { return service_->api_token(); } + quint64 user_id() { return service_->user_id(); } + QString country_code() { return service_->country_code(); } + QString username() { return service_->username(); } + QString password() { return service_->password(); } + QString quality() { return service_->quality(); } + int artistssearchlimit() { return service_->artistssearchlimit(); } + int albumssearchlimit() { return service_->albumssearchlimit(); } + int songssearchlimit() { return service_->songssearchlimit(); } + + QString access_token() { return service_->access_token(); } + QString session_id() { return service_->session_id(); } + + bool authenticated() { return service_->authenticated(); } + bool login_sent() { return service_->login_sent(); } + int max_login_attempts() { return service_->max_login_attempts(); } + int login_attempts() { return service_->login_attempts(); } + + virtual void NeedLogin() = 0; + + private slots: + void HandleSSLErrors(QList ssl_errors); + + private: + + static const char *kApiUrl; + + TidalService *service_; + NetworkAccessManager *network_; + +}; + +#endif // TIDALBASEREQUEST_H diff --git a/src/tidal/tidalfavoriterequest.cpp b/src/tidal/tidalfavoriterequest.cpp new file mode 100644 index 00000000..047625a0 --- /dev/null +++ b/src/tidal/tidalfavoriterequest.cpp @@ -0,0 +1,297 @@ +/* + * Strawberry Music Player + * Copyright 2018, 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 "core/logging.h" +#include "core/network.h" +#include "core/closure.h" +#include "core/song.h" +#include "tidalservice.h" +#include "tidalbaserequest.h" +#include "tidalfavoriterequest.h" + +TidalFavoriteRequest::TidalFavoriteRequest(TidalService *service, NetworkAccessManager *network, QObject *parent) + : TidalBaseRequest(service, network, parent), + service_(service), + network_(network), + need_login_(false) {} + +TidalFavoriteRequest::~TidalFavoriteRequest() { + + while (!replies_.isEmpty()) { + QNetworkReply *reply = replies_.takeFirst(); + disconnect(reply, 0, this, 0); + reply->abort(); + reply->deleteLater(); + } + +} + +QString TidalFavoriteRequest::FavoriteText(const FavoriteType type) { + + switch (type) { + case FavoriteType_Artists: + return "artists"; + case FavoriteType_Albums: + return "albums"; + case FavoriteType_Songs: + default: + return "tracks"; + } + +} + +void TidalFavoriteRequest::AddArtists(const SongList &songs) { + AddFavorites(FavoriteType_Artists, songs); +} + +void TidalFavoriteRequest::AddAlbums(const SongList &songs) { + AddFavorites(FavoriteType_Albums, songs); +} + +void TidalFavoriteRequest::AddSongs(const SongList &songs) { + AddFavorites(FavoriteType_Songs, songs); +} + +void TidalFavoriteRequest::AddFavorites(const FavoriteType type, const SongList &songs) { + + if (songs.isEmpty()) return; + + QString text; + switch (type) { + case FavoriteType_Artists: + text = "artistIds"; + break; + case FavoriteType_Albums: + text = "albumIds"; + break; + case FavoriteType_Songs: + text = "trackIds"; + break; + } + + QStringList ids_list; + for (const Song &song : songs) { + QString id; + switch (type) { + case FavoriteType_Artists: + if (song.artist_id().isEmpty()) continue; + id = song.artist_id(); + break; + case FavoriteType_Albums: + if (song.album_id().isEmpty()) continue; + id = song.album_id(); + break; + case FavoriteType_Songs: + if (song.song_id().isEmpty()) continue; + id = song.song_id(); + break; + } + if (id.isEmpty()) continue; + if (!ids_list.contains(id)) { + ids_list << id; + } + } + if (ids_list.isEmpty()) return; + + QString ids = ids_list.join(','); + + typedef QPair EncodedParam; + + ParamList params = ParamList() << Param("countryCode", country_code()) + << Param(text, ids); + + QUrlQuery url_query; + for (const Param& param : params) { + EncodedParam encoded_param(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second)); + url_query.addQueryItem(encoded_param.first, encoded_param.second); + } + + QUrl url(api_url() + QString("/") + "users/" + QString::number(service_->user_id()) + "/favorites/" + FavoriteText(type)); + QNetworkRequest req(url); + req.setAttribute(QNetworkRequest::FollowRedirectsAttribute, true); + req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); + if (!access_token().isEmpty()) req.setRawHeader("authorization", "Bearer " + access_token().toUtf8()); + if (!session_id().isEmpty()) req.setRawHeader("X-Tidal-SessionId", session_id().toUtf8()); + QByteArray query = url_query.toString(QUrl::FullyEncoded).toUtf8(); + QNetworkReply *reply = network_->post(req, query); + NewClosure(reply, SIGNAL(finished()), this, SLOT(AddFavoritesReply(QNetworkReply*, FavoriteType, SongList)), reply, type, songs); + replies_ << reply; + + qLog(Debug) << "Tidal: Sending request" << url << query; + +} + +void TidalFavoriteRequest::AddFavoritesReply(QNetworkReply *reply, const FavoriteType type, const SongList &songs) { + + if (replies_.contains(reply)) { + replies_.removeAll(reply); + reply->deleteLater(); + } + else { + return; + } + + QString error; + QByteArray data = GetReplyData(reply, false); + + if (reply->error() != QNetworkReply::NoError) { + return; + } + + qLog(Debug) << "Tidal:" << songs.count() << "songs added to" << FavoriteText(type) << "favorites."; + + switch (type) { + case FavoriteType_Artists: + emit ArtistsAdded(songs); + break; + case FavoriteType_Albums: + emit AlbumsAdded(songs); + break; + case FavoriteType_Songs: + emit SongsAdded(songs); + break; + } + +} + +void TidalFavoriteRequest::RemoveArtists(const SongList &songs) { + RemoveFavorites(FavoriteType_Artists, songs); +} + +void TidalFavoriteRequest::RemoveAlbums(const SongList &songs) { + RemoveFavorites(FavoriteType_Albums, songs); +} + +void TidalFavoriteRequest::RemoveSongs(const SongList &songs) { + RemoveFavorites(FavoriteType_Songs, songs); +} + +void TidalFavoriteRequest::RemoveFavorites(const FavoriteType type, const SongList songs) { + + if (songs.isEmpty()) return; + + QStringList ids; + QMultiMap songs_map; + for (const Song &song : songs) { + QString id; + switch (type) { + case FavoriteType_Artists: + if (song.artist_id() <= 0) continue; + id = song.artist_id(); + break; + case FavoriteType_Albums: + if (song.album_id().isEmpty()) continue; + id = song.album_id().toLongLong(); + break; + case FavoriteType_Songs: + if (song.song_id() <= 0) continue; + id = song.song_id(); + break; + } + if (!ids.contains(id)) ids << id; + songs_map.insertMulti(id, song); + } + + for (const QString &id : ids) { + SongList songs_list = songs_map.values(id); + RemoveFavorites(type, id, songs_list); + } + +} + +void TidalFavoriteRequest::RemoveFavorites(const FavoriteType type, const QString &id, const SongList &songs) { + + ParamList params = ParamList() << Param("countryCode", country_code()); + + QUrlQuery url_query; + for (const Param& param : params) { + EncodedParam encoded_param(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second)); + url_query.addQueryItem(encoded_param.first, encoded_param.second); + } + + QUrl url(api_url() + QString("/") + "users/" + QString::number(service_->user_id()) + "/favorites/" + FavoriteText(type) + QString("/") + id); + url.setQuery(url_query); + QNetworkRequest req(url); + req.setAttribute(QNetworkRequest::FollowRedirectsAttribute, true); + req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); + if (!access_token().isEmpty()) req.setRawHeader("authorization", "Bearer " + access_token().toUtf8()); + if (!session_id().isEmpty()) req.setRawHeader("X-Tidal-SessionId", session_id().toUtf8()); + QNetworkReply *reply = network_->deleteResource(req); + NewClosure(reply, SIGNAL(finished()), this, SLOT(RemoveFavoritesReply(QNetworkReply*, FavoriteType, SongList)), reply, type, songs); + replies_ << reply; + + qLog(Debug) << "Tidal: Sending request" << url << "with" << songs.count() << "songs"; + +} + +void TidalFavoriteRequest::RemoveFavoritesReply(QNetworkReply *reply, const FavoriteType type, const SongList &songs) { + + if (replies_.contains(reply)) { + replies_.removeAll(reply); + reply->deleteLater(); + } + else { + return; + } + + QString error; + QByteArray data = GetReplyData(reply, false); + if (reply->error() != QNetworkReply::NoError) { + return; + } + + qLog(Debug) << "Tidal:" << songs.count() << "songs removed from" << FavoriteText(type) << "favorites."; + + switch (type) { + case FavoriteType_Artists: + emit ArtistsRemoved(songs); + break; + case FavoriteType_Albums: + emit AlbumsRemoved(songs); + break; + case FavoriteType_Songs: + emit SongsRemoved(songs); + break; + } + +} + +void TidalFavoriteRequest::Error(const QString &error, const QVariant &debug) { + + qLog(Error) << "Tidal:" << error; + if (debug.isValid()) qLog(Debug) << debug; + +} diff --git a/src/tidal/tidalfavoriterequest.h b/src/tidal/tidalfavoriterequest.h new file mode 100644 index 00000000..c19a49b8 --- /dev/null +++ b/src/tidal/tidalfavoriterequest.h @@ -0,0 +1,88 @@ +/* + * Strawberry Music Player + * Copyright 2018, 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 TIDALFAVORITEREQUEST_H +#define TIDALFAVORITEREQUEST_H + +#include "config.h" + +#include +#include +#include +#include + +#include "tidalbaserequest.h" +#include "core/song.h" + +class QNetworkReply; +class TidalService; +class NetworkAccessManager; + +class TidalFavoriteRequest : public TidalBaseRequest { + Q_OBJECT + + public: + TidalFavoriteRequest(TidalService *service, NetworkAccessManager *network, QObject *parent); + ~TidalFavoriteRequest(); + + enum FavoriteType { + FavoriteType_Artists, + FavoriteType_Albums, + FavoriteType_Songs + }; + + bool need_login() { return need_login_; } + + void NeedLogin() { need_login_ = true; } + + signals: + void ArtistsAdded(const SongList &songs); + void AlbumsAdded(const SongList &songs); + void SongsAdded(const SongList &songs); + void ArtistsRemoved(const SongList &songs); + void AlbumsRemoved(const SongList &songs); + void SongsRemoved(const SongList &songs); + + private slots: + void AddArtists(const SongList &songs); + void AddAlbums(const SongList &songs); + void AddSongs(const SongList &songs); + + void RemoveArtists(const SongList &songs); + void RemoveAlbums(const SongList &songs); + void RemoveSongs(const SongList &songs); + + void AddFavoritesReply(QNetworkReply *reply, const FavoriteType type, const SongList &songs); + void RemoveFavoritesReply(QNetworkReply *reply, const FavoriteType type, const SongList &songs); + + private: + void Error(const QString &error, const QVariant &debug = QVariant()); + QString FavoriteText(const FavoriteType type); + void AddFavorites(const FavoriteType type, const SongList &songs); + void RemoveFavorites(const FavoriteType type, const SongList songs); + void RemoveFavorites(const FavoriteType type, const QString &id, const SongList &songs); + + TidalService *service_; + NetworkAccessManager *network_; + QList replies_; + bool need_login_; + +}; + +#endif // TIDALFAVORITEREQUEST_H diff --git a/src/tidal/tidalrequest.cpp b/src/tidal/tidalrequest.cpp new file mode 100644 index 00000000..f3699590 --- /dev/null +++ b/src/tidal/tidalrequest.cpp @@ -0,0 +1,1253 @@ +/* + * Strawberry Music Player + * Copyright 2018, 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 "core/closure.h" +#include "core/logging.h" +#include "core/network.h" +#include "core/song.h" +#include "core/timeconstants.h" +#include "core/application.h" +#include "covermanager/albumcoverloader.h" +#include "tidalservice.h" +#include "tidalurlhandler.h" +#include "tidalbaserequest.h" +#include "tidalrequest.h" + +const char *TidalRequest::kResourcesUrl = "https://resources.tidal.com"; +const int TidalRequest::kMaxConcurrentArtistsRequests = 3; +const int TidalRequest::kMaxConcurrentAlbumsRequests = 3; +const int TidalRequest::kMaxConcurrentSongsRequests = 3; +const int TidalRequest::kMaxConcurrentArtistAlbumsRequests = 3; +const int TidalRequest::kMaxConcurrentAlbumSongsRequests = 3; +const int TidalRequest::kMaxConcurrentAlbumCoverRequests = 1; + +TidalRequest::TidalRequest(TidalService *service, TidalUrlHandler *url_handler, Application *app, NetworkAccessManager *network, QueryType type, QObject *parent) + : TidalBaseRequest(service, network, parent), + service_(service), + url_handler_(url_handler), + app_(app), + network_(network), + type_(type), + fetchalbums_(service->fetchalbums()), + coversize_(service_->coversize()), + query_id_(-1), + finished_(false), + artists_requests_active_(0), + artists_total_(0), + artists_received_(0), + albums_requests_active_(0), + songs_requests_active_(0), + artist_albums_requests_active_(0), + artist_albums_requested_(0), + artist_albums_received_(0), + album_songs_requests_active_(0), + album_songs_requested_(0), + album_songs_received_(0), + album_covers_requests_active_(), + album_covers_requested_(0), + album_covers_received_(0), + need_login_(false), + no_results_(false) {} + +TidalRequest::~TidalRequest() { + + while (!replies_.isEmpty()) { + QNetworkReply *reply = replies_.takeFirst(); + disconnect(reply, 0, this, 0); + if (reply->isRunning()) reply->abort(); + reply->deleteLater(); + } + + while (!album_cover_replies_.isEmpty()) { + QNetworkReply *reply = album_cover_replies_.takeFirst(); + disconnect(reply, 0, this, 0); + if (reply->isRunning()) reply->abort(); + reply->deleteLater(); + } + +} + +void TidalRequest::LoginComplete(const bool success, QString error) { + + if (!need_login_) return; + need_login_ = false; + + if (!success) { + Error(error); + return; + } + + Process(); + +} + +void TidalRequest::Process() { + + if (!service_->authenticated()) { + emit UpdateStatus(query_id_, tr("Authenticating...")); + need_login_ = true; + service_->TryLogin(); + return; + } + + switch (type_) { + case QueryType::QueryType_Artists: + GetArtists(); + break; + case QueryType::QueryType_Albums: + GetAlbums(); + break; + case QueryType::QueryType_Songs: + GetSongs(); + break; + case QueryType::QueryType_SearchArtists: + ArtistsSearch(); + break; + case QueryType::QueryType_SearchAlbums: + AlbumsSearch(); + break; + case QueryType::QueryType_SearchSongs: + SongsSearch(); + break; + default: + Error("Invalid query type."); + break; + } + +} + +void TidalRequest::Search(const int query_id, const QString &search_text) { + query_id_ = query_id; + search_text_ = search_text; +} + +void TidalRequest::GetArtists() { + + emit UpdateStatus(query_id_, tr("Retrieving artists...")); + emit UpdateProgress(query_id_, 0); + AddArtistsRequest(); + +} + +void TidalRequest::AddArtistsRequest(const int offset, const int limit) { + + Request request; + request.limit = limit; + request.offset = offset; + artists_requests_queue_.enqueue(request); + if (artists_requests_active_ < kMaxConcurrentArtistsRequests) FlushArtistsRequests(); + +} + +void TidalRequest::FlushArtistsRequests() { + + while (!artists_requests_queue_.isEmpty() && artists_requests_active_ < kMaxConcurrentArtistsRequests) { + + Request request = artists_requests_queue_.dequeue(); + ++artists_requests_active_; + + ParamList parameters; + if (type_ == QueryType_SearchArtists) parameters << Param("query", search_text_); + if (request.limit > 0) parameters << Param("limit", QString::number(request.limit)); + if (request.offset > 0) parameters << Param("offset", QString::number(request.offset)); + QNetworkReply *reply; + if (type_ == QueryType_Artists) { + reply = CreateRequest(QString("users/%1/favorites/artists").arg(service_->user_id()), parameters); + } + if (type_ == QueryType_SearchArtists) { + reply = CreateRequest("search/artists", parameters); + } + if (!reply) continue; + replies_ << reply; + NewClosure(reply, SIGNAL(finished()), this, SLOT(ArtistsReplyReceived(QNetworkReply*, int, int)), reply, request.limit, request.offset); + + } + +} + +void TidalRequest::GetAlbums() { + + emit UpdateStatus(query_id_, tr("Retrieving albums...")); + emit UpdateProgress(query_id_, 0); + AddAlbumsRequest(); + +} + +void TidalRequest::AddAlbumsRequest(const int offset, const int limit) { + + Request request; + request.limit = limit; + request.offset = offset; + albums_requests_queue_.enqueue(request); + if (albums_requests_active_ < kMaxConcurrentAlbumsRequests) FlushAlbumsRequests(); + +} + +void TidalRequest::FlushAlbumsRequests() { + + while (!albums_requests_queue_.isEmpty() && albums_requests_active_ < kMaxConcurrentAlbumsRequests) { + + Request request = albums_requests_queue_.dequeue(); + ++albums_requests_active_; + + ParamList parameters; + if (type_ == QueryType_SearchAlbums) parameters << Param("query", search_text_); + if (request.limit > 0) parameters << Param("limit", QString::number(request.limit)); + if (request.offset > 0) parameters << Param("offset", QString::number(request.offset)); + QNetworkReply *reply; + if (type_ == QueryType_Albums) { + reply = CreateRequest(QString("users/%1/favorites/albums").arg(service_->user_id()), parameters); + } + if (type_ == QueryType_SearchAlbums) { + reply = CreateRequest("search/albums", parameters); + } + if (!reply) continue; + replies_ << reply; + NewClosure(reply, SIGNAL(finished()), this, SLOT(AlbumsReplyReceived(QNetworkReply*, int, int)), reply, request.limit, request.offset); + + } + +} + +void TidalRequest::GetSongs() { + + emit UpdateStatus(query_id_, tr("Retrieving songs...")); + emit UpdateProgress(query_id_, 0); + AddSongsRequest(); + +} + +void TidalRequest::AddSongsRequest(const int offset, const int limit) { + + Request request; + request.limit = limit; + request.offset = offset; + songs_requests_queue_.enqueue(request); + if (songs_requests_active_ < kMaxConcurrentSongsRequests) FlushSongsRequests(); + +} + +void TidalRequest::FlushSongsRequests() { + + while (!songs_requests_queue_.isEmpty() && songs_requests_active_ < kMaxConcurrentSongsRequests) { + + Request request = songs_requests_queue_.dequeue(); + ++songs_requests_active_; + + ParamList parameters; + if (type_ == QueryType_SearchSongs) parameters << Param("query", search_text_); + if (request.limit > 0) parameters << Param("limit", QString::number(request.limit)); + if (request.offset > 0) parameters << Param("offset", QString::number(request.offset)); + QNetworkReply *reply; + if (type_ == QueryType_Songs) { + reply = CreateRequest(QString("users/%1/favorites/tracks").arg(service_->user_id()), parameters); + } + if (type_ == QueryType_SearchSongs) { + reply = CreateRequest("search/tracks", parameters); + } + if (!reply) continue; + replies_ << reply; + NewClosure(reply, SIGNAL(finished()), this, SLOT(SongsReplyReceived(QNetworkReply*, int, int)), reply, request.limit, request.offset); + + } + +} + +void TidalRequest::ArtistsSearch() { + + emit UpdateStatus(query_id_, tr("Searching...")); + emit UpdateProgress(query_id_, 0); + AddArtistsSearchRequest(); + +} + +void TidalRequest::AddArtistsSearchRequest(const int offset) { + + AddArtistsRequest(offset, service_->artistssearchlimit()); + +} + +void TidalRequest::AlbumsSearch() { + + emit UpdateStatus(query_id_, tr("Searching...")); + emit UpdateProgress(query_id_, 0); + AddAlbumsSearchRequest(); + +} + +void TidalRequest::AddAlbumsSearchRequest(const int offset) { + + AddAlbumsRequest(offset, service_->albumssearchlimit()); + +} + +void TidalRequest::SongsSearch() { + + emit UpdateStatus(query_id_, tr("Searching...")); + emit UpdateProgress(query_id_, 0); + AddSongsSearchRequest(); + +} + +void TidalRequest::AddSongsSearchRequest(const int offset) { + + AddSongsRequest(offset, service_->songssearchlimit()); + +} + +void TidalRequest::ArtistsReplyReceived(QNetworkReply *reply, const int limit_requested, const int offset_requested) { + + if (!replies_.contains(reply)) return; + replies_.removeAll(reply); + reply->deleteLater(); + + QByteArray data = GetReplyData(reply, (offset_requested == 0)); + + --artists_requests_active_; + + if (finished_) return; + + if (data.isEmpty()) { + ArtistsFinishCheck(); + return; + } + + QJsonObject json_obj = ExtractJsonObj(data); + if (json_obj.isEmpty()) { + ArtistsFinishCheck(); + return; + } + + if (!json_obj.contains("limit") || + !json_obj.contains("offset") || + !json_obj.contains("totalNumberOfItems") || + !json_obj.contains("items")) { + ArtistsFinishCheck(); + Error("Json object missing values.", json_obj); + return; + } + //int limit = json_obj["limit"].toInt(); + int offset = json_obj["offset"].toInt(); + int artists_total = json_obj["totalNumberOfItems"].toInt(); + + if (offset_requested == 0) { + artists_total_ = artists_total; + } + else if (artists_total != artists_total_) { + Error(QString("totalNumberOfItems returned does not match previous totalNumberOfItems! %1 != %2").arg(artists_total).arg(artists_total_)); + ArtistsFinishCheck(); + return; + } + + if (offset != offset_requested) { + Error(QString("Offset returned does not match offset requested! %1 != %2").arg(offset).arg(offset_requested)); + ArtistsFinishCheck(); + return; + } + + if (offset_requested == 0) { + emit ProgressSetMaximum(query_id_, artists_total_); + emit UpdateProgress(query_id_, artists_received_); + } + + QJsonValue json_value = ExtractItems(json_obj); + if (!json_value.isArray()) { + ArtistsFinishCheck(); + return; + } + + QJsonArray json_items = json_value.toArray(); + if (json_items.isEmpty()) { // Empty array means no results + if (offset_requested == 0) no_results_ = true; + ArtistsFinishCheck(); + return; + } + + int artists_received = 0; + for (const QJsonValue &value : json_items) { + + ++artists_received; + + if (!value.isObject()) { + Error("Invalid Json reply, item not a object.", value); + continue; + } + QJsonObject json_obj = value.toObject(); + + if (json_obj.contains("item")) { + QJsonValue json_item = json_obj["item"]; + if (!json_item.isObject()) { + Error("Invalid Json reply, item not a object.", json_item); + continue; + } + json_obj = json_item.toObject(); + } + + if (!json_obj.contains("id") || !json_obj.contains("name")) { + Error("Invalid Json reply, item missing id or album.", json_obj); + continue; + } + + QString artist_id; + if (json_obj["id"].isString()) { + artist_id = json_obj["id"].toString(); + } + else { + artist_id = QString::number(json_obj["id"].toInt()); + } + if (artist_albums_requests_pending_.contains(artist_id)) continue; + artist_albums_requests_pending_.append(artist_id); + + } + artists_received_ += artists_received; + + if (offset_requested != 0) emit UpdateProgress(query_id_, artists_received_); + + ArtistsFinishCheck(limit_requested, offset, artists_received); + +} + +void TidalRequest::ArtistsFinishCheck(const int limit, const int offset, const int artists_received) { + + if (finished_) return; + + if ((limit == 0 || limit > artists_received) && artists_received_ < artists_total_) { + int offset_next = offset + artists_received; + if (offset_next > 0 && offset_next < artists_total_) { + if (type_ == QueryType_Artists) AddArtistsRequest(offset_next); + else if (type_ == QueryType_SearchArtists) AddArtistsSearchRequest(offset_next); + } + } + + if (!artists_requests_queue_.isEmpty() && artists_requests_active_ < kMaxConcurrentArtistsRequests) FlushArtistsRequests(); + + if (artists_requests_queue_.isEmpty() && artists_requests_active_ <= 0) { // Artist query is finished, get all albums for all artists. + + // Get artist albums + for (const QString &artist_id : artist_albums_requests_pending_) { + AddArtistAlbumsRequest(artist_id); + ++artist_albums_requested_; + } + artist_albums_requests_pending_.clear(); + + if (artist_albums_requested_ > 0) { + if (artist_albums_requested_ == 1) emit UpdateStatus(query_id_, tr("Retrieving albums for %1 artist...").arg(artist_albums_requested_)); + else emit UpdateStatus(query_id_, tr("Retrieving albums for %1 artists...").arg(artist_albums_requested_)); + emit ProgressSetMaximum(query_id_, artist_albums_requested_); + emit UpdateProgress(query_id_, 0); + } + + } + + FinishCheck(); + +} + +void TidalRequest::AlbumsReplyReceived(QNetworkReply *reply, const int limit_requested, const int offset_requested) { + --albums_requests_active_; + AlbumsReceived(reply, QString(), limit_requested, offset_requested, (offset_requested == 0)); + if (!albums_requests_queue_.isEmpty() && albums_requests_active_ < kMaxConcurrentAlbumsRequests) FlushAlbumsRequests(); +} + +void TidalRequest::AddArtistAlbumsRequest(const QString &artist_id, const int offset) { + + Request request; + request.artist_id = artist_id; + request.offset = offset; + artist_albums_requests_queue_.enqueue(request); + if (artist_albums_requests_active_ < kMaxConcurrentArtistAlbumsRequests) FlushArtistAlbumsRequests(); + +} + +void TidalRequest::FlushArtistAlbumsRequests() { + + while (!artist_albums_requests_queue_.isEmpty() && artist_albums_requests_active_ < kMaxConcurrentArtistAlbumsRequests) { + + Request request = artist_albums_requests_queue_.dequeue(); + ++artist_albums_requests_active_; + + ParamList parameters; + if (request.offset > 0) parameters << Param("offset", QString::number(request.offset)); + QNetworkReply *reply = CreateRequest(QString("artists/%1/albums").arg(request.artist_id), parameters); + NewClosure(reply, SIGNAL(finished()), this, SLOT(ArtistAlbumsReplyReceived(QNetworkReply*, QString, int)), reply, request.artist_id, request.offset); + replies_ << reply; + + } + +} + +void TidalRequest::ArtistAlbumsReplyReceived(QNetworkReply *reply, const QString &artist_id, const int offset_requested) { + + --artist_albums_requests_active_; + ++artist_albums_received_; + emit UpdateProgress(query_id_, artist_albums_received_); + AlbumsReceived(reply, artist_id, 0, offset_requested, false); + if (!artist_albums_requests_queue_.isEmpty() && artist_albums_requests_active_ < kMaxConcurrentArtistAlbumsRequests) FlushArtistAlbumsRequests(); + +} + +void TidalRequest::AlbumsReceived(QNetworkReply *reply, const QString &artist_id_requested, const int limit_requested, const int offset_requested, const bool auto_login) { + + if (!replies_.contains(reply)) return; + replies_.removeAll(reply); + reply->deleteLater(); + + QByteArray data = GetReplyData(reply, auto_login); + + if (finished_) return; + + if (data.isEmpty()) { + AlbumsFinishCheck(artist_id_requested); + return; + } + + QJsonObject json_obj = ExtractJsonObj(data); + if (json_obj.isEmpty()) { + AlbumsFinishCheck(artist_id_requested); + return; + } + + if (!json_obj.contains("limit") || + !json_obj.contains("offset") || + !json_obj.contains("totalNumberOfItems") || + !json_obj.contains("items")) { + Error("Json object missing values.", json_obj); + AlbumsFinishCheck(artist_id_requested); + return; + } + + //int limit = json_obj["limit"].toInt(); + int offset = json_obj["offset"].toInt(); + int albums_total = json_obj["totalNumberOfItems"].toInt(); + + if (offset != offset_requested) { + Error(QString("Offset returned does not match offset requested! %1 != %2").arg(offset).arg(offset_requested)); + AlbumsFinishCheck(artist_id_requested); + return; + } + + QJsonValue json_value = ExtractItems(json_obj); + if (!json_value.isArray()) { + AlbumsFinishCheck(artist_id_requested); + return; + } + QJsonArray json_items = json_value.toArray(); + if (json_items.isEmpty()) { + if ((type_ == QueryType_Albums || type_ == QueryType_SearchAlbums || (type_ == QueryType_SearchSongs && fetchalbums_)) && offset_requested == 0) { + no_results_ = true; + } + AlbumsFinishCheck(artist_id_requested); + return; + } + + int albums_received = 0; + for (const QJsonValue &value : json_items) { + + ++albums_received; + + if (!value.isObject()) { + Error("Invalid Json reply, item not a object.", value); + continue; + } + QJsonObject json_obj = value.toObject(); + + if (json_obj.contains("item")) { + QJsonValue json_item = json_obj["item"]; + if (!json_item.isObject()) { + Error("Invalid Json reply, item not a object.", json_item); + continue; + } + json_obj = json_item.toObject(); + } + + QString album_id; + QString album; + if (json_obj.contains("type")) { // This was a albums request or search + if (!json_obj.contains("id") || !json_obj.contains("title")) { + Error("Invalid Json reply, item is missing ID or title.", json_obj); + continue; + } + if (json_obj["id"].isString()) { + album_id = json_obj["id"].toString(); + } + else { + album_id = QString::number(json_obj["id"].toInt()); + } + album = json_obj["title"].toString(); + } + else if (json_obj.contains("album")) { // This was a tracks request or search + QJsonValue json_value_album = json_obj["album"]; + if (!json_value_album.isObject()) { + Error("Invalid Json reply, item album is not a object.", json_value_album); + continue; + } + QJsonObject json_album = json_value_album.toObject(); + if (!json_album.contains("id") || !json_album.contains("title")) { + Error("Invalid Json reply, item album is missing ID or title.", json_album); + continue; + } + if (json_album["id"].isString()) { + album_id = json_album["id"].toString(); + } + else { + album_id = QString::number(json_album["id"].toInt()); + } + album = json_album["title"].toString(); + + } + else { + Error("Invalid Json reply, item missing type or album.", json_obj); + continue; + } + + if (album_songs_requests_pending_.contains(album_id)) continue; + + if (!json_obj.contains("artist") || !json_obj.contains("title") || !json_obj.contains("audioQuality")) { + Error("Invalid Json reply, item missing artist, title or audioQuality.", json_obj); + continue; + } + QJsonValue json_value_artist = json_obj["artist"]; + if (!json_value_artist.isObject()) { + Error("Invalid Json reply, item artist is not a object.", json_value_artist); + continue; + } + QJsonObject json_artist = json_value_artist.toObject(); + if (!json_artist.contains("id") || !json_artist.contains("name")) { + Error("Invalid Json reply, item artist missing id or name.", json_artist); + continue; + } + + QString artist_id; + if (json_artist["id"].isString()) { + artist_id = json_artist["id"].toString(); + } + else { + artist_id = QString::number(json_artist["id"].toInt()); + } + QString artist = json_artist["name"].toString(); + + QString quality = json_obj["audioQuality"].toString(); + QString copyright = json_obj["copyright"].toString(); + + //qLog(Debug) << "Tidal:" << artist << album << quality << copyright; + + Request request; + if (artist_id_requested.isEmpty()) { + request.artist_id = artist_id; + } + else { + request.artist_id = artist_id_requested; + } + request.album_id = album_id; + request.album_artist = artist; + album_songs_requests_pending_.insert(album_id, request); + + } + + AlbumsFinishCheck(artist_id_requested, limit_requested, offset, albums_total, albums_received); + +} + +void TidalRequest::AlbumsFinishCheck(const QString &artist_id, const int limit, const int offset, const int albums_total, const int albums_received) { + + if (finished_) return; + + if (limit == 0 || limit > albums_received) { + int offset_next = offset + albums_received; + if (offset_next > 0 && offset_next < albums_total) { + switch (type_) { + case QueryType_Albums: + AddAlbumsRequest(offset_next); + break; + case QueryType_SearchAlbums: + AddAlbumsSearchRequest(offset_next); + break; + case QueryType_Artists: + case QueryType_SearchArtists: + AddArtistAlbumsRequest(artist_id, offset_next); + break; + default: + break; + } + } + } + + if ( + albums_requests_queue_.isEmpty() && + albums_requests_active_ <= 0 && + artist_albums_requests_queue_.isEmpty() && + artist_albums_requests_active_ <= 0 + ) { // Artist albums query is finished, get all songs for all albums. + + // Get songs for all the albums. + + QHash ::iterator i; + for (i = album_songs_requests_pending_.begin() ; i != album_songs_requests_pending_.end() ; ++i) { + Request request = i.value(); + AddAlbumSongsRequest(request.artist_id, request.album_id, request.album_artist); + } + album_songs_requests_pending_.clear(); + + if (album_songs_requested_ > 0) { + if (album_songs_requested_ == 1) emit UpdateStatus(query_id_, tr("Retrieving songs for %1 album...").arg(album_songs_requested_)); + else emit UpdateStatus(query_id_, tr("Retrieving songs for %1 albums...").arg(album_songs_requested_)); + emit ProgressSetMaximum(query_id_, album_songs_requested_); + emit UpdateProgress(query_id_, 0); + } + } + + FinishCheck(); + +} + +void TidalRequest::SongsReplyReceived(QNetworkReply *reply, const int limit_requested, const int offset_requested) { + + --songs_requests_active_; + if (type_ == QueryType_SearchSongs && fetchalbums_) { + AlbumsReceived(reply, 0, limit_requested, offset_requested, (offset_requested == 0)); + } + else { + SongsReceived(reply, 0, 0, limit_requested, offset_requested, (offset_requested == 0)); + } + +} + +void TidalRequest::AddAlbumSongsRequest(const QString &artist_id, const QString &album_id, const QString &album_artist, const int offset) { + + Request request; + request.artist_id = artist_id; + request.album_id = album_id; + request.album_artist = album_artist; + request.offset = offset; + album_songs_requests_queue_.enqueue(request); + ++album_songs_requested_; + if (album_songs_requests_active_ < kMaxConcurrentAlbumSongsRequests) FlushAlbumSongsRequests(); + +} + +void TidalRequest::FlushAlbumSongsRequests() { + + while (!album_songs_requests_queue_.isEmpty() && album_songs_requests_active_ < kMaxConcurrentAlbumSongsRequests) { + + Request request = album_songs_requests_queue_.dequeue(); + ++album_songs_requests_active_; + ParamList parameters; + if (request.offset > 0) parameters << Param("offset", QString::number(request.offset)); + QNetworkReply *reply = CreateRequest(QString("albums/%1/tracks").arg(request.album_id), parameters); + replies_ << reply; + NewClosure(reply, SIGNAL(finished()), this, SLOT(AlbumSongsReplyReceived(QNetworkReply*, QString, QString, int, QString)), reply, request.artist_id, request.album_id, request.offset, request.album_artist); + + } + +} + +void TidalRequest::AlbumSongsReplyReceived(QNetworkReply *reply, const QString &artist_id, const QString &album_id, const int offset_requested, const QString &album_artist) { + + --album_songs_requests_active_; + ++album_songs_received_; + if (offset_requested == 0) { + emit UpdateProgress(query_id_, album_songs_received_); + } + SongsReceived(reply, artist_id, album_id, 0, offset_requested, false, album_artist); + +} + +void TidalRequest::SongsReceived(QNetworkReply *reply, const QString &artist_id, const QString &album_id, const int limit_requested, const int offset_requested, const bool auto_login, const QString &album_artist) { + + if (!replies_.contains(reply)) return; + replies_.removeAll(reply); + reply->deleteLater(); + + QByteArray data = GetReplyData(reply, auto_login); + + if (finished_) return; + + if (data.isEmpty()) { + SongsFinishCheck(artist_id, album_id, limit_requested, offset_requested, 0, 0, album_artist); + return; + } + + QJsonObject json_obj = ExtractJsonObj(data); + if (json_obj.isEmpty()) { + SongsFinishCheck(artist_id, album_id, limit_requested, offset_requested, 0, 0, album_artist); + return; + } + + if (!json_obj.contains("limit") || + !json_obj.contains("offset") || + !json_obj.contains("totalNumberOfItems") || + !json_obj.contains("items")) { + Error("Json object missing values.", json_obj); + SongsFinishCheck(artist_id, album_id, limit_requested, offset_requested, 0, 0, album_artist); + return; + } + + //int limit = json_obj["limit"].toInt(); + int offset = json_obj["offset"].toInt(); + int songs_total = json_obj["totalNumberOfItems"].toInt(); + + if (offset != offset_requested) { + Error(QString("Offset returned does not match offset requested! %1 != %2").arg(offset).arg(offset_requested)); + SongsFinishCheck(artist_id, album_id, limit_requested, offset_requested, songs_total, 0, album_artist); + return; + } + + QJsonValue json_value = ExtractItems(json_obj); + if (!json_value.isArray()) { + SongsFinishCheck(artist_id, album_id, limit_requested, offset_requested, songs_total, 0, album_artist); + return; + } + + QJsonArray json_items = json_value.toArray(); + if (json_items.isEmpty()) { + if ((type_ == QueryType_Songs || type_ == QueryType_SearchSongs) && offset_requested == 0) { + no_results_ = true; + } + SongsFinishCheck(artist_id, album_id, limit_requested, offset_requested, songs_total, 0, album_artist); + return; + } + + bool compilation = false; + bool multidisc = false; + SongList songs; + int songs_received = 0; + for (const QJsonValue &value : json_items) { + + if (!value.isObject()) { + Error("Invalid Json reply, track is not a object.", value); + continue; + } + QJsonObject json_obj = value.toObject(); + + if (json_obj.contains("item")) { + QJsonValue json_item = json_obj["item"]; + if (!json_item.isObject()) { + Error("Invalid Json reply, item not a object.", json_item); + continue; + } + json_obj = json_item.toObject(); + } + + ++songs_received; + Song song(Song::Source_Tidal); + ParseSong(song, json_obj, artist_id, album_id, album_artist); + if (!song.is_valid()) continue; + if (song.disc() >= 2) multidisc = true; + if (song.is_compilation()) compilation = true; + songs << song; + } + + for (Song &song : songs) { + if (compilation) song.set_compilation_detected(true); + if (multidisc) { + QString album_full(QString("%1 - (Disc %2)").arg(song.album()).arg(song.disc())); + song.set_album(album_full); + } + songs_ << song; + } + + SongsFinishCheck(artist_id, album_id, limit_requested, offset_requested, songs_total, songs_received, album_artist); + +} + +void TidalRequest::SongsFinishCheck(const QString &artist_id, const QString &album_id, const int limit, const int offset, const int songs_total, const int songs_received, const QString &album_artist) { + + if (finished_) return; + + if (limit == 0 || limit > songs_received) { + int offset_next = offset + songs_received; + if (offset_next > 0 && offset_next < songs_total) { + switch (type_) { + case QueryType_Songs: + AddSongsRequest(offset_next); + break; + case QueryType_SearchSongs: + // If artist_id and album_id isn't zero it means that it's a songs search where we fetch all albums too. So fallthrough. + if (artist_id.isEmpty() && album_id.isEmpty()) { + AddSongsSearchRequest(offset_next); + break; + } + // fallthrough + case QueryType_Artists: + case QueryType_SearchArtists: + case QueryType_Albums: + case QueryType_SearchAlbums: + AddAlbumSongsRequest(artist_id, album_id, album_artist, offset_next); + break; + default: + break; + } + } + } + + if (!songs_requests_queue_.isEmpty() && songs_requests_active_ < kMaxConcurrentAlbumSongsRequests) FlushAlbumSongsRequests(); + if (!album_songs_requests_queue_.isEmpty() && album_songs_requests_active_ < kMaxConcurrentAlbumSongsRequests) FlushAlbumSongsRequests(); + + if ( + service_->download_album_covers() && + IsQuery() && + songs_requests_queue_.isEmpty() && + songs_requests_active_ <= 0 && + album_songs_requests_queue_.isEmpty() && + album_songs_requests_active_ <= 0 && + album_cover_requests_queue_.isEmpty() && + album_covers_received_ <= 0 && + album_covers_requests_sent_.isEmpty() && + album_songs_received_ >= album_songs_requested_ + ) { + GetAlbumCovers(); + } + + FinishCheck(); + +} + +QString TidalRequest::ParseSong(Song &song, const QJsonObject &json_obj, const QString &artist_id_requested, const QString &album_id_requested, const QString &album_artist) { + + Q_UNUSED(artist_id_requested); + + if ( + !json_obj.contains("album") || + !json_obj.contains("allowStreaming") || + !json_obj.contains("artist") || + !json_obj.contains("artists") || + !json_obj.contains("audioQuality") || + !json_obj.contains("duration") || + !json_obj.contains("id") || + !json_obj.contains("streamReady") || + !json_obj.contains("title") || + !json_obj.contains("trackNumber") || + !json_obj.contains("url") || + !json_obj.contains("volumeNumber") || + !json_obj.contains("copyright") + ) { + Error("Invalid Json reply, track is missing one or more values.", json_obj); + return QString(); + } + + QJsonValue json_value_artist = json_obj["artist"]; + QJsonValue json_value_album = json_obj["album"]; + QJsonValue json_duration = json_obj["duration"]; + QJsonArray json_artists = json_obj["artists"].toArray(); + + QString song_id; + if (json_obj["id"].isString()) { + song_id = json_obj["id"].toString(); + } + else { + song_id = QString::number(json_obj["id"].toInt()); + } + + QString title = json_obj["title"].toString(); + QString urlstr = json_obj["url"].toString(); + int track = json_obj["trackNumber"].toInt(); + int disc = json_obj["volumeNumber"].toInt(); + bool allow_streaming = json_obj["allowStreaming"].toBool(); + bool stream_ready = json_obj["streamReady"].toBool(); + QString copyright = json_obj["copyright"].toString(); + + if (!json_value_artist.isObject()) { + Error("Invalid Json reply, track artist is not a object.", json_value_artist); + return QString(); + } + QJsonObject json_artist = json_value_artist.toObject(); + if (!json_artist.contains("id") || !json_artist.contains("name")) { + Error("Invalid Json reply, track artist is missing id or name.", json_artist); + return QString(); + } + QString artist_id; + if (json_artist["id"].isString()) { + artist_id = json_artist["id"].toString(); + } + else { + artist_id = QString::number(json_artist["id"].toInt()); + } + QString artist = json_artist["name"].toString(); + + if (!json_value_album.isObject()) { + Error("Invalid Json reply, track album is not a object.", json_value_album); + return QString(); + } + QJsonObject json_album = json_value_album.toObject(); + if (!json_album.contains("id") || !json_album.contains("title") || !json_album.contains("cover")) { + Error("Invalid Json reply, track album is missing id, title or cover.", json_album); + return QString(); + } + QString album_id; + if (json_album["id"].isString()) { + album_id = json_album["id"].toString(); + } + else { + album_id = QString::number(json_album["id"].toInt()); + } + if (!album_id_requested.isEmpty() && album_id_requested != album_id) { + Error("Invalid Json reply, track album id is wrong.", json_album); + return QString(); + } + QString album = json_album["title"].toString(); + QString cover = json_album["cover"].toString(); + + if (!allow_streaming) { + Warn(QString("Song %1 %2 %3 is not allowStreaming").arg(artist).arg(album).arg(title)); + } + + if (!stream_ready) { + Warn(QString("Song %1 %2 %3 is not streamReady").arg(artist).arg(album).arg(title)); + } + + QUrl url; + url.setScheme(url_handler_->scheme()); + url.setPath(song_id); + + QVariant q_duration = json_duration.toVariant(); + quint64 duration = 0; + if (q_duration.isValid() && (q_duration.type() == QVariant::Int || q_duration.type() == QVariant::Double)) { + duration = q_duration.toLongLong() * kNsecPerSec; + } + else { + Error("Invalid duration for song.", json_duration); + return QString(); + } + + cover = cover.replace("-", "/"); + QUrl cover_url (QString("%1/images/%2/%3.jpg").arg(kResourcesUrl).arg(cover).arg(coversize_)); + + title.remove(Song::kTitleRemoveMisc); + + //qLog(Debug) << "id" << song_id << "track" << track << "disc" << disc << "title" << title << "album" << album << "album artist" << album_artist << "artist" << artist << cover << allow_streaming << url; + + song.set_source(Song::Source_Tidal); + song.set_song_id(song_id); + song.set_album_id(album_id); + song.set_artist_id(artist_id); + if (album_artist != artist) song.set_albumartist(album_artist); + song.set_album(album); + song.set_artist(artist); + song.set_title(title); + song.set_track(track); + song.set_disc(disc); + song.set_url(url); + song.set_length_nanosec(duration); + song.set_art_automatic(cover_url); + song.set_comment(copyright); + song.set_directory_id(0); + song.set_filetype(Song::FileType_Stream); + song.set_filesize(0); + song.set_mtime(0); + song.set_ctime(0); + song.set_valid(true); + + return song_id; + +} + +void TidalRequest::GetAlbumCovers() { + + for (Song &song : songs_) { + AddAlbumCoverRequest(song); + } + FlushAlbumCoverRequests(); + + if (album_covers_requested_ == 1) emit UpdateStatus(query_id_, tr("Retrieving album cover for %1 album...").arg(album_covers_requested_)); + else emit UpdateStatus(query_id_, tr("Retrieving album covers for %1 albums...").arg(album_covers_requested_)); + emit ProgressSetMaximum(query_id_, album_covers_requested_); + emit UpdateProgress(query_id_, 0); + +} + +void TidalRequest::AddAlbumCoverRequest(Song &song) { + + if (album_covers_requests_sent_.contains(song.album_id())) { + album_covers_requests_sent_.insertMulti(song.album_id(), &song); + return; + } + + AlbumCoverRequest request; + request.album_id = song.album_id(); + request.url = QUrl(song.art_automatic()); + request.filename = app_->album_cover_loader()->CoverFilePath(song.source(), song.effective_albumartist(), song.effective_album(), song.album_id(), QString(), request.url); + if (request.filename.isEmpty()) return; + + album_covers_requests_sent_.insertMulti(song.album_id(), &song); + ++album_covers_requested_; + + album_cover_requests_queue_.enqueue(request); + +} + +void TidalRequest::FlushAlbumCoverRequests() { + + while (!album_cover_requests_queue_.isEmpty() && album_covers_requests_active_ < kMaxConcurrentAlbumCoverRequests) { + + AlbumCoverRequest request = album_cover_requests_queue_.dequeue(); + ++album_covers_requests_active_; + + QNetworkRequest req(request.url); + req.setAttribute(QNetworkRequest::FollowRedirectsAttribute, true); + QNetworkReply *reply = network_->get(req); + album_cover_replies_ << reply; + NewClosure(reply, SIGNAL(finished()), this, SLOT(AlbumCoverReceived(QNetworkReply*, QString, QUrl, QString)), reply, request.album_id, request.url, request.filename); + + } + +} + +void TidalRequest::AlbumCoverReceived(QNetworkReply *reply, const QString &album_id, const QUrl &url, const QString &filename) { + + if (album_cover_replies_.contains(reply)) { + album_cover_replies_.removeAll(reply); + reply->deleteLater(); + } + else { + AlbumCoverFinishCheck(); + return; + } + + --album_covers_requests_active_; + ++album_covers_received_; + + if (finished_) return; + + emit UpdateProgress(query_id_, album_covers_received_); + + if (!album_covers_requests_sent_.contains(album_id)) { + AlbumCoverFinishCheck(); + return; + } + + if (reply->error() != QNetworkReply::NoError) { + Error(QString("%1 (%2)").arg(reply->errorString()).arg(reply->error())); + album_covers_requests_sent_.remove(album_id); + AlbumCoverFinishCheck(); + return; + } + + QByteArray data = reply->readAll(); + if (data.isEmpty()) { + Error(QString("Received empty image data for %1").arg(url.toString())); + album_covers_requests_sent_.remove(album_id); + AlbumCoverFinishCheck(); + return; + } + + QImage image; + if (image.loadFromData(data)) { + + if (image.save(filename, "JPG")) { + while (album_covers_requests_sent_.contains(album_id)) { + Song *song = album_covers_requests_sent_.take(album_id); + song->set_art_automatic(QUrl::fromLocalFile(filename)); + } + } + + } + else { + album_covers_requests_sent_.remove(album_id); + Error(QString("Error decoding image data from %1").arg(url.toString())); + } + + AlbumCoverFinishCheck(); + +} + +void TidalRequest::AlbumCoverFinishCheck() { + + if (!album_cover_requests_queue_.isEmpty() && album_covers_requests_active_ < kMaxConcurrentAlbumCoverRequests) + FlushAlbumCoverRequests(); + + FinishCheck(); + +} + +void TidalRequest::FinishCheck() { + + if ( + !finished_ && + !need_login_ && + albums_requests_queue_.isEmpty() && + artists_requests_queue_.isEmpty() && + songs_requests_queue_.isEmpty() && + artist_albums_requests_queue_.isEmpty() && + album_songs_requests_queue_.isEmpty() && + album_cover_requests_queue_.isEmpty() && + artist_albums_requests_pending_.isEmpty() && + album_songs_requests_pending_.isEmpty() && + album_covers_requests_sent_.isEmpty() && + artists_requests_active_ <= 0 && + albums_requests_active_ <= 0 && + songs_requests_active_ <= 0 && + artist_albums_requests_active_ <= 0 && + artist_albums_received_ >= artist_albums_requested_ && + album_songs_requests_active_ <= 0 && + album_songs_received_ >= album_songs_requested_ && + album_covers_requested_ <= album_covers_received_ && + album_covers_requests_active_ <= 0 && + album_covers_received_ >= album_covers_requested_ + ) { + finished_ = true; + if (no_results_ && songs_.isEmpty()) { + if (IsSearch()) + emit Results(query_id_, SongList(), tr("No match.")); + else + emit Results(query_id_, SongList(), QString()); + } + else { + if (songs_.isEmpty() && errors_.isEmpty()) + emit Results(query_id_, songs_, tr("Unknown error")); + else + emit Results(query_id_, songs_, ErrorsToHTML(errors_)); + } + } + +} + +void TidalRequest::Error(const QString &error, const QVariant &debug) { + + if (!error.isEmpty()) { + errors_ << error; + qLog(Error) << "Tidal:" << error; + } + + if (debug.isValid()) qLog(Debug) << debug; + + FinishCheck(); + +} + +void TidalRequest::Warn(QString error, QVariant debug) { + + qLog(Error) << "Tidal:" << error; + if (debug.isValid()) qLog(Debug) << debug; + +} + diff --git a/src/tidal/tidalrequest.h b/src/tidal/tidalrequest.h new file mode 100644 index 00000000..320cf73c --- /dev/null +++ b/src/tidal/tidalrequest.h @@ -0,0 +1,212 @@ +/* + * Strawberry Music Player + * Copyright 2018, 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 TIDALREQUEST_H +#define TIDALREQUEST_H + +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/song.h" +#include "tidalbaserequest.h" + +class QNetworkReply; +class Application; +class NetworkAccessManager; +class TidalService; +class TidalUrlHandler; + +class TidalRequest : public TidalBaseRequest { + Q_OBJECT + + public: + + TidalRequest(TidalService *service, TidalUrlHandler *url_handler, Application *app, NetworkAccessManager *network, QueryType type, QObject *parent); + ~TidalRequest(); + + void ReloadSettings(); + + void Process(); + void NeedLogin() { need_login_ = true; } + void Search(const int search_id, const QString &search_text); + + signals: + void Login(); + void Login(const QString &username, const QString &password, const QString &token); + void LoginSuccess(); + void LoginFailure(QString failure_reason); + void Results(const int id, const SongList &songs, const QString &error); + void UpdateStatus(const int id, const QString &text); + void ProgressSetMaximum(const int id, const int max); + void UpdateProgress(const int id, const int max); + void StreamURLFinished(const QUrl original_url, const QUrl url, const Song::FileType, QString error = QString()); + + private slots: + void LoginComplete(const bool success, QString error = QString()); + + void ArtistsReplyReceived(QNetworkReply *reply, const int limit_requested, const int offset_requested); + + void AlbumsReplyReceived(QNetworkReply *reply, const int limit_requested, const int offset_requested); + void AlbumsReceived(QNetworkReply *reply, const QString &artist_id_requested, const int limit_requested, const int offset_requested, const bool auto_login); + + void SongsReplyReceived(QNetworkReply *reply, const int limit_requested, const int offset_requested); + void SongsReceived(QNetworkReply *reply, const QString &artist_id, const QString &album_id, const int limit_requested, const int offset_requested, const bool auto_login = false, const QString &album_artist = QString()); + + void ArtistAlbumsReplyReceived(QNetworkReply *reply, const QString &artist_id, const int offset_requested); + void AlbumSongsReplyReceived(QNetworkReply *reply, const QString &artist_id, const QString &album_id, const int offset_requested, const QString &album_artist); + void AlbumCoverReceived(QNetworkReply *reply, const QString &album_id, const QUrl &url, const QString &filename); + + private: + typedef QPair Param; + typedef QList ParamList; + + struct Request { + QString artist_id = 0; + QString album_id = 0; + QString song_id = 0; + int offset = 0; + int limit = 0; + QString album_artist; + }; + struct AlbumCoverRequest { + qint64 artist_id = 0; + QString album_id = 0; + QUrl url; + QString filename; + }; + + bool IsQuery() { return (type_ == QueryType_Artists || type_ == QueryType_Albums || type_ == QueryType_Songs); } + bool IsSearch() { return (type_ == QueryType_SearchArtists || type_ == QueryType_SearchAlbums || type_ == QueryType_SearchSongs); } + + void GetArtists(); + void GetAlbums(); + void GetSongs(); + + void ArtistsSearch(); + void AlbumsSearch(); + void SongsSearch(); + + void AddArtistsRequest(const int offset = 0, const int limit = 0); + void AddArtistsSearchRequest(const int offset = 0); + void FlushArtistsRequests(); + void AddAlbumsRequest(const int offset = 0, const int limit = 0); + void AddAlbumsSearchRequest(const int offset = 0); + void FlushAlbumsRequests(); + void AddSongsRequest(const int offset = 0, const int limit = 0); + void AddSongsSearchRequest(const int offset = 0); + void FlushSongsRequests(); + + void ArtistsFinishCheck(const int limit = 0, const int offset = 0, const int artists_received = 0); + void AlbumsFinishCheck(const QString &artist_id, const int limit = 0, const int offset = 0, const int albums_total = 0, const int albums_received = 0); + void SongsFinishCheck(const QString &artist_id, const QString &album_id, const int limit, const int offset, const int songs_total, const int songs_received, const QString &album_artist); + + void AddArtistAlbumsRequest(const QString &artist_id, const int offset = 0); + void FlushArtistAlbumsRequests(); + + void AddAlbumSongsRequest(const QString &artist_id, const QString &album_id, const QString &album_artist, const int offset = 0); + void FlushAlbumSongsRequests(); + + QString ParseSong(Song &song, const QJsonObject &json_obj, const QString &artist_id_requested = QString(), const QString &album_id_requested = QString(), const QString &album_artist = QString()); + + void GetAlbumCovers(); + void AddAlbumCoverRequest(Song &song); + void FlushAlbumCoverRequests(); + void AlbumCoverFinishCheck(); + + void FinishCheck(); + void Warn(QString error, QVariant debug = QVariant()); + void Error(const QString &error, const QVariant &debug = QVariant()); + + static const char *kResourcesUrl; + static const int kMaxConcurrentArtistsRequests; + static const int kMaxConcurrentAlbumsRequests; + static const int kMaxConcurrentSongsRequests; + static const int kMaxConcurrentArtistAlbumsRequests; + static const int kMaxConcurrentAlbumSongsRequests; + static const int kMaxConcurrentAlbumCoverRequests; + + TidalService *service_; + TidalUrlHandler *url_handler_; + Application *app_; + NetworkAccessManager *network_; + + QueryType type_; + bool fetchalbums_; + QString coversize_; + + int query_id_; + QString search_text_; + + bool finished_; + + QQueue artists_requests_queue_; + QQueue albums_requests_queue_; + QQueue songs_requests_queue_; + + QQueue artist_albums_requests_queue_; + QQueue album_songs_requests_queue_; + QQueue album_cover_requests_queue_; + + QList artist_albums_requests_pending_; + QHash album_songs_requests_pending_; + QMultiMap album_covers_requests_sent_; + + int artists_requests_active_; + int artists_total_; + int artists_received_; + + int albums_requests_active_; + int songs_requests_active_; + + int artist_albums_requests_active_; + int artist_albums_requested_; + int artist_albums_received_; + + int album_songs_requests_active_; + int album_songs_requested_; + int album_songs_received_; + + int album_covers_requests_active_; + int album_covers_requested_; + int album_covers_received_; + + SongList songs_; + QStringList errors_; + bool need_login_; + bool no_results_; + QList replies_; + QList album_cover_replies_; + +}; + +#endif // TIDALREQUEST_H diff --git a/src/tidal/tidalservice.cpp b/src/tidal/tidalservice.cpp new file mode 100644 index 00000000..86fba2fd --- /dev/null +++ b/src/tidal/tidalservice.cpp @@ -0,0 +1,966 @@ +/* + * Strawberry Music Player + * Copyright 2018, 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 +#include +#include +#include +#include +#include + +#include "core/application.h" +#include "core/player.h" +#include "core/closure.h" +#include "core/logging.h" +#include "core/network.h" +#include "core/database.h" +#include "core/song.h" +#include "core/utilities.h" +#include "internet/internetsearchview.h" +#include "collection/collectionbackend.h" +#include "collection/collectionmodel.h" +#include "tidalservice.h" +#include "tidalurlhandler.h" +#include "tidalbaserequest.h" +#include "tidalrequest.h" +#include "tidalfavoriterequest.h" +#include "tidalstreamurlrequest.h" +#include "settings/settingsdialog.h" +#include "settings/tidalsettingspage.h" + +const Song::Source TidalService::kSource = Song::Source_Tidal; +const char *TidalService::kOAuthUrl = "https://login.tidal.com/authorize"; +const char *TidalService::kOAuthAccessTokenUrl = "https://login.tidal.com/oauth2/token"; +const char *TidalService::kOAuthRedirectUrl = "tidal://login/auth"; +const char *TidalService::kAuthUrl = "https://api.tidalhifi.com/v1/login/username"; +const int TidalService::kLoginAttempts = 2; +const int TidalService::kTimeResetLoginAttempts = 60000; + +const char *TidalService::kArtistsSongsTable = "tidal_artists_songs"; +const char *TidalService::kAlbumsSongsTable = "tidal_albums_songs"; +const char *TidalService::kSongsTable = "tidal_songs"; + +const char *TidalService::kArtistsSongsFtsTable = "tidal_artists_songs_fts"; +const char *TidalService::kAlbumsSongsFtsTable = "tidal_albums_songs_fts"; +const char *TidalService::kSongsFtsTable = "tidal_songs_fts"; + +TidalService::TidalService(Application *app, QObject *parent) + : InternetService(Song::Source_Tidal, "Tidal", "tidal", TidalSettingsPage::kSettingsGroup, SettingsDialog::Page_Tidal, app, parent), + app_(app), + network_(new NetworkAccessManager(this)), + url_handler_(new TidalUrlHandler(app, this)), + artists_collection_backend_(nullptr), + albums_collection_backend_(nullptr), + songs_collection_backend_(nullptr), + artists_collection_model_(nullptr), + albums_collection_model_(nullptr), + songs_collection_model_(nullptr), + artists_collection_sort_model_(new QSortFilterProxyModel(this)), + albums_collection_sort_model_(new QSortFilterProxyModel(this)), + songs_collection_sort_model_(new QSortFilterProxyModel(this)), + timer_search_delay_(new QTimer(this)), + timer_login_attempt_(new QTimer(this)), + favorite_request_(new TidalFavoriteRequest(this, network_, this)), + user_id_(0), + search_delay_(1500), + artistssearchlimit_(1), + albumssearchlimit_(1), + songssearchlimit_(1), + fetchalbums_(true), + download_album_covers_(true), + pending_search_id_(0), + next_pending_search_id_(1), + search_id_(0), + login_sent_(false), + login_attempts_(0) + { + + app->player()->RegisterUrlHandler(url_handler_); + + // Backends + + artists_collection_backend_ = new CollectionBackend(); + artists_collection_backend_->moveToThread(app_->database()->thread()); + artists_collection_backend_->Init(app_->database(), Song::Source_Tidal, kArtistsSongsTable, QString(), QString(), kArtistsSongsFtsTable); + + albums_collection_backend_ = new CollectionBackend(); + albums_collection_backend_->moveToThread(app_->database()->thread()); + albums_collection_backend_->Init(app_->database(), Song::Source_Tidal, kAlbumsSongsTable, QString(), QString(), kAlbumsSongsFtsTable); + + songs_collection_backend_ = new CollectionBackend(); + songs_collection_backend_->moveToThread(app_->database()->thread()); + songs_collection_backend_->Init(app_->database(), Song::Source_Tidal, kSongsTable, QString(), QString(), kSongsFtsTable); + + artists_collection_model_ = new CollectionModel(artists_collection_backend_, app_, this); + albums_collection_model_ = new CollectionModel(albums_collection_backend_, app_, this); + songs_collection_model_ = new CollectionModel(songs_collection_backend_, app_, this); + + artists_collection_sort_model_->setSourceModel(artists_collection_model_); + artists_collection_sort_model_->setSortRole(CollectionModel::Role_SortText); + artists_collection_sort_model_->setDynamicSortFilter(true); + artists_collection_sort_model_->setSortLocaleAware(true); + artists_collection_sort_model_->sort(0); + + albums_collection_sort_model_->setSourceModel(albums_collection_model_); + albums_collection_sort_model_->setSortRole(CollectionModel::Role_SortText); + albums_collection_sort_model_->setDynamicSortFilter(true); + albums_collection_sort_model_->setSortLocaleAware(true); + albums_collection_sort_model_->sort(0); + + songs_collection_sort_model_->setSourceModel(songs_collection_model_); + songs_collection_sort_model_->setSortRole(CollectionModel::Role_SortText); + songs_collection_sort_model_->setDynamicSortFilter(true); + songs_collection_sort_model_->setSortLocaleAware(true); + songs_collection_sort_model_->sort(0); + + // Search + + timer_search_delay_->setSingleShot(true); + connect(timer_search_delay_, SIGNAL(timeout()), SLOT(StartSearch())); + + timer_login_attempt_->setSingleShot(true); + connect(timer_login_attempt_, SIGNAL(timeout()), SLOT(ResetLoginAttempts())); + + connect(this, SIGNAL(Login()), SLOT(SendLogin())); + connect(this, SIGNAL(Login(QString, QString, QString)), SLOT(SendLogin(QString, QString, QString))); + + connect(this, SIGNAL(AddArtists(SongList)), favorite_request_, SLOT(AddArtists(SongList))); + connect(this, SIGNAL(AddAlbums(SongList)), favorite_request_, SLOT(AddAlbums(SongList))); + connect(this, SIGNAL(AddSongs(SongList)), favorite_request_, SLOT(AddSongs(SongList))); + + connect(this, SIGNAL(RemoveArtists(SongList)), favorite_request_, SLOT(RemoveArtists(SongList))); + connect(this, SIGNAL(RemoveAlbums(SongList)), favorite_request_, SLOT(RemoveAlbums(SongList))); + connect(this, SIGNAL(RemoveSongs(SongList)), favorite_request_, SLOT(RemoveSongs(SongList))); + + connect(favorite_request_, SIGNAL(ArtistsAdded(SongList)), artists_collection_backend_, SLOT(AddOrUpdateSongs(SongList))); + connect(favorite_request_, SIGNAL(AlbumsAdded(SongList)), albums_collection_backend_, SLOT(AddOrUpdateSongs(SongList))); + connect(favorite_request_, SIGNAL(SongsAdded(SongList)), songs_collection_backend_, SLOT(AddOrUpdateSongs(SongList))); + + connect(favorite_request_, SIGNAL(ArtistsRemoved(SongList)), artists_collection_backend_, SLOT(DeleteSongs(SongList))); + connect(favorite_request_, SIGNAL(AlbumsRemoved(SongList)), albums_collection_backend_, SLOT(DeleteSongs(SongList))); + connect(favorite_request_, SIGNAL(SongsRemoved(SongList)), songs_collection_backend_, SLOT(DeleteSongs(SongList))); + + ReloadSettings(); + +} + +TidalService::~TidalService() { + + while (!stream_url_requests_.isEmpty()) { + TidalStreamURLRequest *stream_url_req = stream_url_requests_.takeFirst(); + disconnect(stream_url_req, 0, this, 0); + stream_url_req->deleteLater(); + } + + artists_collection_backend_->deleteLater(); + albums_collection_backend_->deleteLater(); + songs_collection_backend_->deleteLater(); + +} + +void TidalService::Exit() { + + wait_for_exit_ << artists_collection_backend_ << albums_collection_backend_ << songs_collection_backend_; + + connect(artists_collection_backend_, SIGNAL(ExitFinished()), this, SLOT(ExitReceived())); + connect(albums_collection_backend_, SIGNAL(ExitFinished()), this, SLOT(ExitReceived())); + connect(songs_collection_backend_, SIGNAL(ExitFinished()), this, SLOT(ExitReceived())); + + artists_collection_backend_->ExitAsync(); + albums_collection_backend_->ExitAsync(); + songs_collection_backend_->ExitAsync(); + +} + +void TidalService::ExitReceived() { + + QObject *obj = static_cast(sender()); + disconnect(obj, 0, this, 0); + qLog(Debug) << obj << "successfully exited."; + wait_for_exit_.removeAll(obj); + if (wait_for_exit_.isEmpty()) emit ExitFinished(); + +} + +void TidalService::ShowConfig() { + app_->OpenSettingsDialogAtPage(SettingsDialog::Page_Tidal); +} + +void TidalService::ReloadSettings() { + + QSettings s; + s.beginGroup(TidalSettingsPage::kSettingsGroup); + + oauth_ = s.value("oauth", false).toBool(); + client_id_ = s.value("client_id").toString(); + api_token_ = s.value("api_token").toString(); + + username_ = s.value("username").toString(); + QByteArray password = s.value("password").toByteArray(); + if (password.isEmpty()) password_.clear(); + else password_ = QString::fromUtf8(QByteArray::fromBase64(password)); + + quality_ = s.value("quality", "LOSSLESS").toString(); + search_delay_ = s.value("searchdelay", 1500).toInt(); + artistssearchlimit_ = s.value("artistssearchlimit", 4).toInt(); + albumssearchlimit_ = s.value("albumssearchlimit", 10).toInt(); + songssearchlimit_ = s.value("songssearchlimit", 10).toInt(); + fetchalbums_ = s.value("fetchalbums", false).toBool(); + coversize_ = s.value("coversize", "320x320").toString(); + download_album_covers_ = s.value("downloadalbumcovers", true).toBool(); + stream_url_method_ = static_cast(s.value("streamurl").toInt()); + + user_id_ = s.value("user_id").toInt(); + country_code_ = s.value("country_code", "US").toString(); + access_token_ = s.value("access_token").toString(); + refresh_token_ = s.value("refresh_token").toString(); + session_id_ = s.value("session_id").toString(); + expiry_time_ = s.value("expiry_time").toDateTime(); + + s.endGroup(); + +} + +void TidalService::StartAuthorisation() { + + login_sent_ = true; + ++login_attempts_; + if (timer_login_attempt_->isActive()) timer_login_attempt_->stop(); + timer_login_attempt_->setInterval(kTimeResetLoginAttempts); + timer_login_attempt_->start(); + + code_verifier_ = Utilities::CryptographicRandomString(44); + code_challenge_ = QString(QCryptographicHash::hash(code_verifier_.toUtf8(), QCryptographicHash::Sha256).toBase64(QByteArray::Base64UrlEncoding)); + + if (code_challenge_.lastIndexOf(QChar('=')) == code_challenge_.length() - 1) { + code_challenge_.chop(1); + } + + const ParamList params = ParamList() << Param("response_type", "code") + << Param("code_challenge", code_challenge_) + << Param("code_challenge_method", "S256") + << Param("redirect_uri", kOAuthRedirectUrl) + << Param("client_id", client_id_) + << Param("scope", "r_usr w_usr"); + + QUrlQuery url_query; + for (const Param ¶m : params) { + EncodedParam encoded_param(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second)); + url_query.addQueryItem(encoded_param.first, encoded_param.second); + } + + QUrl url = QUrl(kOAuthUrl); + url.setQuery(url_query); + QDesktopServices::openUrl(url); + +} + +void TidalService::AuthorisationUrlReceived(const QUrl &url) { + + qLog(Debug) << "Tidal: Authorisation URL Received" << url; + + QUrlQuery url_query(url); + + if (url_query.hasQueryItem("token_type") && url_query.hasQueryItem("expires_in") && url_query.hasQueryItem("access_token")) { + + access_token_ = url_query.queryItemValue("access_token").toUtf8(); + int expires_in = url_query.queryItemValue("expires_in").toInt(); + expiry_time_ = QDateTime::currentDateTime().addSecs(expires_in - 120); + session_id_.clear(); + + QSettings s; + s.beginGroup(TidalSettingsPage::kSettingsGroup); + s.setValue("access_token", access_token_); + s.setValue("expiry_time", expiry_time_); + s.remove("refresh_token"); + s.remove("session_id"); + s.endGroup(); + + login_attempts_ = 0; + if (timer_login_attempt_->isActive()) timer_login_attempt_->stop(); + + emit LoginComplete(true); + emit LoginSuccess(); + } + + else if (url_query.hasQueryItem("code") && url_query.hasQueryItem("state")) { + + QString code = url_query.queryItemValue("code"); + QString state = url_query.queryItemValue("state"); + + const ParamList params = ParamList() << Param("code", code) + << Param("client_id", client_id_) + << Param("grant_type", "authorization_code") + << Param("redirect_uri", kOAuthRedirectUrl) + << Param("scope", "r_usr w_usr") + << Param("code_verifier", code_verifier_); + + QUrlQuery url_query; + for (const Param ¶m : params) { + EncodedParam encoded_param(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second)); + url_query.addQueryItem(encoded_param.first, encoded_param.second); + } + + QUrl url(kOAuthAccessTokenUrl); + QNetworkRequest request = QNetworkRequest(url); + QByteArray query = url_query.toString(QUrl::FullyEncoded).toUtf8(); + + login_errors_.clear(); + QNetworkReply *reply = network_->post(request, query); + connect(reply, SIGNAL(sslErrors(QList)), this, SLOT(HandleLoginSSLErrors(QList))); + NewClosure(reply, SIGNAL(finished()), this, SLOT(AccessTokenRequestFinished(QNetworkReply*)), reply); + + } + + else { + + LoginError(tr("Reply from Tidal is missing query items.")); + return; + } + +} + +void TidalService::HandleLoginSSLErrors(QList ssl_errors) { + + for (QSslError &ssl_error : ssl_errors) { + login_errors_ += ssl_error.errorString(); + } + +} + +void TidalService::AccessTokenRequestFinished(QNetworkReply *reply) { + + reply->deleteLater(); + + login_sent_ = false; + + if (reply->error() != QNetworkReply::NoError || reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() != 200) { + if (reply->error() != QNetworkReply::NoError && reply->error() < 200) { + // This is a network error, there is nothing more to do. + LoginError(QString("%1 (%2)").arg(reply->errorString()).arg(reply->error())); + return; + } + else { + // See if there is Json data containing "status" and "userMessage" then use that instead. + QByteArray data(reply->readAll()); + QJsonParseError json_error; + QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error); + if (json_error.error == QJsonParseError::NoError && !json_doc.isNull() && !json_doc.isEmpty() && json_doc.isObject()) { + QJsonObject json_obj = json_doc.object(); + if (!json_obj.isEmpty() && json_obj.contains("status") && json_obj.contains("userMessage")) { + int status = json_obj["status"].toInt(); + int sub_status = json_obj["subStatus"].toInt(); + QString user_message = json_obj["userMessage"].toString(); + login_errors_ << QString("Authentication failure: %1 (%2) (%3)").arg(user_message).arg(status).arg(sub_status); + } + } + if (login_errors_.isEmpty()) { + if (reply->error() != QNetworkReply::NoError) { + login_errors_ << QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()); + } + else { + login_errors_ << QString("Received HTTP code %1").arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt()); + } + } + LoginError(); + return; + } + } + + QByteArray data(reply->readAll()); + QJsonParseError json_error; + QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error); + + if (json_error.error != QJsonParseError::NoError) { + LoginError("Authentication reply from server missing Json data."); + return; + } + + if (json_doc.isNull() || json_doc.isEmpty()) { + LoginError("Authentication reply from server has empty Json document."); + return; + } + + if (!json_doc.isObject()) { + LoginError("Authentication reply from server has Json document that is not an object.", json_doc); + return; + } + + QJsonObject json_obj = json_doc.object(); + if (json_obj.isEmpty()) { + LoginError("Authentication reply from server has empty Json object.", json_doc); + return; + } + + if (!json_obj.contains("access_token") || + !json_obj.contains("refresh_token") || + !json_obj.contains("expires_in") || + !json_obj.contains("user") + ) { + LoginError("Authentication reply from server is missing access_token, refresh_token, expires_in or user", json_obj); + return; + } + + access_token_ = json_obj["access_token"].toString(); + refresh_token_ = json_obj["refresh_token"].toString(); + int expires_in = json_obj["expires_in"].toInt(); + expiry_time_ = QDateTime::currentDateTime().addSecs(expires_in - 120); + + QJsonValue json_user = json_obj["user"]; + if (!json_user.isObject()) { + LoginError("Authentication reply from server has Json user that is not an object.", json_doc); + return; + } + QJsonObject json_obj_user = json_user.toObject(); + if (json_obj_user.isEmpty()) { + LoginError("Authentication reply from server has empty Json user object.", json_doc); + return; + } + + country_code_ = json_obj_user["countryCode"].toString(); + user_id_ = json_obj_user["userId"].toInt(); + session_id_.clear(); + + QSettings s; + s.beginGroup(TidalSettingsPage::kSettingsGroup); + s.setValue("access_token", access_token_); + s.setValue("refresh_token", refresh_token_); + s.setValue("expiry_time", expiry_time_); + s.setValue("country_code", country_code_); + s.setValue("user_id", user_id_); + s.remove("session_id"); + s.endGroup(); + + qLog(Debug) << "Tidal: Login successful" << "user id" << user_id_ << "access token" << access_token_; + + login_attempts_ = 0; + if (timer_login_attempt_->isActive()) timer_login_attempt_->stop(); + + emit LoginComplete(true); + emit LoginSuccess(); + +} + +void TidalService::SendLogin() { + SendLogin(api_token_, username_, password_); +} + +void TidalService::SendLogin(const QString &api_token, const QString &username, const QString &password) { + + login_sent_ = true; + ++login_attempts_; + if (timer_login_attempt_->isActive()) timer_login_attempt_->stop(); + timer_login_attempt_->setInterval(kTimeResetLoginAttempts); + timer_login_attempt_->start(); + + const ParamList params = ParamList() << Param("token", (api_token.isEmpty() ? api_token_ : api_token)) + << Param("username", username) + << Param("password", password) + << Param("clientVersion", "2.2.1--7"); + + QUrlQuery url_query; + for (const Param ¶m : params) { + EncodedParam encoded_param(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second)); + url_query.addQueryItem(encoded_param.first, encoded_param.second); + } + + QUrl url(kAuthUrl); + QNetworkRequest req(url); + + req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); + req.setRawHeader("X-Tidal-Token", (api_token.isEmpty() ? api_token_.toUtf8() : api_token.toUtf8())); + + QByteArray query = url_query.toString(QUrl::FullyEncoded).toUtf8(); + QNetworkReply *reply = network_->post(req, query); + connect(reply, SIGNAL(sslErrors(QList)), this, SLOT(HandleLoginSSLErrors(QList))); + NewClosure(reply, SIGNAL(finished()), this, SLOT(HandleAuthReply(QNetworkReply*)), reply); + + //qLog(Debug) << "Tidal: Sending request" << url << query; + +} + +void TidalService::HandleAuthReply(QNetworkReply *reply) { + + reply->deleteLater(); + + login_sent_ = false; + + if (reply->error() != QNetworkReply::NoError || reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() != 200) { + if (reply->error() != QNetworkReply::NoError && reply->error() < 200) { + // This is a network error, there is nothing more to do. + LoginError(QString("%1 (%2)").arg(reply->errorString()).arg(reply->error())); + login_errors_.clear(); + return; + } + else { + // See if there is Json data containing "status" and "userMessage" - then use that instead. + QByteArray data(reply->readAll()); + QJsonParseError json_error; + QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error); + if (json_error.error == QJsonParseError::NoError && !json_doc.isNull() && !json_doc.isEmpty() && json_doc.isObject()) { + QJsonObject json_obj = json_doc.object(); + if (!json_obj.isEmpty() && json_obj.contains("status") && json_obj.contains("userMessage")) { + int status = json_obj["status"].toInt(); + int sub_status = json_obj["subStatus"].toInt(); + QString user_message = json_obj["userMessage"].toString(); + login_errors_ << QString("Authentication failure: %1 (%2) (%3)").arg(user_message).arg(status).arg(sub_status); + } + } + if (login_errors_.isEmpty()) { + if (reply->error() != QNetworkReply::NoError) { + login_errors_ << QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()); + } + else { + login_errors_ << QString("Received HTTP code %1").arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt()); + } + } + LoginError(); + login_errors_.clear(); + return; + } + } + + login_errors_.clear(); + + QByteArray data(reply->readAll()); + QJsonParseError json_error; + QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error); + + if (json_error.error != QJsonParseError::NoError) { + LoginError("Authentication reply from server missing Json data."); + return; + } + + if (json_doc.isNull() || json_doc.isEmpty()) { + LoginError("Authentication reply from server has empty Json document."); + return; + } + + if (!json_doc.isObject()) { + LoginError("Authentication reply from server has Json document that is not an object.", json_doc); + return; + } + + QJsonObject json_obj = json_doc.object(); + if (json_obj.isEmpty()) { + LoginError("Authentication reply from server has empty Json object.", json_doc); + return; + } + + if (!json_obj.contains("userId") || !json_obj.contains("sessionId") || !json_obj.contains("countryCode") ) { + LoginError("Authentication reply from server is missing userId, sessionId or countryCode", json_obj); + return; + } + + country_code_ = json_obj["countryCode"].toString(); + session_id_ = json_obj["sessionId"].toString(); + user_id_ = json_obj["userId"].toInt(); + access_token_.clear(); + refresh_token_.clear(); + expiry_time_ = QDateTime(); + + QSettings s; + s.beginGroup(TidalSettingsPage::kSettingsGroup); + s.remove("access_token"); + s.remove("refresh_token"); + s.remove("expiry_time"); + s.setValue("user_id", user_id_); + s.setValue("session_id", session_id_); + s.setValue("country_code", country_code_); + s.endGroup(); + + qLog(Debug) << "Tidal: Login successful" << "user id" << user_id_ << "session id" << session_id_ << "country code" << country_code_; + + login_attempts_ = 0; + if (timer_login_attempt_->isActive()) timer_login_attempt_->stop(); + + emit LoginComplete(true); + emit LoginSuccess(); + +} + +void TidalService::Logout() { + + access_token_.clear(); + session_id_.clear(); + expiry_time_ = QDateTime(); + + QSettings s; + s.beginGroup(TidalSettingsPage::kSettingsGroup); + s.remove("user_id"); + s.remove("country_code"); + s.remove("access_token"); + s.remove("session_id"); + s.remove("expiry_time"); + s.endGroup(); + +} + +void TidalService::ResetLoginAttempts() { + login_attempts_ = 0; +} + +void TidalService::TryLogin() { + + if (authenticated() || login_sent_) return; + + if (api_token_.isEmpty()) { + emit LoginComplete(false, tr("Missing Tidal API token.")); + return; + } + if (username_.isEmpty()) { + emit LoginComplete(false, tr("Missing Tidal username.")); + return; + } + if (password_.isEmpty()) { + emit LoginComplete(false, tr("Missing Tidal password.")); + return; + } + if (login_attempts_ >= kLoginAttempts) { + emit LoginComplete(false, tr("Not authenticated with Tidal and reached maximum number of login attempts.")); + return; + } + + emit Login(); + +} + +void TidalService::ResetArtistsRequest() { + + if (artists_request_.get()) { + disconnect(artists_request_.get(), 0, this, 0); + disconnect(this, 0, artists_request_.get(), 0); + artists_request_.reset(); + } + +} + +void TidalService::GetArtists() { + + if (!authenticated()) { + if (oauth_) { + emit ArtistsResults(SongList(), tr("Not authenticated with Tidal.")); + ShowConfig(); + return; + } + else if (api_token_.isEmpty() || username_.isEmpty() || password_.isEmpty()) { + emit ArtistsResults(SongList(), tr("Missing Tidal API token, username or password.")); + ShowConfig(); + return; + } + } + + ResetArtistsRequest(); + + artists_request_.reset(new TidalRequest(this, url_handler_, app_, network_, TidalBaseRequest::QueryType_Artists, this)); + + connect(artists_request_.get(), SIGNAL(Results(int, SongList, QString)), SLOT(ArtistsResultsReceived(int, SongList, QString))); + connect(artists_request_.get(), SIGNAL(UpdateStatus(int, QString)), SLOT(ArtistsUpdateStatusReceived(int, QString))); + connect(artists_request_.get(), SIGNAL(ProgressSetMaximum(int, int)), SLOT(ArtistsProgressSetMaximumReceived(int, int))); + connect(artists_request_.get(), SIGNAL(UpdateProgress(int, int)), SLOT(ArtistsUpdateProgressReceived(int, int))); + connect(this, SIGNAL(LoginComplete(bool, QString)), artists_request_.get(), SLOT(LoginComplete(bool, QString))); + + artists_request_->Process(); + +} + +void TidalService::ArtistsResultsReceived(const int id, const SongList &songs, const QString &error) { + Q_UNUSED(id); + emit ArtistsResults(songs, error); +} + +void TidalService::ArtistsUpdateStatusReceived(const int id, const QString &text) { + Q_UNUSED(id); + emit ArtistsUpdateStatus(text); +} + +void TidalService::ArtistsProgressSetMaximumReceived(const int id, const int max) { + Q_UNUSED(id); + emit ArtistsProgressSetMaximum(max); +} + +void TidalService::ArtistsUpdateProgressReceived(const int id, const int progress) { + Q_UNUSED(id); + emit ArtistsUpdateProgress(progress); +} + +void TidalService::ResetAlbumsRequest() { + + if (albums_request_.get()) { + disconnect(albums_request_.get(), 0, this, 0); + disconnect(this, 0, albums_request_.get(), 0); + albums_request_.reset(); + } + +} + +void TidalService::GetAlbums() { + + if (!authenticated()) { + if (oauth_) { + emit AlbumsResults(SongList(), tr("Not authenticated with Tidal.")); + ShowConfig(); + return; + } + else if (api_token_.isEmpty() || username_.isEmpty() || password_.isEmpty()) { + emit AlbumsResults(SongList(), tr("Missing Tidal API token, username or password.")); + ShowConfig(); + return; + } + } + + ResetAlbumsRequest(); + albums_request_.reset(new TidalRequest(this, url_handler_, app_, network_, TidalBaseRequest::QueryType_Albums, this)); + connect(albums_request_.get(), SIGNAL(Results(int, SongList, QString)), SLOT(AlbumsResultsReceived(int, SongList, QString))); + connect(albums_request_.get(), SIGNAL(UpdateStatus(int, QString)), SLOT(AlbumsUpdateStatusReceived(int, QString))); + connect(albums_request_.get(), SIGNAL(ProgressSetMaximum(int, int)), SLOT(AlbumsProgressSetMaximumReceived(int, int))); + connect(albums_request_.get(), SIGNAL(UpdateProgress(int, int)), SLOT(AlbumsUpdateProgressReceived(int, int))); + connect(this, SIGNAL(LoginComplete(bool, QString)), albums_request_.get(), SLOT(LoginComplete(bool, QString))); + + albums_request_->Process(); + +} + +void TidalService::AlbumsResultsReceived(const int id, const SongList &songs, const QString &error) { + Q_UNUSED(id); + emit AlbumsResults(songs, error); +} + +void TidalService::AlbumsUpdateStatusReceived(const int id, const QString &text) { + Q_UNUSED(id); + emit AlbumsUpdateStatus(text); +} + +void TidalService::AlbumsProgressSetMaximumReceived(const int id, const int max) { + Q_UNUSED(id); + emit AlbumsProgressSetMaximum(max); +} + +void TidalService::AlbumsUpdateProgressReceived(const int id, const int progress) { + Q_UNUSED(id); + emit AlbumsUpdateProgress(progress); +} + +void TidalService::ResetSongsRequest() { + + if (songs_request_.get()) { + disconnect(songs_request_.get(), 0, this, 0); + disconnect(this, 0, songs_request_.get(), 0); + songs_request_.reset(); + } + +} + +void TidalService::GetSongs() { + + if (!authenticated()) { + if (oauth_) { + emit SongsResults(SongList(), tr("Not authenticated with Tidal.")); + ShowConfig(); + return; + } + else if (api_token_.isEmpty() || username_.isEmpty() || password_.isEmpty()) { + emit SongsResults(SongList(), tr("Missing Tidal API token, username or password.")); + ShowConfig(); + return; + } + } + + ResetSongsRequest(); + songs_request_.reset(new TidalRequest(this, url_handler_, app_, network_, TidalBaseRequest::QueryType_Songs, this)); + connect(songs_request_.get(), SIGNAL(Results(int, SongList, QString)), SLOT(SongsResultsReceived(int, SongList, QString))); + connect(songs_request_.get(), SIGNAL(UpdateStatus(int, QString)), SLOT(SongsUpdateStatusReceived(int, QString))); + connect(songs_request_.get(), SIGNAL(ProgressSetMaximum(int, int)), SLOT(SongsProgressSetMaximumReceived(int, int))); + connect(songs_request_.get(), SIGNAL(UpdateProgress(int, int)), SLOT(SongsUpdateProgressReceived(int, int))); + connect(this, SIGNAL(LoginComplete(bool, QString)), songs_request_.get(), SLOT(LoginComplete(bool, QString))); + + songs_request_->Process(); + +} + +void TidalService::SongsResultsReceived(const int id, const SongList &songs, const QString &error) { + Q_UNUSED(id); + emit SongsResults(songs, error); +} + +void TidalService::SongsUpdateStatusReceived(const int id, const QString &text) { + Q_UNUSED(id); + emit SongsUpdateStatus(text); +} + +void TidalService::SongsProgressSetMaximumReceived(const int id, const int max) { + Q_UNUSED(id); + emit SongsProgressSetMaximum(max); +} + +void TidalService::SongsUpdateProgressReceived(const int id, const int progress) { + Q_UNUSED(id); + emit SongsUpdateProgress(progress); +} + +int TidalService::Search(const QString &text, InternetSearchView::SearchType type) { + + pending_search_id_ = next_pending_search_id_; + pending_search_text_ = text; + pending_search_type_ = type; + + next_pending_search_id_++; + + if (text.isEmpty()) { + timer_search_delay_->stop(); + return pending_search_id_; + } + timer_search_delay_->setInterval(search_delay_); + timer_search_delay_->start(); + + return pending_search_id_; + +} + +void TidalService::StartSearch() { + + if (!authenticated()) { + if (oauth_) { + emit SearchResults(pending_search_id_, SongList(), tr("Not authenticated with Tidal.")); + ShowConfig(); + return; + } + else if (api_token_.isEmpty() || username_.isEmpty() || password_.isEmpty()) { + emit SearchResults(pending_search_id_, SongList(), tr("Missing Tidal API token, username or password.")); + ShowConfig(); + return; + } + } + + search_id_ = pending_search_id_; + search_text_ = pending_search_text_; + + SendSearch(); + +} + +void TidalService::CancelSearch() { +} + +void TidalService::SendSearch() { + + TidalBaseRequest::QueryType type; + + switch (pending_search_type_) { + case InternetSearchView::SearchType_Artists: + type = TidalBaseRequest::QueryType_SearchArtists; + break; + case InternetSearchView::SearchType_Albums: + type = TidalBaseRequest::QueryType_SearchAlbums; + break; + case InternetSearchView::SearchType_Songs: + type = TidalBaseRequest::QueryType_SearchSongs; + break; + default: + //Error("Invalid search type."); + return; + } + + search_request_.reset(new TidalRequest(this, url_handler_, app_, network_, type, this)); + + connect(search_request_.get(), SIGNAL(Results(int, SongList, QString)), SLOT(SearchResultsReceived(int, SongList, QString))); + connect(search_request_.get(), SIGNAL(UpdateStatus(int, QString)), SIGNAL(SearchUpdateStatus(int, QString))); + connect(search_request_.get(), SIGNAL(ProgressSetMaximum(int, int)), SIGNAL(SearchProgressSetMaximum(int, int))); + connect(search_request_.get(), SIGNAL(UpdateProgress(int, int)), SIGNAL(SearchUpdateProgress(int, int))); + connect(this, SIGNAL(LoginComplete(bool, QString)), search_request_.get(), SLOT(LoginComplete(bool, QString))); + + search_request_->Search(search_id_, search_text_); + search_request_->Process(); + +} + +void TidalService::SearchResultsReceived(const int id, const SongList &songs, const QString &error) { + emit SearchResults(id, songs, error); +} + +void TidalService::GetStreamURL(const QUrl &url) { + + if (!authenticated()) { + if (oauth_) { + emit StreamURLFinished(url, url, Song::FileType_Stream, -1, -1, -1, tr("Not authenticated with Tidal.")); + return; + } + else if (api_token_.isEmpty() || username_.isEmpty() || password_.isEmpty()) { + emit StreamURLFinished(url, url, Song::FileType_Stream, -1, -1, -1, tr("Missing Tidal API token, username or password.")); + return; + } + } + + TidalStreamURLRequest *stream_url_req = new TidalStreamURLRequest(this, network_, url, this); + stream_url_requests_ << stream_url_req; + + connect(stream_url_req, SIGNAL(TryLogin()), this, SLOT(TryLogin())); + connect(stream_url_req, SIGNAL(StreamURLFinished(QUrl, QUrl, Song::FileType, int, int, qint64, QString)), this, SLOT(HandleStreamURLFinished(QUrl, QUrl, Song::FileType, int, int, qint64, QString))); + connect(this, SIGNAL(LoginComplete(bool, QString)), stream_url_req, SLOT(LoginComplete(bool, QString))); + + stream_url_req->Process(); + +} + +void TidalService::HandleStreamURLFinished(const QUrl &original_url, const QUrl &stream_url, const Song::FileType filetype, const int samplerate, const int bit_depth, const qint64 duration, QString error) { + + TidalStreamURLRequest *stream_url_req = qobject_cast(sender()); + if (!stream_url_req || !stream_url_requests_.contains(stream_url_req)) return; + stream_url_req->deleteLater(); + stream_url_requests_.removeAll(stream_url_req); + + emit StreamURLFinished(original_url, stream_url, filetype, samplerate, bit_depth, duration, error); + +} + +void TidalService::LoginError(const QString &error, const QVariant &debug) { + + if (!error.isEmpty()) login_errors_ << error; + + QString error_html; + for (const QString &error : login_errors_) { + qLog(Error) << "Tidal:" << error; + error_html += error + "
"; + } + if (debug.isValid()) qLog(Debug) << debug; + + emit LoginFailure(error_html); + emit LoginComplete(false, error_html); + + login_errors_.clear(); + +} diff --git a/src/tidal/tidalservice.h b/src/tidal/tidalservice.h new file mode 100644 index 00000000..9d19516e --- /dev/null +++ b/src/tidal/tidalservice.h @@ -0,0 +1,256 @@ +/* + * Strawberry Music Player + * Copyright 2018, 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 TIDALSERVICE_H +#define TIDALSERVICE_H + +#include "config.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/song.h" +#include "internet/internetservice.h" +#include "internet/internetsearchview.h" +#include "settings/tidalsettingspage.h" + +class QSortFilterProxyModel; +class QNetworkReply; +class QTimer; + +class Application; +class NetworkAccessManager; +class TidalUrlHandler; +class TidalRequest; +class TidalFavoriteRequest; +class TidalStreamURLRequest; +class CollectionBackend; +class CollectionModel; + +class TidalService : public InternetService { + Q_OBJECT + + public: + TidalService(Application *app, QObject *parent); + ~TidalService(); + + static const Song::Source kSource; + + void Exit(); + void ReloadSettings(); + + void Logout(); + int Search(const QString &query, InternetSearchView::SearchType type); + void CancelSearch(); + + int max_login_attempts() { return kLoginAttempts; } + + Application *app() { return app_; } + + bool oauth() { return oauth_; } + QString client_id() { return client_id_; } + QString api_token() { return api_token_; } + quint64 user_id() { return user_id_; } + QString country_code() { return country_code_; } + QString username() { return username_; } + QString password() { return password_; } + QString quality() { return quality_; } + int search_delay() { return search_delay_; } + int artistssearchlimit() { return artistssearchlimit_; } + int albumssearchlimit() { return albumssearchlimit_; } + int songssearchlimit() { return songssearchlimit_; } + bool fetchalbums() { return fetchalbums_; } + QString coversize() { return coversize_; } + bool download_album_covers() { return download_album_covers_; } + TidalSettingsPage::StreamUrlMethod stream_url_method() { return stream_url_method_; } + + QString access_token() { return access_token_; } + QString session_id() { return session_id_; } + + bool authenticated() { return (!access_token_.isEmpty() || !session_id_.isEmpty()); } + bool login_sent() { return login_sent_; } + bool login_attempts() { return login_attempts_; } + + void GetStreamURL(const QUrl &url); + + CollectionBackend *artists_collection_backend() { return artists_collection_backend_; } + CollectionBackend *albums_collection_backend() { return albums_collection_backend_; } + CollectionBackend *songs_collection_backend() { return songs_collection_backend_; } + + CollectionModel *artists_collection_model() { return artists_collection_model_; } + CollectionModel *albums_collection_model() { return albums_collection_model_; } + CollectionModel *songs_collection_model() { return songs_collection_model_; } + + QSortFilterProxyModel *artists_collection_sort_model() { return artists_collection_sort_model_; } + QSortFilterProxyModel *albums_collection_sort_model() { return albums_collection_sort_model_; } + QSortFilterProxyModel *songs_collection_sort_model() { return songs_collection_sort_model_; } + + enum QueryType { + QueryType_Artists, + QueryType_Albums, + QueryType_Songs, + QueryType_SearchArtists, + QueryType_SearchAlbums, + QueryType_SearchSongs, + }; + + signals: + + public slots: + void ShowConfig(); + void TryLogin(); + void SendLogin(const QString &api_token, const QString &username, const QString &password); + void GetArtists(); + void GetAlbums(); + void GetSongs(); + void ResetArtistsRequest(); + void ResetAlbumsRequest(); + void ResetSongsRequest(); + + private slots: + void ExitReceived(); + void StartAuthorisation(); + void AuthorisationUrlReceived(const QUrl &url); + void HandleLoginSSLErrors(QList ssl_errors); + void AccessTokenRequestFinished(QNetworkReply *reply); + void SendLogin(); + void HandleAuthReply(QNetworkReply *reply); + void ResetLoginAttempts(); + void StartSearch(); + void ArtistsResultsReceived(const int id, const SongList &songs, const QString &error); + void AlbumsResultsReceived(const int id, const SongList &songs, const QString &error); + void SongsResultsReceived(const int id, const SongList &songs, const QString &error); + void SearchResultsReceived(const int id, const SongList &songs, const QString &error); + void ArtistsUpdateStatusReceived(const int id, const QString &text); + void AlbumsUpdateStatusReceived(const int id, const QString &text); + void SongsUpdateStatusReceived(const int id, const QString &text); + void ArtistsProgressSetMaximumReceived(const int id, const int max); + void AlbumsProgressSetMaximumReceived(const int id, const int max); + void SongsProgressSetMaximumReceived(const int id, const int max); + void ArtistsUpdateProgressReceived(const int id, const int progress); + void AlbumsUpdateProgressReceived(const int id, const int progress); + void SongsUpdateProgressReceived(const int id, const int progress); + void HandleStreamURLFinished(const QUrl &original_url, const QUrl &stream_url, const Song::FileType filetype, const int samplerate, const int bit_depth, const qint64 duration, QString error = QString()); + + private: + typedef QPair Param; + typedef QList ParamList; + + typedef QPair EncodedParam; + typedef QList EncodedParamList; + + void SendSearch(); + void LoginError(const QString &error = QString(), const QVariant &debug = QVariant()); + + static const char *kOAuthUrl; + static const char *kOAuthAccessTokenUrl; + static const char *kOAuthRedirectUrl; + static const char *kAuthUrl; + static const int kLoginAttempts; + static const int kTimeResetLoginAttempts; + + static const char *kArtistsSongsTable; + static const char *kAlbumsSongsTable; + static const char *kSongsTable; + + static const char *kArtistsSongsFtsTable; + static const char *kAlbumsSongsFtsTable; + static const char *kSongsFtsTable; + + Application *app_; + NetworkAccessManager *network_; + TidalUrlHandler *url_handler_; + + CollectionBackend *artists_collection_backend_; + CollectionBackend *albums_collection_backend_; + CollectionBackend *songs_collection_backend_; + + CollectionModel *artists_collection_model_; + CollectionModel *albums_collection_model_; + CollectionModel *songs_collection_model_; + + QSortFilterProxyModel *artists_collection_sort_model_; + QSortFilterProxyModel *albums_collection_sort_model_; + QSortFilterProxyModel *songs_collection_sort_model_; + + QTimer *timer_search_delay_; + QTimer *timer_login_attempt_; + + std::shared_ptr artists_request_; + std::shared_ptr albums_request_; + std::shared_ptr songs_request_; + std::shared_ptr search_request_; + TidalFavoriteRequest *favorite_request_; + + bool oauth_; + QString client_id_; + QString api_token_; + quint64 user_id_; + QString country_code_; + QString username_; + QString password_; + QString quality_; + int search_delay_; + int artistssearchlimit_; + int albumssearchlimit_; + int songssearchlimit_; + bool fetchalbums_; + QString coversize_; + bool download_album_covers_; + TidalSettingsPage::StreamUrlMethod stream_url_method_; + + QString access_token_; + QString refresh_token_; + QString session_id_; + QDateTime expiry_time_; + + int pending_search_id_; + int next_pending_search_id_; + QString pending_search_text_; + InternetSearchView::SearchType pending_search_type_; + + int search_id_; + QString search_text_; + bool login_sent_; + int login_attempts_; + + QString code_verifier_; + QString code_challenge_; + + QList stream_url_requests_; + + QStringList login_errors_; + + QList wait_for_exit_; + +}; + +#endif // TIDALSERVICE_H diff --git a/src/tidal/tidalstreamurlrequest.cpp b/src/tidal/tidalstreamurlrequest.cpp new file mode 100644 index 00000000..45cbca5d --- /dev/null +++ b/src/tidal/tidalstreamurlrequest.cpp @@ -0,0 +1,299 @@ +/* + * Strawberry Music Player + * Copyright 2018, 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 +#include + +#include "core/logging.h" +#include "core/network.h" +#include "core/song.h" +#include "settings/tidalsettingspage.h" +#include "tidalservice.h" +#include "tidalbaserequest.h" +#include "tidalstreamurlrequest.h" + +TidalStreamURLRequest::TidalStreamURLRequest(TidalService *service, NetworkAccessManager *network, const QUrl &original_url, QObject *parent) + : TidalBaseRequest(service, network, parent), + service_(service), + reply_(nullptr), + original_url_(original_url), + song_id_(original_url.path().toInt()), + tries_(0), + need_login_(false) {} + +TidalStreamURLRequest::~TidalStreamURLRequest() { + + if (reply_) { + disconnect(reply_, 0, this, 0); + if (reply_->isRunning()) reply_->abort(); + reply_->deleteLater(); + } + +} + +void TidalStreamURLRequest::LoginComplete(const bool success, QString error) { + + if (!need_login_) return; + need_login_ = false; + + if (!success) { + emit StreamURLFinished(original_url_, original_url_, Song::FileType_Stream, -1, -1, -1, error); + return; + } + + Process(); + +} + +void TidalStreamURLRequest::Process() { + + if (!authenticated()) { + if (oauth()) { + emit StreamURLFinished(original_url_, original_url_, Song::FileType_Stream, -1, -1, -1, tr("Not authenticated with Tidal.")); + return; + } + else if (api_token().isEmpty() || username().isEmpty() || password().isEmpty()) { + emit StreamURLFinished(original_url_, original_url_, Song::FileType_Stream, -1, -1, -1, tr("Missing Tidal API token, username or password.")); + return; + } + need_login_ = true; + emit TryLogin(); + return; + } + + GetStreamURL(); + +} + +void TidalStreamURLRequest::Cancel() { + + if (reply_ && reply_->isRunning()) { + reply_->abort(); + } + else { + emit StreamURLFinished(original_url_, original_url_, Song::FileType_Stream, -1, -1, -1, tr("Cancelled.")); + } + +} + +void TidalStreamURLRequest::GetStreamURL() { + + ++tries_; + + if (reply_) { + disconnect(reply_, 0, this, 0); + if (reply_->isRunning()) reply_->abort(); + reply_->deleteLater(); + } + + ParamList params; + + switch (stream_url_method()) { + case TidalSettingsPage::StreamUrlMethod_StreamUrl: + params << Param("soundQuality", quality()); + reply_ = CreateRequest(QString("tracks/%1/streamUrl").arg(song_id_), params); + connect(reply_, SIGNAL(finished()), this, SLOT(StreamURLReceived())); + break; + case TidalSettingsPage::StreamUrlMethod_UrlPostPaywall: + params << Param("audioquality", quality()); + params << Param("playbackmode", "STREAM"); + params << Param("assetpresentation", "FULL"); + params << Param("urlusagemode", "STREAM"); + reply_ = CreateRequest(QString("tracks/%1/urlpostpaywall").arg(song_id_), params); + connect(reply_, SIGNAL(finished()), this, SLOT(StreamURLReceived())); + break; + case TidalSettingsPage::StreamUrlMethod_PlaybackInfoPostPaywall: + params << Param("audioquality", quality()); + params << Param("playbackmode", "STREAM"); + params << Param("assetpresentation", "FULL"); + reply_ = CreateRequest(QString("tracks/%1/playbackinfopostpaywall").arg(song_id_), params); + connect(reply_, SIGNAL(finished()), this, SLOT(StreamURLReceived())); + break; + } + +} + +void TidalStreamURLRequest::StreamURLReceived() { + + if (!reply_) return; + disconnect(reply_, 0, this, 0); + reply_->deleteLater(); + + QByteArray data = GetReplyData(reply_, true); + if (data.isEmpty()) { + reply_ = nullptr; + if (!authenticated() && login_sent() && tries_ <= 1) { + need_login_ = true; + return; + } + emit StreamURLFinished(original_url_, original_url_, Song::FileType_Stream, -1, -1, -1, errors_.first()); + return; + } + reply_ = nullptr; + + //qLog(Debug) << "Tidal:" << data; + + QJsonObject json_obj = ExtractJsonObj(data); + if (json_obj.isEmpty()) { + emit StreamURLFinished(original_url_, original_url_, Song::FileType_Stream, -1, -1, -1, errors_.first()); + return; + } + + if (!json_obj.contains("trackId")) { + Error("Invalid Json reply, stream missing trackId.", json_obj); + emit StreamURLFinished(original_url_, original_url_, Song::FileType_Stream, -1, -1, -1, errors_.first()); + return; + } + int track_id(json_obj["trackId"].toInt()); + if (track_id != song_id_) { + Error("Incorrect track ID returned.", json_obj); + emit StreamURLFinished(original_url_, original_url_, Song::FileType_Stream, -1, -1, -1, errors_.first()); + return; + } + + Song::FileType filetype(Song::FileType_Unknown); + + if (json_obj.contains("codec") || json_obj.contains("codecs")) { + QString codec; + if (json_obj.contains("codec")) codec = json_obj["codec"].toString().toLower(); + if (json_obj.contains("codecs")) codec = json_obj["codecs"].toString().toLower(); + filetype = Song::FiletypeByExtension(codec); + if (filetype == Song::FileType_Unknown) { + qLog(Debug) << "Tidal: Unknown codec" << codec; + filetype = Song::FileType_Stream; + } + } + + QList urls; + + if (json_obj.contains("manifest")) { + + QString manifest(json_obj["manifest"].toString()); + QByteArray data_manifest = QByteArray::fromBase64(manifest.toUtf8()); + + //qLog(Debug) << "Tidal:" << data_manifest; + + QXmlStreamReader xml_reader(data_manifest); + if (xml_reader.readNextStartElement()) { + + QString filepath = QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + "/tidalstreams"; + QString filename = "tidal-" + QString::number(song_id_) + ".xml"; + if (!QDir().mkpath(filepath)) { + Error(QString("Failed to create directory %1.").arg(filepath), json_obj); + emit StreamURLFinished(original_url_, original_url_, Song::FileType_Stream, -1, -1, -1, errors_.first()); + return; + } + QUrl url("file://" + filepath + "/" + filename); + QFile file(url.toLocalFile()); + if (file.exists()) + file.remove(); + if (!file.open(QIODevice::WriteOnly)) { + Error(QString("Failed to open file %1 for writing.").arg(url.toLocalFile()), json_obj); + emit StreamURLFinished(original_url_, original_url_, Song::FileType_Stream, -1, -1, -1, errors_.first()); + return; + } + file.write(data_manifest); + file.close(); + + urls << url; + + } + + else { + + json_obj = ExtractJsonObj(data_manifest); + if (json_obj.isEmpty()) { + emit StreamURLFinished(original_url_, original_url_, Song::FileType_Stream, -1, -1, -1, errors_.first()); + return; + } + + if (!json_obj.contains("mimeType")) { + Error("Invalid Json reply, stream url reply manifest is missing mimeType.", json_obj); + emit StreamURLFinished(original_url_, original_url_, Song::FileType_Stream, -1, -1, -1, errors_.first()); + return; + } + + QString mimetype = json_obj["mimeType"].toString(); + QMimeDatabase mimedb; + for (QString suffix : mimedb.mimeTypeForName(mimetype.toUtf8()).suffixes()) { + filetype = Song::FiletypeByExtension(suffix); + if (filetype != Song::FileType_Unknown) break; + } + if (filetype == Song::FileType_Unknown) { + qLog(Debug) << "Tidal: Unknown mimetype" << mimetype; + filetype = Song::FileType_Stream; + } + } + + } + + if (json_obj.contains("urls")) { + QJsonValue json_urls = json_obj["urls"]; + if (!json_urls.isArray()) { + Error("Invalid Json reply, urls is not an array.", json_urls); + emit StreamURLFinished(original_url_, original_url_, Song::FileType_Stream, -1, -1, -1, errors_.first()); + return; + } + QJsonArray json_array_urls = json_urls.toArray(); + for (const QJsonValue &value : json_array_urls) { + urls << QUrl(value.toString()); + } + } + else if (json_obj.contains("url")) { + QUrl new_url(json_obj["url"].toString()); + urls << new_url; + } + + if (urls.isEmpty()) { + Error("Missing stream urls.", json_obj); + emit StreamURLFinished(original_url_, original_url_, filetype, -1, -1, -1, errors_.first()); + return; + } + + emit StreamURLFinished(original_url_, urls.first(), filetype, -1, -1, -1); + +} + +void TidalStreamURLRequest::Error(const QString &error, const QVariant &debug) { + + qLog(Error) << "Tidal:" << error; + if (debug.isValid()) qLog(Debug) << debug; + + if (!error.isEmpty()) { + errors_ << error; + } + +} diff --git a/src/tidal/tidalstreamurlrequest.h b/src/tidal/tidalstreamurlrequest.h new file mode 100644 index 00000000..aacb86f0 --- /dev/null +++ b/src/tidal/tidalstreamurlrequest.h @@ -0,0 +1,79 @@ +/* + * Strawberry Music Player + * Copyright 2018, 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 TIDALSTREAMURLREQUEST_H +#define TIDALSTREAMURLREQUEST_H + +#include "config.h" + +#include +#include +#include +#include +#include +#include + +#include "core/song.h" +#include "tidalservice.h" +#include "tidalbaserequest.h" +#include "settings/tidalsettingspage.h" + +class QNetworkReply; +class NetworkAccessManager; + +class TidalStreamURLRequest : public TidalBaseRequest { + Q_OBJECT + + public: + TidalStreamURLRequest(TidalService *service, NetworkAccessManager *network, const QUrl &original_url, QObject *parent); + ~TidalStreamURLRequest(); + + void GetStreamURL(); + void Process(); + void NeedLogin() { need_login_ = true; } + void Cancel(); + + bool oauth() { return service_->oauth(); } + TidalSettingsPage::StreamUrlMethod stream_url_method() { return service_->stream_url_method(); } + QUrl original_url() { return original_url_; } + int song_id() { return song_id_; } + bool need_login() { return need_login_; } + + signals: + void TryLogin(); + void StreamURLFinished(const QUrl &original_url, const QUrl &stream_url, const Song::FileType filetype, const int samplerate, const int bit_depth, const qint64 duration, QString error = QString()); + + private slots: + void LoginComplete(const bool success, QString error = QString()); + void StreamURLReceived(); + + private: + void Error(const QString &error, const QVariant &debug = QVariant()); + + TidalService *service_; + QNetworkReply *reply_; + QUrl original_url_; + int song_id_; + int tries_; + bool need_login_; + QStringList errors_; + +}; + +#endif // TIDALSTREAMURLREQUEST_H diff --git a/src/tidal/tidalurlhandler.cpp b/src/tidal/tidalurlhandler.cpp new file mode 100644 index 00000000..64089628 --- /dev/null +++ b/src/tidal/tidalurlhandler.cpp @@ -0,0 +1,68 @@ +/* + * Strawberry Music Player + * Copyright 2018, 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 "core/application.h" +#include "core/taskmanager.h" +#include "core/song.h" +#include "tidal/tidalservice.h" +#include "tidalurlhandler.h" + +TidalUrlHandler::TidalUrlHandler(Application *app, TidalService *service) : + UrlHandler(service), + app_(app), + service_(service), + task_id_(-1) + { + + connect(service, SIGNAL(StreamURLFinished(QUrl, QUrl, Song::FileType, int, int, qint64, QString)), this, SLOT(GetStreamURLFinished(QUrl, QUrl, Song::FileType, int, int, qint64, QString))); + +} + +UrlHandler::LoadResult TidalUrlHandler::StartLoading(const QUrl &url) { + + LoadResult ret(url); + if (task_id_ != -1) return ret; + task_id_ = app_->task_manager()->StartTask(QString("Loading %1 stream...").arg(url.scheme())); + service_->GetStreamURL(url); + ret.type_ = LoadResult::WillLoadAsynchronously; + return ret; + +} + +void TidalUrlHandler::GetStreamURLFinished(const QUrl &original_url, const QUrl &stream_url, const Song::FileType filetype, const int samplerate, const int bit_depth, const qint64 duration, QString error) { + + if (task_id_ == -1) return; + CancelTask(); + if (error.isEmpty()) + emit AsyncLoadComplete(LoadResult(original_url, LoadResult::TrackAvailable, stream_url, filetype, samplerate, bit_depth, duration)); + else + emit AsyncLoadComplete(LoadResult(original_url, LoadResult::Error, stream_url, filetype, -1, -1, -1, error)); + +} + +void TidalUrlHandler::CancelTask() { + app_->task_manager()->SetTaskFinished(task_id_); + task_id_ = -1; +} diff --git a/src/tidal/tidalurlhandler.h b/src/tidal/tidalurlhandler.h new file mode 100644 index 00000000..519510a6 --- /dev/null +++ b/src/tidal/tidalurlhandler.h @@ -0,0 +1,57 @@ +/* + * Strawberry Music Player + * Copyright 2018, 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 TIDALURLHANDLER_H +#define TIDALURLHANDLER_H + +#include "config.h" + +#include +#include +#include +#include + +#include "core/urlhandler.h" +#include "core/song.h" +#include "tidal/tidalservice.h" + +class Application; + +class TidalUrlHandler : public UrlHandler { + Q_OBJECT + + public: + TidalUrlHandler(Application *app, TidalService *service); + + QString scheme() const { return service_->url_scheme(); } + LoadResult StartLoading(const QUrl &url); + + void CancelTask(); + + private slots: + void GetStreamURLFinished(const QUrl &original_url, const QUrl &stream_url, const Song::FileType filetype, const int samplerate, const int bit_depth, const qint64 duration, QString error = QString()); + + private: + Application *app_; + TidalService *service_; + int task_id_; + +}; + +#endif From 12150c2180c146232cc468f780ea883aca940939 Mon Sep 17 00:00:00 2001 From: Jonas Kvinge Date: Mon, 13 Apr 2020 19:05:55 +0200 Subject: [PATCH 2/2] Change database file --- src/core/database.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/database.cpp b/src/core/database.cpp index 4964311f..2ddee167 100644 --- a/src/core/database.cpp +++ b/src/core/database.cpp @@ -53,7 +53,7 @@ #include "application.h" #include "scopedtransaction.h" -const char *Database::kDatabaseFilename = "strawberry-tidal.db"; +const char *Database::kDatabaseFilename = "strawberry.db"; const int Database::kSchemaVersion = 12; const char *Database::kMagicAllSongsTables = "%allsongstables";