diff --git a/CMakeLists.txt b/CMakeLists.txt index 67a420314..0ccca7c50 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 210f09b84..4dc238bbc 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 ff491cc7a..9993765b1 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 000000000..5b7e2f11e Binary files /dev/null and b/data/icons/128x128/tidal.png differ diff --git a/data/icons/22x22/tidal.png b/data/icons/22x22/tidal.png new file mode 100644 index 000000000..496105541 Binary files /dev/null and b/data/icons/22x22/tidal.png differ diff --git a/data/icons/32x32/tidal.png b/data/icons/32x32/tidal.png new file mode 100644 index 000000000..c206ac0a3 Binary files /dev/null and b/data/icons/32x32/tidal.png differ diff --git a/data/icons/48x48/tidal.png b/data/icons/48x48/tidal.png new file mode 100644 index 000000000..fb0143432 Binary files /dev/null and b/data/icons/48x48/tidal.png differ diff --git a/data/icons/64x64/tidal.png b/data/icons/64x64/tidal.png new file mode 100644 index 000000000..cbd33703d Binary files /dev/null and b/data/icons/64x64/tidal.png differ diff --git a/data/icons/full/tidal.png b/data/icons/full/tidal.png new file mode 100644 index 000000000..316831c69 Binary files /dev/null and b/data/icons/full/tidal.png differ diff --git a/data/schema/schema-12.sql b/data/schema/schema-12.sql new file mode 100644 index 000000000..4a5960c2e --- /dev/null +++ b/data/schema/schema-12.sql @@ -0,0 +1,217 @@ +CREATE TABLE IF NOT EXISTS tidal_artists_songs ( + + title TEXT, + album TEXT, + artist TEXT, + albumartist TEXT, + track INTEGER NOT NULL DEFAULT -1, + disc INTEGER NOT NULL DEFAULT -1, + year INTEGER NOT NULL DEFAULT -1, + originalyear INTEGER NOT NULL DEFAULT 0, + genre TEXT, + compilation INTEGER NOT NULL DEFAULT 0, + composer TEXT, + performer TEXT, + grouping TEXT, + comment TEXT, + lyrics TEXT, + + artist_id TEXT, + album_id TEXT, + song_id TEXT, + + beginning INTEGER NOT NULL DEFAULT 0, + length INTEGER NOT NULL DEFAULT 0, + + bitrate INTEGER NOT NULL DEFAULT -1, + samplerate INTEGER NOT NULL DEFAULT -1, + bitdepth INTEGER NOT NULL DEFAULT -1, + + source INTEGER NOT NULL DEFAULT 0, + directory_id INTEGER NOT NULL DEFAULT -1, + url TEXT NOT NULL, + filetype INTEGER NOT NULL DEFAULT 0, + filesize INTEGER NOT NULL DEFAULT -1, + mtime INTEGER NOT NULL DEFAULT -1, + ctime INTEGER NOT NULL DEFAULT -1, + unavailable INTEGER DEFAULT 0, + + playcount INTEGER NOT NULL DEFAULT 0, + skipcount INTEGER NOT NULL DEFAULT 0, + lastplayed INTEGER NOT NULL DEFAULT -1, + + compilation_detected INTEGER DEFAULT 0, + compilation_on INTEGER NOT NULL DEFAULT 0, + compilation_off INTEGER NOT NULL DEFAULT 0, + compilation_effective INTEGER NOT NULL DEFAULT 0, + + art_automatic TEXT, + art_manual TEXT, + + effective_albumartist TEXT, + effective_originalyear INTEGER NOT NULL DEFAULT 0, + + cue_path TEXT + +); + +CREATE TABLE IF NOT EXISTS tidal_albums_songs ( + + title TEXT, + album TEXT, + artist TEXT, + albumartist TEXT, + track INTEGER NOT NULL DEFAULT -1, + disc INTEGER NOT NULL DEFAULT -1, + year INTEGER NOT NULL DEFAULT -1, + originalyear INTEGER NOT NULL DEFAULT 0, + genre TEXT, + compilation INTEGER NOT NULL DEFAULT 0, + composer TEXT, + performer TEXT, + grouping TEXT, + comment TEXT, + lyrics TEXT, + + artist_id TEXT, + album_id TEXT, + song_id TEXT, + + beginning INTEGER NOT NULL DEFAULT 0, + length INTEGER NOT NULL DEFAULT 0, + + bitrate INTEGER NOT NULL DEFAULT -1, + samplerate INTEGER NOT NULL DEFAULT -1, + bitdepth INTEGER NOT NULL DEFAULT -1, + + source INTEGER NOT NULL DEFAULT 0, + directory_id INTEGER NOT NULL DEFAULT -1, + url TEXT NOT NULL, + filetype INTEGER NOT NULL DEFAULT 0, + filesize INTEGER NOT NULL DEFAULT -1, + mtime INTEGER NOT NULL DEFAULT -1, + ctime INTEGER NOT NULL DEFAULT -1, + unavailable INTEGER DEFAULT 0, + + playcount INTEGER NOT NULL DEFAULT 0, + skipcount INTEGER NOT NULL DEFAULT 0, + lastplayed INTEGER NOT NULL DEFAULT -1, + + compilation_detected INTEGER DEFAULT 0, + compilation_on INTEGER NOT NULL DEFAULT 0, + compilation_off INTEGER NOT NULL DEFAULT 0, + compilation_effective INTEGER NOT NULL DEFAULT 0, + + art_automatic TEXT, + art_manual TEXT, + + effective_albumartist TEXT, + effective_originalyear INTEGER NOT NULL DEFAULT 0, + + cue_path TEXT + +); + +CREATE TABLE IF NOT EXISTS tidal_songs ( + + title TEXT, + album TEXT, + artist TEXT, + albumartist TEXT, + track INTEGER NOT NULL DEFAULT -1, + disc INTEGER NOT NULL DEFAULT -1, + year INTEGER NOT NULL DEFAULT -1, + originalyear INTEGER NOT NULL DEFAULT 0, + genre TEXT, + compilation INTEGER NOT NULL DEFAULT 0, + composer TEXT, + performer TEXT, + grouping TEXT, + comment TEXT, + lyrics TEXT, + + artist_id TEXT, + album_id TEXT, + song_id TEXT, + + beginning INTEGER NOT NULL DEFAULT 0, + length INTEGER NOT NULL DEFAULT 0, + + bitrate INTEGER NOT NULL DEFAULT -1, + samplerate INTEGER NOT NULL DEFAULT -1, + bitdepth INTEGER NOT NULL DEFAULT -1, + + source INTEGER NOT NULL DEFAULT 0, + directory_id INTEGER NOT NULL DEFAULT -1, + url TEXT NOT NULL, + filetype INTEGER NOT NULL DEFAULT 0, + filesize INTEGER NOT NULL DEFAULT -1, + mtime INTEGER NOT NULL DEFAULT -1, + ctime INTEGER NOT NULL DEFAULT -1, + unavailable INTEGER DEFAULT 0, + + playcount INTEGER NOT NULL DEFAULT 0, + skipcount INTEGER NOT NULL DEFAULT 0, + lastplayed INTEGER NOT NULL DEFAULT -1, + + compilation_detected INTEGER DEFAULT 0, + compilation_on INTEGER NOT NULL DEFAULT 0, + compilation_off INTEGER NOT NULL DEFAULT 0, + compilation_effective INTEGER NOT NULL DEFAULT 0, + + art_automatic TEXT, + art_manual TEXT, + + effective_albumartist TEXT, + effective_originalyear INTEGER NOT NULL DEFAULT 0, + + cue_path TEXT + +); + +CREATE VIRTUAL TABLE IF NOT EXISTS tidal_artists_songs_fts USING fts5( + + ftstitle, + ftsalbum, + ftsartist, + ftsalbumartist, + ftscomposer, + ftsperformer, + ftsgrouping, + ftsgenre, + ftscomment, + tokenize = "unicode61 remove_diacritics 0" + +); + +CREATE VIRTUAL TABLE IF NOT EXISTS tidal_albums_songs_fts USING fts5( + + ftstitle, + ftsalbum, + ftsartist, + ftsalbumartist, + ftscomposer, + ftsperformer, + ftsgrouping, + ftsgenre, + ftscomment, + tokenize = "unicode61 remove_diacritics 0" + +); + +CREATE VIRTUAL TABLE IF NOT EXISTS tidal_songs_fts USING fts5( + + ftstitle, + ftsalbum, + ftsartist, + ftsalbumartist, + ftscomposer, + ftsperformer, + ftsgrouping, + ftsgenre, + ftscomment, + tokenize = "unicode61 remove_diacritics 0" + +); + +UPDATE schema_version SET version=12; diff --git a/data/schema/schema.sql b/data/schema/schema.sql index 802fad5ad..c5cff9a7c 100644 --- a/data/schema/schema.sql +++ b/data/schema/schema.sql @@ -4,7 +4,7 @@ CREATE TABLE IF NOT EXISTS schema_version ( DELETE FROM schema_version; -INSERT INTO schema_version (version) VALUES (11); +INSERT INTO schema_version (version) VALUES (12); CREATE TABLE IF NOT EXISTS directories ( path TEXT NOT NULL, @@ -74,6 +74,177 @@ CREATE TABLE IF NOT EXISTS songs ( ); +CREATE TABLE IF NOT EXISTS tidal_artists_songs ( + + title TEXT, + album TEXT, + artist TEXT, + albumartist TEXT, + track INTEGER NOT NULL DEFAULT -1, + disc INTEGER NOT NULL DEFAULT -1, + year INTEGER NOT NULL DEFAULT -1, + originalyear INTEGER NOT NULL DEFAULT 0, + genre TEXT, + compilation INTEGER NOT NULL DEFAULT 0, + composer TEXT, + performer TEXT, + grouping TEXT, + comment TEXT, + lyrics TEXT, + + artist_id TEXT, + album_id TEXT, + song_id TEXT, + + beginning INTEGER NOT NULL DEFAULT 0, + length INTEGER NOT NULL DEFAULT 0, + + bitrate INTEGER NOT NULL DEFAULT -1, + samplerate INTEGER NOT NULL DEFAULT -1, + bitdepth INTEGER NOT NULL DEFAULT -1, + + source INTEGER NOT NULL DEFAULT 0, + directory_id INTEGER NOT NULL DEFAULT -1, + url TEXT NOT NULL, + filetype INTEGER NOT NULL DEFAULT 0, + filesize INTEGER NOT NULL DEFAULT -1, + mtime INTEGER NOT NULL DEFAULT -1, + ctime INTEGER NOT NULL DEFAULT -1, + unavailable INTEGER DEFAULT 0, + + playcount INTEGER NOT NULL DEFAULT 0, + skipcount INTEGER NOT NULL DEFAULT 0, + lastplayed INTEGER NOT NULL DEFAULT -1, + + compilation_detected INTEGER DEFAULT 0, + compilation_on INTEGER NOT NULL DEFAULT 0, + compilation_off INTEGER NOT NULL DEFAULT 0, + compilation_effective INTEGER NOT NULL DEFAULT 0, + + art_automatic TEXT, + art_manual TEXT, + + effective_albumartist TEXT, + effective_originalyear INTEGER NOT NULL DEFAULT 0, + + cue_path TEXT + +); + +CREATE TABLE IF NOT EXISTS tidal_albums_songs ( + + title TEXT, + album TEXT, + artist TEXT, + albumartist TEXT, + track INTEGER NOT NULL DEFAULT -1, + disc INTEGER NOT NULL DEFAULT -1, + year INTEGER NOT NULL DEFAULT -1, + originalyear INTEGER NOT NULL DEFAULT 0, + genre TEXT, + compilation INTEGER NOT NULL DEFAULT 0, + composer TEXT, + performer TEXT, + grouping TEXT, + comment TEXT, + lyrics TEXT, + + artist_id TEXT, + album_id TEXT, + song_id TEXT, + + beginning INTEGER NOT NULL DEFAULT 0, + length INTEGER NOT NULL DEFAULT 0, + + bitrate INTEGER NOT NULL DEFAULT -1, + samplerate INTEGER NOT NULL DEFAULT -1, + bitdepth INTEGER NOT NULL DEFAULT -1, + + source INTEGER NOT NULL DEFAULT 0, + directory_id INTEGER NOT NULL DEFAULT -1, + url TEXT NOT NULL, + filetype INTEGER NOT NULL DEFAULT 0, + filesize INTEGER NOT NULL DEFAULT -1, + mtime INTEGER NOT NULL DEFAULT -1, + ctime INTEGER NOT NULL DEFAULT -1, + unavailable INTEGER DEFAULT 0, + + playcount INTEGER NOT NULL DEFAULT 0, + skipcount INTEGER NOT NULL DEFAULT 0, + lastplayed INTEGER NOT NULL DEFAULT -1, + + compilation_detected INTEGER DEFAULT 0, + compilation_on INTEGER NOT NULL DEFAULT 0, + compilation_off INTEGER NOT NULL DEFAULT 0, + compilation_effective INTEGER NOT NULL DEFAULT 0, + + art_automatic TEXT, + art_manual TEXT, + + effective_albumartist TEXT, + effective_originalyear INTEGER NOT NULL DEFAULT 0, + + cue_path TEXT + +); + +CREATE TABLE IF NOT EXISTS tidal_songs ( + + title TEXT, + album TEXT, + artist TEXT, + albumartist TEXT, + track INTEGER NOT NULL DEFAULT -1, + disc INTEGER NOT NULL DEFAULT -1, + year INTEGER NOT NULL DEFAULT -1, + originalyear INTEGER NOT NULL DEFAULT 0, + genre TEXT, + compilation INTEGER NOT NULL DEFAULT 0, + composer TEXT, + performer TEXT, + grouping TEXT, + comment TEXT, + lyrics TEXT, + + artist_id TEXT, + album_id TEXT, + song_id TEXT, + + beginning INTEGER NOT NULL DEFAULT 0, + length INTEGER NOT NULL DEFAULT 0, + + bitrate INTEGER NOT NULL DEFAULT -1, + samplerate INTEGER NOT NULL DEFAULT -1, + bitdepth INTEGER NOT NULL DEFAULT -1, + + source INTEGER NOT NULL DEFAULT 0, + directory_id INTEGER NOT NULL DEFAULT -1, + url TEXT NOT NULL, + filetype INTEGER NOT NULL DEFAULT 0, + filesize INTEGER NOT NULL DEFAULT -1, + mtime INTEGER NOT NULL DEFAULT -1, + ctime INTEGER NOT NULL DEFAULT -1, + unavailable INTEGER DEFAULT 0, + + playcount INTEGER NOT NULL DEFAULT 0, + skipcount INTEGER NOT NULL DEFAULT 0, + lastplayed INTEGER NOT NULL DEFAULT -1, + + compilation_detected INTEGER DEFAULT 0, + compilation_on INTEGER NOT NULL DEFAULT 0, + compilation_off INTEGER NOT NULL DEFAULT 0, + compilation_effective INTEGER NOT NULL DEFAULT 0, + + art_automatic TEXT, + art_manual TEXT, + + effective_albumartist TEXT, + effective_originalyear INTEGER NOT NULL DEFAULT 0, + + cue_path TEXT + +); + CREATE TABLE IF NOT EXISTS subsonic_songs ( title TEXT, @@ -243,6 +414,51 @@ CREATE VIRTUAL TABLE IF NOT EXISTS songs_fts USING fts5( ); +CREATE VIRTUAL TABLE IF NOT EXISTS tidal_artists_songs_fts USING fts5( + + ftstitle, + ftsalbum, + ftsartist, + ftsalbumartist, + ftscomposer, + ftsperformer, + ftsgrouping, + ftsgenre, + ftscomment, + tokenize = "unicode61 remove_diacritics 0" + +); + +CREATE VIRTUAL TABLE IF NOT EXISTS tidal_albums_songs_fts USING fts5( + + ftstitle, + ftsalbum, + ftsartist, + ftsalbumartist, + ftscomposer, + ftsperformer, + ftsgrouping, + ftsgenre, + ftscomment, + tokenize = "unicode61 remove_diacritics 0" + +); + +CREATE VIRTUAL TABLE IF NOT EXISTS tidal_songs_fts USING fts5( + + ftstitle, + ftsalbum, + ftsartist, + ftsalbumartist, + ftscomposer, + ftsperformer, + ftsgrouping, + ftsgenre, + ftscomment, + tokenize = "unicode61 remove_diacritics 0" + +); + CREATE VIRTUAL TABLE IF NOT EXISTS subsonic_songs_fts USING fts5( ftstitle, diff --git a/dist/unix/org.strawberrymusicplayer.strawberry.desktop b/dist/unix/org.strawberrymusicplayer.strawberry.desktop index a8959b21f..c687087af 100755 --- a/dist/unix/org.strawberrymusicplayer.strawberry.desktop +++ b/dist/unix/org.strawberrymusicplayer.strawberry.desktop @@ -10,5 +10,5 @@ Icon=strawberry Terminal=false Categories=AudioVideo;Player;Qt;Audio; StartupNotify=false -MimeType=x-content/audio-player;application/ogg;application/x-ogg;application/x-ogm-audio;audio/flac;audio/ogg;audio/vorbis;audio/aac;audio/mp4;audio/mpeg;audio/mpegurl;audio/vnd.rn-realaudio;audio/x-flac;audio/x-oggflac;audio/x-vorbis;audio/x-vorbis+ogg;audio/x-speex;audio/x-wav;audio/x-wavpack;audio/x-ape;audio/x-mp3;audio/x-mpeg;audio/x-mpegurl;audio/x-ms-wma;audio/x-musepack;audio/x-pn-realaudio;audio/x-scpls;video/x-ms-asf; +MimeType=x-content/audio-player;application/ogg;application/x-ogg;application/x-ogm-audio;audio/flac;audio/ogg;audio/vorbis;audio/aac;audio/mp4;audio/mpeg;audio/mpegurl;audio/vnd.rn-realaudio;audio/x-flac;audio/x-oggflac;audio/x-vorbis;audio/x-vorbis+ogg;audio/x-speex;audio/x-wav;audio/x-wavpack;audio/x-ape;audio/x-mp3;audio/x-mpeg;audio/x-mpegurl;audio/x-ms-wma;audio/x-musepack;audio/x-pn-realaudio;audio/x-scpls;video/x-ms-asf;x-scheme-handler/tidal; StartupWMClass=strawberry diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index f1b8f2372..623c46603 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -921,6 +921,29 @@ optional_source(HAVE_SUBSONIC settings/subsonicsettingspage.ui ) +optional_source(HAVE_TIDAL + SOURCES + tidal/tidalservice.cpp + tidal/tidalurlhandler.cpp + tidal/tidalbaserequest.cpp + tidal/tidalrequest.cpp + tidal/tidalstreamurlrequest.cpp + tidal/tidalfavoriterequest.cpp + settings/tidalsettingspage.cpp + covermanager/tidalcoverprovider.cpp + HEADERS + tidal/tidalservice.h + tidal/tidalurlhandler.h + tidal/tidalbaserequest.h + tidal/tidalrequest.h + tidal/tidalstreamurlrequest.h + tidal/tidalfavoriterequest.h + settings/tidalsettingspage.h + covermanager/tidalcoverprovider.h + UI + settings/tidalsettingspage.ui +) + # Moodbar optional_source(HAVE_MOODBAR SOURCES diff --git a/src/config.h.in b/src/config.h.in index e8ee1b242..7ba2d3888 100644 --- a/src/config.h.in +++ b/src/config.h.in @@ -49,6 +49,7 @@ #cmakedefine XINE_ANALYZER #cmakedefine HAVE_SUBSONIC +#cmakedefine HAVE_TIDAL #cmakedefine HAVE_MOODBAR diff --git a/src/core/application.cpp b/src/core/application.cpp index 84d016712..eb3b5350b 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -70,6 +70,11 @@ # include "subsonic/subsonicservice.h" #endif +#ifdef HAVE_TIDAL +# include "tidal/tidalservice.h" +# include "covermanager/tidalcoverprovider.h" +#endif + #ifdef HAVE_MOODBAR # include "moodbar/moodbarcontroller.h" # include "moodbar/moodbarloader.h" @@ -111,6 +116,9 @@ class ApplicationImpl { cover_providers->AddProvider(new 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 7ace63889..2ddee1674 100644 --- a/src/core/database.cpp +++ b/src/core/database.cpp @@ -54,7 +54,7 @@ #include "scopedtransaction.h" const char *Database::kDatabaseFilename = "strawberry.db"; -const int Database::kSchemaVersion = 11; +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 f111c8df3..e1aa8db61 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 9facf9358..449b96df1 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 257b88859..8b68c6326 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 000000000..7a6658d42 --- /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 000000000..9eb3ee2e4 --- /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 2bf2bb06c..33c348d31 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 4d2dc2435..0b8575cca 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 000000000..90df87c46 --- /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 000000000..87cd6a6cf --- /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 000000000..835c2bc7a --- /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 000000000..1adf5754b --- /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 000000000..c18c8ddee --- /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 000000000..047625a04 --- /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 000000000..c19a49b8d --- /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 000000000..f3699590b --- /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 000000000..320cf73c8 --- /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 000000000..86fba2fdf --- /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 000000000..9d19516e3 --- /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 000000000..45cbca5df --- /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 000000000..aacb86f0d --- /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 000000000..640896280 --- /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 000000000..519510a6b --- /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