From 0d7e12e781f565d3ce227859d3a5232217577e76 Mon Sep 17 00:00:00 2001 From: Jonas Kvinge Date: Sun, 23 Dec 2018 18:54:27 +0100 Subject: [PATCH] Add scrobbler with support for Last.fm, Libre.fm and ListenBrainz --- README.md | 9 +- data/icons.qrc | 10 + dist/debian/control | 5 +- dist/debian/copyright | 9 +- dist/fedora/strawberry.spec.in | 5 +- dist/opensuse/strawberry.spec.in | 5 +- src/CMakeLists.txt | 23 + src/core/application.cpp | 7 +- src/core/application.h | 9 +- src/core/mainwindow.cpp | 221 +++---- src/core/mainwindow.h | 12 +- src/core/mainwindow.ui | 25 + src/core/player.cpp | 17 +- src/core/song.cpp | 3 + src/core/song.h | 1 + src/globalshortcuts/globalshortcuts.cpp | 1 + src/globalshortcuts/globalshortcuts.h | 1 + src/lyrics/apiseedslyricsprovider.cpp | 10 +- src/playlist/playlist.cpp | 40 +- src/playlist/playlist.h | 15 + src/scrobbler/audioscrobbler.cpp | 173 ++++++ src/scrobbler/audioscrobbler.h | 88 +++ src/scrobbler/lastfmscrobbler.cpp | 82 +++ src/scrobbler/lastfmscrobbler.h | 82 +++ src/scrobbler/librefmscrobbler.cpp | 82 +++ src/scrobbler/librefmscrobbler.h | 82 +++ src/scrobbler/listenbrainzscrobbler.cpp | 482 +++++++++++++++ src/scrobbler/listenbrainzscrobbler.h | 117 ++++ src/scrobbler/scrobblercache.cpp | 230 ++++++++ src/scrobbler/scrobblercache.h | 64 ++ src/scrobbler/scrobblercacheitem.cpp | 39 ++ src/scrobbler/scrobblercacheitem.h | 48 ++ src/scrobbler/scrobblerservice.cpp | 57 ++ src/scrobbler/scrobblerservice.h | 76 +++ src/scrobbler/scrobblerservices.cpp | 77 +++ src/scrobbler/scrobblerservices.h | 70 +++ src/scrobbler/scrobblingapi20.cpp | 740 ++++++++++++++++++++++++ src/scrobbler/scrobblingapi20.h | 158 +++++ src/settings/scrobblersettingspage.cpp | 230 ++++++++ src/settings/scrobblersettingspage.h | 71 +++ src/settings/scrobblersettingspage.ui | 243 ++++++++ src/settings/settingsdialog.cpp | 12 +- src/settings/settingsdialog.h | 3 +- 43 files changed, 3565 insertions(+), 169 deletions(-) create mode 100644 src/scrobbler/audioscrobbler.cpp create mode 100644 src/scrobbler/audioscrobbler.h create mode 100644 src/scrobbler/lastfmscrobbler.cpp create mode 100644 src/scrobbler/lastfmscrobbler.h create mode 100644 src/scrobbler/librefmscrobbler.cpp create mode 100644 src/scrobbler/librefmscrobbler.h create mode 100644 src/scrobbler/listenbrainzscrobbler.cpp create mode 100644 src/scrobbler/listenbrainzscrobbler.h create mode 100644 src/scrobbler/scrobblercache.cpp create mode 100644 src/scrobbler/scrobblercache.h create mode 100644 src/scrobbler/scrobblercacheitem.cpp create mode 100644 src/scrobbler/scrobblercacheitem.h create mode 100644 src/scrobbler/scrobblerservice.cpp create mode 100644 src/scrobbler/scrobblerservice.h create mode 100644 src/scrobbler/scrobblerservices.cpp create mode 100644 src/scrobbler/scrobblerservices.h create mode 100644 src/scrobbler/scrobblingapi20.cpp create mode 100644 src/scrobbler/scrobblingapi20.h create mode 100644 src/settings/scrobblersettingspage.cpp create mode 100644 src/settings/scrobblersettingspage.h create mode 100644 src/settings/scrobblersettingspage.ui diff --git a/README.md b/README.md index 978a8424..a5627f49 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,10 @@ Strawberry is a audio player and music collection organizer. It is a fork of Cle * Song lyrics from AudD and API Seeds * Support for multiple backends * Audio analyzer - * Equalizer + * Audio equalizer * Transfer music to iPod, iPhone, MTP or mass-storage USB player - * Integrated Tidal and Deezer support + * Streaming support for Tidal and Deezer + * Scrobbler with support for Last.fm, Libre.fm and ListenBrainz It has so far been tested to work on Linux, OpenBSD, MacOs and Windows. @@ -52,8 +53,8 @@ Either GStreamer, Xine, VLC, Deezer or Phonon engine is required, but only GStre You should also install the gstreamer plugins base and good, and optionally bad and ugly. Deezer streams with full songs are encrypted and only urls for preview streams (MP3) are exposed by the API. -Full length songs requires the use of deezers own engine (Deezer SDK) or the dzmedia library (I dont have it). -Deezer SDK can be found here: https://build-repo.deezer.com/native_sdk/deezer-native-sdk-v1.2.10.zip +Full length songs requires the use of deezers own engine (Deezer SDK). +The Deezer SDK can be found here: https://build-repo.deezer.com/native_sdk/deezer-native-sdk-v1.2.10.zip Optional: diff --git a/data/icons.qrc b/data/icons.qrc index cc9f52f8..08abfcaa 100644 --- a/data/icons.qrc +++ b/data/icons.qrc @@ -86,6 +86,8 @@ icons/128x128/zoom-out.png icons/128x128/tidal.png icons/128x128/deezer.png + icons/128x128/scrobble.png + icons/128x128/scrobble-disabled.png icons/64x64/albums.png icons/64x64/alsa.png icons/64x64/application-exit.png @@ -172,6 +174,8 @@ icons/64x64/zoom-out.png icons/64x64/tidal.png icons/64x64/deezer.png + icons/64x64/scrobble.png + icons/64x64/scrobble-disabled.png icons/48x48/albums.png icons/48x48/alsa.png icons/48x48/application-exit.png @@ -260,6 +264,8 @@ icons/48x48/zoom-in.png icons/48x48/zoom-out.png icons/48x48/tidal.png + icons/48x48/scrobble.png + icons/48x48/scrobble-disabled.png icons/32x32/albums.png icons/32x32/alsa.png icons/32x32/application-exit.png @@ -350,6 +356,8 @@ icons/32x32/zoom-out.png icons/32x32/tidal.png icons/32x32/deezer.png + icons/32x32/scrobble.png + icons/32x32/scrobble-disabled.png icons/22x22/albums.png icons/22x22/alsa.png icons/22x22/application-exit.png @@ -440,5 +448,7 @@ icons/22x22/zoom-out.png icons/22x22/tidal.png icons/22x22/deezer.png + icons/22x22/scrobble.png + icons/22x22/scrobble-disabled.png diff --git a/dist/debian/control b/dist/debian/control index 41eab663..95eca3db 100644 --- a/dist/debian/control +++ b/dist/debian/control @@ -62,8 +62,9 @@ Description: Audio player and music collection organizer - Song lyrics from AudD and API Seeds - Support for multiple backends - Audio analyzer - - Equalizer + - Audio equalizer - Transfer music to iPod, iPhone, MTP or mass-storage USB player - - Integrated Tidal support + - Streaming support for Tidal and Deezer + - Scrobbler with support for Last.fm, Libre.fm and ListenBrainz . It is a fork of Clementine. The name is inspired by the band Strawbs. diff --git a/dist/debian/copyright b/dist/debian/copyright index 6d26505e..e5011b21 100644 --- a/dist/debian/copyright +++ b/dist/debian/copyright @@ -35,11 +35,10 @@ Files: src/core/main.h src/engine/phononengine.h src/internet/internetservice.cpp src/internet/internetservice.h - src/lyrics/* - src/tidal/* - src/deezer/* src/settings/backendsettingspage.cpp src/settings/backendsettingspage.h + src/settings/scrobblersettingspage.cpp + src/settings/scrobblersettingspage.h src/settings/deezersettingspage.cpp src/settings/deezersettingspage.h src/settings/tidalsettingspage.cpp @@ -48,6 +47,10 @@ Files: src/core/main.h src/covermanager/lastfmcoverprovider.h src/covermanager/musicbrainzcoverprovider.cpp src/covermanager/musicbrainzcoverprovider.h + src/lyrics/* + src/scrobbler/* + src/tidal/* + src/deezer/* Copyright: 2012-2014, 2017-2018, Jonas Kvinge License: GPL-3+ diff --git a/dist/fedora/strawberry.spec.in b/dist/fedora/strawberry.spec.in index 60bc8366..8f9d7295 100644 --- a/dist/fedora/strawberry.spec.in +++ b/dist/fedora/strawberry.spec.in @@ -64,9 +64,10 @@ Features: * Song lyrics from AudD and API Seeds * Support for multiple backends * Audio analyzer - * Equalizer + * Audio equalizer * Transfer music to iPod, iPhone, MTP or mass-storage USB player - * Integrated Tidal and Deezer support + * Streaming support for Tidal and Deezer + * Scrobbler with support for Last.fm, Libre.fm and ListenBrainz %prep %setup -qn %{name}-@STRAWBERRY_VERSION_PACKAGE@ diff --git a/dist/opensuse/strawberry.spec.in b/dist/opensuse/strawberry.spec.in index 20f07bf3..a1782f49 100644 --- a/dist/opensuse/strawberry.spec.in +++ b/dist/opensuse/strawberry.spec.in @@ -75,9 +75,10 @@ Features: * Song lyrics from AudD and API Seeds * Support for multiple backends * Audio analyzer - * Equalizer + * Audio equalizer * Transfer music to iPod, iPhone, MTP or mass-storage USB player - * Integrated Tidal and Deezer support + * Streaming support for Tidal and Deezer + * Scrobbler with support for Last.fm, Libre.fm and ListenBrainz %prep %setup -q -n %{name}-@STRAWBERRY_VERSION_PACKAGE@ diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 462f9027..4b8133a7 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -213,6 +213,7 @@ set(SOURCES settings/shortcutssettingspage.cpp settings/appearancesettingspage.cpp settings/notificationssettingspage.cpp + settings/scrobblersettingspage.cpp dialogs/about.cpp dialogs/console.cpp @@ -265,6 +266,16 @@ set(SOURCES internet/internetsearchitemdelegate.cpp internet/localredirectserver.cpp + scrobbler/audioscrobbler.cpp + scrobbler/scrobblerservices.cpp + scrobbler/scrobblerservice.cpp + scrobbler/scrobblercache.cpp + scrobbler/scrobblercacheitem.cpp + scrobbler/scrobblingapi20.cpp + scrobbler/lastfmscrobbler.cpp + scrobbler/librefmscrobbler.cpp + scrobbler/listenbrainzscrobbler.cpp + ) set(HEADERS @@ -379,6 +390,7 @@ set(HEADERS settings/shortcutssettingspage.h settings/appearancesettingspage.h settings/notificationssettingspage.h + settings/scrobblersettingspage.h dialogs/about.h dialogs/errordialog.h @@ -426,6 +438,16 @@ set(HEADERS internet/internetsearchmodel.h internet/localredirectserver.h + scrobbler/audioscrobbler.h + scrobbler/scrobblerservices.h + scrobbler/scrobblerservice.h + scrobbler/scrobblercache.h + scrobbler/scrobblercacheitem.h + scrobbler/scrobblingapi20.h + scrobbler/lastfmscrobbler.h + scrobbler/librefmscrobbler.h + scrobbler/listenbrainzscrobbler.h + ) set(UI @@ -462,6 +484,7 @@ set(UI settings/shortcutssettingspage.ui settings/appearancesettingspage.ui settings/notificationssettingspage.ui + settings/scrobblersettingspage.ui equalizer/equalizer.ui equalizer/equalizerslider.ui diff --git a/src/core/application.cpp b/src/core/application.cpp index 10e30c23..1ead1880 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -69,6 +69,8 @@ # include "deezer/deezerservice.h" #endif +#include "scrobbler/audioscrobbler.h" + bool Application::kIsPortable = false; class ApplicationImpl { @@ -136,7 +138,7 @@ class ApplicationImpl { #ifdef HAVE_STREAM_DEEZER deezer_search_([=]() { return new InternetSearch(app, Song::Source_Deezer, app); }), #endif - dummy_([=]() { return new QVariant; }) + scrobbler_([=]() { return new AudioScrobbler(app, app); }) {} Lazy tag_reader_client_; @@ -162,7 +164,7 @@ class ApplicationImpl { #ifdef HAVE_STREAM_DEEZER Lazy deezer_search_; #endif - Lazy dummy_; + Lazy scrobbler_; }; @@ -236,3 +238,4 @@ InternetSearch *Application::tidal_search() const { return p_->tidal_search_.get #ifdef HAVE_STREAM_DEEZER InternetSearch *Application::deezer_search() const { return p_->deezer_search_.get(); } #endif +AudioScrobbler *Application::scrobbler() const { return p_->scrobbler_.get(); } diff --git a/src/core/application.h b/src/core/application.h index 54aaa63f..e419c4df 100644 --- a/src/core/application.h +++ b/src/core/application.h @@ -19,8 +19,8 @@ * */ -#ifndef APPLICATION_H_ -#define APPLICATION_H_ +#ifndef APPLICATION_H +#define APPLICATION_H #include "config.h" @@ -57,6 +57,7 @@ class CurrentArtLoader; class LyricsProviders; class InternetServices; class InternetSearch; +class AudioScrobbler; class Application : public QObject { Q_OBJECT @@ -98,6 +99,8 @@ class Application : public QObject { InternetSearch *deezer_search() const; #endif + AudioScrobbler *scrobbler() const; + void MoveToNewThread(QObject *object); void MoveToThread(QObject *object, QThread *thread); @@ -117,4 +120,4 @@ signals: }; -#endif // APPLICATION_H_ +#endif // APPLICATION_H diff --git a/src/core/mainwindow.cpp b/src/core/mainwindow.cpp index 2d735212..44d0880d 100644 --- a/src/core/mainwindow.cpp +++ b/src/core/mainwindow.cpp @@ -144,6 +144,8 @@ #include "internet/internetservice.h" #include "internet/internetsearchview.h" +#include "scrobbler/audioscrobbler.h" + #if defined(HAVE_GSTREAMER) && defined(HAVE_CHROMAPRINT) # include "musicbrainz/tagfetcher.h" #endif @@ -362,6 +364,10 @@ MainWindow::MainWindow(Application *app, SystemTrayIcon *tray_icon, OSD *osd, co ui_->action_update_collection->setIcon(IconLoader::Load("view-refresh")); ui_->action_full_collection_scan->setIcon(IconLoader::Load("view-refresh")); ui_->action_settings->setIcon(IconLoader::Load("configure")); + + // Scrobble + + ui_->action_toggle_scrobbling->setIcon(IconLoader::Load("scrobble-disabled", 22)); // File view connections connect(file_view_, SIGNAL(AddToPlaylist(QMimeData*)), SLOT(AddToPlaylist(QMimeData*))); @@ -411,6 +417,9 @@ MainWindow::MainWindow(Application *app, SystemTrayIcon *tray_icon, OSD *osd, co connect(ui_->action_full_collection_scan, SIGNAL(triggered()), app_->collection(), SLOT(FullScan())); //connect(ui_->action_add_files_to_transcoder, SIGNAL(triggered()), SLOT(AddFilesToTranscoder())); + connect(ui_->action_toggle_scrobbling, SIGNAL(triggered()), app_->scrobbler(), SLOT(ToggleScrobbling())); + connect(app_->scrobbler(), SIGNAL(ErrorMessage(QString)), SLOT(ShowErrorDialog(QString))); + // Playlist view actions ui_->action_next_playlist->setShortcuts(QList() << QKeySequence::fromString("Ctrl+Tab")<< QKeySequence::fromString("Ctrl+PgDown")); ui_->action_previous_playlist->setShortcuts(QList() << QKeySequence::fromString("Ctrl+Shift+Tab")<< QKeySequence::fromString("Ctrl+PgUp")); @@ -424,6 +433,7 @@ MainWindow::MainWindow(Application *app, SystemTrayIcon *tray_icon, OSD *osd, co ui_->back_button->setDefaultAction(ui_->action_previous_track); ui_->pause_play_button->setDefaultAction(ui_->action_play_pause); ui_->stop_button->setDefaultAction(ui_->action_stop); + ui_->button_scrobble->setDefaultAction(ui_->action_toggle_scrobbling); ui_->playlist->SetActions(ui_->action_new_playlist, ui_->action_load_playlist, ui_->action_save_playlist, ui_->action_clear_playlist, ui_->action_next_playlist, /* These two actions aren't associated */ ui_->action_previous_playlist /* to a button but to the main window */ ); @@ -570,7 +580,6 @@ MainWindow::MainWindow(Application *app, SystemTrayIcon *tray_icon, OSD *osd, co playlist_copy_to_device_ = playlist_menu_->addAction(IconLoader::Load("device"), tr("Copy to device..."), this, SLOT(PlaylistCopyToDevice())); #endif #endif - //playlist_delete_ = playlist_menu_->addAction(IconLoader::Load("edit-delete"), tr("Delete from disk..."), this, SLOT(PlaylistDelete())); playlist_open_in_browser_ = playlist_menu_->addAction(IconLoader::Load("document-open-folder"), tr("Show in file browser..."), this, SLOT(PlaylistOpenInBrowser())); playlist_open_in_browser_->setVisible(false); playlist_show_in_collection_ = playlist_menu_->addAction(IconLoader::Load("edit-find"), tr("Show in collection..."), this, SLOT(ShowInCollection())); @@ -595,6 +604,9 @@ MainWindow::MainWindow(Application *app, SystemTrayIcon *tray_icon, OSD *osd, co connect(app_->device_manager()->connected_devices_model(), SIGNAL(IsEmptyChanged(bool)), playlist_copy_to_device_, SLOT(setDisabled(bool))); #endif + connect(app_->scrobbler(), SIGNAL(ScrobblingEnabledChanged(bool)), SLOT(ScrobblingEnabledChanged(bool))); + connect(app_->scrobbler(), SIGNAL(ScrobbleButtonVisibilityChanged(bool)), SLOT(ScrobbleButtonVisibilityChanged(bool))); + #ifdef Q_OS_MACOS mac::SetApplicationHandler(this); #endif @@ -636,9 +648,10 @@ MainWindow::MainWindow(Application *app, SystemTrayIcon *tray_icon, OSD *osd, co connect(global_shortcuts_, SIGNAL(ShowHide()), SLOT(ToggleShowHide())); connect(global_shortcuts_, SIGNAL(ShowOSD()), app_->player(), SLOT(ShowOSD())); connect(global_shortcuts_, SIGNAL(TogglePrettyOSD()), app_->player(), SLOT(TogglePrettyOSD())); + connect(global_shortcuts_, SIGNAL(ToggleScrobbling()), app_->scrobbler(), SLOT(ToggleScrobbling())); // Fancy tabs - connect(ui_->tabs, SIGNAL(ModeChanged(FancyTabWidget::Mode)), SLOT(SaveGeometry())); + connect(ui_->tabs, SIGNAL(ModeChanged(FancyTabWidget::Mode)), SLOT(SaveTabMode())); connect(ui_->tabs, SIGNAL(CurrentChanged(int)), SLOT(TabSwitched())); connect(ui_->tabs, SIGNAL(CurrentChanged(int)), SLOT(SaveGeometry())); @@ -650,22 +663,8 @@ MainWindow::MainWindow(Application *app, SystemTrayIcon *tray_icon, OSD *osd, co connect(app_->player(), SIGNAL(Error()), context_view_, SLOT(Error())); // Analyzer - //ui_->analyzer->SetEngine(app_->player()->engine()); - connect(ui_->analyzer, SIGNAL(WheelEvent(int)), SLOT(VolumeWheelEvent(int))); -#if 0 - // Equalizer - qLog(Debug) << "Creating equalizer"; - connect(equalizer_.get(), SIGNAL(ParametersChanged(int,QList)), app_->player()->engine(), SLOT(SetEqualizerParameters(int,QList))); - connect(equalizer_.get(), SIGNAL(EnabledChanged(bool)), app_->player()->engine(), SLOT(SetEqualizerEnabled(bool))); - connect(equalizer_.get(), SIGNAL(StereoBalanceChanged(float)), app_->player()->engine(), SLOT(SetStereoBalance(float))); - - app_->player()->engine()->SetEqualizerEnabled(equalizer_->is_enabled()); - app_->player()->engine()->SetEqualizerParameters(equalizer_->preamp_value(), equalizer_->gain_values()); - app_->player()->engine()->SetStereoBalance(equalizer_->stereo_balance()); -#endif - // Statusbar widgets ui_->playlist_summary->setMinimumWidth(QFontMetrics(font()).width("WW selected of WW tracks - [ WW:WW ]")); ui_->status_bar_stack->setCurrentWidget(ui_->playlist_summary_page); @@ -709,6 +708,9 @@ MainWindow::MainWindow(Application *app, SystemTrayIcon *tray_icon, OSD *osd, co connect(app_->playlist_manager()->sequence(), SIGNAL(RepeatModeChanged(PlaylistSequence::RepeatMode)), osd_, SLOT(RepeatModeChanged(PlaylistSequence::RepeatMode))); connect(app_->playlist_manager()->sequence(), SIGNAL(ShuffleModeChanged(PlaylistSequence::ShuffleMode)), osd_, SLOT(ShuffleModeChanged(PlaylistSequence::ShuffleMode))); + ScrobbleButtonVisibilityChanged(app_->scrobbler()->ScrobbleButton()); + ScrobblingEnabledChanged(app_->scrobbler()->IsEnabled()); + // Load settings qLog(Debug) << "Loading settings"; settings_.beginGroup(kSettingsGroup); @@ -724,7 +726,7 @@ MainWindow::MainWindow(Application *app, SystemTrayIcon *tray_icon, OSD *osd, co ui_->splitter->setSizes(QList() << 250 << width() - 250); } - ui_->tabs->setCurrentIndex(settings_.value("current_tab", 1 /* Collection tab */ ).toInt()); + ui_->tabs->setCurrentIndex(settings_.value("current_tab", 1).toInt()); FancyTabWidget::Mode default_mode = FancyTabWidget::Mode_LargeSidebar; int tab_mode_int = settings_.value("tab_mode", default_mode).toInt(); FancyTabWidget::Mode tab_mode = FancyTabWidget::Mode(tab_mode_int); @@ -788,6 +790,9 @@ MainWindow::MainWindow(Application *app, SystemTrayIcon *tray_icon, OSD *osd, co qLog(Debug) << "Started"; initialised_ = true; + app_->scrobbler()->ConnectError(); + if (app_->scrobbler()->IsEnabled() && !app_->scrobbler()->IsOffline()) app_->scrobbler()->Submit(); + } MainWindow::~MainWindow() { @@ -916,9 +921,11 @@ void MainWindow::MediaPlaying() { bool enable_play_pause(false); bool can_seek(false); - if (app_->player()->GetCurrentItem()) { - enable_play_pause = !(app_->player()->GetCurrentItem()->options() & PlaylistItem::PauseDisabled); - can_seek = !(app_->player()->GetCurrentItem()->options() & PlaylistItem::SeekDisabled); + + PlaylistItemPtr item(app_->player()->GetCurrentItem()); + if (item) { + enable_play_pause = !(item->options() & PlaylistItem::PauseDisabled); + can_seek = !(item->options() & PlaylistItem::SeekDisabled); } ui_->action_play_pause->setEnabled(enable_play_pause); ui_->track_slider->SetCanSeek(can_seek); @@ -928,6 +935,13 @@ void MainWindow::MediaPlaying() { track_slider_timer_->start(); UpdateTrackPosition(); + // Send now playing to scrobble services + Playlist *playlist = app_->playlist_manager()->active(); + if (app_->scrobbler()->IsEnabled() && playlist && !playlist->nowplaying() && item->Metadata().is_metadata_good() && item->Metadata().length_nanosec() > 0) { + app_->scrobbler()->UpdateNowPlaying(item->Metadata()); + playlist->set_nowplaying(true); + } + } void MainWindow::VolumeChanged(int volume) { @@ -983,23 +997,31 @@ void MainWindow::TabSwitched() { ui_->widget_playing->SetEnabled(); if (!initialised_) return; - SaveGeometry(); + + settings_.setValue("current_tab", ui_->tabs->currentIndex()); + ui_->tabs->saveSettings(kSettingsGroup); } void MainWindow::SaveGeometry() { + if (!initialised_) return; + was_maximized_ = isMaximized(); settings_.setValue("maximized", was_maximized_); if (was_maximized_) settings_.remove("geometry"); else settings_.setValue("geometry", saveGeometry()); settings_.setValue("splitter_state", ui_->splitter->saveState()); - settings_.setValue("current_tab", ui_->tabs->currentIndex()); - settings_.setValue("tab_mode", ui_->tabs->mode()); ui_->tabs->saveSettings(kSettingsGroup); } +void MainWindow::SaveTabMode() { + if (!initialised_) return; + settings_.setValue("tab_mode", ui_->tabs->mode()); + ui_->tabs->saveSettings(kSettingsGroup); +} + void MainWindow::SavePlaybackStatus() { QSettings settings; @@ -1178,25 +1200,32 @@ void MainWindow::Seeked(qlonglong microseconds) { void MainWindow::UpdateTrackPosition() { - // Track position in seconds - //Playlist *playlist = app_->playlist_manager()->active(); - PlaylistItemPtr item(app_->player()->GetCurrentItem()); if (!item) return; - const int position = std::floor(float(app_->player()->engine()->position_nanosec()) / kNsecPerSec + 0.5); - const int length = item->Metadata().length_nanosec() / kNsecPerSec; - if (length <= 0) { - // Probably a stream that we don't know the length of - return; - } + const int length = (item->Metadata().length_nanosec() / kNsecPerSec); + if (length <= 0) return; + const int position = std::floor(float(app_->player()->engine()->position_nanosec()) / kNsecPerSec + 0.5); // Update the tray icon every 10 seconds - if (position % 10 == 0 && tray_icon_) tray_icon_->SetProgress(double(position) / length * 100); + if (tray_icon_ && position % 10 == 0) tray_icon_->SetProgress(double(position) / length * 100); + + // Send Scrobble + if (app_->scrobbler()->IsEnabled() && item->Metadata().is_metadata_good()) { + Playlist *playlist = app_->playlist_manager()->active(); + if (playlist && playlist->nowplaying() && !playlist->scrobbled()) { + const int scrobble_point = (playlist->scrobble_point_nanosec() / kNsecPerSec); + if (position >= scrobble_point) { + app_->scrobbler()->Scrobble(item->Metadata(), scrobble_point); + playlist->set_scrobbled(true); + } + } + } } void MainWindow::UpdateTrackSliderPosition() { + PlaylistItemPtr item(app_->player()->GetCurrentItem()); const int slider_position = std::floor(float(app_->player()->engine()->position_nanosec()) / kNsecPerMsec); @@ -1204,6 +1233,7 @@ void MainWindow::UpdateTrackSliderPosition() { // Update the slider ui_->track_slider->SetValue(slider_position, slider_length); + } void MainWindow::ApplyAddBehaviour(MainWindow::AddBehaviour b, MimeData *data) const { @@ -1974,47 +2004,6 @@ void MainWindow::PlaylistOrganiseSelected(bool copy) { } #endif -#if 0 -void MainWindow::PlaylistDelete() { - - // Note: copied from CollectionView::Delete - - if (QMessageBox::warning(this, tr("Delete files"), - tr("These files will be deleted from disk, are you sure you want to continue?"), - QMessageBox::Yes, QMessageBox::Cancel) != QMessageBox::Yes) - return; - - std::shared_ptr storage(new FilesystemMusicStorage("/")); - - // Get selected songs - SongList selected_songs; - QModelIndexList proxy_indexes = ui_->playlist->view()->selectionModel()->selectedRows(); - for (const QModelIndex &proxy_index : proxy_indexes) { - QModelIndex index = app_->playlist_manager()->current()->proxy()->mapToSource(proxy_index); - selected_songs << app_->playlist_manager()->current()->item_at(index.row())->Metadata(); - } - - if (app_->player()->GetState() == Engine::Playing) { - if (app_->playlist_manager()->current()->rowCount() == selected_songs.length()) { - app_->player()->Stop(); - } - else { - for (Song x : selected_songs) { - if (x == app_->player()->GetCurrentItem()->Metadata()) { - app_->player()->Next(); - } - } - } - } - - ui_->playlist->view()->RemoveSelected(true); - - DeleteFiles *delete_files = new DeleteFiles(app_->task_manager(), storage); - connect(delete_files, SIGNAL(Finished(SongList)), SLOT(DeleteFinished(SongList))); - delete_files->Start(selected_songs); -} -#endif - void MainWindow::PlaylistOpenInBrowser() { QList urls; @@ -2028,16 +2017,6 @@ void MainWindow::PlaylistOpenInBrowser() { Utilities::OpenInFileBrowser(urls); } -#if 0 -void MainWindow::DeleteFinished(const SongList &songs_with_errors) { - if (songs_with_errors.isEmpty()) return; - - OrganiseErrorDialog *dialog = new OrganiseErrorDialog(this); - dialog->Show(OrganiseErrorDialog::Type_Delete, songs_with_errors); - // It deletes itself when the user closes it -} -#endif - void MainWindow::PlaylistQueue() { QModelIndexList indexes; @@ -2105,14 +2084,6 @@ void MainWindow::ChangeCollectionQueryMode(QAction *action) { void MainWindow::ShowCoverManager() { - //if (!cover_manager_) { - //cover_manager_.reset(new AlbumCoverManager(app_, app_->collection_backend())); - //cover_manager_->Init(); - - // Cover manager connections - //connect(cover_manager_.get(), SIGNAL(AddToPlaylist(QMimeData*)), SLOT(AddToPlaylist(QMimeData*))); - //} - cover_manager_->show(); } @@ -2121,7 +2092,6 @@ SettingsDialog *MainWindow::CreateSettingsDialog() { SettingsDialog *settings_dialog = new SettingsDialog(app_); settings_dialog->SetGlobalShortcutManager(global_shortcuts_); - //settings_dialog->SetSongInfoView(song_info_view_); // Settings connect(settings_dialog, SIGNAL(accepted()), SLOT(ReloadAllSettings())); @@ -2132,31 +2102,13 @@ SettingsDialog *MainWindow::CreateSettingsDialog() { } -void MainWindow::EnsureSettingsDialogCreated() { - - //if (settings_dialog_) return; - - //settings_dialog_.reset(new SettingsDialog(app_)); - //settings_dialog_->SetGlobalShortcutManager(global_shortcuts_); - //settings_dialog_->SetSongInfoView(song_info_view_); - - // Settings - //connect(settings_dialog_.get(), SIGNAL(accepted()), SLOT(ReloadAllSettings())); - - // Allows custom notification preview - //connect(settings_dialog_.get(), SIGNAL(NotificationPreview(OSD::Behaviour,QString,QString)), SLOT(HandleNotificationPreview(OSD::Behaviour, QString, QString))); - -} - void MainWindow::OpenSettingsDialog() { - EnsureSettingsDialogCreated(); settings_dialog_->show(); } void MainWindow::OpenSettingsDialogAtPage(SettingsDialog::Page page) { - EnsureSettingsDialogCreated(); settings_dialog_->OpenAtPage(page); } @@ -2169,22 +2121,8 @@ EditTagDialog *MainWindow::CreateEditTagDialog() { } -void MainWindow::EnsureEditTagDialogCreated() { - - //if (edit_tag_dialog_) return; - - //edit_tag_dialog_.reset(new EditTagDialog(app_)); - //connect(edit_tag_dialog_.get(), SIGNAL(accepted()), SLOT(EditTagDialogAccepted())); - //connect(edit_tag_dialog_.get(), SIGNAL(Error(QString)), SLOT(ShowErrorDialog(QString))); - -} - void MainWindow::ShowAboutDialog() { - //if (!about_dialog_) { - //about_dialog_.reset(new About); - //} - about_dialog_->show(); } @@ -2192,18 +2130,13 @@ void MainWindow::ShowAboutDialog() { #ifdef HAVE_GSTREAMER void MainWindow::ShowTranscodeDialog() { - //if (!transcode_dialog_) { - // transcode_dialog_.reset(new TranscodeDialog); - //} transcode_dialog_->show(); } #endif void MainWindow::ShowErrorDialog(const QString &message) { - //if (!error_dialog_) { - // error_dialog_.reset(new ErrorDialog); - //} + error_dialog_->ShowMessage(message); } @@ -2270,6 +2203,7 @@ void MainWindow::Exit() { SaveGeometry(); SavePlaybackStatus(); + app_->scrobbler()->WriteCache(); if (app_->player()->engine()->is_fadeout_enabled()) { // To shut down the application when fadeout will be finished @@ -2442,3 +2376,28 @@ void MainWindow::GetCoverAutomatically() { if (search) album_cover_choice_controller_->SearchCoverAutomatically(song_); } + +void MainWindow::ScrobblingEnabledChanged(bool value) { + if (app_->scrobbler()->ScrobbleButton()) SetToggleScrobblingIcon(value); +} + +void MainWindow::ScrobbleButtonVisibilityChanged(bool value) { + ui_->button_scrobble->setVisible(value); + ui_->action_toggle_scrobbling->setVisible(value); + if (value) SetToggleScrobblingIcon(app_->scrobbler()->IsEnabled()); + +} + +void MainWindow::SetToggleScrobblingIcon(bool value) { + + if (value) { + if (app_->playlist_manager()->active()->scrobbled()) + ui_->action_toggle_scrobbling->setIcon(IconLoader::Load("scrobble", 22)); + else + ui_->action_toggle_scrobbling->setIcon(IconLoader::Load("scrobble", 22)); // TODO: Create a faint version of the icon + } + else { + ui_->action_toggle_scrobbling->setIcon(IconLoader::Load("scrobble-disabled", 22)); + } + +} diff --git a/src/core/mainwindow.h b/src/core/mainwindow.h index 65fb143d..b7d324fb 100644 --- a/src/core/mainwindow.h +++ b/src/core/mainwindow.h @@ -194,7 +194,6 @@ signals: #endif void PlaylistOrganiseSelected(bool copy); #endif - //void PlaylistDelete(); void PlaylistOpenInBrowser(); void ShowInCollection(); @@ -210,9 +209,7 @@ signals: #ifdef HAVE_GSTREAMER void CopyFilesToCollection(const QList& urls); void MoveFilesToCollection(const QList& urls); -//#ifndef Q_OS_WIN void CopyFilesToDevice(const QList& urls); -//#endif #endif void EditFileTags(const QList& urls); @@ -253,8 +250,6 @@ signals: void ShowTranscodeDialog(); #endif void ShowErrorDialog(const QString& message); - void EnsureSettingsDialogCreated(); - void EnsureEditTagDialogCreated(); SettingsDialog *CreateSettingsDialog(); EditTagDialog *CreateEditTagDialog(); void OpenSettingsDialog(); @@ -262,6 +257,7 @@ signals: void TabSwitched(); void SaveGeometry(); + void SaveTabMode(); void SavePlaybackStatus(); void LoadPlaybackStatus(); void ResumePlayback(); @@ -284,6 +280,9 @@ signals: void SearchCoverAutomatically(); void AlbumArtLoaded(const Song &song, const QString &uri, const QImage &image); + void ScrobblingEnabledChanged(bool value); + void ScrobbleButtonVisibilityChanged(bool value); + private: void ApplyAddBehaviour(AddBehaviour b, MimeData *data) const; @@ -296,6 +295,8 @@ signals: void GetCoverAutomatically(); + void SetToggleScrobblingIcon(bool value); + private: Ui_MainWindow *ui_; Windows7ThumbBar *thumbbar_; @@ -357,7 +358,6 @@ signals: #ifndef Q_OS_WIN QAction *playlist_copy_to_device_; #endif - //QAction *playlist_delete_; #endif QAction *playlist_open_in_browser_; QAction *playlist_queue_; diff --git a/src/core/mainwindow.ui b/src/core/mainwindow.ui index f1d256b0..7d7fa719 100644 --- a/src/core/mainwindow.ui +++ b/src/core/mainwindow.ui @@ -349,6 +349,31 @@ + + + + true + + + + 0 + 0 + + + + + + + + 16 + 16 + + + + true + + + diff --git a/src/core/player.cpp b/src/core/player.cpp index 28b0cfc4..b2260f5c 100644 --- a/src/core/player.cpp +++ b/src/core/player.cpp @@ -76,6 +76,7 @@ #include "settings/playlistsettingspage.h" #include "internet/internetservices.h" #include "internet/internetservice.h" +#include "scrobbler/audioscrobbler.h" using std::shared_ptr; @@ -538,9 +539,20 @@ void Player::PlayAt(int index, Engine::TrackChangeFlags change, bool reshuffle) } void Player::CurrentMetadataChanged(const Song &metadata) { + // those things might have changed (especially when a previously invalid song was reloaded) so we push the latest version into Engine engine_->RefreshMarkers(metadata.beginning_nanosec(), metadata.end_nanosec()); + // Send now playing to scrobble services + if (app_->scrobbler()->IsEnabled() && engine_->state() == Engine::Playing) { + Playlist *playlist = app_->playlist_manager()->active(); + current_item_ = playlist->current_item(); + if (playlist && current_item_ && !playlist->nowplaying() && current_item_->Metadata() == metadata && current_item_->Metadata().length_nanosec() > 0) { + app_->scrobbler()->UpdateNowPlaying(metadata); + playlist->set_nowplaying(true); + } + } + } void Player::SeekTo(int seconds) { @@ -555,17 +567,18 @@ void Player::SeekTo(int seconds) { const qint64 nanosec = qBound(0ll, qint64(seconds) * kNsecPerSec, length_nanosec); engine_->Seek(nanosec); + qLog(Debug) << "Track seeked to" << nanosec << "ns - updating scrobble point"; + app_->playlist_manager()->active()->UpdateScrobblePoint(nanosec); + emit Seeked(nanosec / 1000); } void Player::SeekForward() { SeekTo(engine()->position_nanosec() / kNsecPerSec + seek_step_sec_); - //SeekTo(engine()->position_nanosec() / kNsecPerSec + 10); } void Player::SeekBackward() { - //SeekTo(engine()->position_nanosec() / kNsecPerSec - 10); SeekTo(engine()->position_nanosec() / kNsecPerSec - seek_step_sec_); } diff --git a/src/core/song.cpp b/src/core/song.cpp index 4c900352..9a1b157c 100644 --- a/src/core/song.cpp +++ b/src/core/song.cpp @@ -307,6 +307,9 @@ bool Song::is_cdda() const { return d->source_ == Source_CDDA; } bool Song::is_collection_song() const { return !is_cdda() && !is_stream() && id() != -1; } +bool Song::is_metadata_good() const { + return !d->title_.isEmpty() && !d->album_.isEmpty() && !d->artist_.isEmpty() && !d->url_.isEmpty() && d->end_ > 0; +} const QString &Song::art_automatic() const { return d->art_automatic_; } const QString &Song::art_manual() const { return d->art_manual_; } bool Song::has_manually_unset_cover() const { return d->art_manual_ == kManuallyUnsetCover; } diff --git a/src/core/song.h b/src/core/song.h index f7a4a847..39c4f8a3 100644 --- a/src/core/song.h +++ b/src/core/song.h @@ -227,6 +227,7 @@ class Song { bool is_collection_song() const; bool is_stream() const; bool is_cdda() const; + bool is_metadata_good() const; // Playlist views are special because you don't want to fill in album artists automatically for compilations, but you do for normal albums: const QString &playlist_albumartist() const; diff --git a/src/globalshortcuts/globalshortcuts.cpp b/src/globalshortcuts/globalshortcuts.cpp index f22f04f9..37bbcc49 100644 --- a/src/globalshortcuts/globalshortcuts.cpp +++ b/src/globalshortcuts/globalshortcuts.cpp @@ -66,6 +66,7 @@ GlobalShortcuts::GlobalShortcuts(QWidget *parent) AddShortcut("toggle_pretty_osd", tr("Toggle Pretty OSD"), SIGNAL(TogglePrettyOSD())); // Toggling possible only for pretty OSD AddShortcut("shuffle_mode", tr("Change shuffle mode"), SIGNAL(CycleShuffleMode())); AddShortcut("repeat_mode", tr("Change repeat mode"), SIGNAL(CycleRepeatMode())); + AddShortcut("toggle_scrobbling", tr("Enable/disable scrobbling"), SIGNAL(ToggleScrobbling())); // Create backends - these do the actual shortcut registration gnome_backend_ = new GnomeGlobalShortcutBackend(this); diff --git a/src/globalshortcuts/globalshortcuts.h b/src/globalshortcuts/globalshortcuts.h index 10f8d5b8..fbbf561e 100644 --- a/src/globalshortcuts/globalshortcuts.h +++ b/src/globalshortcuts/globalshortcuts.h @@ -80,6 +80,7 @@ signals: void CycleShuffleMode(); void CycleRepeatMode(); void RemoveCurrentSong(); + void ToggleScrobbling(); private: void AddShortcut(const QString &id, const QString &name, const char *signal, const QKeySequence &default_key = QKeySequence(0)); diff --git a/src/lyrics/apiseedslyricsprovider.cpp b/src/lyrics/apiseedslyricsprovider.cpp index 02ba5972..b2c4925f 100644 --- a/src/lyrics/apiseedslyricsprovider.cpp +++ b/src/lyrics/apiseedslyricsprovider.cpp @@ -73,7 +73,7 @@ bool APISeedsLyricsProvider::StartSearch(const QString &artist, const QString &a QNetworkReply *reply = network_->get(QNetworkRequest(url)); NewClosure(reply, SIGNAL(finished()), this, SLOT(HandleSearchReply(QNetworkReply*, quint64, QString, QString)), reply, id, artist, title); - //qLog(Debug) << "APISeedsLyrics: Sending request for" << url; + //qLog(Debug) << "APISeeds Lyrics: Sending request for" << url; return true; @@ -90,13 +90,13 @@ void APISeedsLyricsProvider::HandleSearchReply(QNetworkReply *reply, quint64 id, if (json_obj.isEmpty()) return; if (!json_obj.contains("artist") || !json_obj.contains("track")) { - Error(id, "APISeedsLyrics: Invalid Json reply, result is missing artist or track.", json_obj); + Error(id, "APISeeds Lyrics: Invalid Json reply, result is missing artist or track.", json_obj); return; } QJsonObject json_artist(json_obj["artist"].toObject()); QJsonObject json_track(json_obj["track"].toObject()); if (!json_track.contains("text")) { - Error(id, "APISeedsLyrics: Invalid Json reply, track is missing text.", json_obj); + Error(id, "APISeeds Lyrics: Invalid Json reply, track is missing text.", json_obj); return; } @@ -109,7 +109,7 @@ void APISeedsLyricsProvider::HandleSearchReply(QNetworkReply *reply, quint64 id, if (result.artist.toLower() == artist.toLower()) result.score += 1.0; if (result.title.toLower() == title.toLower()) result.score += 1.0; - //qLog(Debug) << "APISeedsLyrics:" << result.artist << result.title << result.lyrics; + //qLog(Debug) << "APISeeds Lyrics:" << result.artist << result.title << result.lyrics; results << result; @@ -207,7 +207,7 @@ QJsonObject APISeedsLyricsProvider::ExtractResult(QNetworkReply *reply, quint64 void APISeedsLyricsProvider::Error(quint64 id, QString error, QVariant debug) { LyricsSearchResults results; - if (!error.isEmpty()) qLog(Error) << "APISeedsLyrics:" << error; + if (!error.isEmpty()) qLog(Error) << "APISeeds Lyrics:" << error; if (debug.isValid()) qLog(Debug) << debug; emit SearchFinished(id, results); } diff --git a/src/playlist/playlist.cpp b/src/playlist/playlist.cpp index 1857a5ab..5207495d 100644 --- a/src/playlist/playlist.cpp +++ b/src/playlist/playlist.cpp @@ -68,6 +68,7 @@ #include "core/mimedata.h" #include "core/tagreaderclient.h" #include "core/song.h" +#include "core/timeconstants.h" #include "collection/collection.h" #include "collection/collectionbackend.h" #include "collection/collectionplaylistitem.h" @@ -116,6 +117,9 @@ const char *Playlist::kWriteMetadata = "write_metadata"; const int Playlist::kUndoStackSize = 20; const int Playlist::kUndoItemLimit = 500; +const qint64 Playlist::kMinScrobblePointNsecs = 1ll * kNsecPerSec; +const qint64 Playlist::kMaxScrobblePointNsecs = 240ll * kNsecPerSec; + Playlist::Playlist(PlaylistBackend *backend, TaskManager *task_manager, CollectionBackend *collection, int id, const QString &special_type, bool favorite, QObject *parent) : QAbstractListModel(parent), is_loading_(false), @@ -133,7 +137,10 @@ Playlist::Playlist(PlaylistBackend *backend, TaskManager *task_manager, Collecti ignore_sorting_(false), undo_stack_(new QUndoStack(this)), special_type_(special_type), - cancel_restore_(false) { + cancel_restore_(false), + scrobbled_(false), + nowplaying_(false), + scrobble_point_(-1) { undo_stack_->setUndoLimit(kUndoStackSize); @@ -612,6 +619,9 @@ void Playlist::set_current_row(int i, bool is_stopping) { Save(); } + UpdateScrobblePoint(); + nowplaying_ = false; + } Qt::ItemFlags Playlist::flags(const QModelIndex &index) const { @@ -1488,6 +1498,8 @@ void Playlist::SetStreamMetadata(const QUrl &url, const Song &song) { InformOfCurrentSongChange(); + UpdateScrobblePoint(); + } void Playlist::ClearStreamMetadata() { @@ -1495,6 +1507,7 @@ void Playlist::ClearStreamMetadata() { if (!current_item()) return; current_item()->ClearTemporaryMetadata(); + UpdateScrobblePoint(); emit dataChanged(index(current_item_index_.row(), 0), index(current_item_index_.row(), ColumnCount-1)); @@ -1944,3 +1957,28 @@ void Playlist::SkipTracks(const QModelIndexList &source_indexes) { } +void Playlist::UpdateScrobblePoint(qint64 seek_point_nanosec) { + + const qint64 length = current_item_metadata().length_nanosec(); + + if (seek_point_nanosec <= 0) { + if (length == 0) { + scrobble_point_ = kMaxScrobblePointNsecs; + } + else { + scrobble_point_ = qBound(kMinScrobblePointNsecs, length / 2, kMaxScrobblePointNsecs); + } + } + else { + if (length <= 0) { + scrobble_point_ = seek_point_nanosec + kMaxScrobblePointNsecs; + } + else { + scrobble_point_ = qBound(seek_point_nanosec + kMinScrobblePointNsecs, seek_point_nanosec + (length / 2), seek_point_nanosec + kMaxScrobblePointNsecs); + } + } + + scrobbled_ = false; + +} + diff --git a/src/playlist/playlist.h b/src/playlist/playlist.h index c989c08f..52162c56 100644 --- a/src/playlist/playlist.h +++ b/src/playlist/playlist.h @@ -160,6 +160,9 @@ class Playlist : public QAbstractListModel { static const int kUndoStackSize; static const int kUndoItemLimit; + static const qint64 kMinScrobblePointNsecs; + static const qint64 kMaxScrobblePointNsecs; + static bool CompareItems(int column, Qt::SortOrder order, PlaylistItemPtr a, PlaylistItemPtr b); static QString column_name(Column column); @@ -213,6 +216,13 @@ class Playlist : public QAbstractListModel { QUndoStack *undo_stack() const { return undo_stack_; } + bool scrobbled() const { return scrobbled_; } + bool nowplaying() const { return nowplaying_; } + void set_scrobbled(bool state) { scrobbled_ = state; } + void set_nowplaying(bool state) { nowplaying_ = state; } + qint64 scrobble_point_nanosec() const { return scrobble_point_; } + void UpdateScrobblePoint(qint64 seek_point_nanosec = 0); + // Changing the playlist void InsertItems (const PlaylistItemList &items, int pos = -1, bool play_now = false, bool enqueue = false, bool enqueue_next = false); void InsertCollectionItems (const SongList &items, int pos = -1, bool play_now = false, bool enqueue = false, bool enqueue_next = false); @@ -373,6 +383,11 @@ private: // Cancel async restore if songs are already replaced bool cancel_restore_; + + bool scrobbled_; + bool nowplaying_; + qint64 scrobble_point_; + }; // QDataStream& operator <<(QDataStream&, const Playlist*); diff --git a/src/scrobbler/audioscrobbler.cpp b/src/scrobbler/audioscrobbler.cpp new file mode 100644 index 00000000..7352e03b --- /dev/null +++ b/src/scrobbler/audioscrobbler.cpp @@ -0,0 +1,173 @@ +/* + * 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/application.h" +#include "core/closure.h" +#include "core/logging.h" +#include "core/network.h" +#include "core/player.h" +#include "core/song.h" +#include "core/taskmanager.h" +#include "core/iconloader.h" +#include "settings/settingsdialog.h" +#include "settings/scrobblersettingspage.h" + +#include "audioscrobbler.h" +#include "scrobblerservices.h" +#include "scrobblerservice.h" +#include "lastfmscrobbler.h" +#include "librefmscrobbler.h" +#include "listenbrainzscrobbler.h" + +AudioScrobbler::AudioScrobbler(Application *app, QObject *parent) : + QObject(parent), + app_(app), + scrobbler_services_(new ScrobblerServices(this)), + enabled_(false), + offline_(false), + scrobble_button_(false) { + + scrobbler_services_->AddService(new LastFMScrobbler(app_, scrobbler_services_)); + scrobbler_services_->AddService(new LibreFMScrobbler(app_, scrobbler_services_)); + scrobbler_services_->AddService(new ListenBrainzScrobbler(app_, scrobbler_services_)); + + ReloadSettings(); + +} + +AudioScrobbler::~AudioScrobbler() {} + +void AudioScrobbler::ReloadSettings() { + + QSettings s; + s.beginGroup(ScrobblerSettingsPage::kSettingsGroup); + enabled_ = s.value("enabled", false).toBool(); + offline_ = s.value("offline", false).toBool(); + scrobble_button_ = s.value("scrobble_button", false).toBool(); + s.endGroup(); + + emit ScrobblingEnabledChanged(enabled_); + emit ScrobbleButtonVisibilityChanged(scrobble_button_); + + for (ScrobblerService *service : scrobbler_services_->List()) { + service->ReloadSettings(); + } + +} + +void AudioScrobbler::ToggleScrobbling() { + + bool enabled_old_ = enabled_; + enabled_ = !enabled_; + + QSettings s; + s.beginGroup(ScrobblerSettingsPage::kSettingsGroup); + s.setValue("enabled", enabled_); + s.endGroup(); + + if (enabled_ != enabled_old_) emit ScrobblingEnabledChanged(enabled_); + if (enabled_ && !offline_) { Submit(); } + +} + +void AudioScrobbler::ToggleOffline() { + + bool offline_old_ = offline_; + offline_ = !offline_; + + QSettings s; + s.beginGroup(ScrobblerSettingsPage::kSettingsGroup); + s.setValue("offline", offline_); + s.endGroup(); + + if (offline_ != offline_old_) { emit ScrobblingOfflineChanged(offline_); } + if (enabled_ && !offline_) { Submit(); } + +} + +void AudioScrobbler::ShowConfig() { + app_->OpenSettingsDialogAtPage(SettingsDialog::Page_Scrobbler); +} + +void AudioScrobbler::UpdateNowPlaying(const Song &song) { + qLog(Debug) << "Sending now playing for song" << song.title(); + for (ScrobblerService *service : scrobbler_services_->List()) { + if (!service->IsEnabled()) continue; + service->UpdateNowPlaying(song); + } +} + +void AudioScrobbler::Scrobble(const Song &song, const int scrobble_point) { + qLog(Debug) << "Scrobbling song" << QString("") + song.title() + QString("") << "at" << scrobble_point; + for (ScrobblerService *service : scrobbler_services_->List()) { + if (!service->IsEnabled()) continue; + service->Scrobble(song); + } +} + +void AudioScrobbler::Love(const Song &song) { + for (ScrobblerService *service : scrobbler_services_->List()) { + if (!service->IsEnabled() || !service->IsAuthenticated()) continue; + service->Love(song); + } +} + +void AudioScrobbler::Submit() { + for (ScrobblerService *service : scrobbler_services_->List()) { + if (!service->IsEnabled() || !service->IsAuthenticated() || service->IsSubmitted()) continue; + service->Submitted(); + DoInAMinuteOrSo(service, SLOT(Submit())); + } +} + +void AudioScrobbler::WriteCache() { + for (ScrobblerService *service : scrobbler_services_->List()) { + if (!service->IsEnabled()) continue; + service->WriteCache(); + } +} + +void AudioScrobbler::ConnectError() { + for (ScrobblerService *service : scrobbler_services_->List()) { + connect(service, SIGNAL(ErrorMessage(QString)), SLOT(ErrorReceived(QString))); + } +} + +void AudioScrobbler::ErrorReceived(QString error) { + emit ErrorMessage(error); +} diff --git a/src/scrobbler/audioscrobbler.h b/src/scrobbler/audioscrobbler.h new file mode 100644 index 00000000..fc87aaa0 --- /dev/null +++ b/src/scrobbler/audioscrobbler.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 AUDIOSCROBBLER_H +#define AUDIOSCROBBLER_H + +#include "config.h" + +#include + +#include +#include +#include + +#include "scrobblerservices.h" + +class Application; +class Song; +class NetworkAccessManager; +class ScrobblerService; + +class AudioScrobbler : public QObject { + Q_OBJECT + + public: + explicit AudioScrobbler(Application *app, QObject *parent = nullptr); + ~AudioScrobbler(); + + void ReloadSettings(); + + bool IsEnabled() const { return enabled_; } + bool IsOffline() const { return offline_; } + bool ScrobbleButton() const { return scrobble_button_; } + + void UpdateNowPlaying(const Song &song); + void Scrobble(const Song &song, const int scrobble_point); + void Love(const Song &song); + void ShowConfig(); + void ConnectError(); + + ScrobblerService *ServiceByName(const QString &name) const { return scrobbler_services_->ServiceByName(name); } + + template + T *Service() { + return static_cast(this->ServiceByName(T::kName)); + } + + public slots: + void ToggleScrobbling(); + void ToggleOffline(); + void Submit(); + void WriteCache(); + void ErrorReceived(QString); + + signals: + void ErrorMessage(QString); + void ScrobblingEnabledChanged(bool value); + void ScrobblingOfflineChanged(bool value); + void ScrobbleButtonVisibilityChanged(bool value); + + private: + + Application *app_; + ScrobblerServices *scrobbler_services_; + + bool enabled_; + bool offline_; + bool scrobble_button_; + +}; + +#endif // AUDIOSCROBBLER_H diff --git a/src/scrobbler/lastfmscrobbler.cpp b/src/scrobbler/lastfmscrobbler.cpp new file mode 100644 index 00000000..afa34eb0 --- /dev/null +++ b/src/scrobbler/lastfmscrobbler.cpp @@ -0,0 +1,82 @@ +/* + * 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 "core/application.h" +#include "core/player.h" +#include "core/closure.h" +#include "core/network.h" +#include "core/song.h" +#include "core/timeconstants.h" +#include "core/logging.h" +#include "internet/localredirectserver.h" +#include "settings/settingsdialog.h" +#include "settings/scrobblersettingspage.h" + +#include "audioscrobbler.h" +#include "scrobblerservices.h" +#include "scrobblercache.h" +#include "scrobblingapi20.h" +#include "lastfmscrobbler.h" + +const char *LastFMScrobbler::kName = "Last.fm"; +const char *LastFMScrobbler::kSettingsGroup = "LastFM"; +const char *LastFMScrobbler::kAuthUrl = "https://www.last.fm/api/auth/"; +const char *LastFMScrobbler::kApiUrl = "https://ws.audioscrobbler.com/2.0/"; +const char *LastFMScrobbler::kCacheFile = "lastfmscrobbler.cache"; + +LastFMScrobbler::LastFMScrobbler(Application *app, QObject *parent) : ScrobblingAPI20(kName, kSettingsGroup, kAuthUrl, kApiUrl, true, app, parent), + auth_url_(kAuthUrl), + api_url_(kApiUrl), + app_(app), + network_(new NetworkAccessManager(this)), + cache_(new ScrobblerCache(kCacheFile, this)), + enabled_(false), + subscriber_(false), + submitted_(false) { + + ReloadSettings(); + LoadSession(); + +} + +LastFMScrobbler::~LastFMScrobbler() {} diff --git a/src/scrobbler/lastfmscrobbler.h b/src/scrobbler/lastfmscrobbler.h new file mode 100644 index 00000000..c54fb47e --- /dev/null +++ b/src/scrobbler/lastfmscrobbler.h @@ -0,0 +1,82 @@ +/* + * 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 LASTFMSCROBBLER_H +#define LASTFMSCROBBLER_H + +#include "config.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/song.h" +#include "scrobblerservice.h" +#include "scrobblingapi20.h" + +class Application; +class NetworkAccessManager; +class LocalRedirectServer; +class ScrobblerCache; + +class LastFMScrobbler : public ScrobblingAPI20 { + Q_OBJECT + + public: + explicit LastFMScrobbler(Application *app, QObject *parent = nullptr); + ~LastFMScrobbler(); + + static const char *kName; + static const char *kSettingsGroup; + + NetworkAccessManager *network() { return network_; } + ScrobblerCache *cache() { return cache_; } + + private: + static const char *kAuthUrl; + static const char *kApiUrl; + static const char *kCacheFile; + + QString settings_group_; + QString auth_url_; + QString api_url_; + QString api_key_; + QString secret_; + Application *app_; + NetworkAccessManager *network_; + ScrobblerCache *cache_; + bool enabled_; + bool subscriber_; + QString username_; + QString session_key_; + bool submitted_; + Song song_playing_; + quint64 timestamp_; + +}; + +#endif // LASTFMSCROBBLER_H diff --git a/src/scrobbler/librefmscrobbler.cpp b/src/scrobbler/librefmscrobbler.cpp new file mode 100644 index 00000000..8dddfaa4 --- /dev/null +++ b/src/scrobbler/librefmscrobbler.cpp @@ -0,0 +1,82 @@ +/* + * 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 "core/application.h" +#include "core/player.h" +#include "core/closure.h" +#include "core/network.h" +#include "core/song.h" +#include "core/timeconstants.h" +#include "core/logging.h" +#include "internet/localredirectserver.h" +#include "settings/settingsdialog.h" +#include "settings/scrobblersettingspage.h" + +#include "audioscrobbler.h" +#include "scrobblerservices.h" +#include "scrobblercache.h" +#include "scrobblingapi20.h" +#include "librefmscrobbler.h" + +const char *LibreFMScrobbler::kName = "Libre.fm"; +const char *LibreFMScrobbler::kSettingsGroup = "LibreFM"; +const char *LibreFMScrobbler::kAuthUrl = "https://www.libre.fm/api/auth/"; +const char *LibreFMScrobbler::kApiUrl = "https://libre.fm/2.0/"; +const char *LibreFMScrobbler::kCacheFile = "librefmscrobbler.cache"; + +LibreFMScrobbler::LibreFMScrobbler(Application *app, QObject *parent) : ScrobblingAPI20(kName, kSettingsGroup, kAuthUrl, kApiUrl, false, app, parent), + auth_url_(kAuthUrl), + api_url_(kApiUrl), + app_(app), + network_(new NetworkAccessManager(this)), + cache_(new ScrobblerCache(kCacheFile, this)), + enabled_(false), + subscriber_(false), + submitted_(false) { + + ReloadSettings(); + LoadSession(); + +} + +LibreFMScrobbler::~LibreFMScrobbler() {} diff --git a/src/scrobbler/librefmscrobbler.h b/src/scrobbler/librefmscrobbler.h new file mode 100644 index 00000000..a1dab1e4 --- /dev/null +++ b/src/scrobbler/librefmscrobbler.h @@ -0,0 +1,82 @@ +/* + * 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 LIBREFMSCROBBLER_H +#define LIBREFMSCROBBLER_H + +#include "config.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/song.h" +#include "scrobblerservice.h" +#include "scrobblingapi20.h" + +class Application; +class NetworkAccessManager; +class LocalRedirectServer; +class ScrobblerCache; + +class LibreFMScrobbler : public ScrobblingAPI20 { + Q_OBJECT + + public: + explicit LibreFMScrobbler(Application *app, QObject *parent = nullptr); + ~LibreFMScrobbler(); + + static const char *kName; + static const char *kSettingsGroup; + + NetworkAccessManager *network() { return network_; } + ScrobblerCache *cache() { return cache_; } + + private: + static const char *kAuthUrl; + static const char *kApiUrl; + static const char *kCacheFile; + + QString settings_group_; + QString auth_url_; + QString api_url_; + QString api_key_; + QString secret_; + Application *app_; + NetworkAccessManager *network_; + ScrobblerCache *cache_; + bool enabled_; + bool subscriber_; + QString username_; + QString session_key_; + bool submitted_; + Song song_playing_; + quint64 timestamp_; + +}; + +#endif // LIBREFMSCROBBLER_H diff --git a/src/scrobbler/listenbrainzscrobbler.cpp b/src/scrobbler/listenbrainzscrobbler.cpp new file mode 100644 index 00000000..3babff4a --- /dev/null +++ b/src/scrobbler/listenbrainzscrobbler.cpp @@ -0,0 +1,482 @@ +/* + * 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 "core/application.h" +#include "core/player.h" +#include "core/closure.h" +#include "core/network.h" +#include "core/song.h" +#include "core/timeconstants.h" +#include "core/logging.h" +#include "internet/localredirectserver.h" +#include "settings/settingsdialog.h" +#include "settings/scrobblersettingspage.h" + +#include "audioscrobbler.h" +#include "scrobblerservices.h" +#include "scrobblerservice.h" +#include "scrobblercache.h" +#include "scrobblercacheitem.h" +#include "listenbrainzscrobbler.h" + +const char *ListenBrainzScrobbler::kName = "ListenBrainz"; +const char *ListenBrainzScrobbler::kSettingsGroup = "ListenBrainz"; +const char *ListenBrainzScrobbler::kAuthUrl = "https://musicbrainz.org/oauth2/authorize"; +const char *ListenBrainzScrobbler::kAuthTokenUrl = "https://musicbrainz.org/oauth2/token"; +const char *ListenBrainzScrobbler::kRedirectUrl = "http://localhost"; +const char *ListenBrainzScrobbler::kApiUrl = "https://api.listenbrainz.org"; +const char *ListenBrainzScrobbler::kClientID = "oeAUNwqSQer0er09Fiqi0Q"; +const char *ListenBrainzScrobbler::kClientSecret = "ROFghkeQ3F3oPyEhqiyWPA"; +const char *ListenBrainzScrobbler::kCacheFile = "listenbrainzscrobbler.cache"; +const int ListenBrainzScrobbler::kScrobblesPerRequest = 10; + +ListenBrainzScrobbler::ListenBrainzScrobbler(Application *app, QObject *parent) : ScrobblerService(kName, app, parent), + app_(app), + network_(new NetworkAccessManager(this)), + cache_(new ScrobblerCache(kCacheFile, this)), + enabled_(false), + expires_in_(-1), + submitted_(false) { + + ReloadSettings(); + LoadSession(); + +} + +ListenBrainzScrobbler::~ListenBrainzScrobbler() {} + +void ListenBrainzScrobbler::ReloadSettings() { + + QSettings s; + s.beginGroup(kSettingsGroup); + enabled_ = s.value("enabled", false).toBool(); + user_token_ = s.value("user_token").toString(); + s.endGroup(); + +} + +void ListenBrainzScrobbler::LoadSession() { + + QSettings s; + s.beginGroup(kSettingsGroup); + access_token_ = s.value("access_token").toString(); + expires_in_ = s.value("expires_in", -1).toInt(); + token_type_ = s.value("token_type").toString(); + refresh_token_ = s.value("refresh_token").toString(); + s.endGroup(); + +} + +void ListenBrainzScrobbler::Logout() { + + access_token_.clear(); + expires_in_ = -1; + token_type_.clear(); + refresh_token_.clear(); + + QSettings settings; + settings.beginGroup(kSettingsGroup); + settings.remove("access_token"); + settings.remove("expires_in"); + settings.remove("token_type"); + settings.remove("refresh_token"); + settings.endGroup(); + +} + +void ListenBrainzScrobbler::Authenticate() { + + QUrl url(kAuthUrl); + + LocalRedirectServer *server = new LocalRedirectServer(this); + server->Listen(); + NewClosure(server, SIGNAL(Finished()), this, &ListenBrainzScrobbler::RedirectArrived, server); + + QUrl redirect_url(kRedirectUrl); + redirect_url.setPort(server->url().port()); + + QUrlQuery url_query; + url_query.addQueryItem("response_type", "code"); + url_query.addQueryItem("client_id", kClientID); + url_query.addQueryItem("redirect_uri", redirect_url.toString()); + url_query.addQueryItem("scope", "profile;email;tag;rating;collection;submit_isrc;submit_barcode"); + url.setQuery(url_query); + + bool result = QDesktopServices::openUrl(url); + if (!result) { + QMessageBox box(QMessageBox::NoIcon, "Scrobbler Authentication", QString("Please open this URL in your browser: %1").arg(url.toString()), QMessageBox::Ok); + box.setTextFormat(Qt::RichText); + qLog(Debug) << "Scrobbler authentication URL: " << url.toString(); + box.exec(); + } + +} + +void ListenBrainzScrobbler::RedirectArrived(LocalRedirectServer *server) { + + server->deleteLater(); + + QUrl url = server->request_url(); + if (!QUrlQuery(url).queryItemValue("error").isEmpty()) { + AuthError(QUrlQuery(url).queryItemValue("error")); + return; + } + if (QUrlQuery(url).queryItemValue("code").isEmpty()) { + AuthError("Redirect missing token code!"); + return; + } + + RequestSession(url, QUrlQuery(url).queryItemValue("code").toUtf8()); + +} + +void ListenBrainzScrobbler::RequestSession(QUrl url, QString token) { + + QUrl session_url(kAuthTokenUrl); + QUrlQuery url_query; + url_query.addQueryItem("grant_type", "authorization_code"); + url_query.addQueryItem("code", token); + url_query.addQueryItem("client_id", kClientID); + url_query.addQueryItem("client_secret", kClientSecret); + url_query.addQueryItem("redirect_uri", url.toString()); + + QNetworkRequest req(session_url); + req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); + QByteArray query = url_query.toString(QUrl::FullyEncoded).toUtf8(); + QNetworkReply *reply = network_->post(req, query); + NewClosure(reply, SIGNAL(finished()), this, SLOT(AuthenticateReplyFinished(QNetworkReply*)), reply); + +} + +void ListenBrainzScrobbler::AuthenticateReplyFinished(QNetworkReply *reply) { + + reply->deleteLater(); + + QByteArray data; + + if (reply->error() == QNetworkReply::NoError && reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() == 200) { + data = reply->readAll(); + } + else { + if (reply->error() < 200) { + // This is a network error, there is nothing more to do. + QString failure_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()); + AuthError(failure_reason); + } + else { + // See if there is Json data containing "error" and "error_description" - then use that instead. + data = reply->readAll(); + QJsonParseError error; + QJsonDocument json_doc = QJsonDocument::fromJson(data, &error); + QString failure_reason; + if (error.error == QJsonParseError::NoError && !json_doc.isNull() && !json_doc.isEmpty() && json_doc.isObject()) { + QJsonObject json_obj = json_doc.object(); + if (json_obj.contains("error") && json_obj.contains("error_description")) { + QString error = json_obj["error"].toString(); + failure_reason = json_obj["error_description"].toString(); + } + else { + failure_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()); + } + } + else { + failure_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()); + } + AuthError(failure_reason); + } + return; + } + + QJsonObject json_obj = ExtractJsonObj(data); + if (json_obj.isEmpty()) { + AuthError("Json document from server was empty."); + return; + } + + if (json_obj.contains("error") && json_obj.contains("error_description")) { + QString error = json_obj["error"].toString(); + QString failure_reason = json_obj["error_description"].toString(); + AuthError(failure_reason); + return; + } + + if (!json_obj.contains("access_token") || !json_obj.contains("expires_in") || !json_obj.contains("token_type") || !json_obj.contains("refresh_token")) { + AuthError("Json session object is missing values."); + return; + } + + access_token_ = json_obj["access_token"].toString(); + expires_in_ = json_obj["expires_in"].toInt(); + token_type_ = json_obj["token_type"].toString(); + refresh_token_ = json_obj["refresh_token"].toString(); + + QSettings s; + s.beginGroup(kSettingsGroup); + s.setValue("access_token", access_token_); + s.setValue("expires_in", expires_in_); + s.setValue("token_type", token_type_); + s.setValue("refresh_token", refresh_token_); + s.endGroup(); + + emit AuthenticationComplete(true); + +} + +QNetworkReply *ListenBrainzScrobbler::CreateRequest(const QUrl &url, const QJsonDocument &json_doc) { + + QNetworkRequest req(url); + req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + req.setRawHeader("Authorization", QString("Token %1").arg(user_token_).toUtf8()); + QNetworkReply *reply = network_->post(req, json_doc.toJson()); + + //qLog(Debug) << "ListenBrainz: Sending request" << json_doc.toJson(); + + return reply; + +} + +QByteArray ListenBrainzScrobbler::GetReplyData(QNetworkReply *reply) { + + QByteArray data; + + if (reply->error() == QNetworkReply::NoError && reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() == 200) { + data = reply->readAll(); + } + else { + if (reply->error() < 200) { + // This is a network error, there is nothing more to do. + QString error_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()); + Error(error_reason); + } + else { + // See if there is Json data containing "error" and "error_description" - then use that instead. + data = reply->readAll(); + QJsonParseError error; + QJsonDocument json_doc = QJsonDocument::fromJson(data, &error); + int error_code = -1; + QString error_reason; + if (error.error == QJsonParseError::NoError && !json_doc.isNull() && !json_doc.isEmpty() && json_doc.isObject()) { + QJsonObject json_obj = json_doc.object(); + if (json_obj.contains("code") && json_obj.contains("error")) { + error_code = json_obj["code"].toInt(); + QString message = json_obj["error"].toString(); + error_reason = QString("%1 (%2)").arg(message).arg(error_code); + } + else { + error_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()); + } + } + else { + error_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()); + } + if (reply->error() == QNetworkReply::ContentAccessDenied || reply->error() == QNetworkReply::ContentOperationNotPermittedError || reply->error() == QNetworkReply::AuthenticationRequiredError) { + // Session is probably expired + Logout(); + Error(error_reason); + } + else if (reply->error() == QNetworkReply::ContentNotFoundError) { // Ignore this error + Error(error_reason); + } + else { // Fail + Error(error_reason); + } + } + return QByteArray(); + } + + return data; + +} + +void ListenBrainzScrobbler::UpdateNowPlaying(const Song &song) { + + song_playing_ = song; + timestamp_ = QDateTime::currentDateTime().toTime_t(); + + if (!song.is_metadata_good()) return; + + // FIXME: This does not work: BAD REQUEST (302) + return; + + QJsonObject object_listen; + object_listen.insert("listened_at", QJsonValue::fromVariant(timestamp_)); + QJsonObject object_track_metadata; + object_track_metadata.insert("artist_name", QJsonValue::fromVariant(song.artist())); + object_track_metadata.insert("release_name", QJsonValue::fromVariant(song.album())); + object_track_metadata.insert("track_name", QJsonValue::fromVariant(song.title())); + object_listen.insert("track_metadata", object_track_metadata); + + QJsonArray array_payload; + array_payload.append(object_listen); + + QJsonObject object; + object.insert("listen_type", "playing_now"); + object.insert("payload", array_payload); + QJsonDocument doc(object); + + QUrl url(QString("%1/1/submit-listens").arg(kApiUrl)); + QNetworkReply *reply = CreateRequest(url, doc); + NewClosure(reply, SIGNAL(finished()), this, SLOT(UpdateNowPlayingRequestFinished(QNetworkReply*)), reply); + +} + +void ListenBrainzScrobbler::UpdateNowPlayingRequestFinished(QNetworkReply *reply) { + + reply->deleteLater(); + + QByteArray data = GetReplyData(reply); + if (data.isEmpty()) { + return; + } + qLog(Debug) << data; + + // TODO + +} + +void ListenBrainzScrobbler::Scrobble(const Song &song) { + + if (song.id() != song_playing_.id() || song.url() != song_playing_.url() || !song.is_metadata_good()) return; + + cache_->Add(song, timestamp_); + + if (app_->scrobbler()->IsOffline()) return; + + if (!IsAuthenticated()) { + emit ErrorMessage("ListenBrainz is not authenticated!"); + return; + } + + if (!submitted_) { + DoInAMinuteOrSo(this, SLOT(Submit())); + submitted_ = true; + } + +} + +void ListenBrainzScrobbler::Submit() { + + qLog(Debug) << __PRETTY_FUNCTION__; + + submitted_ = false; + + if (!IsEnabled() || !IsAuthenticated() || app_->scrobbler()->IsOffline()) return; + + QJsonArray array; + int i(0); + QList list; + for (ScrobblerCacheItem *item : cache_->List()) { + if (item->sent_) continue; + item->sent_ = true; + i++; + list << item->timestamp_; + QJsonObject object_listen; + object_listen.insert("listened_at", QJsonValue::fromVariant(item->timestamp_)); + QJsonObject object_track_metadata; + object_track_metadata.insert("artist_name", QJsonValue::fromVariant(item->artist_)); + object_track_metadata.insert("release_name", QJsonValue::fromVariant(item->album_)); + object_track_metadata.insert("track_name", QJsonValue::fromVariant(item->song_)); + object_listen.insert("track_metadata", object_track_metadata); + array.append(QJsonValue::fromVariant(object_listen)); + if (i >= kScrobblesPerRequest) break; + } + + if (i <= 0) return; + + QJsonObject object; + object.insert("listen_type", "import"); + object.insert("payload", array); + QJsonDocument doc(object); + + QUrl url(QString("%1/1/submit-listens").arg(kApiUrl)); + QNetworkReply *reply = CreateRequest(url, doc); + NewClosure(reply, SIGNAL(finished()), this, SLOT(ScrobbleRequestFinished(QNetworkReply*, QList)), reply, list); + +} + +void ListenBrainzScrobbler::ScrobbleRequestFinished(QNetworkReply *reply, QList list) { + + reply->deleteLater(); + + QByteArray data = GetReplyData(reply); + if (data.isEmpty()) { + cache_->ClearSent(list); + return; + } + + QJsonObject json_obj = ExtractJsonObj(data); + if (json_obj.isEmpty()) { + cache_->ClearSent(list); + return; + } + + if (json_obj.contains("error") && json_obj.contains("error_description")) { + QString error_code = json_obj["error"].toString(); + QString error_desc = json_obj["error_description"].toString(); + Error(error_desc); + cache_->ClearSent(list); + return; + } + + if (json_obj.contains("status")) { + QString status = json_obj["status"].toString(); + qLog(Debug) << "MusicBrainz: Received scrobble status:" << status; + } + + cache_->Flush(list); + +} + +void ListenBrainzScrobbler::Love(const Song &song) {} + +void ListenBrainzScrobbler::AuthError(QString error) { + emit AuthenticationComplete(false, error); +} + +void ListenBrainzScrobbler::Error(QString error, QVariant debug) { + + qLog(Error) << "ListenBrainz:" << error; + if (debug.isValid()) qLog(Debug) << debug; + +} + diff --git a/src/scrobbler/listenbrainzscrobbler.h b/src/scrobbler/listenbrainzscrobbler.h new file mode 100644 index 00000000..1a2556aa --- /dev/null +++ b/src/scrobbler/listenbrainzscrobbler.h @@ -0,0 +1,117 @@ +/* + * 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 LISTENBRAINZSCROBBLER_H +#define LISTENBRAINZSCROBBLER_H + +#include "config.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/song.h" +#include "scrobblerservice.h" +#include "scrobblercache.h" + +class Application; +class NetworkAccessManager; +class LocalRedirectServer; +class ScrobblerCacheItem; + +class ListenBrainzScrobbler : public ScrobblerService { + Q_OBJECT + + public: + explicit ListenBrainzScrobbler(Application *app, QObject *parent = nullptr); + ~ListenBrainzScrobbler(); + + static const char *kName; + static const char *kSettingsGroup; + + void ReloadSettings(); + void LoadSession(); + + bool IsEnabled() const { return enabled_; } + bool IsAuthenticated() const { return !access_token_.isEmpty() && !user_token_.isEmpty(); } + bool IsSubmitted() const { return submitted_; } + void Submitted() { submitted_ = true; } + QString user_token() const { return user_token_; } + + void Authenticate(); + void Logout(); + void ShowConfig(); + void Submit(); + void UpdateNowPlaying(const Song &song); + void Scrobble(const Song &song); + void Love(const Song &song); + + signals: + void AuthenticationComplete(bool success, QString error = QString()); + + public slots: + void WriteCache() { cache_->WriteCache(); } + + private slots: + void RedirectArrived(LocalRedirectServer *server); + void AuthenticateReplyFinished(QNetworkReply *reply); + void UpdateNowPlayingRequestFinished(QNetworkReply *reply); + void ScrobbleRequestFinished(QNetworkReply *reply, QList); + + private: + QNetworkReply *CreateRequest(const QUrl &url, const QJsonDocument &json_doc); + QByteArray GetReplyData(QNetworkReply *reply); + + void RequestSession(QUrl url, QString token); + void AuthError(QString error); + void Error(QString error, QVariant debug = QVariant()); + + static const char *kAuthUrl; + static const char *kAuthTokenUrl; + static const char *kApiUrl; + static const char *kClientID; + static const char *kClientSecret; + static const char *kCacheFile; + static const char *kRedirectUrl; + static const int kScrobblesPerRequest; + + Application *app_; + NetworkAccessManager *network_; + ScrobblerCache *cache_; + bool enabled_; + QString user_token_; + QString access_token_; + qint64 expires_in_; + QString token_type_; + QString refresh_token_; + bool submitted_; + Song song_playing_; + quint64 timestamp_; + +}; + +#endif // LISTENBRAINZSCROBBLER_H diff --git a/src/scrobbler/scrobblercache.cpp b/src/scrobbler/scrobblercache.cpp new file mode 100644 index 00000000..ecd1fc94 --- /dev/null +++ b/src/scrobbler/scrobblercache.cpp @@ -0,0 +1,230 @@ +/* + * 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 "core/song.h" +#include "core/logging.h" +#include "core/closure.h" + +#include "scrobblercache.h" +#include "scrobblercacheitem.h" + +ScrobblerCache::ScrobblerCache(const QString &filename, QObject *parent) : + QObject(parent), + filename_(QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + "/" + filename), + loaded_(false) { + ReadCache(); + loaded_ = true; +} + +ScrobblerCache::~ScrobblerCache() {} + +void ScrobblerCache::ReadCache() { + + QFile file(filename_); + bool result = file.open(QIODevice::ReadOnly | QIODevice::Text); + if (!result) return; + + QTextStream stream(&file); + stream.setCodec("UTF-8"); + QString data = stream.readAll(); + file.close(); + + if (data.isEmpty()) return; + + QJsonParseError error; + QJsonDocument json_doc = QJsonDocument::fromJson(data.toUtf8(), &error); + if (error.error != QJsonParseError::NoError) { + qLog(Error) << "Scrobbler cache is missing JSON data."; + return; + } + if (json_doc.isNull() || json_doc.isEmpty()) { + qLog(Error) << "Scrobbler cache has empty JSON document."; + return; + } + if (!json_doc.isObject()) { + qLog(Error) << "Scrobbler cache JSON document is not an object."; + return; + } + QJsonObject json_obj = json_doc.object(); + if (json_obj.isEmpty()) { + qLog(Error) << "Scrobbler cache has empty JSON object."; + return; + } + if (!json_obj.contains("tracks")) { + qLog(Error) << "Scrobbler cache is missing JSON tracks."; + return; + } + QJsonValue json_tracks = json_obj["tracks"]; + if (!json_tracks.isArray()) { + qLog(Error) << "Scrobbler cache JSON tracks is not an array."; + return; + } + QJsonArray json_array = json_tracks.toArray(); + if (json_array.isEmpty()) { + return; + } + + for (const QJsonValue &value : json_array) { + if (!value.isObject()) { + qLog(Error) << "Scrobbler cache JSON tracks array value is not an object."; + qLog(Debug) << value; + continue; + } + QJsonObject json_obj_track = value.toObject(); + if ( + !json_obj_track.contains("timestamp") || + !json_obj_track.contains("song") || + !json_obj_track.contains("album") || + !json_obj_track.contains("artist") || + !json_obj_track.contains("albumartist") || + !json_obj_track.contains("track") || + !json_obj_track.contains("duration") + ) { + qLog(Error) << "Scrobbler cache JSON tracks array value is missing data."; + qLog(Debug) << value; + continue; + } + + quint64 timestamp = json_obj_track["timestamp"].toVariant().toULongLong(); + QString artist = json_obj_track["artist"].toString(); + QString album = json_obj_track["album"].toString(); + QString song = json_obj_track["song"].toString(); + QString albumartist = json_obj_track["albumartist"].toString(); + int track = json_obj_track["track"].toInt(); + qint64 duration = json_obj_track["duration"].toVariant().toLongLong(); + + if (timestamp <= 0 || artist.isEmpty() || album.isEmpty() || song.isEmpty() || duration <= 0) { + qLog(Error) << "Invalid cache data" << "for song" << song; + continue; + } + if (scrobbler_cache_.contains(timestamp)) continue; + ScrobblerCacheItem *item = new ScrobblerCacheItem(artist, album, song, albumartist, track, duration, timestamp); + scrobbler_cache_.insert(timestamp, item); + + } + +} + +void ScrobblerCache::WriteCache() { + + if (!loaded_) return; + + qLog(Debug) << "Writing scrobbler cache file" << filename_; + + QJsonArray array; + + QMultiHash ::iterator i; + for (i = scrobbler_cache_.begin() ; i != scrobbler_cache_.end() ; ++i) { + ScrobblerCacheItem *item = i.value(); + QJsonObject object; + object.insert("timestamp", QJsonValue::fromVariant(item->timestamp_)); + object.insert("artist", QJsonValue::fromVariant(item->artist_)); + object.insert("album", QJsonValue::fromVariant(item->album_)); + object.insert("song", QJsonValue::fromVariant(item->song_)); + object.insert("albumartist", QJsonValue::fromVariant(item->albumartist_)); + object.insert("track", QJsonValue::fromVariant(item->track_)); + object.insert("duration", QJsonValue::fromVariant(item->duration_)); + array.append(QJsonValue::fromVariant(object)); + } + + QJsonObject object; + object.insert("tracks", array); + QJsonDocument doc(object); + + QFile file(filename_); + bool result = file.open(QIODevice::WriteOnly | QIODevice::Text); + if (!result) { + qLog(Error) << "Unable to open scrobbler cache file" << filename_; + return; + } + QTextStream stream(&file); + stream.setCodec("UTF-8"); + stream << doc.toJson(); + file.close(); + +} + +ScrobblerCacheItem *ScrobblerCache::Add(const Song &song, const quint64 ×tamp) { + + if (scrobbler_cache_.contains(timestamp)) return nullptr; + + ScrobblerCacheItem *item = new ScrobblerCacheItem(song.artist(), song.album(), song.title(), song.albumartist(), song.track(), song.length_nanosec(), timestamp); + scrobbler_cache_.insert(timestamp, item); + + if (loaded_) DoInAMinuteOrSo(this, SLOT(WriteCache())); + + return item; + +} + +ScrobblerCacheItem *ScrobblerCache::Get(const quint64 hash) { + + if (scrobbler_cache_.contains(hash)) { return scrobbler_cache_.value(hash); } + else return nullptr; + +} + +void ScrobblerCache::Remove(const quint64 hash) { + + if (!scrobbler_cache_.contains(hash)) { + qLog(Error) << "Tried to remove non-existing hash" << hash; + return; + } + + delete scrobbler_cache_.take(hash); + +} + +void ScrobblerCache::Remove(ScrobblerCacheItem &item) { + delete scrobbler_cache_.take(item.timestamp_); +} + +void ScrobblerCache::ClearSent(const QList list) { + for (quint64 timestamp : list) { + if (!scrobbler_cache_.contains(timestamp)) continue; + ScrobblerCacheItem *item = scrobbler_cache_.take(timestamp); + item->sent_ = false; + } +} + +void ScrobblerCache::Flush(const QList list) { + + for (quint64 timestamp : list) { + if (!scrobbler_cache_.contains(timestamp)) continue; + delete scrobbler_cache_.take(timestamp); + } + DoInAMinuteOrSo(this, SLOT(WriteCache())); + +} diff --git a/src/scrobbler/scrobblercache.h b/src/scrobbler/scrobblercache.h new file mode 100644 index 00000000..0d68a684 --- /dev/null +++ b/src/scrobbler/scrobblercache.h @@ -0,0 +1,64 @@ +/* + * 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 SCROBBLERCACHE_H +#define SCROBBLERCACHE_H + +#include "config.h" + +#include + +#include +#include +#include +#include +#include + +class Application; +class Song; +class ScrobblerCacheItem; + +class ScrobblerCache : public QObject { + Q_OBJECT + + public: + explicit ScrobblerCache(const QString &filename, QObject *parent); + ~ScrobblerCache(); + + void ReadCache(); + + ScrobblerCacheItem *Add(const Song &song, const quint64 ×tamp); + ScrobblerCacheItem *Get(const quint64 hash); + void Remove(const quint64 hash); + void Remove(ScrobblerCacheItem &item); + QList List() const { return scrobbler_cache_.values(); } + void ClearSent(const QList list); + void Flush(const QList list); + + public slots: + void WriteCache(); + + private: + QString filename_; + bool loaded_; + QHash scrobbler_cache_; + +}; + +#endif // SCROBBLERCACHE_H diff --git a/src/scrobbler/scrobblercacheitem.cpp b/src/scrobbler/scrobblercacheitem.cpp new file mode 100644 index 00000000..09d10b9e --- /dev/null +++ b/src/scrobbler/scrobblercacheitem.cpp @@ -0,0 +1,39 @@ +/* + * 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 "scrobblercacheitem.h" + +ScrobblerCacheItem::ScrobblerCacheItem(const QString &artist, const QString &album, const QString &song, const QString &albumartist, const int track, const qint64 duration, const quint64 ×tamp) : + artist_(artist), + album_(album), + song_(song), + albumartist_(albumartist), + track_(track), + duration_(duration), + timestamp_(timestamp), + sent_(false) {} + +ScrobblerCacheItem::~ScrobblerCacheItem() {} diff --git a/src/scrobbler/scrobblercacheitem.h b/src/scrobbler/scrobblercacheitem.h new file mode 100644 index 00000000..b8a33941 --- /dev/null +++ b/src/scrobbler/scrobblercacheitem.h @@ -0,0 +1,48 @@ +/* + * 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 SCROBBLERCACHEITEM_H +#define SCROBBLERCACHEITEM_H + +#include "config.h" + +#include +#include +#include + +class ScrobblerCacheItem : public QObject { + Q_OBJECT + + public: + explicit ScrobblerCacheItem(const QString &artist, const QString &album, const QString &song, const QString &albumartist, const int track, const qint64 duration, const quint64 ×tamp); + ~ScrobblerCacheItem(); + + public: + QString artist_; + QString album_; + QString song_; + QString albumartist_; + int track_; + qint64 duration_; + quint64 timestamp_; + bool sent_; + +}; + +#endif // SCROBBLERCACHEITEM_H diff --git a/src/scrobbler/scrobblerservice.cpp b/src/scrobbler/scrobblerservice.cpp new file mode 100644 index 00000000..cce36e5b --- /dev/null +++ b/src/scrobbler/scrobblerservice.cpp @@ -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 . + * + */ + +#include "config.h" + +#include +#include +#include +#include +#include + +#include "scrobblerservice.h" + +ScrobblerService::ScrobblerService(const QString &name, Application *app, QObject *parent) : QObject(parent), name_(name) {} + +QJsonObject ScrobblerService::ExtractJsonObj(const QByteArray &data) { + + QJsonParseError error; + QJsonDocument json_doc = QJsonDocument::fromJson(data, &error); + + if (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.", json_doc); + 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; + +} diff --git a/src/scrobbler/scrobblerservice.h b/src/scrobbler/scrobblerservice.h new file mode 100644 index 00000000..f211e81b --- /dev/null +++ b/src/scrobbler/scrobblerservice.h @@ -0,0 +1,76 @@ +/* + * 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 SCROBBLERSERVICE_H +#define SCROBBLERSERVICE_H + +#include "config.h" + +#include + +#include +#include +#include +#include +#include +#include + +class Application; +class Song; + +class ScrobblerService : public QObject { + Q_OBJECT + + public: + explicit ScrobblerService(const QString &name, Application *app, QObject *parent); + + QString name() const { return name_; } + + virtual void ReloadSettings() = 0; + + virtual bool IsEnabled() const { return false; } + virtual bool IsAuthenticated() const { return false; } + + virtual void UpdateNowPlaying(const Song &song) = 0; + virtual void Scrobble(const Song &song) = 0; + virtual void Love(const Song &song) = 0; + virtual void Error(QString error, QVariant debug = QVariant()) = 0; + + virtual void Submitted() = 0; + virtual bool IsSubmitted() const { return false; } + + typedef QPair Param; + typedef QPair EncodedParam; + typedef QList ParamList; + + QJsonObject ExtractJsonObj(const QByteArray &data); + + public slots: + virtual void Submit() = 0; + virtual void WriteCache() = 0; + + signals: + void ErrorMessage(QString); + + private: + QString name_; + +}; + +#endif // SCROBBLERSERVICE_H diff --git a/src/scrobbler/scrobblerservices.cpp b/src/scrobbler/scrobblerservices.cpp new file mode 100644 index 00000000..52f4c270 --- /dev/null +++ b/src/scrobbler/scrobblerservices.cpp @@ -0,0 +1,77 @@ +/* + * 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 "core/application.h" +#include "core/logging.h" + +#include "scrobblerservices.h" +#include "scrobblerservice.h" + +ScrobblerServices::ScrobblerServices(QObject *parent) : QObject(parent) {} + +ScrobblerServices::~ScrobblerServices() {} + +void ScrobblerServices::AddService(ScrobblerService *service) { + + { + QMutexLocker locker(&mutex_); + scrobbler_services_.insert(service->name(), service); + connect(service, SIGNAL(destroyed()), SLOT(ServiceDestroyed())); + } + + qLog(Debug) << "Registered scrobbler service" << service->name(); + +} + +void ScrobblerServices::RemoveService(ScrobblerService *service) { + + if (!service || !scrobbler_services_.contains(service->name())) return; + + { + QMutexLocker locker(&mutex_); + scrobbler_services_.remove(service->name()); + disconnect(service, 0, this, 0); + } + + qLog(Debug) << "Unregistered scrobbler service" << service->name(); + +} + +void ScrobblerServices::ServiceDestroyed() { + + ScrobblerService *service = static_cast(sender()); + RemoveService(service); + +} + +int ScrobblerServices::NextId() { return next_id_.fetchAndAddRelaxed(1); } + +ScrobblerService *ScrobblerServices::ServiceByName(const QString &name) { + + if (scrobbler_services_.contains(name)) return scrobbler_services_.value(name); + return nullptr; + +} diff --git a/src/scrobbler/scrobblerservices.h b/src/scrobbler/scrobblerservices.h new file mode 100644 index 00000000..aeb6030e --- /dev/null +++ b/src/scrobbler/scrobblerservices.h @@ -0,0 +1,70 @@ +/* + * 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 SCROBBLERSERVICES_H +#define SCROBBLERSERVICES_H + +#include "config.h" + +#include + +#include +#include +#include +#include +#include +#include +#include + +class Application; +class ScrobblerService; + +class ScrobblerServices : public QObject { + Q_OBJECT + + public: + explicit ScrobblerServices(QObject *parent = nullptr); + ~ScrobblerServices(); + + void AddService(ScrobblerService *service); + void RemoveService(ScrobblerService *service); + QList List() const { return scrobbler_services_.values(); } + bool HasAnyServices() const { return !scrobbler_services_.isEmpty(); } + int NextId(); + + ScrobblerService *ServiceByName(const QString &name); + template + T *Service() { + return static_cast(this->ServiceByName(T::kName)); + } + + private slots: + void ServiceDestroyed(); + + private: + Q_DISABLE_COPY(ScrobblerServices); + + QMap scrobbler_services_; + QMutex mutex_; + + QAtomicInt next_id_; + +}; + +#endif // SCROBBLERSERVICES_H diff --git a/src/scrobbler/scrobblingapi20.cpp b/src/scrobbler/scrobblingapi20.cpp new file mode 100644 index 00000000..f7e25c83 --- /dev/null +++ b/src/scrobbler/scrobblingapi20.cpp @@ -0,0 +1,740 @@ +/* + * 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 "core/application.h" +#include "core/player.h" +#include "core/closure.h" +#include "core/network.h" +#include "core/song.h" +#include "core/timeconstants.h" +#include "core/logging.h" +#include "internet/localredirectserver.h" +#include "settings/settingsdialog.h" +#include "settings/scrobblersettingspage.h" + +#include "audioscrobbler.h" +#include "scrobblerservices.h" +#include "scrobblingapi20.h" +#include "scrobblercache.h" +#include "scrobblercacheitem.h" + +const char *ScrobblingAPI20::kApiKey = "211990b4c96782c05d1536e7219eb56e"; +const char *ScrobblingAPI20::kSecret = "80fd738f49596e9709b1bf9319c444a8"; +const char *ScrobblingAPI20::kRedirectUrl = "https://oauth.strawbs.net"; +const int ScrobblingAPI20::kScrobblesPerRequest = 50; + +ScrobblingAPI20::ScrobblingAPI20(const QString &name, const QString &settings_group, const QString &auth_url, const QString &api_url, const bool batch, Application *app, QObject *parent) : + ScrobblerService(name, app, parent), + name_(name), + settings_group_(settings_group), + auth_url_(auth_url), + api_url_(api_url), + batch_(batch), + app_(app), + enabled_(false), + subscriber_(false), + submitted_(false) {} + +ScrobblingAPI20::~ScrobblingAPI20() {} + +void ScrobblingAPI20::ReloadSettings() { + + QSettings s; + s.beginGroup(settings_group_); + enabled_ = s.value("enabled", false).toBool(); + s.endGroup(); + +} + +void ScrobblingAPI20::LoadSession() { + + QSettings s; + s.beginGroup(settings_group_); + subscriber_ = s.value("subscriber", false).toBool(); + username_ = s.value("username").toString(); + session_key_ = s.value("session_key").toString(); + s.endGroup(); + +} + +void ScrobblingAPI20::Logout() { + + subscriber_ = false; + username_.clear(); + session_key_.clear(); + + QSettings settings; + settings.beginGroup(settings_group_); + settings.remove("subscriber"); + settings.remove("username"); + settings.remove("session_key"); + settings.endGroup(); + +} + +void ScrobblingAPI20::Authenticate() { + + QUrl url(auth_url_); + + LocalRedirectServer *server = new LocalRedirectServer(this); + server->Listen(); + NewClosure(server, SIGNAL(Finished()), this, &ScrobblingAPI20::RedirectArrived, server); + + QUrl redirect_url(kRedirectUrl); + QUrlQuery redirect_url_query; + const QString port = QString::number(server->url().port()); + redirect_url_query.addQueryItem("port", port); + redirect_url.setQuery(redirect_url_query); + + QUrlQuery url_query; + url_query.addQueryItem("api_key", kApiKey); + url_query.addQueryItem("cb", redirect_url.toString()); + url.setQuery(url_query); + + bool result = QDesktopServices::openUrl(url); + if (!result) { + QMessageBox box(QMessageBox::NoIcon, "Scrobbler Authentication", QString("Please open this URL in your browser: %1").arg(url.toString()), QMessageBox::Ok); + box.setTextFormat(Qt::RichText); + qLog(Debug) << "Scrobbler authentication URL: " << url.toString(); + box.exec(); + } + +} + +void ScrobblingAPI20::RedirectArrived(LocalRedirectServer *server) { + + server->deleteLater(); + + QUrl url = server->request_url(); + RequestSession(QUrlQuery(url).queryItemValue("token").toUtf8()); + +} + +void ScrobblingAPI20::RequestSession(QString token) { + + QUrl session_url(api_url_); + QUrlQuery session_url_query; + session_url_query.addQueryItem("api_key", kApiKey); + session_url_query.addQueryItem("method", "auth.getSession"); + session_url_query.addQueryItem("token", token); + QString data_to_sign; + for (QPair param : session_url_query.queryItems()) { + data_to_sign += param.first + param.second; + } + data_to_sign += kSecret; + QByteArray const digest = QCryptographicHash::hash(data_to_sign.toUtf8(), QCryptographicHash::Md5); + QString signature = QString::fromLatin1(digest.toHex()).rightJustified(32, '0').toLower(); + session_url_query.addQueryItem("api_sig", signature); + session_url_query.addQueryItem(QUrl::toPercentEncoding("format"), QUrl::toPercentEncoding("json")); + session_url.setQuery(session_url_query); + + QNetworkRequest req(session_url); + QNetworkReply *reply = network()->get(req); + NewClosure(reply, SIGNAL(finished()), this, SLOT(AuthenticateReplyFinished(QNetworkReply*)), reply); + +} + +void ScrobblingAPI20::AuthenticateReplyFinished(QNetworkReply *reply) { + + reply->deleteLater(); + + QByteArray data; + + if (reply->error() == QNetworkReply::NoError && reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() == 200) { + data = reply->readAll(); + } + else { + if (reply->error() < 200) { + // This is a network error, there is nothing more to do. + QString failure_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()); + AuthError(failure_reason); + } + else { + // See if there is Json data containing "error" and "message" - then use that instead. + data = reply->readAll(); + QJsonParseError error; + QJsonDocument json_doc = QJsonDocument::fromJson(data, &error); + QString failure_reason; + if (error.error == QJsonParseError::NoError && !json_doc.isNull() && !json_doc.isEmpty() && json_doc.isObject()) { + QJsonObject json_obj = json_doc.object(); + if (json_obj.contains("error") && json_obj.contains("message")) { + int error = json_obj["error"].toInt(); + QString message = json_obj["message"].toString(); + failure_reason = "Error: " + QString::number(error) + ": " + message; + } + else { + failure_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()); + } + } + else { + failure_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()); + } + AuthError(failure_reason); + } + return; + } + + QJsonObject json_obj = ExtractJsonObj(data); + if (json_obj.isEmpty()) { + AuthError("Json document from server was empty."); + return; + } + + if (json_obj.contains("error") && json_obj.contains("message")) { + int error = json_obj["error"].toInt(); + QString message = json_obj["message"].toString(); + QString failure_reason = "Error: " + QString::number(error) + ": " + message; + AuthError(failure_reason); + return; + } + + if (!json_obj.contains("session")) { + AuthError("Json reply from server is missing session."); + return; + } + + QJsonValue json_session = json_obj["session"]; + if (!json_session.isObject()) { + AuthError("Json session is not an object."); + return; + } + json_obj = json_session.toObject(); + if (json_obj.isEmpty()) { + AuthError("Json session object is empty."); + return; + } + if (!json_obj.contains("subscriber") || !json_obj.contains("name") || !json_obj.contains("key")) { + AuthError("Json session object is missing values."); + return; + } + + subscriber_ = json_obj["subscriber"].toBool(); + username_ = json_obj["name"].toString(); + session_key_ = json_obj["key"].toString(); + + QSettings s; + s.beginGroup(settings_group_); + s.setValue("subscriber", subscriber_); + s.setValue("username", username_); + s.setValue("session_key", session_key_); + s.endGroup(); + + emit AuthenticationComplete(true); + +} + +QNetworkReply *ScrobblingAPI20::CreateRequest(const ParamList &request_params) { + + ParamList params = ParamList() + << Param("api_key", kApiKey) + << Param("sk", session_key_) + << Param("lang", QLocale().name().left(2).toLower()) + << request_params; + + std::sort(params.begin(), params.end()); + + QUrlQuery url_query; + QString data_to_sign; + 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); + data_to_sign += param.first + param.second; + } + data_to_sign += kSecret; + + QByteArray const digest = QCryptographicHash::hash(data_to_sign.toUtf8(), QCryptographicHash::Md5); + QString signature = QString::fromLatin1(digest.toHex()).rightJustified(32, '0').toLower(); + + url_query.addQueryItem("api_sig", QUrl::toPercentEncoding(signature)); + url_query.addQueryItem("format", QUrl::toPercentEncoding("json")); + + QUrl url(api_url_); + QNetworkRequest req(url); + req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); + QByteArray query = url_query.toString(QUrl::FullyEncoded).toUtf8(); + QNetworkReply *reply = network()->post(req, query); + + //qLog(Debug) << name_ << "Sending request" << query; + + return reply; + +} + +QByteArray ScrobblingAPI20::GetReplyData(QNetworkReply *reply) { + + QByteArray data; + + if (reply->error() == QNetworkReply::NoError && reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() == 200) { + data = reply->readAll(); + } + else { + if (reply->error() < 200) { + // This is a network error, there is nothing more to do. + QString error_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()); + Error(error_reason); + } + else { + // See if there is Json data containing "error" and "message" - then use that instead. + data = reply->readAll(); + QJsonParseError error; + QJsonDocument json_doc = QJsonDocument::fromJson(data, &error); + int error_code = -1; + QString error_reason; + if (error.error == QJsonParseError::NoError && !json_doc.isNull() && !json_doc.isEmpty() && json_doc.isObject()) { + QJsonObject json_obj = json_doc.object(); + if (json_obj.contains("error") && json_obj.contains("message")) { + error_code = json_obj["error"].toInt(); + QString message = json_obj["message"].toString(); + error_reason = QString("%1 (%2)").arg(message).arg(error_code); + } + else { + error_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()); + } + } + else { + error_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()); + } + if (reply->error() == QNetworkReply::ContentAccessDenied || + reply->error() == QNetworkReply::ContentOperationNotPermittedError || + reply->error() == QNetworkReply::AuthenticationRequiredError || + error_code == ScrobbleErrorCode::InvalidSessionKey || + error_code == ScrobbleErrorCode::AuthenticationFailed + ){ + // Session is probably expired + Logout(); + Error(error_reason); + } + else if (reply->error() == QNetworkReply::ContentNotFoundError) { // Ignore this error + Error(error_reason); + } + else { // Fail + Error(error_reason); + } + } + return QByteArray(); + } + + return data; + +} + +void ScrobblingAPI20::UpdateNowPlaying(const Song &song) { + + song_playing_ = song; + timestamp_ = QDateTime::currentDateTime().toTime_t(); + + if (!IsAuthenticated() || !song.is_metadata_good()) return; + + ParamList params = ParamList() + << Param("method", "track.updateNowPlaying") + << Param("artist", song.artist()) + << Param("track", song.title()) + << Param("album", song.album()); + + QNetworkReply *reply = CreateRequest(params); + NewClosure(reply, SIGNAL(finished()), this, SLOT(UpdateNowPlayingRequestFinished(QNetworkReply*)), reply); + +} + +void ScrobblingAPI20::UpdateNowPlayingRequestFinished(QNetworkReply *reply) { + + reply->deleteLater(); + + QByteArray data = GetReplyData(reply); + if (data.isEmpty()) { + return; + } + + // TODO + +} + +void ScrobblingAPI20::Scrobble(const Song &song) { + + if (song.id() != song_playing_.id() || song.url() != song_playing_.url() || !song.is_metadata_good()) return; + + cache()->Add(song, timestamp_); + + if (app_->scrobbler()->IsOffline()) return; + + if (!IsAuthenticated()) { + emit ErrorMessage(QString("Scrobbler %1 is not authenticated!").arg(name_)); + return; + } + + if (!submitted_) { + DoInAMinuteOrSo(this, SLOT(Submit())); + submitted_ = true; + } + +} + +void ScrobblingAPI20::Submit() { + + qLog(Debug) << __PRETTY_FUNCTION__ << name_; + + submitted_ = false; + + if (!IsEnabled() || !IsAuthenticated() || app_->scrobbler()->IsOffline()) return; + + ParamList params = ParamList() + << Param("method", "track.scrobble"); + + int i(0); + QList list; + for (ScrobblerCacheItem *item : cache()->List()) { + if (item->sent_) continue; + item->sent_ = true; + if (!batch_) { SendSingleScrobble(item); continue; } + i++; + list << item->timestamp_; + params << Param(QString("%1[%2]").arg("artist").arg(i), item->artist_); + params << Param(QString("%1[%2]").arg("album").arg(i), item->album_); + params << Param(QString("%1[%2]").arg("track").arg(i), item->song_); + params << Param(QString("%1[%2]").arg("timestamp").arg(i), QString::number(item->timestamp_)); + params << Param(QString("%1[%2]").arg("duration").arg(i), QString::number(item->duration_ / kNsecPerSec)); + if (!item->albumartist_.isEmpty()) params << Param(QString("%1[%2]").arg("albumArtist").arg(i), item->albumartist_); + if (item->track_ > 0) params << Param(QString("%1[%2]").arg(i).arg("trackNumber"), QString::number(item->track_)); + if (i >= kScrobblesPerRequest) break; + } + + if (!batch_ || i <= 0) return; + + QNetworkReply *reply = CreateRequest(params); + NewClosure(reply, SIGNAL(finished()), this, SLOT(ScrobbleRequestFinished(QNetworkReply*, QList)), reply, list); + +} + +void ScrobblingAPI20::ScrobbleRequestFinished(QNetworkReply *reply, QList list) { + + reply->deleteLater(); + + QByteArray data = GetReplyData(reply); + if (data.isEmpty()) { + cache()->ClearSent(list); + return; + } + + QJsonObject json_obj = ExtractJsonObj(data); + if (json_obj.isEmpty()) { + cache()->ClearSent(list); + return; + } + + if (!json_obj.contains("scrobbles")) { + if (json_obj.contains("error")) { + int error = json_obj["error"].toInt(); + Error(QString("Error: %1: %2").arg(QString::number(error)).arg(error)); + } + Error("Json reply from server is missing session."); + cache()->ClearSent(list); + return; + } + + cache()->Flush(list); + + QJsonValue json_scrobbles = json_obj["scrobbles"]; + if (!json_scrobbles.isObject()) { + Error("Json scrobbles is not an object.", json_obj); + return; + } + json_obj = json_scrobbles.toObject(); + if (json_obj.isEmpty()) { + Error("Json scrobbles object is empty.", json_scrobbles); + return; + } + if (!json_obj.contains("@attr") || !json_obj.contains("scrobble")) { + Error("Json scrobbles object is missing values.", json_obj); + return; + } + + QJsonValue json_attr = json_obj["@attr"]; + if (!json_attr.isObject()) { + Error("Json scrobbles attr is not an object.", json_attr); + return; + } + QJsonObject json_obj_attr = json_attr.toObject(); + if (json_obj_attr.isEmpty()) { + Error("Json scrobbles attr is empty.", json_attr); + return; + } + if (!json_obj_attr.contains("accepted") || !json_obj_attr.contains("ignored")) { + Error("Json scrobbles attr is missing values.", json_obj_attr); + return; + } + int accepted = json_obj_attr["accepted"].toInt(); + int ignored = json_obj_attr["ignored"].toInt(); + qLog(Debug) << name_ << "Scrobbles accepted:" << accepted << "ignored:" << ignored; + + QJsonValue json_scrobble = json_obj["scrobble"]; + if (!json_scrobble.isArray()) { + Error("Json scrobbles scrobble is not array.", json_scrobble); + return; + } + QJsonArray json_array_scrobble = json_scrobble.toArray(); + if (json_array_scrobble.isEmpty()) { + Error("Json scrobbles scrobble array is empty.", json_scrobble); + return; + } + + for (const QJsonValue &value : json_array_scrobble) { + + if (!value.isObject()) { + Error("Json scrobbles scrobble array value is not an object.", value); + continue; + } + QJsonObject json_track = value.toObject(); + if (json_track.isEmpty()) { + continue; + } + + if (!json_track.contains("artist") || + !json_track.contains("album") || + !json_track.contains("albumArtist") || + !json_track.contains("track") || + !json_track.contains("timestamp") || + !json_track.contains("ignoredMessage") + ) { + Error("Json scrobbles scrobble is missing values.", json_track); + return; + } + + QJsonValue json_value_artist = json_track["artist"]; + QJsonValue json_value_album = json_track["album"]; + QJsonValue json_value_song = json_track["track"]; + //quint64 timestamp = json_track["timestamp"].toVariant().toULongLong(); + + if (!json_value_artist.isObject() || !json_value_album.isObject() || !json_value_song.isObject()) { + Error("Json scrobbles scrobble values are not objects.", json_track); + continue; + } + + QJsonObject json_obj_artist = json_value_artist.toObject(); + QJsonObject json_obj_album = json_value_album.toObject(); + QJsonObject json_obj_song = json_value_song.toObject(); + + if (json_obj_artist.isEmpty() || json_obj_album.isEmpty() || json_obj_song.isEmpty()) { + Error("Json scrobbles scrobble values objects are empty.", json_track); + continue; + } + + if (!json_obj_artist.contains("#text") || !json_obj_album.contains("#text") || !json_obj_song.contains("#text")) { + Error("Json scrobbles scrobble values objects are empty.", json_track); + continue; + } + + // TODO + + } + +} + +void ScrobblingAPI20::SendSingleScrobble(ScrobblerCacheItem *item) { + + ParamList params = ParamList() + << Param("method", "track.scrobble") + << Param("artist", item->artist_) + << Param("album", item->album_) + << Param("track", item->song_) + << Param("timestamp", QString::number(item->timestamp_)) + << Param("duration", QString::number(item->duration_ / kNsecPerSec)); + + if (!item->albumartist_.isEmpty()) params << Param("albumArtist", item->albumartist_); + if (item->track_ > 0) params << Param("trackNumber", QString::number(item->track_)); + + QNetworkReply *reply = CreateRequest(params); + NewClosure(reply, SIGNAL(finished()), this, SLOT(SingleScrobbleRequestFinished(QNetworkReply*, quint64)), reply, item->timestamp_); + +} + +void ScrobblingAPI20::SingleScrobbleRequestFinished(QNetworkReply *reply, quint64 timestamp) { + + reply->deleteLater(); + + ScrobblerCacheItem *item = cache()->Get(timestamp); + if (!item) { + Error(QString("Received reply for non-existing cache entry %1.").arg(timestamp)); + return; + } + + QByteArray data = GetReplyData(reply); + if (data.isEmpty()) { + return; + } + + QJsonObject json_obj = ExtractJsonObj(data); + if (json_obj.isEmpty()) { + return; + } + + if (!json_obj.contains("scrobbles")) { + if (json_obj.contains("error")) { + int error = json_obj["error"].toInt(); + Error(QString("Error: %1: %2").arg(QString::number(error)).arg(error)); + } + Error("Json reply from server is missing session."); + return; + } + + QJsonValue json_scrobbles = json_obj["scrobbles"]; + if (!json_scrobbles.isObject()) { + Error("Json scrobbles is not an object.", json_obj); + return; + } + json_obj = json_scrobbles.toObject(); + if (json_obj.isEmpty()) { + Error("Json scrobbles object is empty.", json_scrobbles); + return; + } + if (!json_obj.contains("@attr") || !json_obj.contains("scrobble")) { + Error("Json scrobbles object is missing values.", json_obj); + return; + } + + QJsonValue json_attr = json_obj["@attr"]; + if (!json_attr.isObject()) { + Error("Json scrobbles attr is not an object.", json_attr); + return; + } + QJsonObject json_obj_attr = json_attr.toObject(); + if (json_obj_attr.isEmpty()) { + Error("Json scrobbles attr is empty.", json_attr); + return; + } + + QJsonValue json_scrobble = json_obj["scrobble"]; + if (!json_scrobble.isObject()) { + Error("Json scrobbles scrobble is not an object.", json_scrobble); + return; + } + QJsonObject json_obj_scrobble = json_scrobble.toObject(); + if (json_obj_scrobble.isEmpty()) { + Error("Json scrobbles scrobble is empty.", json_scrobble); + return; + } + + if (!json_obj_attr.contains("accepted") || !json_obj_attr.contains("ignored")) { + Error("Json scrobbles attr is missing values.", json_obj_attr); + return; + } + + if (!json_obj_scrobble.contains("artist") || !json_obj_scrobble.contains("album") || !json_obj_scrobble.contains("albumArtist") || !json_obj_scrobble.contains("track") || !json_obj_scrobble.contains("timestamp")) { + Error("Json scrobbles scrobble is missing values.", json_obj_scrobble); + return; + } + + int accepted = json_obj_attr["accepted"].toVariant().toInt(); + if (accepted == 1) { + qLog(Debug) << name_ << "Scrobble for" << item->song_ << "accepted"; + } + else { + Error(QString("Scrobble for \"%1\" not accepted").arg(item->song_)); + qLog(Debug) << name_ << json_obj_attr["accepted"]; + } + + cache()->Remove(timestamp); + +} + +void ScrobblingAPI20::Love(const Song &song) { + + if (!IsAuthenticated()) app_->scrobbler()->ShowConfig(); + + ParamList params = ParamList() + << Param("method", "track.love") + << Param("artist", song.artist()) + << Param("track", song.title()) + << Param("album", song.album()); + + QNetworkReply *reply = CreateRequest(params); + NewClosure(reply, SIGNAL(finished()), this, SLOT(RequestFinished(QNetworkReply*)), reply); + +} + +void ScrobblingAPI20::AuthError(QString error) { + emit AuthenticationComplete(false, error); +} + +void ScrobblingAPI20::Error(QString error, QVariant debug) { + + qLog(Error) << name_ << error; + if (debug.isValid()) qLog(Debug) << debug; + +} + +QString ScrobblingAPI20::ErrorString(ScrobbleErrorCode error) const { + + switch (error) { + case ScrobbleErrorCode::InvalidService: + return QString("Invalid service - This service does not exist"); + case ScrobbleErrorCode::InvalidMethod: + return QString("Invalid Method - No method with that name in this package"); + case ScrobbleErrorCode::AuthenticationFailed: + return QString("Authentication Failed - You do not have permissions to access the service"); + case ScrobbleErrorCode::InvalidFormat: + return QString("Invalid format - This service doesn't exist in that format"); + case ScrobbleErrorCode::InvalidParameters: + return QString("Invalid parameters - Your request is missing a required parameter"); + case ScrobbleErrorCode::InvalidResourceSpecified: + return QString("Invalid resource specified"); + case ScrobbleErrorCode::OperationFailed: + return QString("Operation failed - Something else went wrong"); + case ScrobbleErrorCode::InvalidSessionKey: + return QString("Invalid session key - Please re-authenticate"); + case ScrobbleErrorCode::InvalidApiKey: + return QString("Invalid API key - You must be granted a valid key by last.fm"); + case ScrobbleErrorCode::ServiceOffline: + return QString("Service Offline - This service is temporarily offline. Try again later."); + case ScrobbleErrorCode::InvalidMethodSignature: + return QString("Invalid method signature supplied"); + case ScrobbleErrorCode::TempError: + return QString("There was a temporary error processing your request. Please try again"); + case ScrobbleErrorCode::SuspendedAPIKey: + return QString("Suspended API key - Access for your account has been suspended, please contact Last.fm"); + case ScrobbleErrorCode::RateLimitExceeded: + return QString("Rate limit exceeded - Your IP has made too many requests in a short period"); + default: + return QString("Unknown error"); + } + +} diff --git a/src/scrobbler/scrobblingapi20.h b/src/scrobbler/scrobblingapi20.h new file mode 100644 index 00000000..e3acc3a7 --- /dev/null +++ b/src/scrobbler/scrobblingapi20.h @@ -0,0 +1,158 @@ +/* + * 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 SCROBBLINGAPI20_H +#define SCROBBLINGAPI20_H + +#include "config.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/song.h" +#include "scrobblerservice.h" +#include "scrobblercache.h" + +class Application; +class NetworkAccessManager; +class LocalRedirectServer; +class ScrobblerService; +class ScrobblerCache; +class ScrobblerCacheItem; + +class ScrobblingAPI20 : public ScrobblerService { + Q_OBJECT + + public: + explicit ScrobblingAPI20(const QString &name, const QString &settings_group, const QString &auth_url, const QString &api_url, const bool batch, Application *app, QObject *parent = nullptr); + ~ScrobblingAPI20(); + + static const char *kRedirectUrl; + + void ReloadSettings(); + void LoadSession(); + + virtual NetworkAccessManager *network() = 0; + virtual ScrobblerCache *cache() = 0; + + bool IsEnabled() const { return enabled_; } + bool IsAuthenticated() const { return !username_.isEmpty() && !session_key_.isEmpty(); } + bool IsSubscriber() const { return subscriber_; } + bool IsSubmitted() const { return submitted_; } + void Submitted() { submitted_ = true; } + QString username() const { return username_; } + + void Authenticate(); + void Logout(); + void UpdateNowPlaying(const Song &song); + void Scrobble(const Song &song); + void Submit(); + void Love(const Song &song); + + signals: + void AuthenticationComplete(bool success, QString error = QString()); + + public slots: + void WriteCache() { cache()->WriteCache(); } + + private slots: + void RedirectArrived(LocalRedirectServer *server); + void AuthenticateReplyFinished(QNetworkReply *reply); + void UpdateNowPlayingRequestFinished(QNetworkReply *reply); + void ScrobbleRequestFinished(QNetworkReply *reply, QList); + void SingleScrobbleRequestFinished(QNetworkReply *reply, quint64 timestamp); + + private: + + enum ScrobbleErrorCode { + Unknown = 0, + NoError = 1, + InvalidService = 2, + InvalidMethod = 3, + AuthenticationFailed = 4, + InvalidFormat = 5, + InvalidParameters = 6, + InvalidResourceSpecified = 7, + OperationFailed = 8, + InvalidSessionKey = 9, + InvalidApiKey = 10, + ServiceOffline = 11, + Reserved12 = 12, + InvalidMethodSignature = 13, + Reserved14 = 14, + Reserved15 = 15, + TempError = 16, + Reserved17 = 17, + Reserved18 = 18, + Reserved19 = 19, + Reserved20 = 20, + Reserved21 = 21, + Reserved22 = 22, + Reserved23 = 23, + Reserved24 = 24, + Reserved25 = 25, + SuspendedAPIKey = 26, + Reserved27 = 27, + Reserved28 = 28, + RateLimitExceeded = 29, + }; + + static const char *kApiKey; + static const char *kSecret; + static const int kScrobblesPerRequest; + + QNetworkReply *CreateRequest(const ParamList &request_params); + QByteArray GetReplyData(QNetworkReply *reply); + + void RequestSession(QString token); + void AuthError(QString error); + void SendSingleScrobble(ScrobblerCacheItem *item); + void Error(QString error, QVariant debug = QVariant()); + QString ErrorString(ScrobbleErrorCode error) const; + + QString name_; + QString settings_group_; + QString auth_url_; + QString api_url_; + bool batch_; + + Application *app_; + + bool enabled_; + + bool subscriber_; + QString username_; + QString session_key_; + + bool submitted_; + Song song_playing_; + quint64 timestamp_; + +}; + +#endif // SCROBBLINGAPI20_H diff --git a/src/settings/scrobblersettingspage.cpp b/src/settings/scrobblersettingspage.cpp new file mode 100644 index 00000000..1470b1c8 --- /dev/null +++ b/src/settings/scrobblersettingspage.cpp @@ -0,0 +1,230 @@ +/* + * 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 "scrobblersettingspage.h" +#include "ui_scrobblersettingspage.h" + +#include +#include +#include + +#include "core/application.h" +#include "core/iconloader.h" + +#include "scrobbler/audioscrobbler.h" +#include "scrobbler/scrobblerservice.h" +#include "scrobbler/lastfmscrobbler.h" +#include "scrobbler/librefmscrobbler.h" +#include "scrobbler/listenbrainzscrobbler.h" + +const char *ScrobblerSettingsPage::kSettingsGroup = "Scrobbler"; + +ScrobblerSettingsPage::ScrobblerSettingsPage(SettingsDialog *parent) + : SettingsPage(parent), + scrobbler_(dialog()->app()->scrobbler()), + lastfmscrobbler_(dialog()->app()->scrobbler()->Service()), + librefmscrobbler_(dialog()->app()->scrobbler()->Service()), + listenbrainzscrobbler_(dialog()->app()->scrobbler()->Service()), + ui_(new Ui_ScrobblerSettingsPage), + lastfm_waiting_for_auth_(false), + librefm_waiting_for_auth_(false), + listenbrainz_waiting_for_auth_(false) + { + + ui_->setupUi(this); + setWindowIcon(IconLoader::Load("scrobble")); + + // Last.fm + connect(lastfmscrobbler_, SIGNAL(AuthenticationComplete(bool, QString)), SLOT(LastFM_AuthenticationComplete(bool, QString))); + connect(ui_->button_lastfm_login, SIGNAL(clicked()), SLOT(LastFM_Login())); + connect(ui_->widget_lastfm_login_state, SIGNAL(LoginClicked()), SLOT(LastFM_Login())); + connect(ui_->widget_lastfm_login_state, SIGNAL(LogoutClicked()), SLOT(LastFM_Logout())); + ui_->widget_lastfm_login_state->AddCredentialGroup(ui_->widget_lastfm_login); + + // Libre.fm + connect(librefmscrobbler_, SIGNAL(AuthenticationComplete(bool, QString)), SLOT(LibreFM_AuthenticationComplete(bool, QString))); + connect(ui_->button_librefm_login, SIGNAL(clicked()), SLOT(LibreFM_Login())); + connect(ui_->widget_librefm_login_state, SIGNAL(LoginClicked()), SLOT(LibreFM_Login())); + connect(ui_->widget_librefm_login_state, SIGNAL(LogoutClicked()), SLOT(LibreFM_Logout())); + ui_->widget_librefm_login_state->AddCredentialGroup(ui_->widget_librefm_login); + + // ListenBrainz + connect(listenbrainzscrobbler_, SIGNAL(AuthenticationComplete(bool, QString)), SLOT(ListenBrainz_AuthenticationComplete(bool, QString))); + connect(ui_->button_listenbrainz_login, SIGNAL(clicked()), SLOT(ListenBrainz_Login())); + connect(ui_->widget_listenbrainz_login_state, SIGNAL(LoginClicked()), SLOT(ListenBrainz_Login())); + connect(ui_->widget_listenbrainz_login_state, SIGNAL(LogoutClicked()), SLOT(ListenBrainz_Logout())); + ui_->widget_listenbrainz_login_state->AddCredentialGroup(ui_->widget_listenbrainz_login); + + resize(sizeHint()); + +} + +ScrobblerSettingsPage::~ScrobblerSettingsPage() { delete ui_; } + +void ScrobblerSettingsPage::Load() { + + ui_->checkbox_enable->setChecked(scrobbler_->IsEnabled()); + ui_->checkbox_scrobble_button->setChecked(scrobbler_->ScrobbleButton()); + ui_->checkbox_offline->setChecked(scrobbler_->IsOffline()); + + ui_->checkbox_lastfm_enable->setChecked(lastfmscrobbler_->IsEnabled()); + LastFM_RefreshControls(lastfmscrobbler_->IsAuthenticated()); + + ui_->checkbox_librefm_enable->setChecked(librefmscrobbler_->IsEnabled()); + LibreFM_RefreshControls(librefmscrobbler_->IsAuthenticated()); + + ui_->checkbox_listenbrainz_enable->setChecked(listenbrainzscrobbler_->IsEnabled()); + ui_->lineedit_listenbrainz_user_token->setText(listenbrainzscrobbler_->user_token()); + ListenBrainz_RefreshControls(listenbrainzscrobbler_->IsAuthenticated()); + +} + +void ScrobblerSettingsPage::Save() { + + QSettings s; + + s.beginGroup(kSettingsGroup); + s.setValue("enabled", ui_->checkbox_enable->isChecked()); + s.setValue("scrobble_button", ui_->checkbox_scrobble_button->isChecked()); + s.setValue("offline", ui_->checkbox_offline->isChecked()); + s.endGroup(); + + s.beginGroup(LastFMScrobbler::kSettingsGroup); + s.setValue("enabled", ui_->checkbox_lastfm_enable->isChecked()); + s.endGroup(); + + s.beginGroup(LibreFMScrobbler::kSettingsGroup); + s.setValue("enabled", ui_->checkbox_librefm_enable->isChecked()); + s.endGroup(); + + s.beginGroup(ListenBrainzScrobbler::kSettingsGroup); + s.setValue("enabled", ui_->checkbox_listenbrainz_enable->isChecked()); + s.setValue("user_token", ui_->lineedit_listenbrainz_user_token->text()); + s.endGroup(); + + scrobbler_->ReloadSettings(); + +} + +void ScrobblerSettingsPage::LastFM_Login() { + + lastfm_waiting_for_auth_ = true; + ui_->widget_lastfm_login_state->SetLoggedIn(LoginStateWidget::LoginInProgress); + lastfmscrobbler_->Authenticate(); + +} + +void ScrobblerSettingsPage::LastFM_Logout() { + + lastfmscrobbler_->Logout(); + LastFM_RefreshControls(false); + +} + +void ScrobblerSettingsPage::LastFM_AuthenticationComplete(const bool success, QString error) { + + if (!lastfm_waiting_for_auth_) return; + lastfm_waiting_for_auth_ = false; + + if (success) { + Save(); + } + else { + QMessageBox::warning(this, "Authentication failed", error); + } + + LastFM_RefreshControls(success); + +} + +void ScrobblerSettingsPage::LastFM_RefreshControls(bool authenticated) { + ui_->widget_lastfm_login_state->SetLoggedIn(authenticated ? LoginStateWidget::LoggedIn : LoginStateWidget::LoggedOut, lastfmscrobbler_->username()); +} + +void ScrobblerSettingsPage::LibreFM_Login() { + + librefm_waiting_for_auth_ = true; + ui_->widget_librefm_login_state->SetLoggedIn(LoginStateWidget::LoginInProgress); + librefmscrobbler_->Authenticate(); + +} + +void ScrobblerSettingsPage::LibreFM_Logout() { + + librefmscrobbler_->Logout(); + LibreFM_RefreshControls(false); + +} + +void ScrobblerSettingsPage::LibreFM_AuthenticationComplete(const bool success, QString error) { + + if (!librefm_waiting_for_auth_) return; + librefm_waiting_for_auth_ = false; + + if (success) { + Save(); + } + else { + QMessageBox::warning(this, "Authentication failed", error); + } + + LibreFM_RefreshControls(success); + +} + +void ScrobblerSettingsPage::LibreFM_RefreshControls(bool authenticated) { + ui_->widget_librefm_login_state->SetLoggedIn(authenticated ? LoginStateWidget::LoggedIn : LoginStateWidget::LoggedOut, librefmscrobbler_->username()); +} + +void ScrobblerSettingsPage::ListenBrainz_Login() { + + listenbrainz_waiting_for_auth_ = true; + ui_->widget_listenbrainz_login_state->SetLoggedIn(LoginStateWidget::LoginInProgress); + listenbrainzscrobbler_->Authenticate(); + +} + +void ScrobblerSettingsPage::ListenBrainz_Logout() { + + listenbrainzscrobbler_->Logout(); + ListenBrainz_RefreshControls(false); + +} + +void ScrobblerSettingsPage::ListenBrainz_AuthenticationComplete(const bool success, QString error) { + + if (!listenbrainz_waiting_for_auth_) return; + listenbrainz_waiting_for_auth_ = false; + + if (success) { + Save(); + } + else { + QMessageBox::warning(this, "Authentication failed", error); + } + + ListenBrainz_RefreshControls(success); + +} + +void ScrobblerSettingsPage::ListenBrainz_RefreshControls(bool authenticated) { + ui_->widget_listenbrainz_login_state->SetLoggedIn(authenticated ? LoginStateWidget::LoggedIn : LoginStateWidget::LoggedOut); +} + + diff --git a/src/settings/scrobblersettingspage.h b/src/settings/scrobblersettingspage.h new file mode 100644 index 00000000..13f916f1 --- /dev/null +++ b/src/settings/scrobblersettingspage.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 SCROBBLERSETTINGSPAGE_H +#define SCROBBLERSETTINGSPAGE_H + +#include "settingspage.h" + +class Ui_ScrobblerSettingsPage; +class AudioScrobbler; +class LastFMScrobbler; +class LibreFMScrobbler; +class ListenBrainzScrobbler; + +class ScrobblerSettingsPage : public SettingsPage { + Q_OBJECT + + public: + explicit ScrobblerSettingsPage(SettingsDialog *dialog); + ~ScrobblerSettingsPage(); + + static const char *kSettingsGroup; + + void Load(); + void Save(); + + private slots: + void LastFM_Login(); + void LastFM_Logout(); + void LastFM_AuthenticationComplete(const bool success, QString error = QString()); + void LibreFM_Login(); + void LibreFM_Logout(); + void LibreFM_AuthenticationComplete(const bool success, QString error = QString()); + void ListenBrainz_Login(); + void ListenBrainz_Logout(); + void ListenBrainz_AuthenticationComplete(const bool success, QString error = QString()); + + private: + AudioScrobbler *scrobbler_; + LastFMScrobbler *lastfmscrobbler_; + LibreFMScrobbler *librefmscrobbler_; + ListenBrainzScrobbler *listenbrainzscrobbler_; + Ui_ScrobblerSettingsPage* ui_; + + bool lastfm_waiting_for_auth_; + bool librefm_waiting_for_auth_; + bool listenbrainz_waiting_for_auth_; + + void LastFM_RefreshControls(bool authenticated); + void LibreFM_RefreshControls(bool authenticated); + void ListenBrainz_RefreshControls(bool authenticated); + +}; + +#endif // SCROBBLERSETTINGSPAGE_H diff --git a/src/settings/scrobblersettingspage.ui b/src/settings/scrobblersettingspage.ui new file mode 100644 index 00000000..5abdfe19 --- /dev/null +++ b/src/settings/scrobblersettingspage.ui @@ -0,0 +1,243 @@ + + + ScrobblerSettingsPage + + + + 0 + 0 + 769 + 611 + + + + Scrobbler + + + + + + true + + + Enable + + + false + + + + + + + Enable offline mode + + + + + + + Show scrobble button + + + + + + + Last.fm + + + + + + Enable + + + + + + + + + + + + + + + Login + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + + + Libre.fm + + + + + + Enable + + + + + + + + + + + + + + + Login + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + + + Listenbrainz + + + + + + Enable + + + + + + + + + + 80 + 0 + + + + User token: + + + + + + + + + + + + <html><head/><body><p>Enter your user token found on: <a href="https://listenbrainz.org/profile/"><span style=" text-decoration: underline; color:#0000ff;">https://listenbrainz.org/profile/</span></a></p></body></html> + + + + + + + + + + + + + + + Login + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + LoginStateWidget + QWidget +
widgets/loginstatewidget.h
+ 1 +
+
+ + +
diff --git a/src/settings/settingsdialog.cpp b/src/settings/settingsdialog.cpp index 335eeae6..e4a62399 100644 --- a/src/settings/settingsdialog.cpp +++ b/src/settings/settingsdialog.cpp @@ -56,12 +56,13 @@ #include "backendsettingspage.h" #include "behavioursettingspage.h" #include "collectionsettingspage.h" -#include "networkproxysettingspage.h" #include "notificationssettingspage.h" #include "playbacksettingspage.h" #include "playlistsettingspage.h" #include "shortcutssettingspage.h" #include "transcodersettingspage.h" +#include "networkproxysettingspage.h" +#include "scrobblersettingspage.h" #ifdef HAVE_STREAM_TIDAL # include "tidalsettingspage.h" #endif @@ -123,10 +124,11 @@ SettingsDialog::SettingsDialog(Application *app, QWidget *parent) AddPage(Page_Backend, new BackendSettingsPage(this), general); AddPage(Page_Playback, new PlaybackSettingsPage(this), general); AddPage(Page_Playlist, new PlaylistSettingsPage(this), general); - AddPage(Page_Proxy, new NetworkProxySettingsPage(this), general); #ifdef HAVE_GSTREAMER AddPage(Page_Transcoding, new TranscoderSettingsPage(this), general); #endif + AddPage(Page_Proxy, new NetworkProxySettingsPage(this), general); + AddPage(Page_Scrobbler, new ScrobblerSettingsPage(this), general); QTreeWidgetItem *iface = AddCategory(tr("User interface")); AddPage(Page_Appearance, new AppearanceSettingsPage(this), iface); @@ -134,13 +136,13 @@ SettingsDialog::SettingsDialog(Application *app, QWidget *parent) AddPage(Page_GlobalShortcuts, new GlobalShortcutsSettingsPage(this), iface); #if defined(HAVE_STREAM_TIDAL) || defined(HAVE_STREAM_DEEZER) - QTreeWidgetItem *internet = AddCategory(tr("Streaming")); + QTreeWidgetItem *streaming = AddCategory(tr("Streaming")); #endif #ifdef HAVE_STREAM_TIDAL - AddPage(Page_Tidal, new TidalSettingsPage(this), internet); + AddPage(Page_Tidal, new TidalSettingsPage(this), streaming); #endif #ifdef HAVE_STREAM_DEEZER - AddPage(Page_Deezer, new DeezerSettingsPage(this), internet); + AddPage(Page_Deezer, new DeezerSettingsPage(this), streaming); #endif // List box diff --git a/src/settings/settingsdialog.h b/src/settings/settingsdialog.h index 219462cd..901b930d 100644 --- a/src/settings/settingsdialog.h +++ b/src/settings/settingsdialog.h @@ -79,8 +79,9 @@ public: Page_GlobalShortcuts, Page_Appearance, Page_Notifications, - Page_Proxy, Page_Transcoding, + Page_Proxy, + Page_Scrobbler, Page_Tidal, Page_Deezer, };