From 45bc353341b2698a964b68b69a8eeba0d61ce293 Mon Sep 17 00:00:00 2001 From: Pascal Below Date: Wed, 23 Sep 2020 17:55:12 +0200 Subject: [PATCH] Add Subsonic scrobble support (#545) * add SubsonicScrobbler, add Scrobble method in SubsonicService * new class SubsonicScrobbleRequest, use queue again, clean up * add checkbox to enable server-side scrobbling to Subsonic settings page * Check serversidescrobbling in SubsonicScrobbler::ReloadSettings instead of SubsonicService TODO: SubsonicScrobbler::ReloadSettings needs to be called when SubsonicSettings change. --- src/CMakeLists.txt | 4 + src/scrobbler/audioscrobbler.cpp | 2 + src/scrobbler/subsonicscrobbler.cpp | 129 ++++++++++++++++++ src/scrobbler/subsonicscrobbler.h | 75 +++++++++++ src/settings/subsonicsettingspage.cpp | 2 + src/settings/subsonicsettingspage.ui | 7 + src/subsonic/subsonicscrobblerequest.cpp | 161 +++++++++++++++++++++++ src/subsonic/subsonicscrobblerequest.h | 90 +++++++++++++ src/subsonic/subsonicservice.cpp | 17 +++ src/subsonic/subsonicservice.h | 4 + 10 files changed, 491 insertions(+) create mode 100644 src/scrobbler/subsonicscrobbler.cpp create mode 100644 src/scrobbler/subsonicscrobbler.h create mode 100644 src/subsonic/subsonicscrobblerequest.cpp create mode 100644 src/subsonic/subsonicscrobblerequest.h diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 887d2782..6c0e56cd 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -242,6 +242,7 @@ set(SOURCES scrobbler/librefmscrobbler.cpp scrobbler/listenbrainzscrobbler.cpp scrobbler/lastfmimport.cpp + scrobbler/subsonicscrobbler.cpp organize/organize.cpp organize/organizeformat.cpp @@ -456,6 +457,7 @@ set(HEADERS scrobbler/librefmscrobbler.h scrobbler/listenbrainzscrobbler.h scrobbler/lastfmimport.h + scrobbler/subsonicscrobbler.h organize/organize.h organize/organizedialog.h @@ -903,12 +905,14 @@ optional_source(HAVE_SUBSONIC subsonic/subsonicurlhandler.cpp subsonic/subsonicbaserequest.cpp subsonic/subsonicrequest.cpp + subsonic/subsonicscrobblerequest.cpp settings/subsonicsettingspage.cpp HEADERS subsonic/subsonicservice.h subsonic/subsonicurlhandler.h subsonic/subsonicbaserequest.h subsonic/subsonicrequest.h + subsonic/subsonicscrobblerequest.h settings/subsonicsettingspage.h UI settings/subsonicsettingspage.ui diff --git a/src/scrobbler/audioscrobbler.cpp b/src/scrobbler/audioscrobbler.cpp index fa44eafc..70ca2b67 100644 --- a/src/scrobbler/audioscrobbler.cpp +++ b/src/scrobbler/audioscrobbler.cpp @@ -37,6 +37,7 @@ #include "lastfmscrobbler.h" #include "librefmscrobbler.h" #include "listenbrainzscrobbler.h" +#include "subsonicscrobbler.h" AudioScrobbler::AudioScrobbler(Application *app, QObject *parent) : QObject(parent), @@ -54,6 +55,7 @@ AudioScrobbler::AudioScrobbler(Application *app, QObject *parent) : scrobbler_services_->AddService(new LastFMScrobbler(app_, scrobbler_services_)); scrobbler_services_->AddService(new LibreFMScrobbler(app_, scrobbler_services_)); scrobbler_services_->AddService(new ListenBrainzScrobbler(app_, scrobbler_services_)); + scrobbler_services_->AddService(new SubsonicScrobbler(app_, scrobbler_services_)); ReloadSettings(); diff --git a/src/scrobbler/subsonicscrobbler.cpp b/src/scrobbler/subsonicscrobbler.cpp new file mode 100644 index 00000000..c368eca5 --- /dev/null +++ b/src/scrobbler/subsonicscrobbler.cpp @@ -0,0 +1,129 @@ +/* + * Strawberry Music Player + * Copyright 2018, Jonas Kvinge + * Copyright 2020, Pascal Below + * + * 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 "core/application.h" +#include "core/song.h" +#include "core/timeconstants.h" +#include "core/logging.h" +#include "core/closure.h" +#include "internet/internetservices.h" +#include "settings/subsonicsettingspage.h" +#include "subsonic/subsonicservice.h" + +#include "audioscrobbler.h" +#include "scrobblerservice.h" +#include "subsonicscrobbler.h" + +const char *SubsonicScrobbler::kName = "Subsonic"; + +SubsonicScrobbler::SubsonicScrobbler(Application *app, QObject *parent) : ScrobblerService(kName, app, parent), + app_(app), + service_(app->internet_services()->Service()), + enabled_(false), + submitted_(false) { + + ReloadSettings(); + +} + +SubsonicScrobbler::~SubsonicScrobbler() { + +} + +void SubsonicScrobbler::ReloadSettings() { + + QSettings s; + s.beginGroup(SubsonicSettingsPage::kSettingsGroup); + enabled_ = s.value("serversidescrobbling", false).toBool(); + s.endGroup(); + +} + +void SubsonicScrobbler::UpdateNowPlaying(const Song &song) { + + if (song.source() != Song::Source::Source_Subsonic) return; + + song_playing_ = song; + time_ = QDateTime::currentDateTime(); + + if (!song.is_metadata_good() || app_->scrobbler()->IsOffline()) return; + + service_->Scrobble(song.song_id(), false, time_); + +} + +void SubsonicScrobbler::ClearPlaying() { + + song_playing_ = Song(); + time_ = QDateTime(); + +} + +void SubsonicScrobbler::Scrobble(const Song &song) { + + if (song.source() != Song::Source::Source_Subsonic) return; + + if (song.id() != song_playing_.id() || song.url() != song_playing_.url() || !song.is_metadata_good()) return; + + if (app_->scrobbler()->IsOffline()) return; + + if (!submitted_) { + submitted_ = true; + if (app_->scrobbler()->SubmitDelay() <= 0) { + Submit(); + } + else { + qint64 msec = (app_->scrobbler()->SubmitDelay() * 60 * kMsecPerSec); + DoAfter(this, SLOT(Submit()), msec); + } + } + +} + +void SubsonicScrobbler::DoSubmit() { + +} + +void SubsonicScrobbler::Submit() { + + qLog(Debug) << "SubsonicScrobbler: Submitting scrobble for " << song_playing_.song_id(); + submitted_ = false; + + if (app_->scrobbler()->IsOffline()) return; + + service_->Scrobble(song_playing_.song_id(), true, time_); + +} + +void SubsonicScrobbler::Error(const QString &error, const QVariant &debug) { + + qLog(Error) << "SubsonicScrobbler:" << error; + if (debug.isValid()) qLog(Debug) << debug; + +} diff --git a/src/scrobbler/subsonicscrobbler.h b/src/scrobbler/subsonicscrobbler.h new file mode 100644 index 00000000..053c0221 --- /dev/null +++ b/src/scrobbler/subsonicscrobbler.h @@ -0,0 +1,75 @@ +/* + * Strawberry Music Player + * Copyright 2018, Jonas Kvinge + * Copyright 2020, Pascal Below + * + * 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 SUBSONICSCROBBLER_H +#define SUBSONICSCROBBLER_H + +#include "config.h" + +#include +#include +#include +#include +#include + +#include "core/song.h" +#include "scrobblerservice.h" + +class Application; +class SubsonicService; + +class SubsonicScrobbler : public ScrobblerService { + Q_OBJECT + + public: + explicit SubsonicScrobbler(Application *app, QObject *parent = nullptr); + ~SubsonicScrobbler() override; + + static const char *kName; + + void ReloadSettings() override; + + bool IsEnabled() const override { return enabled_; } + bool IsAuthenticated() const override { return true; } + + void UpdateNowPlaying(const Song &song) override; + void ClearPlaying() override; + void Scrobble(const Song &song) override; + void Error(const QString &error, const QVariant &debug = QVariant()) override; + + void DoSubmit() override; + void Submitted() override { submitted_ = true; } + bool IsSubmitted() const override { return submitted_; } + + public slots: + void WriteCache() override {} + void Submit() override; + + private: + Application *app_; + SubsonicService *service_; + bool enabled_; + bool submitted_; + Song song_playing_; + QDateTime time_; + +}; + +#endif // SUBSONICSCROBBLER_H diff --git a/src/settings/subsonicsettingspage.cpp b/src/settings/subsonicsettingspage.cpp index e273b443..d388844c 100644 --- a/src/settings/subsonicsettingspage.cpp +++ b/src/settings/subsonicsettingspage.cpp @@ -75,6 +75,7 @@ void SubsonicSettingsPage::Load() { else ui_->password->setText(QString::fromUtf8(QByteArray::fromBase64(password))); ui_->checkbox_verify_certificate->setChecked(s.value("verifycertificate", false).toBool()); ui_->checkbox_download_album_covers->setChecked(s.value("downloadalbumcovers", true).toBool()); + ui_->checkbox_server_scrobbling->setChecked(s.value("serversidescrobbling", false).toBool()); s.endGroup(); Init(ui_->layout_subsonicsettingspage->parentWidget()); @@ -91,6 +92,7 @@ void SubsonicSettingsPage::Save() { s.setValue("password", QString::fromUtf8(ui_->password->text().toUtf8().toBase64())); s.setValue("verifycertificate", ui_->checkbox_verify_certificate->isChecked()); s.setValue("downloadalbumcovers", ui_->checkbox_download_album_covers->isChecked()); + s.setValue("serversidescrobbling", ui_->checkbox_server_scrobbling->isChecked()); s.endGroup(); service_->ReloadSettings(); diff --git a/src/settings/subsonicsettingspage.ui b/src/settings/subsonicsettingspage.ui index 6658ca4f..7aa7a6d8 100644 --- a/src/settings/subsonicsettingspage.ui +++ b/src/settings/subsonicsettingspage.ui @@ -120,6 +120,13 @@ + + + + Server-side scrobbling + + + diff --git a/src/subsonic/subsonicscrobblerequest.cpp b/src/subsonic/subsonicscrobblerequest.cpp new file mode 100644 index 00000000..4f211625 --- /dev/null +++ b/src/subsonic/subsonicscrobblerequest.cpp @@ -0,0 +1,161 @@ +/* + * Strawberry Music Player + * Copyright 2019, Jonas Kvinge + * Copyright 2020, Pascal Below + * + * 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 "core/application.h" +#include "core/logging.h" +#include "subsonicservice.h" +#include "subsonicbaserequest.h" +#include "subsonicscrobblerequest.h" + +const int SubsonicScrobbleRequest::kMaxConcurrentScrobbleRequests = 3; + +SubsonicScrobbleRequest::SubsonicScrobbleRequest(SubsonicService *service, SubsonicUrlHandler *url_handler, Application *app, QObject *parent) + : SubsonicBaseRequest(service, parent), + service_(service), + url_handler_(url_handler), + app_(app), + scrobble_requests_active_(0) + { + +} + +SubsonicScrobbleRequest::~SubsonicScrobbleRequest() { + + while (!replies_.isEmpty()) { + QNetworkReply *reply = replies_.takeFirst(); + disconnect(reply, nullptr, this, nullptr); + if (reply->isRunning()) reply->abort(); + reply->deleteLater(); + } + +} + +void SubsonicScrobbleRequest::CreateScrobbleRequest(const QString song_id, const bool submission, const QDateTime start_time) { + + Request request; + request.song_id = song_id; + request.submission = submission; + request.time_ms = start_time.toMSecsSinceEpoch(); + scrobble_requests_queue_.enqueue(request); + if (scrobble_requests_active_ < kMaxConcurrentScrobbleRequests) FlushScrobbleRequests(); + +} + +void SubsonicScrobbleRequest::FlushScrobbleRequests() { + + while (!scrobble_requests_queue_.isEmpty() && scrobble_requests_active_ < kMaxConcurrentScrobbleRequests) { + + Request request = scrobble_requests_queue_.dequeue(); + ++scrobble_requests_active_; + + ParamList params = ParamList() << + Param("id", request.song_id) << + Param("submission", QVariant(request.submission).toString()) << + Param("time", QVariant(request.time_ms).toString()); + + QNetworkReply *reply; + reply = CreateGetRequest(QString("scrobble"), params); + replies_ << reply; + connect(reply, &QNetworkReply::finished, [=] { ScrobbleReplyReceived(reply); }); + + } + +} + +void SubsonicScrobbleRequest::ScrobbleReplyReceived(QNetworkReply *reply) { + + if (!replies_.contains(reply)) return; + replies_.removeAll(reply); + disconnect(reply, nullptr, this, nullptr); + reply->deleteLater(); + + --scrobble_requests_active_; + + // "subsonic-response" is empty on success, but some keys like status, version, or type might be present. + // Therefore we can only check for errors. + QByteArray data = GetReplyData(reply); + + if (data.isEmpty()) { + FinishCheck(); + return; + } + + QJsonObject json_obj = ExtractJsonObj(data); + if (json_obj.isEmpty()) { + FinishCheck(); + return; + } + + if (json_obj.contains("error")) { + QJsonValue json_error = json_obj["error"]; + if (!json_error.isObject()) { + Error("Json error is not an object.", json_obj); + FinishCheck(); + return; + } + json_obj = json_error.toObject(); + if (!json_obj.isEmpty() && json_obj.contains("code") && json_obj.contains("message")) { + int code = json_obj["code"].toInt(); + QString message = json_obj["message"].toString(); + Error(QString("%1 (%2)").arg(message).arg(code)); + FinishCheck(); + } + else { + Error("Json error object is missing code or message.", json_obj); + FinishCheck(); + return; + } + return; + } + + FinishCheck(); + +} + +void SubsonicScrobbleRequest::FinishCheck() { + + if (!scrobble_requests_queue_.isEmpty() && scrobble_requests_active_ < kMaxConcurrentScrobbleRequests) FlushScrobbleRequests(); + +} + +void SubsonicScrobbleRequest::Error(const QString &error, const QVariant &debug) { + + if (!error.isEmpty()) { + qLog(Error) << "SubsonicScrobbleRequest:" << error; + errors_ << error; + } + if (debug.isValid()) qLog(Debug) << debug; + + FinishCheck(); + +} diff --git a/src/subsonic/subsonicscrobblerequest.h b/src/subsonic/subsonicscrobblerequest.h new file mode 100644 index 00000000..e1c2d0b0 --- /dev/null +++ b/src/subsonic/subsonicscrobblerequest.h @@ -0,0 +1,90 @@ +/* + * Strawberry Music Player + * Copyright 2019, Jonas Kvinge + * Copyright 2020, Pascal Below + * + * 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 SUBSONICSCROBBLEREQUEST_H +#define SUBSONICSCROBBLEREQUEST_H + +#include "config.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "subsonicbaserequest.h" + +class QNetworkReply; +class Application; +class SubsonicService; +class SubsonicUrlHandler; + +class SubsonicScrobbleRequest : public SubsonicBaseRequest { + Q_OBJECT + + public: + explicit SubsonicScrobbleRequest(SubsonicService *service, SubsonicUrlHandler *url_handler, Application *app, QObject *parent); + ~SubsonicScrobbleRequest() override; + + void CreateScrobbleRequest(const QString song_id, const bool submission, const QDateTime start_time); + + private slots: + void ScrobbleReplyReceived(QNetworkReply *reply); + + private: + typedef QPair Param; + typedef QList ParamList; + + struct Request { + explicit Request() : submission(false) {} + // subsonic song id + QString song_id; + // submission: true=Submission, false=NowPlaying + bool submission; + // song start time + qint64 time_ms; + }; + + void FlushScrobbleRequests(); + void FinishCheck(); + + void Error(const QString &error, const QVariant &debug = QVariant()) override; + + static const int kMaxConcurrentScrobbleRequests; + + SubsonicService *service_; + SubsonicUrlHandler *url_handler_; + Application *app_; + + QQueue scrobble_requests_queue_; + + int scrobble_requests_active_; + + QStringList errors_; + QList replies_; + +}; + +#endif // SUBSONICSCROBBLEREQUEST_H diff --git a/src/subsonic/subsonicservice.cpp b/src/subsonic/subsonicservice.cpp index c07edb1e..4ee84eb6 100644 --- a/src/subsonic/subsonicservice.cpp +++ b/src/subsonic/subsonicservice.cpp @@ -27,6 +27,7 @@ #include #include #include +#include #include #include #include @@ -53,6 +54,7 @@ #include "subsonicservice.h" #include "subsonicurlhandler.h" #include "subsonicrequest.h" +#include "subsonicscrobblerequest.h" #include "settings/settingsdialog.h" #include "settings/subsonicsettingspage.h" @@ -379,6 +381,21 @@ void SubsonicService::CheckConfiguration() { } +void SubsonicService::Scrobble(QString song_id, bool submission, QDateTime time) { + + if (!server_url().isValid() || username().isEmpty() || password().isEmpty()) { + return; + } + + if (!scrobble_request_.get()) { + // we're doing requests every 30-240s the whole time, so keep reusing this instance + scrobble_request_.reset(new SubsonicScrobbleRequest(this, url_handler_, app_, this)); + } + + scrobble_request_->CreateScrobbleRequest(song_id, submission, time); + +} + void SubsonicService::ResetSongsRequest() { if (songs_request_.get()) { diff --git a/src/subsonic/subsonicservice.h b/src/subsonic/subsonicservice.h index 695d36eb..046dae68 100644 --- a/src/subsonic/subsonicservice.h +++ b/src/subsonic/subsonicservice.h @@ -35,6 +35,7 @@ #include #include #include +#include #include "core/song.h" #include "internet/internetservice.h" @@ -45,6 +46,7 @@ class QNetworkReply; class Application; class SubsonicUrlHandler; class SubsonicRequest; +class SubsonicScrobbleRequest; class CollectionBackend; class CollectionModel; @@ -79,6 +81,7 @@ class SubsonicService : public InternetService { QSortFilterProxyModel *songs_collection_sort_model() override { return collection_sort_model_; } void CheckConfiguration(); + void Scrobble(QString song_id, bool submission, QDateTime time); public slots: void ShowConfig() override; @@ -116,6 +119,7 @@ class SubsonicService : public InternetService { QSortFilterProxyModel *collection_sort_model_; std::shared_ptr songs_request_; + std::shared_ptr scrobble_request_; QUrl server_url_; QString username_;