diff --git a/src/core/mainwindow.cpp b/src/core/mainwindow.cpp index 443357bad..f727cca75 100644 --- a/src/core/mainwindow.cpp +++ b/src/core/mainwindow.cpp @@ -135,6 +135,7 @@ #include "settings/backendsettingspage.h" #include "settings/playlistsettingspage.h" #ifdef HAVE_TIDAL +# include "tidal/tidalservice.h" # include "settings/tidalsettingspage.h" #endif @@ -548,6 +549,10 @@ MainWindow::MainWindow(Application *app, SystemTrayIcon *tray_icon, OSD *osd, co 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*))); + TidalService *tidalservice = qobject_cast (app_->internet_services()->ServiceBySource(Song::Source_Tidal)); + if (tidalservice) + connect(this, SIGNAL(AuthorisationUrlReceived(const QUrl&)), tidalservice, SLOT(AuthorisationUrlReceived(const QUrl&))); + #endif // Playlist menu @@ -1797,6 +1802,7 @@ void MainWindow::CommandlineOptionsReceived(const quint32 instanceId, const QByt } void MainWindow::CommandlineOptionsReceived(const CommandlineOptions &options) { + switch (options.player_action()) { case CommandlineOptions::Player_Play: if (options.urls().empty()) { @@ -1830,6 +1836,15 @@ 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 8905ac6b6..efacbbe78 100644 --- a/src/core/mainwindow.h +++ b/src/core/mainwindow.h @@ -131,6 +131,8 @@ signals: void IntroPointReached(); + void AuthorisationUrlReceived(const QUrl &url); + private slots: void FilePathChanged(const QString& path); diff --git a/src/internet/internetservice.h b/src/internet/internetservice.h index 3bf7e88b6..49b523653 100644 --- a/src/internet/internetservice.h +++ b/src/internet/internetservice.h @@ -47,6 +47,8 @@ class InternetService : public QObject { virtual void InitialLoadSettings() {} virtual void ReloadSettings() {} virtual QIcon Icon() { return Song::IconForSource(source_); } + virtual const bool oauth() = 0; + virtual const bool authenticated() = 0; virtual int Search(const QString &query, InternetSearch::SearchType type) = 0; virtual void CancelSearch() = 0; diff --git a/src/internet/internettabsview.cpp b/src/internet/internettabsview.cpp index ce1ba9d60..b965a1b71 100644 --- a/src/internet/internettabsview.cpp +++ b/src/internet/internettabsview.cpp @@ -185,6 +185,11 @@ void InternetTabsView::contextMenuEvent(QContextMenuEvent *e) { void InternetTabsView::GetArtists() { + if (!service_->authenticated() && service_->oauth()) { + service_->ShowConfig(); + return; + } + ui_->artists_collection->status()->clear(); ui_->artists_collection->progressbar()->show(); ui_->artists_collection->button_abort()->show(); @@ -224,6 +229,11 @@ void InternetTabsView::ArtistsFinished(SongList songs) { void InternetTabsView::GetAlbums() { + if (!service_->authenticated() && service_->oauth()) { + service_->ShowConfig(); + return; + } + ui_->albums_collection->status()->clear(); ui_->albums_collection->progressbar()->show(); ui_->albums_collection->button_abort()->show(); @@ -263,6 +273,11 @@ void InternetTabsView::AlbumsFinished(SongList songs) { void InternetTabsView::GetSongs() { + if (!service_->authenticated() && service_->oauth()) { + service_->ShowConfig(); + return; + } + ui_->songs_collection->status()->clear(); ui_->songs_collection->progressbar()->show(); ui_->songs_collection->button_abort()->show(); diff --git a/src/settings/tidalsettingspage.cpp b/src/settings/tidalsettingspage.cpp index 21e1e6be2..152950116 100644 --- a/src/settings/tidalsettingspage.cpp +++ b/src/settings/tidalsettingspage.cpp @@ -31,6 +31,7 @@ #include "core/iconloader.h" #include "internet/internetservices.h" #include "tidal/tidalservice.h" +#include "tidal/tidalstreamurlrequest.h" const char *TidalSettingsPage::kSettingsGroup = "Tidal"; @@ -44,7 +45,9 @@ TidalSettingsPage::TidalSettingsPage(SettingsDialog *parent) 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))); @@ -63,6 +66,10 @@ TidalSettingsPage::TidalSettingsPage(SettingsDialog *parent) 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_; } @@ -72,12 +79,19 @@ void TidalSettingsPage::Load() { QSettings s; s.beginGroup(kSettingsGroup); - ui_->checkbox_enable->setChecked(s.value("enabled", false).toBool()); + 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_->user_id->setText(s.value("user_id").toString()); + ui_->country_code->setText(s.value("country_code").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))); - ui_->token->setText(s.value("token").toString()); + dialog()->ComboBoxLoadFromSettings(s, ui_->quality, "quality", "HIGH"); ui_->searchdelay->setValue(s.value("searchdelay", 1500).toInt()); ui_->artistssearchlimit->setValue(s.value("artistssearchlimit", 5).toInt()); @@ -86,6 +100,12 @@ void TidalSettingsPage::Load() { ui_->checkbox_fetchalbums->setChecked(s.value("fetchalbums", false).toBool()); ui_->checkbox_cache_album_covers->setChecked(s.value("cachealbumcovers", 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(); if (service_->authenticated()) ui_->login_state->SetLoggedIn(LoginStateWidget::LoggedIn); @@ -96,10 +116,16 @@ void TidalSettingsPage::Save() { QSettings s; s.beginGroup(kSettingsGroup); - s.setValue("enabled", ui_->checkbox_enable->isChecked()); + 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("user_id", ui_->user_id->text()); + s.setValue("country_code", ui_->country_code->text()); + s.setValue("username", ui_->username->text()); s.setValue("password", QString::fromUtf8(ui_->password->text().toUtf8().toBase64())); - s.setValue("token", ui_->token->text()); + s.setValue("quality", ui_->quality->itemData(ui_->quality->currentIndex())); s.setValue("searchdelay", ui_->searchdelay->value()); s.setValue("artistssearchlimit", ui_->artistssearchlimit->value()); @@ -108,6 +134,7 @@ void TidalSettingsPage::Save() { s.setValue("fetchalbums", ui_->checkbox_fetchalbums->isChecked()); s.setValue("cachealbumcovers", ui_->checkbox_cache_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(); @@ -115,8 +142,19 @@ void TidalSettingsPage::Save() { } void TidalSettingsPage::LoginClicked() { - emit Login(ui_->username->text(), ui_->password->text(), ui_->token->text()); + + if (ui_->oauth->isChecked()) { + emit Login(); + } + else { + if (ui_->username->text().isEmpty() || ui_->password->text().isEmpty()) { + QMessageBox::critical(this, tr("Configuration incomplete"), tr("Missing username or password.")); + return; + } + emit Login(ui_->username->text(), ui_->password->text(), ui_->api_token->text()); + } ui_->button_login->setEnabled(false); + } bool TidalSettingsPage::eventFilter(QObject *object, QEvent *event) { @@ -127,6 +165,16 @@ bool TidalSettingsPage::eventFilter(QObject *object, QEvent *event) { } return SettingsPage::eventFilter(object, event); + +} + +void TidalSettingsPage::OAuthClicked(bool enabled) { + + ui_->client_id->setEnabled(enabled); + ui_->api_token->setEnabled(!enabled); + ui_->username->setEnabled(!enabled); + ui_->password->setEnabled(!enabled); + } void TidalSettingsPage::LogoutClicked() { diff --git a/src/settings/tidalsettingspage.h b/src/settings/tidalsettingspage.h index e791e2160..bf499b76f 100644 --- a/src/settings/tidalsettingspage.h +++ b/src/settings/tidalsettingspage.h @@ -38,15 +38,23 @@ class TidalSettingsPage : public SettingsPage { 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 &username, const QString &password, const QString &token); private slots: + void OAuthClicked(bool enabled); void LoginClicked(); void LogoutClicked(); void LoginSuccess(); diff --git a/src/settings/tidalsettingspage.ui b/src/settings/tidalsettingspage.ui index 29627fef9..b2d1733f7 100644 --- a/src/settings/tidalsettingspage.ui +++ b/src/settings/tidalsettingspage.ui @@ -7,7 +7,7 @@ 0 0 715 - 650 + 794 @@ -15,7 +15,7 @@ - + Enable @@ -37,47 +37,113 @@ - Account details + Authentication - + - Tidal username + Username - + - - - - Login - - - - + - Tidal password + Password - + QLineEdit::Password + + + + + 150 + 0 + + + + Client ID + + + + + + + + + + + 150 + 0 + + + + API Token + + + + + + + + 200 + 0 + + + + + + + + Use OAuth + + + + + + + User ID + + + + + + + + + + Country Code + + + + + + + + + + Login + + + @@ -93,304 +159,136 @@ Preferences - - - - - - - - 150 - 0 - - - - Token - - - - - - - - 200 - 0 - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - - - 150 - 0 - - - - Audio quality - - - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - - - 150 - 0 - - - - Search delay - - - - - - - ms - - - 0 - - - 10000 - - - 50 - - - 1500 - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - - - 150 - 0 - - - - Artists search limit - - - - - - - 1 - - - 100 - - - 50 - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - - - 150 - 0 - - - - Albums search limit - - - - - - - 1 - - - 1000 - - - 50 - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - - - 150 - 0 - - - - Songs search limit - - - - - - - 1 - - - 1000 - - - 50 - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - + + + - Fetch entire albums when searching songs + Audio quality - + + + + + + + Search delay + + + + + + + ms + + + 0 + + + 10000 + + + 50 + + + 1500 + + + + + + + Artists search limit + + + + + + + 1 + + + 100 + + + 50 + + + + + + + Albums search limit + + + + + + + 1 + + + 1000 + + + 50 + + + + + + + Songs search limit + + + + Cache album covers - - - - - - - 150 - 0 - - - - Album cover size - - - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - + + + + Fetch entire albums when searching songs + + + + + + + + + + Album cover size + + + + + + + 1 + + + 1000 + + + 50 + + + + + + + Stream URL method + + + + + @@ -457,7 +355,6 @@ username password - button_login diff --git a/src/tidal/tidalbaserequest.cpp b/src/tidal/tidalbaserequest.cpp index 920cb9672..918a0c0a6 100644 --- a/src/tidal/tidalbaserequest.cpp +++ b/src/tidal/tidalbaserequest.cpp @@ -40,7 +40,6 @@ #include "tidalbaserequest.h" const char *TidalBaseRequest::kApiUrl = "https://api.tidalhifi.com/v1"; -const char *TidalBaseRequest::kApiTokenB64 = "UDVYYmVvNUxGdkVTZUR5Ng=="; TidalBaseRequest::TidalBaseRequest(TidalService *service, NetworkAccessManager *network, QObject *parent) : QObject(parent), @@ -53,7 +52,7 @@ TidalBaseRequest::~TidalBaseRequest() { while (!replies_.isEmpty()) { QNetworkReply *reply = replies_.takeFirst(); disconnect(reply, 0, nullptr, 0); - reply->abort(); + if (reply->isRunning()) reply->abort(); reply->deleteLater(); } @@ -61,11 +60,7 @@ TidalBaseRequest::~TidalBaseRequest() { QNetworkReply *TidalBaseRequest::CreateRequest(const QString &ressource_name, const QList ¶ms_provided) { - typedef QPair EncodedParam; - typedef QList EncodedParamList; - ParamList params = ParamList() << params_provided - << Param("sessionId", session_id()) << Param("countryCode", country_code()); QStringList query_items; @@ -80,7 +75,9 @@ QNetworkReply *TidalBaseRequest::CreateRequest(const QString &ressource_name, co url.setQuery(url_query); QNetworkRequest req(url); req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); - req.setRawHeader("X-Tidal-SessionId", session_id().toUtf8()); + 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); replies_ << reply; @@ -129,7 +126,7 @@ QByteArray TidalBaseRequest::GetReplyData(QNetworkReply *reply, QString &error, } if (status == 401 && sub_status == 6001) { // User does not have a valid session emit service_->Logout(); - if (send_login && login_attempts() < max_login_attempts() && !token().isEmpty() && !username().isEmpty() && !password().isEmpty()) { + if (!oauth() && send_login && login_attempts() < max_login_attempts() && !api_token().isEmpty() && !username().isEmpty() && !password().isEmpty()) { qLog(Error) << "Tidal:" << failure_reason; qLog(Info) << "Tidal:" << "Attempting to login."; NeedLogin(); diff --git a/src/tidal/tidalbaserequest.h b/src/tidal/tidalbaserequest.h index 15a929770..42ab0892b 100644 --- a/src/tidal/tidalbaserequest.h +++ b/src/tidal/tidalbaserequest.h @@ -67,16 +67,23 @@ class TidalBaseRequest : public QObject { 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, QString &error, const bool send_login); 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()); + virtual QString Error(QString error, QVariant debug = QVariant()); QString api_url() { return QString(kApiUrl); } - QString token() { return service_->token(); } + const 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(); } @@ -86,9 +93,8 @@ class TidalBaseRequest : public QObject { bool fetchalbums() { return service_->fetchalbums(); } QString coversize() { return service_->coversize(); } + QString access_token() { return service_->access_token(); } QString session_id() { return service_->session_id(); } - quint64 user_id() { return service_->user_id(); } - QString country_code() { return service_->country_code(); } bool authenticated() { return service_->authenticated(); } bool need_login() { return need_login(); } @@ -101,7 +107,6 @@ class TidalBaseRequest : public QObject { private: static const char *kApiUrl; - static const char *kApiTokenB64; TidalService *service_; NetworkAccessManager *network_; diff --git a/src/tidal/tidalfavoriterequest.cpp b/src/tidal/tidalfavoriterequest.cpp index f1f6ab6b4..dd2ffa698 100644 --- a/src/tidal/tidalfavoriterequest.cpp +++ b/src/tidal/tidalfavoriterequest.cpp @@ -143,7 +143,8 @@ void TidalFavoriteRequest::AddFavorites(const FavoriteType type, const SongList QUrl url(api_url() + QString("/") + "users/" + QString::number(service_->user_id()) + "/favorites/" + FavoriteText(type)); QNetworkRequest req(url); req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); - req.setRawHeader("X-Tidal-SessionId", session_id().toUtf8()); + 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*, const FavoriteType, const SongList&)), reply, type, songs); @@ -233,9 +234,6 @@ void TidalFavoriteRequest::RemoveFavorites(const FavoriteType type, const SongLi void TidalFavoriteRequest::RemoveFavorites(const FavoriteType type, const int id, const SongList &songs) { - typedef QPair EncodedParam; - typedef QList EncodedParamList; - ParamList params = ParamList() << Param("countryCode", country_code()); QStringList query_items; @@ -250,7 +248,8 @@ void TidalFavoriteRequest::RemoveFavorites(const FavoriteType type, const int id url.setQuery(url_query); QNetworkRequest req(url); req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); - req.setRawHeader("X-Tidal-SessionId", session_id().toUtf8()); + 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*, const FavoriteType, const SongList&)), reply, type, songs); replies_ << reply; diff --git a/src/tidal/tidalrequest.cpp b/src/tidal/tidalrequest.cpp index 183846e30..089b443ed 100644 --- a/src/tidal/tidalrequest.cpp +++ b/src/tidal/tidalrequest.cpp @@ -54,6 +54,7 @@ TidalRequest::TidalRequest(TidalService *service, TidalUrlHandler *url_handler, network_(network), type_(type), search_id_(-1), + finished_(false), artists_requests_active_(0), artists_total_(0), artists_received_(0), @@ -73,10 +74,10 @@ TidalRequest::TidalRequest(TidalService *service, TidalUrlHandler *url_handler, TidalRequest::~TidalRequest() { - while (!replies_.isEmpty()) { - QNetworkReply *reply = replies_.takeFirst(); + while (!album_cover_replies_.isEmpty()) { + QNetworkReply *reply = album_cover_replies_.takeFirst(); disconnect(reply, 0, nullptr, 0); - reply->abort(); + if (reply->isRunning()) reply->abort(); reply->deleteLater(); } @@ -310,6 +311,8 @@ void TidalRequest::ArtistsReplyReceived(QNetworkReply *reply, const int limit_re --artists_requests_active_; + if (finished_) return; + if (data.isEmpty()) { ArtistsFinishCheck(); return; @@ -406,6 +409,8 @@ void TidalRequest::ArtistsReplyReceived(QNetworkReply *reply, const int limit_re 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_) { @@ -441,6 +446,7 @@ void TidalRequest::ArtistsFinishCheck(const int limit, const int offset, const i void TidalRequest::AlbumsReplyReceived(QNetworkReply *reply, const int limit_requested, const int offset_requested) { --albums_requests_active_; AlbumsReceived(reply, 0, limit_requested, offset_requested, (offset_requested == 0)); + if (!albums_requests_queue_.isEmpty() && albums_requests_active_ < kMaxConcurrentAlbumsRequests) FlushAlbumsRequests(); } void TidalRequest::AddArtistAlbumsRequest(const int artist_id, const int offset) { @@ -475,6 +481,7 @@ void TidalRequest::ArtistAlbumsReplyReceived(QNetworkReply *reply, const int art ++artist_albums_received_; emit UpdateProgress(artist_albums_received_); AlbumsReceived(reply, artist_id, 0, offset_requested, false); + if (!artist_albums_requests_queue_.isEmpty() && artist_albums_requests_active_ < kMaxConcurrentArtistAlbumsRequests) FlushArtistAlbumsRequests(); } @@ -483,6 +490,8 @@ void TidalRequest::AlbumsReceived(QNetworkReply *reply, const int artist_id_requ QString error; QByteArray data = GetReplyData(reply, error, auto_login); + if (finished_) return; + if (data.isEmpty()) { AlbumsFinishCheck(artist_id_requested); return; @@ -619,6 +628,8 @@ void TidalRequest::AlbumsReceived(QNetworkReply *reply, const int artist_id_requ void TidalRequest::AlbumsFinishCheck(const int 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) { @@ -639,9 +650,6 @@ void TidalRequest::AlbumsFinishCheck(const int artist_id, const int limit, const } } - if (!albums_requests_queue_.isEmpty() && albums_requests_active_ < kMaxConcurrentAlbumsRequests) FlushAlbumsRequests(); - if (!artist_albums_requests_queue_.isEmpty() && artist_albums_requests_active_ < kMaxConcurrentArtistAlbumsRequests) FlushArtistAlbumsRequests(); - if ( albums_requests_queue_.isEmpty() && albums_requests_active_ <= 0 && @@ -726,6 +734,8 @@ void TidalRequest::SongsReceived(QNetworkReply *reply, const int artist_id, cons QString error; QByteArray data = GetReplyData(reply, error, auto_login); + if (finished_) return; + if (data.isEmpty()) { SongsFinishCheck(artist_id, album_id, limit_requested, offset_requested, 0, 0, album_artist); return; @@ -814,6 +824,8 @@ void TidalRequest::SongsReceived(QNetworkReply *reply, const int artist_id, cons void TidalRequest::SongsFinishCheck(const int artist_id, const int 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) { @@ -1020,7 +1032,7 @@ void TidalRequest::FlushAlbumCoverRequests() { QNetworkRequest req(request.url); QNetworkReply *reply = network_->get(req); - replies_ << reply; + album_cover_replies_ << reply; NewClosure(reply, SIGNAL(finished()), this, SLOT(AlbumCoverReceived(QNetworkReply*, int, QUrl)), reply, request.album_id, request.url); } @@ -1029,8 +1041,8 @@ void TidalRequest::FlushAlbumCoverRequests() { void TidalRequest::AlbumCoverReceived(QNetworkReply *reply, const int album_id, const QUrl url) { - if (replies_.contains(reply)) { - replies_.removeAll(reply); + if (album_cover_replies_.contains(reply)) { + album_cover_replies_.removeAll(reply); reply->deleteLater(); } else { @@ -1040,6 +1052,9 @@ void TidalRequest::AlbumCoverReceived(QNetworkReply *reply, const int album_id, --album_covers_requests_active_; ++album_covers_received_; + + if (finished_) return; + emit UpdateProgress(album_covers_received_); if (!album_covers_requests_sent_.contains(album_id)) { @@ -1099,6 +1114,7 @@ void TidalRequest::AlbumCoverFinishCheck() { void TidalRequest::FinishCheck() { if ( + !finished_ && !need_login_ && albums_requests_queue_.isEmpty() && artists_requests_queue_.isEmpty() && @@ -1120,6 +1136,7 @@ void TidalRequest::FinishCheck() { album_covers_requests_active_ <= 0 && album_covers_received_ >= album_covers_requested_ ) { + finished_ = true; if (songs_.isEmpty()) { if (IsSearch()) { if (no_results_) emit ErrorSignal(search_id_, tr("No match")); diff --git a/src/tidal/tidalrequest.h b/src/tidal/tidalrequest.h index 9f0e515f8..3613398ca 100644 --- a/src/tidal/tidalrequest.h +++ b/src/tidal/tidalrequest.h @@ -164,6 +164,8 @@ class TidalRequest : public TidalBaseRequest { int search_id_; QString search_text_; + bool finished_; + QQueue artists_requests_queue_; QQueue albums_requests_queue_; QQueue songs_requests_queue_; @@ -199,7 +201,7 @@ class TidalRequest : public TidalBaseRequest { QString errors_; bool need_login_; bool no_results_; - QList replies_; + QList album_cover_replies_; }; diff --git a/src/tidal/tidalservice.cpp b/src/tidal/tidalservice.cpp index cdc51209e..4fedadb45 100644 --- a/src/tidal/tidalservice.cpp +++ b/src/tidal/tidalservice.cpp @@ -24,6 +24,7 @@ #include #include +#include #include #include #include @@ -55,12 +56,17 @@ #include "tidalfavoriterequest.h" #include "tidalstreamurlrequest.h" #include "settings/tidalsettingspage.h" +#include "internet/localredirectserver.h" using std::shared_ptr; const Song::Source TidalService::kSource = Song::Source_Tidal; -const char *TidalService::kAuthUrl = "https://api.tidalhifi.com/v1/login/username"; +const char *TidalService::kClientIdB64 = "dTVxUE5OWUliRDBTMG8zNk1yQWlGWjU2SzZxTUNyQ21ZUHpadVRuVg=="; const char *TidalService::kApiTokenB64 = "UDVYYmVvNUxGdkVTZUR5Ng=="; +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; @@ -89,13 +95,13 @@ TidalService::TidalService(Application *app, QObject *parent) 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), cache_album_covers_(true), - user_id_(0), pending_search_id_(0), next_pending_search_id_(1), search_id_(0), @@ -169,7 +175,6 @@ TidalService::TidalService(Application *app, QObject *parent) connect(favorite_request_, SIGNAL(SongsRemoved(const SongList&)), songs_collection_backend_, SLOT(DeleteSongs(const SongList&))); ReloadSettings(); - LoadSessionID(); } @@ -178,7 +183,7 @@ TidalService::~TidalService() { while (!stream_url_requests_.isEmpty()) { TidalStreamURLRequest *stream_url_req = stream_url_requests_.takeFirst(); disconnect(stream_url_req, 0, nullptr, 0); - delete stream_url_req; + stream_url_req->deleteLater(); } } @@ -191,12 +196,20 @@ void TidalService::ReloadSettings() { QSettings s; s.beginGroup(TidalSettingsPage::kSettingsGroup); + + oauth_ = s.value("oauth", false).toBool(); + client_id_ = s.value("client_id").toString(); + if (client_id_.isEmpty()) client_id_ = QString::fromUtf8(QByteArray::fromBase64(kClientIdB64)); + api_token_ = s.value("api_token").toString(); + if (api_token_.isEmpty()) api_token_ = QString::fromUtf8(QByteArray::fromBase64(kApiTokenB64)); + user_id_ = s.value("user_id", 0).toInt(); + country_code_ = s.value("country_code", "US").toString(); + username_ = s.value("username").toString(); QByteArray password = s.value("password").toByteArray(); if (password.isEmpty()) password_.clear(); else password_ = QString::fromUtf8(QByteArray::fromBase64(password)); - token_ = s.value("token").toString(); - if (token_.isEmpty()) token_ = QString::fromUtf8(QByteArray::fromBase64(kApiTokenB64)); + quality_ = s.value("quality", "LOSSLESS").toString(); search_delay_ = s.value("searchdelay", 1500).toInt(); artistssearchlimit_ = s.value("artistssearchlimit", 5).toInt(); @@ -205,6 +218,13 @@ void TidalService::ReloadSettings() { fetchalbums_ = s.value("fetchalbums", false).toBool(); coversize_ = s.value("coversize", "320x320").toString(); cache_album_covers_ = s.value("cachealbumcovers", true).toBool(); + stream_url_method_ = static_cast(s.value("streamurl").toInt()); + + 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(); } @@ -213,20 +233,206 @@ QString TidalService::CoverCacheDir() { return QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation) + "/tidalalbumcovers"; } -void TidalService::LoadSessionID() { +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(); + + const ParamList params = ParamList() + //<< Param("response_type", "token") + << Param("response_type", "code") + << Param("code_challenge", "T36p0vieh1pnvNNsG-0kNNpZIk4ZuP8vna5ZAtooxqo") + << 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", "128,113,65,59,36,187,64,14,99,32,149,202,178,5,165,106,14,184,157,42,5,198,243,245,75,115,227,169,183,199,216,67,42,202,105,33,1"); + + 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(); + QNetworkReply *reply = network_->post(request, query); + NewClosure(reply, SIGNAL(finished()), this, SLOT(AccessTokenRequestFinished(QNetworkReply*)), reply); + + } + + else { + + LoginError(tr("Reply from Tidal is missing query items.")); + return; + } + +} + +void TidalService::AccessTokenRequestFinished(QNetworkReply *reply) { + + reply->deleteLater(); + + login_sent_ = false; + + if (reply->error() != QNetworkReply::NoError) { + if (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 "redirectUri" then use that instead. + QByteArray data(reply->readAll()); + QJsonParseError json_error; + QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error); + QString failure_reason; + 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("redirectUri")) { + QString redirect_uri = json_obj["redirectUri"].toString(); + failure_reason = QString("Authentication failure: %1").arg(redirect_uri); + } + } + if (failure_reason.isEmpty()) { + failure_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()); + } + LoginError(failure_reason); + 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); - if (!s.contains("user_id") ||!s.contains("session_id") || !s.contains("country_code")) return; - session_id_ = s.value("session_id").toString(); - user_id_ = s.value("user_id").toInt(); - country_code_ = s.value("country_code").toString(); + 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(username_, password_, token_); + SendLogin(username_, password_, api_token_); } void TidalService::SendLogin(const QString &username, const QString &password, const QString &token) { @@ -239,13 +445,10 @@ void TidalService::SendLogin(const QString &username, const QString &password, c timer_login_attempt_->setInterval(kTimeResetLoginAttempts); timer_login_attempt_->start(); - typedef QPair EncodedParam; - typedef QList EncodedParamList; - - ParamList params = ParamList() << Param("token", token_) - << Param("username", username) - << Param("password", password) - << Param("clientVersion", "2.2.1--7"); + const ParamList params = ParamList() << Param("token", (token.isEmpty() ? api_token_ : token)) + << Param("username", username) + << Param("password", password) + << Param("clientVersion", "2.2.1--7"); QStringList query_items; QUrlQuery url_query; @@ -259,12 +462,13 @@ void TidalService::SendLogin(const QString &username, const QString &password, c QNetworkRequest req(url); req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); - req.setRawHeader("X-Tidal-Token", token_.toUtf8()); + req.setRawHeader("X-Tidal-Token", (token.isEmpty() ? api_token_.toUtf8() : token.toUtf8())); - QNetworkReply *reply = network_->post(req, url_query.toString(QUrl::FullyEncoded).toUtf8()); + QByteArray query = url_query.toString(QUrl::FullyEncoded).toUtf8(); + QNetworkReply *reply = network_->post(req, query); NewClosure(reply, SIGNAL(finished()), this, SLOT(HandleAuthReply(QNetworkReply*)), reply); - - //qLog(Debug) << "Tidal: Sending request" << url; + + //qLog(Debug) << "Tidal: Sending request" << url << query; } @@ -336,12 +540,18 @@ void TidalService::HandleAuthReply(QNetworkReply *reply) { 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.setValue("user_id", user_id_); s.setValue("session_id", session_id_); s.setValue("country_code", country_code_); + s.remove("access_token"); + s.remove("refresh_token"); + s.remove("expiry_time"); s.endGroup(); qLog(Debug) << "Tidal: Login successful" << "user id" << user_id_ << "session id" << session_id_ << "country code" << country_code_; @@ -356,15 +566,15 @@ void TidalService::HandleAuthReply(QNetworkReply *reply) { void TidalService::Logout() { - user_id_ = 0; + access_token_.clear(); session_id_.clear(); - country_code_.clear(); + expiry_time_ = QDateTime(); QSettings s; s.beginGroup(TidalSettingsPage::kSettingsGroup); - s.remove("user_id"); + s.remove("access_token"); s.remove("session_id"); - s.remove("country_code"); + s.remove("expiry_time"); s.endGroup(); } @@ -381,7 +591,7 @@ void TidalService::TryLogin() { emit LoginComplete(false, "Maximum number of login attempts reached."); return; } - if (token_.isEmpty()) { + if (api_token_.isEmpty()) { emit LoginComplete(false, "Missing Tidal API token."); return; } @@ -428,14 +638,12 @@ void TidalService::GetArtists() { void TidalService::ArtistsResultsReceived(SongList songs) { emit ArtistsResults(songs); - ResetArtistsRequest(); } void TidalService::ArtistsErrorReceived(QString error) { emit ArtistsError(error); - ResetArtistsRequest(); } @@ -467,14 +675,12 @@ void TidalService::GetAlbums() { void TidalService::AlbumsResultsReceived(SongList songs) { emit AlbumsResults(songs); - ResetAlbumsRequest(); } void TidalService::AlbumsErrorReceived(QString error) { emit AlbumsError(error); - ResetAlbumsRequest(); } @@ -506,14 +712,12 @@ void TidalService::GetSongs() { void TidalService::SongsResultsReceived(SongList songs) { emit SongsResults(songs); - ResetSongsRequest(); } void TidalService::SongsErrorReceived(QString error) { emit SongsError(error); - ResetSongsRequest(); } @@ -538,8 +742,8 @@ int TidalService::Search(const QString &text, InternetSearch::SearchType type) { void TidalService::StartSearch() { - if (token_.isEmpty() || username_.isEmpty() || password_.isEmpty()) { - emit SearchError(pending_search_id_, tr("Missing token, username and/or password.")); + if ((oauth_ && !authenticated()) || api_token_.isEmpty() || username_.isEmpty() || password_.isEmpty()) { + emit SearchError(pending_search_id_, tr("Not authenticated.")); next_pending_search_id_ = 1; ShowConfig(); return; @@ -605,7 +809,7 @@ void TidalService::HandleStreamURLFinished(const QUrl original_url, const QUrl s TidalStreamURLRequest *stream_url_req = qobject_cast(sender()); if (!stream_url_req || !stream_url_requests_.contains(stream_url_req)) return; - delete stream_url_req; + stream_url_req->deleteLater(); stream_url_requests_.removeAll(stream_url_req); emit StreamURLFinished(original_url, stream_url, filetype, error); diff --git a/src/tidal/tidalservice.h b/src/tidal/tidalservice.h index dd2b152b9..160251c6b 100644 --- a/src/tidal/tidalservice.h +++ b/src/tidal/tidalservice.h @@ -37,6 +37,7 @@ #include "core/song.h" #include "internet/internetservice.h" #include "internet/internetsearch.h" +#include "settings/tidalsettingspage.h" class QSortFilterProxyModel; class Application; @@ -68,7 +69,11 @@ class TidalService : public InternetService { const int max_login_attempts() { return kLoginAttempts; } - QString token() { return token_; } + const 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_; } @@ -79,12 +84,12 @@ class TidalService : public InternetService { bool fetchalbums() { return fetchalbums_; } QString coversize() { return coversize_; } bool cache_album_covers() { return cache_album_covers_; } + TidalSettingsPage::StreamUrlMethod stream_url_method() { return stream_url_method_; } + QString access_token() { return access_token_; } QString session_id() { return session_id_; } - quint64 user_id() { return user_id_; } - QString country_code() { return country_code_; } - const bool authenticated() { return (!session_id_.isEmpty() && !country_code_.isEmpty()); } + const bool authenticated() { return (!access_token_.isEmpty() || !session_id_.isEmpty()); } const bool login_sent() { return login_sent_; } const bool login_attempts() { return login_attempts_; } @@ -125,6 +130,9 @@ class TidalService : public InternetService { void ResetSongsRequest(); private slots: + void StartAuthorisation(); + void AuthorisationUrlReceived(const QUrl &url); + void AccessTokenRequestFinished(QNetworkReply *reply); void SendLogin(); void HandleAuthReply(QNetworkReply *reply); void ResetLoginAttempts(); @@ -141,12 +149,18 @@ class TidalService : public InternetService { typedef QPair Param; typedef QList ParamList; - void LoadSessionID(); + typedef QPair EncodedParam; + typedef QList EncodedParamList; + void SendSearch(); QString LoginError(QString error, QVariant debug = QVariant()); - static const char *kAuthUrl; + static const char *kClientIdB64; static const char *kApiTokenB64; + static const char *kOAuthUrl; + static const char *kOAuthAccessTokenUrl; + static const char *kOAuthRedirectUrl; + static const char *kAuthUrl; static const int kLoginAttempts; static const int kTimeResetLoginAttempts; @@ -183,7 +197,11 @@ class TidalService : public InternetService { std::shared_ptr search_request_; TidalFavoriteRequest *favorite_request_; - QString token_; + bool oauth_; + QString client_id_; + QString api_token_; + quint64 user_id_; + QString country_code_; QString username_; QString password_; QString quality_; @@ -194,10 +212,12 @@ class TidalService : public InternetService { bool fetchalbums_; QString coversize_; bool cache_album_covers_; + TidalSettingsPage::StreamUrlMethod stream_url_method_; + QString access_token_; + QString refresh_token_; QString session_id_; - quint64 user_id_; - QString country_code_; + QDateTime expiry_time_; int pending_search_id_; int next_pending_search_id_; diff --git a/src/tidal/tidalstreamurlrequest.cpp b/src/tidal/tidalstreamurlrequest.cpp index bb09a203e..df0433544 100644 --- a/src/tidal/tidalstreamurlrequest.cpp +++ b/src/tidal/tidalstreamurlrequest.cpp @@ -20,20 +20,29 @@ #include "config.h" #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()), @@ -67,6 +76,10 @@ void TidalStreamURLRequest::LoginComplete(bool success, QString error) { void TidalStreamURLRequest::Process() { if (!authenticated()) { + if (oauth()) { + emit StreamURLFinished(original_url_, original_url_, Song::FileType_Stream, tr("Not authenticated.")); + return; + } need_login_ = true; emit TryLogin(); return; @@ -81,7 +94,7 @@ void TidalStreamURLRequest::Cancel() { reply_->abort(); } else { - emit StreamURLFinished(original_url_, original_url_, Song::FileType_Stream, "Cancelled."); + emit StreamURLFinished(original_url_, original_url_, Song::FileType_Stream, tr("Cancelled.")); } } @@ -90,16 +103,36 @@ void TidalStreamURLRequest::GetStreamURL() { ++tries_; - ParamList parameters; - parameters << Param("soundQuality", quality()); - if (reply_) { disconnect(reply_, 0, nullptr, 0); if (reply_->isRunning()) reply_->abort(); reply_->deleteLater(); } - reply_ = CreateRequest(QString("tracks/%1/streamUrl").arg(song_id_), parameters); - connect(reply_, SIGNAL(finished()), this, SLOT(StreamURLReceived())); + + 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; + } } @@ -123,26 +156,125 @@ void TidalStreamURLRequest::StreamURLReceived() { } reply_ = nullptr; + qLog(Debug) << "Tidal:" << data; + QJsonObject json_obj = ExtractJsonObj(data, error); if (json_obj.isEmpty()) { emit StreamURLFinished(original_url_, original_url_, Song::FileType_Stream, error); return; } - if (!json_obj.contains("url") || !json_obj.contains("codec")) { - error = Error("Invalid Json reply, stream missing url or codec.", json_obj); + if (!json_obj.contains("trackId")) { + error = Error("Invalid Json reply, stream missing trackId.", json_obj); + emit StreamURLFinished(original_url_, original_url_, Song::FileType_Stream, error); + return; + } + int track_id(json_obj["trackId"].toInt()); + if (track_id != song_id_) { + error = Error("Incorrect track ID returned.", json_obj); emit StreamURLFinished(original_url_, original_url_, Song::FileType_Stream, error); return; } - QUrl new_url(json_obj["url"].toString()); - QString codec(json_obj["codec"].toString().toLower()); - Song::FileType filetype(Song::FiletypeByExtension(codec)); - if (filetype == Song::FileType_Unknown) { - qLog(Debug) << "Tidal: Unknown codec" << codec; - filetype = Song::FileType_Stream; + 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; + } } - emit StreamURLFinished(original_url_, new_url, filetype, QString()); + 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.hasError()) { + + QString filepath = QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + "/tidalstreams"; + QString filename = "tidal-" + QString::number(song_id_) + ".xml"; + if (!QDir().mkpath(filepath)) { + error = Error(QString("Failed to create directory %1.").arg(filepath), json_obj); + emit StreamURLFinished(original_url_, original_url_, Song::FileType_Stream, error); + return; + } + QUrl url("file://" + filepath + "/" + filename); + QFile file(url.toLocalFile()); + if (file.exists()) + file.remove(); + if (!file.open(QIODevice::WriteOnly)) { + error = Error(QString("Failed to open file %1 for writing.").arg(url.toLocalFile()), json_obj); + emit StreamURLFinished(original_url_, original_url_, Song::FileType_Stream, error); + return; + } + file.write(data_manifest); + file.close(); + + urls << url; + + } + + else { + + json_obj = ExtractJsonObj(data_manifest, error); + if (json_obj.isEmpty()) { + emit StreamURLFinished(original_url_, original_url_, Song::FileType_Stream, error); + return; + } + + if (!json_obj.contains("mimeType")) { + error = Error("Invalid Json reply, stream url reply manifest is missing mimeType.", json_obj); + emit StreamURLFinished(original_url_, original_url_, Song::FileType_Stream, error); + 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 = Error("Invalid Json reply, urls is not an array.", json_urls); + emit StreamURLFinished(original_url_, original_url_, Song::FileType_Stream, error); + 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 = Error("Missing stream urls.", json_obj); + emit StreamURLFinished(original_url_, original_url_, filetype); + return; + } + + emit StreamURLFinished(original_url_, urls.first(), filetype); } diff --git a/src/tidal/tidalstreamurlrequest.h b/src/tidal/tidalstreamurlrequest.h index d4b6a01ff..dcaf1e31f 100644 --- a/src/tidal/tidalstreamurlrequest.h +++ b/src/tidal/tidalstreamurlrequest.h @@ -27,6 +27,7 @@ #include "core/song.h" #include "tidalbaserequest.h" +#include "settings/tidalsettingspage.h" class QNetworkReply; class NetworkAccessManager; @@ -44,6 +45,8 @@ class TidalStreamURLRequest : public TidalBaseRequest { void NeedLogin() { need_login_ = true; } void Cancel(); + const 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_; } @@ -57,6 +60,7 @@ class TidalStreamURLRequest : public TidalBaseRequest { void StreamURLReceived(); private: + TidalService *service_; QNetworkReply *reply_; QUrl original_url_; int song_id_;