Scrobbler: Refactor

This commit is contained in:
Jonas Kvinge
2025-03-08 23:19:42 +01:00
parent cd516c37b9
commit b02b114caf
18 changed files with 508 additions and 800 deletions

View File

@@ -3,7 +3,7 @@
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2012, 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2018-2024, Jonas Kvinge <jonas@jkvinge.net>
* Copyright 2018-2025, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -208,7 +208,7 @@ class ApplicationImpl {
scrobbler->AddService(make_shared<LibreFMScrobbler>(scrobbler->settings(), app->network()));
scrobbler->AddService(make_shared<ListenBrainzScrobbler>(scrobbler->settings(), app->network()));
#ifdef HAVE_SUBSONIC
scrobbler->AddService(make_shared<SubsonicScrobbler>(scrobbler->settings(), app->streaming_services()->Service<SubsonicService>(), app));
scrobbler->AddService(make_shared<SubsonicScrobbler>(scrobbler->settings(), app->network(), app->streaming_services()->Service<SubsonicService>(), app));
#endif
return scrobbler;
}),

View File

@@ -1,6 +1,6 @@
/*
* Strawberry Music Player
* Copyright 2018-2021, Jonas Kvinge <jonas@jkvinge.net>
* Copyright 2018-2025, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by

View File

@@ -1,6 +1,6 @@
/*
* Strawberry Music Player
* Copyright 2018-2021, Jonas Kvinge <jonas@jkvinge.net>
* Copyright 2018-2025, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by

View File

@@ -1,6 +1,6 @@
/*
* Strawberry Music Player
* Copyright 2020-2021, Jonas Kvinge <jonas@jkvinge.net>
* Copyright 2020-2025, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -55,7 +55,7 @@ constexpr int kRequestsDelay = 2000;
}
LastFMImport::LastFMImport(const SharedPtr<NetworkAccessManager> network, QObject *parent)
: QObject(parent),
: JsonBaseRequest(network, parent),
network_(network),
timer_flush_requests_(new QTimer(this)),
lastplayed_(false),
@@ -117,19 +117,7 @@ QNetworkReply *LastFMImport::CreateRequest(const ParamList &request_params) {
std::sort(params.begin(), params.end());
QUrlQuery url_query;
for (const Param &param : std::as_const(params)) {
url_query.addQueryItem(QString::fromLatin1(QUrl::toPercentEncoding(param.first)), QString::fromLatin1(QUrl::toPercentEncoding(param.second)));
}
QUrl url(QString::fromLatin1(LastFMScrobbler::kApiUrl));
url.setQuery(url_query);
QNetworkRequest req(url);
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
req.setHeader(QNetworkRequest::ContentTypeHeader, u"application/x-www-form-urlencoded"_s);
QNetworkReply *reply = network_->get(req);
replies_ << reply;
QNetworkReply *reply = CreateGetRequest(QUrl(QLatin1String(LastFMScrobbler::kApiUrl)), params);
//qLog(Debug) << "Sending request" << url_query.toString(QUrl::FullyDecoded);
@@ -137,73 +125,52 @@ QNetworkReply *LastFMImport::CreateRequest(const ParamList &request_params) {
}
QByteArray LastFMImport::GetReplyData(QNetworkReply *reply) {
JsonBaseRequest::JsonObjectResult LastFMImport::ParseJsonObject(QNetworkReply *reply) {
QByteArray data;
if (reply->error() == QNetworkReply::NoError && reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() == 200) {
data = reply->readAll();
if (reply->error() != QNetworkReply::NoError && reply->error() < 200) {
return ReplyDataResult(ErrorCode::NetworkError, QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error()));
}
else {
if (reply->error() != QNetworkReply::NoError && reply->error() < 200) {
// This is a network error, there is nothing more to do.
Error(QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error()));
JsonObjectResult result(ErrorCode::Success);
result.network_error = reply->error();
if (reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).isValid()) {
result.http_status_code = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
}
const QByteArray data = reply->readAll();
if (!data.isEmpty()) {
QJsonParseError json_parse_error;
const QJsonDocument json_document = QJsonDocument::fromJson(data, &json_parse_error);
if (json_parse_error.error == QJsonParseError::NoError) {
const QJsonObject json_object = json_document.object();
if (json_object.contains("error"_L1) && json_object.contains("message"_L1)) {
const int error = json_object["error"_L1].toInt();
const QString message = json_object["message"_L1].toString();
result.error_code = ErrorCode::APIError;
result.error_message = QStringLiteral("%1 (%2)").arg(message).arg(error);
}
else {
result.json_object = json_document.object();
}
}
else {
QString error;
// See if there is Json data containing "error" and "message" - then use that instead.
data = reply->readAll();
QJsonParseError json_error;
QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error);
if (json_error.error == QJsonParseError::NoError && !json_doc.isEmpty() && json_doc.isObject()) {
QJsonObject json_obj = json_doc.object();
if (json_obj.contains("error"_L1) && json_obj.contains("message"_L1)) {
int error_code = json_obj["error"_L1].toInt();
QString error_message = json_obj["message"_L1].toString();
error = QStringLiteral("%1 (%2)").arg(error_message).arg(error_code);
}
}
if (error.isEmpty()) {
if (reply->error() != QNetworkReply::NoError) {
error = QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error());
}
else {
error = QStringLiteral("Received HTTP code %1").arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt());
}
}
Error(error);
result.error_code = ErrorCode::ParseError;
result.error_message = json_parse_error.errorString();
}
return QByteArray();
}
return data;
}
QJsonObject LastFMImport::ExtractJsonObj(const QByteArray &data) {
QJsonParseError error;
QJsonDocument json_doc = QJsonDocument::fromJson(data, &error);
if (error.error != QJsonParseError::NoError) {
Error(u"Reply from server missing Json data."_s, data);
return QJsonObject();
}
if (json_doc.isEmpty()) {
Error(u"Received empty Json document."_s, json_doc);
return QJsonObject();
}
if (!json_doc.isObject()) {
Error(u"Json document is not an object."_s, json_doc);
return QJsonObject();
}
QJsonObject json_obj = json_doc.object();
if (json_obj.isEmpty()) {
Error(u"Received empty Json object."_s, json_doc);
return QJsonObject();
if (result.error_code != ErrorCode::APIError) {
if (reply->error() != QNetworkReply::NoError) {
result.error_code = ErrorCode::NetworkError;
result.error_message = QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error());
}
else if (result.http_status_code != 200) {
result.error_code = ErrorCode::HttpError;
result.error_message = QStringLiteral("Received HTTP code %1").arg(result.http_status_code);
}
}
return json_obj;
return result;
}
@@ -279,72 +246,65 @@ void LastFMImport::GetRecentTracksRequestFinished(QNetworkReply *reply, const in
QObject::disconnect(reply, nullptr, this, nullptr);
reply->deleteLater();
QByteArray data = GetReplyData(reply);
if (data.isEmpty()) {
const JsonObjectResult json_object_result = ParseJsonObject(reply);
if (!json_object_result.success()) {
Error(json_object_result.error_message);
return;
}
QJsonObject json_obj = ExtractJsonObj(data);
if (json_obj.isEmpty()) {
QJsonObject json_object = json_object_result.json_object;
if (json_object.isEmpty()) {
return;
}
if (json_obj.contains("error"_L1) && json_obj.contains("message"_L1)) {
int error_code = json_obj["error"_L1].toInt();
QString error_message = json_obj["message"_L1].toString();
QString error_reason = QStringLiteral("%1 (%2)").arg(error_message).arg(error_code);
Error(error_reason);
if (!json_object.contains("recenttracks"_L1)) {
Error(u"JSON reply from server is missing recenttracks."_s, json_object);
return;
}
if (!json_obj.contains("recenttracks"_L1)) {
Error(u"JSON reply from server is missing recenttracks."_s, json_obj);
if (!json_object["recenttracks"_L1].isObject()) {
Error(u"Failed to parse JSON: recenttracks is not an object!"_s, json_object);
return;
}
json_object = json_object["recenttracks"_L1].toObject();
if (!json_object.contains("@attr"_L1)) {
Error(u"JSON reply from server is missing @attr."_s, json_object);
return;
}
if (!json_obj["recenttracks"_L1].isObject()) {
Error(u"Failed to parse JSON: recenttracks is not an object!"_s, json_obj);
return;
}
json_obj = json_obj["recenttracks"_L1].toObject();
if (!json_obj.contains("@attr"_L1)) {
Error(u"JSON reply from server is missing @attr."_s, json_obj);
if (!json_object.contains("track"_L1)) {
Error(u"JSON reply from server is missing track."_s, json_object);
return;
}
if (!json_obj.contains("track"_L1)) {
Error(u"JSON reply from server is missing track."_s, json_obj);
if (!json_object["@attr"_L1].isObject()) {
Error(u"Failed to parse JSON: @attr is not an object."_s, json_object);
return;
}
if (!json_obj["@attr"_L1].isObject()) {
Error(u"Failed to parse JSON: @attr is not an object."_s, json_obj);
if (!json_object["track"_L1].isArray()) {
Error(u"Failed to parse JSON: track is not an object."_s, json_object);
return;
}
if (!json_obj["track"_L1].isArray()) {
Error(u"Failed to parse JSON: track is not an object."_s, json_obj);
return;
}
QJsonObject obj_attr = json_obj["@attr"_L1].toObject();
const QJsonObject obj_attr = json_object["@attr"_L1].toObject();
if (!obj_attr.contains("page"_L1)) {
Error(u"Failed to parse JSON: attr object is missing page."_s, json_obj);
Error(u"Failed to parse JSON: attr object is missing page."_s, json_object);
return;
}
if (!obj_attr.contains("totalPages"_L1)) {
Error(u"Failed to parse JSON: attr object is missing totalPages."_s, json_obj);
Error(u"Failed to parse JSON: attr object is missing totalPages."_s, json_object);
return;
}
if (!obj_attr.contains("total"_L1)) {
Error(u"Failed to parse JSON: attr object is missing total."_s, json_obj);
Error(u"Failed to parse JSON: attr object is missing total."_s, json_object);
return;
}
int total = obj_attr["total"_L1].toString().toInt();
int pages = obj_attr["totalPages"_L1].toString().toInt();
const int total = obj_attr["total"_L1].toString().toInt();
const int pages = obj_attr["totalPages"_L1].toString().toInt();
if (page == 0) {
lastplayed_total_ = total;
@@ -353,7 +313,7 @@ void LastFMImport::GetRecentTracksRequestFinished(QNetworkReply *reply, const in
}
else {
const QJsonArray array_track = json_obj["track"_L1].toArray();
const QJsonArray array_track = json_object["track"_L1].toArray();
for (const QJsonValue &value_track : array_track) {
@@ -374,19 +334,19 @@ void LastFMImport::GetRecentTracksRequestFinished(QNetworkReply *reply, const in
continue;
}
QJsonObject obj_artist = obj_track["artist"_L1].toObject();
QJsonObject obj_album = obj_track["album"_L1].toObject();
QJsonObject obj_date = obj_track["date"_L1].toObject();
const QJsonObject obj_artist = obj_track["artist"_L1].toObject();
const QJsonObject obj_album = obj_track["album"_L1].toObject();
const QJsonObject obj_date = obj_track["date"_L1].toObject();
if (!obj_artist.contains("#text"_L1) || !obj_album.contains("#text"_L1) || !obj_date.contains("#text"_L1)) {
continue;
}
QString artist = obj_artist["#text"_L1].toString();
QString album = obj_album["#text"_L1].toString();
QString date = obj_date["#text"_L1].toString();
QString title = obj_track["name"_L1].toString();
QDateTime datetime = QDateTime::fromString(date, u"dd MMM yyyy, hh:mm"_s);
const QString artist = obj_artist["#text"_L1].toString();
const QString album = obj_album["#text"_L1].toString();
const QString date = obj_date["#text"_L1].toString();
const QString title = obj_track["name"_L1].toString();
const QDateTime datetime = QDateTime::fromString(date, u"dd MMM yyyy, hh:mm"_s);
if (datetime.isValid()) {
Q_EMIT UpdateLastPlayed(artist, album, title, datetime.toSecsSinceEpoch());
}
@@ -442,72 +402,65 @@ void LastFMImport::GetTopTracksRequestFinished(QNetworkReply *reply, const int p
QObject::disconnect(reply, nullptr, this, nullptr);
reply->deleteLater();
QByteArray data = GetReplyData(reply);
if (data.isEmpty()) {
const JsonObjectResult json_object_result = ParseJsonObject(reply);
if (!json_object_result.success()) {
Error(json_object_result.error_message);
return;
}
QJsonObject json_obj = ExtractJsonObj(data);
if (json_obj.isEmpty()) {
QJsonObject json_object = json_object_result.json_object;
if (json_object.isEmpty()) {
return;
}
if (json_obj.contains("error"_L1) && json_obj.contains("message"_L1)) {
int error_code = json_obj["error"_L1].toInt();
QString error_message = json_obj["message"_L1].toString();
QString error_reason = QStringLiteral("%1 (%2)").arg(error_message).arg(error_code);
Error(error_reason);
if (!json_object.contains("toptracks"_L1)) {
Error(u"JSON reply from server is missing toptracks."_s, json_object);
return;
}
if (!json_obj.contains("toptracks"_L1)) {
Error(u"JSON reply from server is missing toptracks."_s, json_obj);
if (!json_object["toptracks"_L1].isObject()) {
Error(u"Failed to parse JSON: toptracks is not an object!"_s, json_object);
return;
}
json_object = json_object["toptracks"_L1].toObject();
if (!json_object.contains("@attr"_L1)) {
Error(u"JSON reply from server is missing @attr."_s, json_object);
return;
}
if (!json_obj["toptracks"_L1].isObject()) {
Error(u"Failed to parse JSON: toptracks is not an object!"_s, json_obj);
return;
}
json_obj = json_obj["toptracks"_L1].toObject();
if (!json_obj.contains("@attr"_L1)) {
Error(u"JSON reply from server is missing @attr."_s, json_obj);
if (!json_object.contains("track"_L1)) {
Error(u"JSON reply from server is missing track."_s, json_object);
return;
}
if (!json_obj.contains("track"_L1)) {
Error(u"JSON reply from server is missing track."_s, json_obj);
if (!json_object["@attr"_L1].isObject()) {
Error(u"Failed to parse JSON: @attr is not an object."_s, json_object);
return;
}
if (!json_obj["@attr"_L1].isObject()) {
Error(u"Failed to parse JSON: @attr is not an object."_s, json_obj);
if (!json_object["track"_L1].isArray()) {
Error(u"Failed to parse JSON: track is not an object."_s, json_object);
return;
}
if (!json_obj["track"_L1].isArray()) {
Error(u"Failed to parse JSON: track is not an object."_s, json_obj);
const QJsonObject object_attr = json_object["@attr"_L1].toObject();
if (!object_attr.contains("page"_L1)) {
Error(u"Failed to parse JSON: attr object is missing page."_s, json_object);
return;
}
if (!object_attr.contains("totalPages"_L1)) {
Error(u"Failed to parse JSON: attr object is missing page."_s, json_object);
return;
}
if (!object_attr.contains("total"_L1)) {
Error(u"Failed to parse JSON: attr object is missing total."_s, json_object);
return;
}
QJsonObject obj_attr = json_obj["@attr"_L1].toObject();
if (!obj_attr.contains("page"_L1)) {
Error(u"Failed to parse JSON: attr object is missing page."_s, json_obj);
return;
}
if (!obj_attr.contains("totalPages"_L1)) {
Error(u"Failed to parse JSON: attr object is missing page."_s, json_obj);
return;
}
if (!obj_attr.contains("total"_L1)) {
Error(u"Failed to parse JSON: attr object is missing total."_s, json_obj);
return;
}
int pages = obj_attr["totalPages"_L1].toString().toInt();
int total = obj_attr["total"_L1].toString().toInt();
const int pages = object_attr["totalPages"_L1].toString().toInt();
const int total = object_attr["total"_L1].toString().toInt();
if (page == 0) {
playcount_total_ = total;
@@ -516,8 +469,8 @@ void LastFMImport::GetTopTracksRequestFinished(QNetworkReply *reply, const int p
}
else {
QJsonArray array_track = json_obj["track"_L1].toArray();
for (QJsonArray::iterator it = array_track.begin(); it != array_track.end(); ++it) {
const QJsonArray array_track = json_object["track"_L1].toArray();
for (QJsonArray::ConstIterator it = array_track.begin(); it != array_track.constEnd(); ++it) {
const QJsonValue &value_track = *it;
@@ -527,7 +480,7 @@ void LastFMImport::GetTopTracksRequestFinished(QNetworkReply *reply, const int p
continue;
}
QJsonObject obj_track = value_track.toObject();
const QJsonObject obj_track = value_track.toObject();
if (!obj_track.contains("artist"_L1) ||
!obj_track.contains("name"_L1) ||
!obj_track.contains("playcount"_L1) ||
@@ -536,14 +489,14 @@ void LastFMImport::GetTopTracksRequestFinished(QNetworkReply *reply, const int p
continue;
}
QJsonObject obj_artist = obj_track["artist"_L1].toObject();
const QJsonObject obj_artist = obj_track["artist"_L1].toObject();
if (!obj_artist.contains("name"_L1)) {
continue;
}
QString artist = obj_artist["name"_L1].toString();
QString title = obj_track["name"_L1].toString();
int playcount = obj_track["playcount"_L1].toString().toInt();
const QString artist = obj_artist["name"_L1].toString();
const QString title = obj_track["name"_L1].toString();
const int playcount = obj_track["playcount"_L1].toString().toInt();
if (playcount <= 0) continue;

View File

@@ -1,6 +1,6 @@
/*
* Strawberry Music Player
* Copyright 2020-2021, Jonas Kvinge <jonas@jkvinge.net>
* Copyright 2020-2025, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -22,8 +22,6 @@
#include "config.h"
#include <QtGlobal>
#include <QObject>
#include <QList>
#include <QVariant>
#include <QByteArray>
@@ -31,20 +29,28 @@
#include <QQueue>
#include <QDateTime>
#include "core/jsonbaserequest.h"
#include "includes/shared_ptr.h"
#include "core/jsonbaserequest.h"
class QTimer;
class QNetworkReply;
class NetworkAccessManager;
class LastFMImport : public QObject {
class LastFMImport : public JsonBaseRequest {
Q_OBJECT
public:
explicit LastFMImport(const SharedPtr<NetworkAccessManager> network, QObject *parent = nullptr);
~LastFMImport() override;
QString service_name() const override { return QStringLiteral("LastFMImport"); }
bool authentication_required() const override { return false; }
bool authenticated() const override { return false; }
bool use_authorization_header() const override { return false; }
QByteArray authorization_header() const override { return QByteArray(); }
void ReloadSettings();
void ImportData(const bool lastplayed = true, const bool playcount = true);
void AbortAll();
@@ -64,8 +70,7 @@ class LastFMImport : public QObject {
private:
QNetworkReply *CreateRequest(const ParamList &request_params);
QByteArray GetReplyData(QNetworkReply *reply);
QJsonObject ExtractJsonObj(const QByteArray &data);
JsonObjectResult ParseJsonObject(QNetworkReply *reply);
void AddGetRecentTracksRequest(const int page = 0);
void AddGetTopTracksRequest(const int page = 0);
@@ -73,7 +78,7 @@ class LastFMImport : public QObject {
void SendGetRecentTracksRequest(GetRecentTracksRequest request);
void SendGetTopTracksRequest(GetTopTracksRequest request);
void Error(const QString &error, const QVariant &debug = QVariant());
void Error(const QString &error, const QVariant &debug = QVariant()) override;
void UpdateTotalCheck();
void UpdateProgressCheck();
@@ -86,7 +91,7 @@ class LastFMImport : public QObject {
void UpdateTotal(const int, const int);
void UpdateProgress(const int, const int);
void Finished();
void FinishedWithError(const QString&);
void FinishedWithError(const QString &error);
private Q_SLOTS:
void FlushRequests();
@@ -106,7 +111,6 @@ class LastFMImport : public QObject {
int lastplayed_received_;
QQueue<GetRecentTracksRequest> recent_tracks_requests_;
QQueue<GetTopTracksRequest> top_tracks_requests_;
QList<QNetworkReply*> replies_;
};
#endif // LASTFMIMPORT_H

View File

@@ -1,6 +1,6 @@
/*
* Strawberry Music Player
* Copyright 2018-2023, Jonas Kvinge <jonas@jkvinge.net>
* Copyright 2018-2025, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -19,8 +19,6 @@
#include "config.h"
#include <QObject>
#include "includes/shared_ptr.h"
#include "core/networkaccessmanager.h"

View File

@@ -1,6 +1,6 @@
/*
* Strawberry Music Player
* Copyright 2018-2023, Jonas Kvinge <jonas@jkvinge.net>
* Copyright 2018-2025, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -22,9 +22,6 @@
#include "config.h"
#include <QtGlobal>
#include <QObject>
#include "includes/shared_ptr.h"
#include "scrobblingapi20.h"
@@ -40,6 +37,7 @@ class LastFMScrobbler : public ScrobblingAPI20 {
static const char *kName;
static const char *kSettingsGroup;
static const char *kApiUrl;
};
#endif // LASTFMSCROBBLER_H

View File

@@ -1,6 +1,6 @@
/*
* Strawberry Music Player
* Copyright 2018-2023, Jonas Kvinge <jonas@jkvinge.net>
* Copyright 2018-2025, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -19,8 +19,6 @@
#include "config.h"
#include <QObject>
#include "includes/shared_ptr.h"
#include "core/networkaccessmanager.h"

View File

@@ -1,6 +1,6 @@
/*
* Strawberry Music Player
* Copyright 2018-2023, Jonas Kvinge <jonas@jkvinge.net>
* Copyright 2018-2025, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -22,9 +22,6 @@
#include "config.h"
#include <QtGlobal>
#include <QObject>
#include "includes/shared_ptr.h"
#include "scrobblingapi20.h"

View File

@@ -1,6 +1,6 @@
/*
* Strawberry Music Player
* Copyright 2018-2023, Jonas Kvinge <jonas@jkvinge.net>
* Copyright 2018-2025, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -23,18 +23,12 @@
#include <utility>
#include <QCoreApplication>
#include <QtGlobal>
#include <QDesktopServices>
#include <QVariant>
#include <QByteArray>
#include <QString>
#include <QUrl>
#include <QUrlQuery>
#include <QDateTime>
#include <QTimer>
#include <QMessageBox>
#include <QSettings>
#include <QNetworkRequest>
#include <QNetworkReply>
#include <QJsonDocument>
#include <QJsonObject>
@@ -46,7 +40,7 @@
#include "core/song.h"
#include "core/logging.h"
#include "core/settings.h"
#include "core/localredirectserver.h"
#include "core/oauthenticator.h"
#include "constants/timeconstants.h"
#include "constants/scrobblersettings.h"
@@ -66,6 +60,7 @@ namespace {
constexpr char kOAuthAuthorizeUrl[] = "https://musicbrainz.org/oauth2/authorize";
constexpr char kOAuthAccessTokenUrl[] = "https://musicbrainz.org/oauth2/token";
constexpr char kOAuthRedirectUrl[] = "http://localhost";
constexpr char kOAuthScope[] = "profile;email;tag;rating;collection;submit_isrc;submit_barcode";
constexpr char kApiUrl[] = "https://api.listenbrainz.org";
constexpr char kClientIDB64[] = "b2VBVU53cVNRZXIwZXIwOUZpcWkwUQ==";
constexpr char kClientSecretB64[] = "Uk9GZ2hrZVEzRjNvUHlFaHFpeVdQQQ==";
@@ -74,44 +69,42 @@ constexpr int kScrobblesPerRequest = 10;
} // namespace
ListenBrainzScrobbler::ListenBrainzScrobbler(const SharedPtr<ScrobblerSettingsService> settings, const SharedPtr<NetworkAccessManager> network, QObject *parent)
: ScrobblerService(QLatin1String(kName), settings, parent),
: ScrobblerService(QLatin1String(kName), network, settings, parent),
network_(network),
oauth_(new OAuthenticator(network, this)),
cache_(new ScrobblerCache(QLatin1String(kCacheFile), this)),
server_(nullptr),
timer_submit_(new QTimer(this)),
enabled_(false),
expires_in_(-1),
login_time_(0),
submitted_(false),
scrobbled_(false),
timestamp_(0),
submit_error_(false),
prefer_albumartist_(false) {
refresh_login_timer_.setSingleShot(true);
QObject::connect(&refresh_login_timer_, &QTimer::timeout, this, &ListenBrainzScrobbler::RequestNewAccessToken);
oauth_->set_settings_group(QLatin1String(kSettingsGroup));
oauth_->set_type(OAuthenticator::Type::Authorization_Code);
oauth_->set_authorize_url(QUrl(QLatin1String(kOAuthAuthorizeUrl)));
oauth_->set_redirect_url(QUrl(QLatin1String(kOAuthRedirectUrl)));
oauth_->set_access_token_url(QUrl(QLatin1String(kOAuthAccessTokenUrl)));
oauth_->set_client_id(QString::fromLatin1(QByteArray::fromBase64(kClientIDB64)));
oauth_->set_client_secret(QString::fromLatin1(QByteArray::fromBase64(kClientSecretB64)));
oauth_->set_scope(QLatin1String(kOAuthScope));
oauth_->set_use_local_redirect_server(true);
oauth_->set_random_port(true);
timer_submit_.setSingleShot(true);
QObject::connect(&timer_submit_, &QTimer::timeout, this, &ListenBrainzScrobbler::Submit);
QObject::connect(oauth_, &OAuthenticator::AuthenticationFinished, this, &ListenBrainzScrobbler::OAuthFinished);
timer_submit_->setSingleShot(true);
QObject::connect(timer_submit_, &QTimer::timeout, this, &ListenBrainzScrobbler::Submit);
ListenBrainzScrobbler::ReloadSettings();
LoadSession();
oauth_->LoadSession();
}
ListenBrainzScrobbler::~ListenBrainzScrobbler() {
bool ListenBrainzScrobbler::authenticated() const {
while (!replies_.isEmpty()) {
QNetworkReply *reply = replies_.takeFirst();
QObject::disconnect(reply, nullptr, this, nullptr);
reply->abort();
reply->deleteLater();
}
if (server_) {
QObject::disconnect(server_, nullptr, this, nullptr);
if (server_->isListening()) server_->close();
server_->deleteLater();
}
return !oauth_->access_token().isEmpty() && !user_token_.isEmpty();
}
@@ -129,252 +122,41 @@ void ListenBrainzScrobbler::ReloadSettings() {
}
void ListenBrainzScrobbler::LoadSession() {
void ListenBrainzScrobbler::Authenticate() {
Settings 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();
login_time_ = s.value("login_time").toLongLong();
s.endGroup();
oauth_->Authenticate();
if (!refresh_token_.isEmpty()) {
qint64 time = expires_in_ - (QDateTime::currentSecsSinceEpoch() - static_cast<qint64>(login_time_));
if (time < 6) time = 6;
refresh_login_timer_.setInterval(static_cast<int>(time * kMsecPerSec));
refresh_login_timer_.start();
}
}
void ListenBrainzScrobbler::Deauthenticate() {
oauth_->ClearSession();
}
void ListenBrainzScrobbler::Logout() {
access_token_.clear();
token_type_.clear();
refresh_token_.clear();
expires_in_ = -1;
login_time_ = 0;
Settings s;
s.beginGroup(kSettingsGroup);
s.remove("access_token");
s.remove("expires_in");
s.remove("token_type");
s.remove("refresh_token");
s.endGroup();
Deauthenticate();
}
void ListenBrainzScrobbler::Authenticate() {
void ListenBrainzScrobbler::OAuthFinished(const bool success, const QString &error) {
if (!server_) {
server_ = new LocalRedirectServer(this);
if (!server_->Listen()) {
AuthError(server_->error());
delete server_;
server_ = nullptr;
return;
}
QObject::connect(server_, &LocalRedirectServer::Finished, this, &ListenBrainzScrobbler::RedirectArrived);
}
QUrl redirect_url(QString::fromLatin1(kOAuthRedirectUrl));
redirect_url.setPort(server_->url().port());
QUrlQuery url_query;
url_query.addQueryItem(u"response_type"_s, u"code"_s);
url_query.addQueryItem(u"client_id"_s, QString::fromLatin1(QByteArray::fromBase64(kClientIDB64)));
url_query.addQueryItem(u"redirect_uri"_s, redirect_url.toString());
url_query.addQueryItem(u"scope"_s, u"profile;email;tag;rating;collection;submit_isrc;submit_barcode"_s);
QUrl url(QString::fromLatin1(kOAuthAuthorizeUrl));
url.setQuery(url_query);
bool result = QDesktopServices::openUrl(url);
if (!result) {
QMessageBox messagebox(QMessageBox::Information, tr("ListenBrainz Authentication"), tr("Please open this URL in your browser") + QStringLiteral(":<br /><a href=\"%1\">%1</a>").arg(url.toString()), QMessageBox::Ok);
messagebox.setTextFormat(Qt::RichText);
messagebox.exec();
}
}
void ListenBrainzScrobbler::RedirectArrived() {
if (!server_) return;
if (server_->error().isEmpty()) {
QUrl url = server_->request_url();
if (url.isValid()) {
QUrlQuery url_query(url);
if (url_query.hasQueryItem(u"error"_s)) {
AuthError(QUrlQuery(url).queryItemValue(u"error"_s));
}
else if (url_query.hasQueryItem(u"code"_s)) {
RequestAccessToken(url, url_query.queryItemValue(u"code"_s));
}
else {
AuthError(tr("Redirect missing token code!"));
}
}
else {
AuthError(tr("Received invalid reply from web browser."));
}
if (success) {
qLog(Debug) << "ListenBrainz: Authentication was successful, login expires in" << oauth_->expires_in();
Q_EMIT AuthenticationComplete(true);
StartSubmit();
}
else {
AuthError(server_->error());
qLog(Debug) << "ListenBrainz: Authentication failed:" << error;
Q_EMIT AuthenticationComplete(false, error);
}
server_->close();
server_->deleteLater();
server_ = nullptr;
}
ListenBrainzScrobbler::ReplyResult ListenBrainzScrobbler::GetJsonObject(QNetworkReply *reply, QJsonObject &json_obj, QString &error_description) {
QNetworkReply *ListenBrainzScrobbler::CreateRequest(const QUrl &url, const QJsonDocument &json_document) {
ReplyResult reply_error_type = ReplyResult::ServerError;
if (reply->error() == QNetworkReply::NoError) {
if (reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).isValid()) {
const int http_status_code = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (http_status_code == 200) {
reply_error_type = ReplyResult::Success;
}
else {
error_description = QStringLiteral("Received HTTP code %1").arg(http_status_code);
}
}
else {
error_description = u"Missing HTTP status code"_s;
}
}
else {
error_description = QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error());
}
// See if there is Json data containing "error" and "error_description" or "code" and "error" - then use that instead.
if (reply->error() == QNetworkReply::NoError || reply->error() >= 200) {
const QByteArray data = reply->readAll();
if (!data.isEmpty() && ExtractJsonObj(data, json_obj, error_description)) {
if (json_obj.contains("error"_L1) && json_obj.contains("error_description"_L1)) {
error_description = json_obj["error_description"_L1].toString();
reply_error_type = ReplyResult::APIError;
}
else if (json_obj.contains("code"_L1) && json_obj.contains("error"_L1)) {
error_description = QStringLiteral("%1 (%2)").arg(json_obj["error"_L1].toString()).arg(json_obj["code"_L1].toInt());
reply_error_type = ReplyResult::APIError;
}
}
if (reply->error() == QNetworkReply::ContentAccessDenied || reply->error() == QNetworkReply::ContentOperationNotPermittedError || reply->error() == QNetworkReply::AuthenticationRequiredError) {
// Session is probably expired
Logout();
}
}
return reply_error_type;
}
void ListenBrainzScrobbler::RequestAccessToken(const QUrl &redirect_url, const QString &code) {
refresh_login_timer_.stop();
ParamList params = ParamList() << Param(u"client_id"_s, QString::fromLatin1(QByteArray::fromBase64(kClientIDB64)))
<< Param(u"client_secret"_s, QString::fromLatin1(QByteArray::fromBase64(kClientSecretB64)));
if (!code.isEmpty() && !redirect_url.isEmpty()) {
params << Param(u"grant_type"_s, u"authorization_code"_s);
params << Param(u"code"_s, code);
params << Param(u"redirect_uri"_s, redirect_url.toString());
}
else if (!refresh_token_.isEmpty() && enabled_) {
params << Param(u"grant_type"_s, u"refresh_token"_s);
params << Param(u"refresh_token"_s, refresh_token_);
}
else {
return;
}
QUrlQuery url_query;
for (const Param &param : std::as_const(params)) {
url_query.addQueryItem(QString::fromLatin1(QUrl::toPercentEncoding(param.first)), QString::fromLatin1(QUrl::toPercentEncoding(param.second)));
}
QUrl session_url(QString::fromLatin1(kOAuthAccessTokenUrl));
QNetworkRequest req(session_url);
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
req.setHeader(QNetworkRequest::ContentTypeHeader, u"application/x-www-form-urlencoded"_s);
QByteArray query = url_query.toString(QUrl::FullyEncoded).toUtf8();
QNetworkReply *reply = network_->post(req, query);
replies_ << reply;
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply]() { AuthenticateReplyFinished(reply); });
}
void ListenBrainzScrobbler::AuthenticateReplyFinished(QNetworkReply *reply) {
if (!replies_.contains(reply)) return;
replies_.removeAll(reply);
QObject::disconnect(reply, nullptr, this, nullptr);
reply->deleteLater();
QJsonObject json_obj;
QString error_message;
if (GetJsonObject(reply, json_obj, error_message) != ReplyResult::Success) {
AuthError(error_message);
return;
}
if (!json_obj.contains("access_token"_L1) || !json_obj.contains("expires_in"_L1) || !json_obj.contains("token_type"_L1)) {
AuthError(u"Json access_token, expires_in or token_type is missing."_s);
return;
}
access_token_ = json_obj["access_token"_L1].toString();
expires_in_ = json_obj["expires_in"_L1].toInt();
token_type_ = json_obj["token_type"_L1].toString();
if (json_obj.contains("refresh_token"_L1)) {
refresh_token_ = json_obj["refresh_token"_L1].toString();
}
login_time_ = QDateTime::currentSecsSinceEpoch();
Settings 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.setValue("login_time", login_time_);
s.endGroup();
if (expires_in_ > 0) {
refresh_login_timer_.setInterval(static_cast<int>(expires_in_ * kMsecPerSec));
refresh_login_timer_.start();
}
Q_EMIT AuthenticationComplete(true);
qLog(Debug) << "ListenBrainz: Authentication was successful, login expires in" << expires_in_;
StartSubmit();
}
QNetworkReply *ListenBrainzScrobbler::CreateRequest(const QUrl &url, const QJsonDocument &json_doc) {
QNetworkRequest req(url);
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
req.setHeader(QNetworkRequest::ContentTypeHeader, u"application/json"_s);
req.setRawHeader("Authorization", QStringLiteral("Token %1").arg(user_token_).toUtf8());
QNetworkReply *reply = network_->post(req, json_doc.toJson());
replies_ << reply;
//qLog(Debug) << "ListenBrainz: Sending request" << json_doc.toJson();
return reply;
return CreatePostRequest(url, json_document);
}
@@ -459,6 +241,65 @@ QJsonObject ListenBrainzScrobbler::JsonTrackMetadata(const ScrobbleMetadata &met
}
JsonBaseRequest::JsonObjectResult ListenBrainzScrobbler::ParseJsonObject(QNetworkReply *reply) {
if (reply->error() != QNetworkReply::NoError && reply->error() < 200) {
return ReplyDataResult(ErrorCode::NetworkError, QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error()));
}
JsonObjectResult result(ErrorCode::Success);
result.network_error = reply->error();
if (reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).isValid()) {
result.http_status_code = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
}
const QByteArray data = reply->readAll();
if (!data.isEmpty()) {
QJsonParseError json_parse_error;
const QJsonDocument json_document = QJsonDocument::fromJson(data, &json_parse_error);
if (json_parse_error.error == QJsonParseError::NoError) {
const QJsonObject json_object = json_document.object();
if (json_object.contains("code"_L1) && json_object.contains("error"_L1)) {
const int code = json_object["code"_L1].toInt();
const QString error = json_object["error"_L1].toString();
result.error_code = ErrorCode::APIError;
result.error_message = QStringLiteral("%1 (%2)").arg(error).arg(code);
}
else if (json_object.contains("error"_L1) && json_object.contains("error_description"_L1)) {
const int error = json_object["error"_L1].toInt();
const QString error_description = json_object["error_description"_L1].toString();
result.error_code = ErrorCode::APIError;
result.error_message = QStringLiteral("%1 (%2)").arg(error_description).arg(error);
}
else {
result.json_object = json_document.object();
}
}
else {
result.error_code = ErrorCode::ParseError;
result.error_message = json_parse_error.errorString();
}
}
if (result.error_code != ErrorCode::APIError) {
if (reply->error() != QNetworkReply::NoError) {
result.error_code = ErrorCode::NetworkError;
result.error_message = QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error());
}
else if (result.http_status_code != 200) {
result.error_code = ErrorCode::HttpError;
result.error_message = QStringLiteral("Received HTTP code %1").arg(result.http_status_code);
}
}
if (reply->error() == QNetworkReply::AuthenticationRequiredError) {
oauth_->ClearSession();
}
return result;
}
void ListenBrainzScrobbler::UpdateNowPlaying(const Song &song) {
CheckScrobblePrevSong();
@@ -476,10 +317,9 @@ void ListenBrainzScrobbler::UpdateNowPlaying(const Song &song) {
QJsonObject object;
object.insert("listen_type"_L1, "playing_now"_L1);
object.insert("payload"_L1, array_payload);
QJsonDocument doc(object);
QJsonDocument json_document(object);
QUrl url(QStringLiteral("%1/1/submit-listens").arg(QLatin1String(kApiUrl)));
QNetworkReply *reply = CreateRequest(url, doc);
QNetworkReply *reply = CreateRequest(QUrl(QStringLiteral("%1/1/submit-listens").arg(QLatin1String(kApiUrl))), json_document);
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply]() { UpdateNowPlayingRequestFinished(reply); });
}
@@ -491,19 +331,19 @@ void ListenBrainzScrobbler::UpdateNowPlayingRequestFinished(QNetworkReply *reply
QObject::disconnect(reply, nullptr, this, nullptr);
reply->deleteLater();
QJsonObject json_obj;
QString error_description;
if (GetJsonObject(reply, json_obj, error_description) != ReplyResult::Success) {
Error(error_description);
const JsonObjectResult json_object_result = ParseJsonObject(reply);
if (!json_object_result.success()) {
Error(json_object_result.error_message);
return;
}
const QJsonObject &json_object = json_object_result.json_object;
if (!json_obj.contains("status"_L1)) {
if (!json_object.contains("status"_L1)) {
Error(u"Now playing request is missing status from server."_s);
return;
}
QString status = json_obj["status"_L1].toString();
const QString status = json_object["status"_L1].toString();
if (status.compare("ok"_L1, Qt::CaseInsensitive) != 0) {
Error(QStringLiteral("Received %1 status for now playing.").arg(status));
}
@@ -537,15 +377,15 @@ void ListenBrainzScrobbler::StartSubmit(const bool initial) {
if (!submitted_ && cache_->Count() > 0) {
if (initial && settings_->submit_delay() <= 0 && !submit_error_) {
if (timer_submit_.isActive()) {
timer_submit_.stop();
if (timer_submit_->isActive()) {
timer_submit_->stop();
}
Submit();
}
else if (!timer_submit_.isActive()) {
else if (!timer_submit_->isActive()) {
int submit_delay = static_cast<int>(std::max(settings_->submit_delay(), submit_error_ ? 30 : 5) * kMsecPerSec);
timer_submit_.setInterval(submit_delay);
timer_submit_.start();
timer_submit_->setInterval(submit_delay);
timer_submit_->start();
}
}
@@ -581,7 +421,7 @@ void ListenBrainzScrobbler::Submit() {
object.insert("payload"_L1, array);
QJsonDocument doc(object);
QUrl url(QStringLiteral("%1/1/submit-listens").arg(QLatin1String(kApiUrl)));
const QUrl url(QStringLiteral("%1/1/submit-listens").arg(QLatin1String(kApiUrl)));
QNetworkReply *reply = CreateRequest(url, doc);
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, cache_items_sent]() { ScrobbleRequestFinished(reply, cache_items_sent); });
@@ -596,12 +436,11 @@ void ListenBrainzScrobbler::ScrobbleRequestFinished(QNetworkReply *reply, Scrobb
submitted_ = false;
QJsonObject json_obj;
QString error_message;
const ReplyResult reply_result = GetJsonObject(reply, json_obj, error_message);
if (reply_result == ReplyResult::Success) {
if (json_obj.contains("status"_L1)) {
QString status = json_obj["status"_L1].toString();
const JsonObjectResult json_object_result = ParseJsonObject(reply);
if (json_object_result.success()) {
const QJsonObject &json_object = json_object_result.json_object;
if (json_object.contains("status"_L1)) {
const QString status = json_object["status"_L1].toString();
qLog(Debug) << "ListenBrainz: Received scrobble status:" << status;
}
else {
@@ -612,20 +451,20 @@ void ListenBrainzScrobbler::ScrobbleRequestFinished(QNetworkReply *reply, Scrobb
}
else {
submit_error_ = true;
if (reply_result == ReplyResult::APIError) {
if (json_object_result.error_code == ErrorCode::APIError) {
if (cache_items.count() == 1) {
const ScrobbleMetadata &metadata = cache_items.first()->metadata;
Error(tr("Unable to scrobble %1 - %2 because of error: %3").arg(metadata.effective_albumartist(), metadata.title, error_message));
Error(tr("Unable to scrobble %1 - %2 because of error: %3").arg(metadata.effective_albumartist(), metadata.title, json_object_result.error_message));
cache_->Flush(cache_items);
}
else {
Error(error_message);
Error(json_object_result.error_message);
cache_->SetError(cache_items);
cache_->ClearSent(cache_items);
}
}
else {
Error(error_message);
Error(json_object_result.error_message);
cache_->ClearSent(cache_items);
}
}
@@ -667,33 +506,25 @@ void ListenBrainzScrobbler::LoveRequestFinished(QNetworkReply *reply) {
QObject::disconnect(reply, nullptr, this, nullptr);
reply->deleteLater();
QJsonObject json_obj;
QString error_message;
if (GetJsonObject(reply, json_obj, error_message) != ReplyResult::Success) {
Error(error_message);
const JsonObjectResult json_object_result = ParseJsonObject(reply);
if (!json_object_result.success()) {
Error(json_object_result.error_message);
return;
}
const QJsonObject &json_object = json_object_result.json_object;
if (json_obj.contains("status"_L1)) {
qLog(Debug) << "ListenBrainz: Received recording-feedback status:" << json_obj["status"_L1].toString();
if (json_object.contains("status"_L1)) {
qLog(Debug) << "ListenBrainz: Received recording-feedback status:" << json_object["status"_L1].toString();
}
}
void ListenBrainzScrobbler::AuthError(const QString &error) {
void ListenBrainzScrobbler::Error(const QString &error_message, const QVariant &debug_output) {
qLog(Error) << "ListenBrainz" << error;
Q_EMIT AuthenticationComplete(false, error);
}
void ListenBrainzScrobbler::Error(const QString &error, const QVariant &debug) {
qLog(Error) << "ListenBrainz:" << error;
if (debug.isValid()) qLog(Debug) << debug;
JsonBaseRequest::Error(error_message, debug_output);
if (settings_->show_error_dialog()) {
Q_EMIT ErrorMessage(tr("ListenBrainz error: %1").arg(error));
Q_EMIT ErrorMessage(tr("ListenBrainz error: %1").arg(error_message));
}
}

View File

@@ -1,6 +1,6 @@
/*
* Strawberry Music Player
* Copyright 2018-2023, Jonas Kvinge <jonas@jkvinge.net>
* Copyright 2018-2025, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -22,15 +22,11 @@
#include "config.h"
#include <QtGlobal>
#include <QObject>
#include <QList>
#include <QVariant>
#include <QByteArray>
#include <QString>
#include <QUrl>
#include <QJsonDocument>
#include <QTimer>
#include "includes/shared_ptr.h"
#include "core/song.h"
@@ -38,31 +34,34 @@
#include "scrobblercache.h"
#include "scrobblemetadata.h"
class QTimer;
class QNetworkReply;
class ScrobblerSettingsService;
class NetworkAccessManager;
class LocalRedirectServer;
class OAuthenticator;
class ListenBrainzScrobbler : public ScrobblerService {
Q_OBJECT
public:
explicit ListenBrainzScrobbler(const SharedPtr<ScrobblerSettingsService> settings, const SharedPtr<NetworkAccessManager> network, QObject *parent = nullptr);
~ListenBrainzScrobbler() override;
static const char *kName;
static const char *kSettingsGroup;
void ReloadSettings() override;
void LoadSession();
bool enabled() const override { return enabled_; }
bool authenticated() const override { return !access_token_.isEmpty() && !user_token_.isEmpty(); }
bool authentication_required() const override { return true; }
bool authenticated() const override;
bool use_authorization_header() const override { return true; }
QByteArray authorization_header() const override { return "Token " + user_token_.toUtf8(); }
bool submitted() const override { return submitted_; }
QString user_token() const { return user_token_; }
void Authenticate();
void Deauthenticate();
void Logout();
void Submit() override;
void UpdateNowPlaying(const Song &song) override;
@@ -70,12 +69,6 @@ class ListenBrainzScrobbler : public ScrobblerService {
void Scrobble(const Song &song) override;
void Love() override;
enum class ReplyResult {
Success,
ServerError,
APIError
};
Q_SIGNALS:
void AuthenticationComplete(const bool success, const QString &error = QString());
@@ -83,44 +76,32 @@ class ListenBrainzScrobbler : public ScrobblerService {
void WriteCache() override { cache_->WriteCache(); }
private Q_SLOTS:
void RedirectArrived();
void AuthenticateReplyFinished(QNetworkReply *reply);
void RequestNewAccessToken() { RequestAccessToken(); }
void OAuthFinished(const bool success, const QString &error);
void UpdateNowPlayingRequestFinished(QNetworkReply *reply);
void ScrobbleRequestFinished(QNetworkReply *reply, ScrobblerCacheItemPtrList cache_items);
void LoveRequestFinished(QNetworkReply *reply);
private:
QNetworkReply *CreateRequest(const QUrl &url, const QJsonDocument &json_doc);
ReplyResult GetJsonObject(QNetworkReply *reply, QJsonObject &json_obj, QString &error_description);
QNetworkReply *CreateRequest(const QUrl &url, const QJsonDocument &json_document);
QJsonObject JsonTrackMetadata(const ScrobbleMetadata &metadata) const;
void AuthError(const QString &error);
void Error(const QString &error, const QVariant &debug = QVariant());
void RequestAccessToken(const QUrl &redirect_url = QUrl(), const QString &code = QString());
JsonObjectResult ParseJsonObject(QNetworkReply *reply);
void Error(const QString &error_message, const QVariant &debug_output = QVariant()) override;
void StartSubmit(const bool initial = false) override;
void CheckScrobblePrevSong();
const SharedPtr<NetworkAccessManager> network_;
OAuthenticator *oauth_;
ScrobblerCache *cache_;
LocalRedirectServer *server_;
QTimer *timer_submit_;
bool enabled_;
QString user_token_;
QString access_token_;
qint64 expires_in_;
QString token_type_;
QString refresh_token_;
quint64 login_time_;
bool submitted_;
Song song_playing_;
bool scrobbled_;
quint64 timestamp_;
QTimer refresh_login_timer_;
QTimer timer_submit_;
bool submit_error_;
bool prefer_albumartist_;
QList<QNetworkReply*> replies_;
};
#endif // LISTENBRAINZSCROBBLER_H

View File

@@ -1,6 +1,6 @@
/*
* Strawberry Music Player
* Copyright 2018-2023, Jonas Kvinge <jonas@jkvinge.net>
* Copyright 2018-2025, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -19,37 +19,14 @@
#include "config.h"
#include <QtGlobal>
#include <QObject>
#include <QString>
#include <QByteArray>
#include <QJsonDocument>
#include <QJsonObject>
#include "scrobblerservice.h"
#include "scrobblersettingsservice.h"
#include "core/song.h"
ScrobblerService::ScrobblerService(const QString &name, const SharedPtr<ScrobblerSettingsService> settings, QObject *parent) : QObject(parent), name_(name), settings_(settings) {}
bool ScrobblerService::ExtractJsonObj(const QByteArray &data, QJsonObject &json_obj, QString &error_description) {
QJsonParseError json_parse_error;
const QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_parse_error);
if (json_parse_error.error != QJsonParseError::NoError) {
error_description = json_parse_error.errorString();
return false;
}
if (json_doc.isObject()) {
json_obj = json_doc.object();
}
return true;
}
ScrobblerService::ScrobblerService(const QString &name, const SharedPtr<NetworkAccessManager> network, const SharedPtr<ScrobblerSettingsService> settings, QObject *parent) : JsonBaseRequest(network, parent), name_(name), settings_(settings) {}
QString ScrobblerService::StripAlbum(const QString &album) const {

View File

@@ -1,6 +1,6 @@
/*
* Strawberry Music Player
* Copyright 2018-2023, Jonas Kvinge <jonas@jkvinge.net>
* Copyright 2018-2025, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -22,31 +22,29 @@
#include "config.h"
#include <QObject>
#include <QPair>
#include <QList>
#include <QVariant>
#include <QByteArray>
#include <QString>
#include <QJsonObject>
#include "includes/shared_ptr.h"
#include "core/song.h"
#include "core/jsonbaserequest.h"
#include "scrobblersettingsservice.h"
class ScrobblerService : public QObject {
class ScrobblerService : public JsonBaseRequest {
Q_OBJECT
public:
explicit ScrobblerService(const QString &name, const SharedPtr<ScrobblerSettingsService> settings, QObject *parent);
explicit ScrobblerService(const QString &name, const SharedPtr<NetworkAccessManager> network, const SharedPtr<ScrobblerSettingsService> settings, QObject *parent);
QString name() const { return name_; }
QString service_name() const override { return name_; }
virtual void ReloadSettings() = 0;
virtual bool enabled() const { return false; }
virtual bool authenticated() const { return false; }
virtual bool authenticated() const override { return false; }
virtual void UpdateNowPlaying(const Song &song) = 0;
virtual void ClearPlaying() = 0;
@@ -57,12 +55,8 @@ class ScrobblerService : public QObject {
virtual bool submitted() const { return false; }
protected:
using Param = QPair<QString, QString>;
using ParamList = QList<Param>;
using EncodedParam = QPair<QByteArray, QByteArray>;
bool ExtractJsonObj(const QByteArray &data, QJsonObject &json_obj, QString &error_description);
QString StripAlbum(const QString &album) const;
QString StripTitle(const QString &title) const;

View File

@@ -1,6 +1,6 @@
/*
* Strawberry Music Player
* Copyright 2018-2023, Jonas Kvinge <jonas@jkvinge.net>
* Copyright 2018-2025, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -68,10 +68,10 @@ const char *ScrobblingAPI20::kApiKey = "211990b4c96782c05d1536e7219eb56e";
namespace {
constexpr char kSecret[] = "80fd738f49596e9709b1bf9319c444a8";
constexpr int kScrobblesPerRequest = 50;
}
} // namespace
ScrobblingAPI20::ScrobblingAPI20(const QString &name, const QString &settings_group, const QString &auth_url, const QString &api_url, const bool batch, const QString &cache_file, const SharedPtr<ScrobblerSettingsService> settings, const SharedPtr<NetworkAccessManager> network, QObject *parent)
: ScrobblerService(name, settings, parent),
: ScrobblerService(name, network, settings, parent),
name_(name),
settings_group_(settings_group),
auth_url_(auth_url),
@@ -79,17 +79,18 @@ ScrobblingAPI20::ScrobblingAPI20(const QString &name, const QString &settings_gr
batch_(batch),
network_(network),
cache_(new ScrobblerCache(cache_file, this)),
server_(nullptr),
local_redirect_server_(nullptr),
enabled_(false),
prefer_albumartist_(false),
subscriber_(false),
submitted_(false),
scrobbled_(false),
timestamp_(0),
submit_error_(false) {
submit_error_(false),
timer_submit_(new QTimer(this)) {
timer_submit_.setSingleShot(true);
QObject::connect(&timer_submit_, &QTimer::timeout, this, &ScrobblingAPI20::Submit);
timer_submit_->setSingleShot(true);
QObject::connect(timer_submit_, &QTimer::timeout, this, &ScrobblingAPI20::Submit);
ScrobblingAPI20::ReloadSettings();
LoadSession();
@@ -98,17 +99,10 @@ ScrobblingAPI20::ScrobblingAPI20(const QString &name, const QString &settings_gr
ScrobblingAPI20::~ScrobblingAPI20() {
while (!replies_.isEmpty()) {
QNetworkReply *reply = replies_.takeFirst();
QObject::disconnect(reply, nullptr, this, nullptr);
reply->abort();
reply->deleteLater();
}
if (server_) {
QObject::disconnect(server_, nullptr, this, nullptr);
if (server_->isListening()) server_->close();
server_->deleteLater();
if (local_redirect_server_) {
QObject::disconnect(local_redirect_server_, nullptr, this, nullptr);
if (local_redirect_server_->isListening()) local_redirect_server_->close();
local_redirect_server_->deleteLater();
}
}
@@ -138,7 +132,7 @@ void ScrobblingAPI20::LoadSession() {
}
void ScrobblingAPI20::Logout() {
void ScrobblingAPI20::ClearSession() {
subscriber_ = false;
username_.clear();
@@ -153,70 +147,22 @@ void ScrobblingAPI20::Logout() {
}
ScrobblingAPI20::ReplyResult ScrobblingAPI20::GetJsonObject(QNetworkReply *reply, QJsonObject &json_obj, QString &error_description) {
ReplyResult reply_error_type = ReplyResult::ServerError;
if (reply->error() == QNetworkReply::NoError) {
if (reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).isValid()) {
const int http_status_code = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (http_status_code == 200) {
reply_error_type = ReplyResult::Success;
}
else {
error_description = QStringLiteral("Received HTTP code %1").arg(http_status_code);
}
}
else {
error_description = u"Missing HTTP status code"_s;
}
}
else {
error_description = QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error());
}
// See if there is Json data containing "error" and "message" - then use that instead.
if (reply->error() == QNetworkReply::NoError || reply->error() >= 200) {
const QByteArray data = reply->readAll();
int error_code = 0;
if (!data.isEmpty() && ExtractJsonObj(data, json_obj, error_description) && json_obj.contains("error"_L1) && json_obj.contains("message"_L1)) {
error_code = json_obj["error"_L1].toInt();
QString error_message = json_obj["message"_L1].toString();
error_description = QStringLiteral("%1 (%2)").arg(error_message).arg(error_code);
reply_error_type = ReplyResult::APIError;
}
const ScrobbleErrorCode lastfm_error_code = static_cast<ScrobbleErrorCode>(error_code);
if (reply->error() == QNetworkReply::AuthenticationRequiredError ||
lastfm_error_code == ScrobbleErrorCode::InvalidSessionKey ||
lastfm_error_code == ScrobbleErrorCode::UnauthorizedToken ||
lastfm_error_code == ScrobbleErrorCode::LoginRequired ||
lastfm_error_code == ScrobbleErrorCode::APIKeySuspended
) {
// Session is probably expired
Logout();
}
}
return reply_error_type;
}
void ScrobblingAPI20::Authenticate() {
if (!server_) {
server_ = new LocalRedirectServer(this);
if (!server_->Listen()) {
AuthError(server_->error());
delete server_;
server_ = nullptr;
if (!local_redirect_server_) {
local_redirect_server_ = new LocalRedirectServer(this);
if (!local_redirect_server_->Listen()) {
AuthError(local_redirect_server_->error());
delete local_redirect_server_;
local_redirect_server_ = nullptr;
return;
}
QObject::connect(server_, &LocalRedirectServer::Finished, this, &ScrobblingAPI20::RedirectArrived);
QObject::connect(local_redirect_server_, &LocalRedirectServer::Finished, this, &ScrobblingAPI20::RedirectArrived);
}
QUrlQuery url_query;
url_query.addQueryItem(u"api_key"_s, QLatin1String(kApiKey));
url_query.addQueryItem(u"cb"_s, server_->url().toString());
url_query.addQueryItem(u"cb"_s, local_redirect_server_->url().toString());
QUrl url(auth_url_);
url.setQuery(url_query);
@@ -238,10 +184,10 @@ void ScrobblingAPI20::Authenticate() {
QApplication::clipboard()->setText(url.toString());
break;
case QMessageBox::Cancel:
if (server_) {
server_->close();
server_->deleteLater();
server_ = nullptr;
if (local_redirect_server_) {
local_redirect_server_->close();
local_redirect_server_->deleteLater();
local_redirect_server_ = nullptr;
}
Q_EMIT AuthenticationComplete(false);
break;
@@ -253,10 +199,10 @@ void ScrobblingAPI20::Authenticate() {
void ScrobblingAPI20::RedirectArrived() {
if (!server_) return;
if (!local_redirect_server_) return;
if (server_->error().isEmpty()) {
QUrl url = server_->request_url();
if (local_redirect_server_->error().isEmpty()) {
const QUrl url = local_redirect_server_->request_url();
if (url.isValid()) {
QUrlQuery url_query(url);
if (url_query.hasQueryItem(u"token"_s)) {
@@ -272,12 +218,12 @@ void ScrobblingAPI20::RedirectArrived() {
}
}
else {
AuthError(server_->error());
AuthError(local_redirect_server_->error());
}
server_->close();
server_->deleteLater();
server_ = nullptr;
local_redirect_server_->close();
local_redirect_server_->deleteLater();
local_redirect_server_ = nullptr;
}
@@ -300,10 +246,7 @@ void ScrobblingAPI20::RequestSession(const QString &token) {
session_url_query.addQueryItem(QString::fromLatin1(QUrl::toPercentEncoding(u"format"_s)), QString::fromLatin1(QUrl::toPercentEncoding(u"json"_s)));
session_url.setQuery(session_url_query);
QNetworkRequest req(session_url);
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
QNetworkReply *reply = network_->get(req);
replies_ << reply;
QNetworkReply *reply = CreateGetRequest(session_url);
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply]() { AuthenticateReplyFinished(reply); });
}
@@ -315,36 +258,36 @@ void ScrobblingAPI20::AuthenticateReplyFinished(QNetworkReply *reply) {
QObject::disconnect(reply, nullptr, this, nullptr);
reply->deleteLater();
QJsonObject json_obj;
QString error_message;
if (GetJsonObject(reply, json_obj, error_message) != ReplyResult::Success) {
AuthError(error_message);
const JsonObjectResult json_object_result = ParseJsonObject(reply);
if (json_object_result.error_code != ErrorCode::Success) {
AuthError(json_object_result.error_message);
return;
}
const QJsonObject &json_object = json_object_result.json_object;
if (!json_obj.contains("session"_L1)) {
if (!json_object.contains("session"_L1)) {
AuthError(u"Json reply from server is missing session."_s);
return;
}
QJsonValue json_session = json_obj["session"_L1];
if (!json_session.isObject()) {
const QJsonValue json_value_session = json_object["session"_L1];
if (!json_value_session.isObject()) {
AuthError(u"Json session is not an object."_s);
return;
}
json_obj = json_session.toObject();
if (json_obj.isEmpty()) {
const QJsonObject json_object_session = json_value_session.toObject();
if (json_object_session.isEmpty()) {
AuthError(u"Json session object is empty."_s);
return;
}
if (!json_obj.contains("subscriber"_L1) || !json_obj.contains("name"_L1) || !json_obj.contains("key"_L1)) {
if (!json_object_session.contains("subscriber"_L1) || !json_object_session.contains("name"_L1) || !json_object_session.contains("key"_L1)) {
AuthError(u"Json session object is missing values."_s);
return;
}
subscriber_ = json_obj["subscriber"_L1].toBool();
username_ = json_obj["name"_L1].toString();
session_key_ = json_obj["key"_L1].toString();
subscriber_ = json_object_session["subscriber"_L1].toBool();
username_ = json_object_session["name"_L1].toString();
session_key_ = json_object_session["key"_L1].toString();
Settings s;
s.beginGroup(settings_group_);
@@ -384,17 +327,60 @@ QNetworkReply *ScrobblingAPI20::CreateRequest(const ParamList &request_params) {
url_query.addQueryItem(u"api_sig"_s, QString::fromLatin1(QUrl::toPercentEncoding(signature)));
url_query.addQueryItem(u"format"_s, u"json"_s);
QUrl url(api_url_);
QNetworkRequest req(url);
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
req.setHeader(QNetworkRequest::ContentTypeHeader, u"application/x-www-form-urlencoded"_s);
QByteArray query = url_query.toString(QUrl::FullyEncoded).toUtf8();
QNetworkReply *reply = network_->post(req, query);
replies_ << reply;
return CreatePostRequest(QUrl(api_url_), url_query);
//qLog(Debug) << name_ << "Sending request" << url_query.toString(QUrl::FullyDecoded);
}
return reply;
JsonBaseRequest::JsonObjectResult ScrobblingAPI20::ParseJsonObject(QNetworkReply *reply) {
if (reply->error() != QNetworkReply::NoError && reply->error() < 200) {
return ReplyDataResult(ErrorCode::NetworkError, QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error()));
}
JsonObjectResult result(ErrorCode::Success);
result.network_error = reply->error();
if (reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).isValid()) {
result.http_status_code = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
}
const QByteArray data = reply->readAll();
if (!data.isEmpty()) {
QJsonParseError json_parse_error;
const QJsonDocument json_document = QJsonDocument::fromJson(data, &json_parse_error);
if (json_parse_error.error == QJsonParseError::NoError) {
const QJsonObject json_object = json_document.object();
if (json_object.contains("error"_L1) && json_object.contains("message"_L1)) {
const int error = json_object["error"_L1].toInt();
const QString message = json_object["message"_L1].toString();
result.error_code = ErrorCode::APIError;
result.error_message = QStringLiteral("%1 (%2)").arg(message).arg(error);
}
else {
result.json_object = json_document.object();
}
}
else {
result.error_code = ErrorCode::ParseError;
result.error_message = json_parse_error.errorString();
}
}
if (result.error_code != ErrorCode::APIError) {
if (reply->error() != QNetworkReply::NoError) {
result.error_code = ErrorCode::NetworkError;
result.error_message = QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error());
}
else if (result.http_status_code != 200) {
result.error_code = ErrorCode::HttpError;
result.error_message = QStringLiteral("Received HTTP code %1").arg(result.http_status_code);
}
}
if (reply->error() == QNetworkReply::AuthenticationRequiredError) {
ClearSession();
}
return result;
}
@@ -433,15 +419,15 @@ void ScrobblingAPI20::UpdateNowPlayingRequestFinished(QNetworkReply *reply) {
QObject::disconnect(reply, nullptr, this, nullptr);
reply->deleteLater();
QJsonObject json_obj;
QString error_message;
if (GetJsonObject(reply, json_obj, error_message) != ReplyResult::Success) {
Error(error_message);
const JsonObjectResult json_object_result = ParseJsonObject(reply);
if (json_object_result.error_code != ErrorCode::Success) {
Error(json_object_result.error_message);
return;
}
const QJsonObject &json_object = json_object_result.json_object;
if (!json_obj.contains("nowplaying"_L1)) {
Error(u"Json reply from server is missing nowplaying."_s, json_obj);
if (!json_object.contains("nowplaying"_L1)) {
Error(u"Json reply from server is missing nowplaying."_s, json_object);
return;
}
@@ -482,15 +468,15 @@ void ScrobblingAPI20::StartSubmit(const bool initial) {
if (!submitted_ && cache_->Count() > 0) {
if (initial && (!batch_ || settings_->submit_delay() <= 0) && !submit_error_) {
if (timer_submit_.isActive()) {
timer_submit_.stop();
if (timer_submit_->isActive()) {
timer_submit_->stop();
}
Submit();
}
else if (!timer_submit_.isActive()) {
else if (!timer_submit_->isActive()) {
int submit_delay = static_cast<int>(std::max(settings_->submit_delay(), submit_error_ ? 30 : 5) * kMsecPerSec);
timer_submit_.setInterval(submit_delay);
timer_submit_.start();
timer_submit_->setInterval(submit_delay);
timer_submit_->start();
}
}
@@ -550,68 +536,68 @@ void ScrobblingAPI20::ScrobbleRequestFinished(QNetworkReply *reply, ScrobblerCac
submitted_ = false;
QJsonObject json_obj;
QString error_message;
if (GetJsonObject(reply, json_obj, error_message) != ReplyResult::Success) {
Error(error_message);
const JsonObjectResult json_object_result = ParseJsonObject(reply);
if (!json_object_result.success()) {
Error(json_object_result.error_message);
cache_->ClearSent(cache_items);
submit_error_ = true;
StartSubmit();
return;
}
const QJsonObject &json_object = json_object_result.json_object;
cache_->Flush(cache_items);
submit_error_ = false;
if (!json_obj.contains("scrobbles"_L1)) {
Error(u"Json reply from server is missing scrobbles."_s, json_obj);
if (!json_object.contains("scrobbles"_L1)) {
Error(u"Json reply from server is missing scrobbles."_s, json_object);
StartSubmit();
return;
}
QJsonValue value_scrobbles = json_obj["scrobbles"_L1];
const QJsonValue value_scrobbles = json_object["scrobbles"_L1];
if (!value_scrobbles.isObject()) {
Error(u"Json scrobbles is not an object."_s, json_obj);
Error(u"Json scrobbles is not an object."_s, json_object);
StartSubmit();
return;
}
json_obj = value_scrobbles.toObject();
if (json_obj.isEmpty()) {
const QJsonObject object_scrobbles = value_scrobbles.toObject();
if (object_scrobbles.isEmpty()) {
Error(u"Json scrobbles object is empty."_s, value_scrobbles);
StartSubmit();
return;
}
if (!json_obj.contains("@attr"_L1) || !json_obj.contains("scrobble"_L1)) {
Error(u"Json scrobbles object is missing values."_s, json_obj);
if (!object_scrobbles.contains("@attr"_L1) || !object_scrobbles.contains("scrobble"_L1)) {
Error(u"Json scrobbles object is missing values."_s, object_scrobbles);
StartSubmit();
return;
}
QJsonValue value_attr = json_obj["@attr"_L1];
const QJsonValue value_attr = object_scrobbles["@attr"_L1];
if (!value_attr.isObject()) {
Error(u"Json scrobbles attr is not an object."_s, value_attr);
StartSubmit();
return;
}
QJsonObject obj_attr = value_attr.toObject();
if (obj_attr.isEmpty()) {
const QJsonObject object_attr = value_attr.toObject();
if (object_attr.isEmpty()) {
Error(u"Json scrobbles attr is empty."_s, value_attr);
StartSubmit();
return;
}
if (!obj_attr.contains("accepted"_L1) || !obj_attr.contains("ignored"_L1)) {
Error(u"Json scrobbles attr is missing values."_s, obj_attr);
if (!object_attr.contains("accepted"_L1) || !object_attr.contains("ignored"_L1)) {
Error(u"Json scrobbles attr is missing values."_s, object_attr);
StartSubmit();
return;
}
int accepted = obj_attr["accepted"_L1].toInt();
int ignored = obj_attr["ignored"_L1].toInt();
int accepted = object_attr["accepted"_L1].toInt();
int ignored = object_attr["ignored"_L1].toInt();
qLog(Debug) << name_ << "Scrobbles accepted:" << accepted << "ignored:" << ignored;
QJsonArray array_scrobble;
QJsonValue value_scrobble = json_obj["scrobble"_L1];
const QJsonValue value_scrobble = object_scrobbles["scrobble"_L1];
if (value_scrobble.isObject()) {
QJsonObject obj_scrobble = value_scrobble.toObject();
if (obj_scrobble.isEmpty()) {
@@ -657,36 +643,36 @@ void ScrobblingAPI20::ScrobbleRequestFinished(QNetworkReply *reply, ScrobblerCac
continue;
}
QJsonValue value_artist = json_track["artist"_L1];
QJsonValue value_album = json_track["album"_L1];
QJsonValue value_song = json_track["track"_L1];
QJsonValue value_ignoredmessage = json_track["ignoredMessage"_L1];
//quint64 timestamp = json_track[u"timestamp"_s].toVariant().toULongLong();
const QJsonValue value_artist = json_track["artist"_L1];
const QJsonValue value_album = json_track["album"_L1];
const QJsonValue value_song = json_track["track"_L1];
const QJsonValue value_ignoredmessage = json_track["ignoredMessage"_L1];
//const quint64 timestamp = json_track[u"timestamp"_s].toVariant().toULongLong();
if (!value_artist.isObject() || !value_album.isObject() || !value_song.isObject() || !value_ignoredmessage.isObject()) {
Error(u"Json scrobbles scrobble values are not objects."_s, json_track);
continue;
}
QJsonObject obj_artist = value_artist.toObject();
QJsonObject obj_album = value_album.toObject();
QJsonObject obj_song = value_song.toObject();
QJsonObject obj_ignoredmessage = value_ignoredmessage.toObject();
const QJsonObject object_artist = value_artist.toObject();
const QJsonObject object_album = value_album.toObject();
const QJsonObject object_song = value_song.toObject();
const QJsonObject object_ignoredmessage = value_ignoredmessage.toObject();
if (obj_artist.isEmpty() || obj_album.isEmpty() || obj_song.isEmpty() || obj_ignoredmessage.isEmpty()) {
if (object_artist.isEmpty() || object_album.isEmpty() || object_song.isEmpty() || object_ignoredmessage.isEmpty()) {
Error(u"Json scrobbles scrobble values objects are empty."_s, json_track);
continue;
}
if (!obj_artist.contains("#text"_L1) || !obj_album.contains("#text"_L1) || !obj_song.contains("#text"_L1)) {
if (!object_artist.contains("#text"_L1) || !object_album.contains("#text"_L1) || !object_song.contains("#text"_L1)) {
continue;
}
//QString artist = obj_artist["#text"].toString();
//QString album = obj_album["#text"].toString();
QString song = obj_song["#text"_L1].toString();
bool ignoredmessage = obj_ignoredmessage["code"_L1].toVariant().toBool();
QString ignoredmessage_text = obj_ignoredmessage["#text"_L1].toString();
//const QString artist = obj_artist["#text"].toString();
//const QString album = obj_album["#text"].toString();
const QString song = object_song["#text"_L1].toString();
const bool ignoredmessage = object_ignoredmessage["code"_L1].toVariant().toBool();
const QString ignoredmessage_text = object_ignoredmessage["#text"_L1].toString();
if (ignoredmessage) {
Error(u"Scrobble for \"%1\" ignored: %2"_s.arg(song, ignoredmessage_text));
@@ -732,97 +718,97 @@ void ScrobblingAPI20::SingleScrobbleRequestFinished(QNetworkReply *reply, Scrobb
QObject::disconnect(reply, nullptr, this, nullptr);
reply->deleteLater();
QJsonObject json_obj;
QString error_message;
if (GetJsonObject(reply, json_obj, error_message) != ReplyResult::Success) {
Error(error_message);
const JsonObjectResult json_object_result = ParseJsonObject(reply);
if (json_object_result.error_code != ErrorCode::Success) {
Error(json_object_result.error_message);
cache_item->sent = false;
return;
}
const QJsonObject &json_object = json_object_result.json_object;
if (!json_obj.contains("scrobbles"_L1)) {
Error(u"Json reply from server is missing scrobbles."_s, json_obj);
if (!json_object.contains("scrobbles"_L1)) {
Error(u"Json reply from server is missing scrobbles."_s, json_object);
cache_item->sent = false;
return;
}
cache_->Remove(cache_item);
QJsonValue value_scrobbles = json_obj["scrobbles"_L1];
const QJsonValue value_scrobbles = json_object["scrobbles"_L1];
if (!value_scrobbles.isObject()) {
Error(u"Json scrobbles is not an object."_s, json_obj);
Error(u"Json scrobbles is not an object."_s, json_object);
return;
}
json_obj = value_scrobbles.toObject();
if (json_obj.isEmpty()) {
const QJsonObject object_scrobbles = value_scrobbles.toObject();
if (object_scrobbles.isEmpty()) {
Error(u"Json scrobbles object is empty."_s, value_scrobbles);
return;
}
if (!json_obj.contains("@attr"_L1) || !json_obj.contains("scrobble"_L1)) {
Error(u"Json scrobbles object is missing values."_s, json_obj);
if (!object_scrobbles.contains("@attr"_L1) || !object_scrobbles.contains("scrobble"_L1)) {
Error(u"Json scrobbles object is missing values."_s, object_scrobbles);
return;
}
QJsonValue value_attr = json_obj["@attr"_L1];
const QJsonValue value_attr = object_scrobbles["@attr"_L1];
if (!value_attr.isObject()) {
Error(u"Json scrobbles attr is not an object."_s, value_attr);
return;
}
QJsonObject obj_attr = value_attr.toObject();
if (obj_attr.isEmpty()) {
const QJsonObject object_attr = value_attr.toObject();
if (object_attr.isEmpty()) {
Error(u"Json scrobbles attr is empty."_s, value_attr);
return;
}
QJsonValue value_scrobble = json_obj["scrobble"_L1];
const QJsonValue value_scrobble = object_scrobbles["scrobble"_L1];
if (!value_scrobble.isObject()) {
Error(u"Json scrobbles scrobble is not an object."_s, value_scrobble);
return;
}
QJsonObject json_obj_scrobble = value_scrobble.toObject();
if (json_obj_scrobble.isEmpty()) {
const QJsonObject json_object_scrobble = value_scrobble.toObject();
if (json_object_scrobble.isEmpty()) {
Error(u"Json scrobbles scrobble is empty."_s, value_scrobble);
return;
}
if (!obj_attr.contains("accepted"_L1) || !obj_attr.contains("ignored"_L1)) {
Error(u"Json scrobbles attr is missing values."_s, obj_attr);
if (!object_attr.contains("accepted"_L1) || !object_attr.contains("ignored"_L1)) {
Error(u"Json scrobbles attr is missing values."_s, object_attr);
return;
}
if (!json_obj_scrobble.contains("artist"_L1) || !json_obj_scrobble.contains("album"_L1) || !json_obj_scrobble.contains("albumArtist"_L1) || !json_obj_scrobble.contains("track"_L1) || !json_obj_scrobble.contains("timestamp"_L1)) {
Error(u"Json scrobbles scrobble is missing values."_s, json_obj_scrobble);
if (!json_object_scrobble.contains("artist"_L1) || !json_object_scrobble.contains("album"_L1) || !json_object_scrobble.contains("albumArtist"_L1) || !json_object_scrobble.contains("track"_L1) || !json_object_scrobble.contains("timestamp"_L1)) {
Error(u"Json scrobbles scrobble is missing values."_s, json_object_scrobble);
return;
}
QJsonValue json_value_artist = json_obj_scrobble["artist"_L1];
QJsonValue json_value_album = json_obj_scrobble["album"_L1];
QJsonValue json_value_song = json_obj_scrobble["track"_L1];
const QJsonValue json_value_artist = json_object_scrobble["artist"_L1];
const QJsonValue json_value_album = json_object_scrobble["album"_L1];
const QJsonValue json_value_song = json_object_scrobble["track"_L1];
if (!json_value_artist.isObject() || !json_value_album.isObject() || !json_value_song.isObject()) {
Error(u"Json scrobbles scrobble values are not objects."_s, json_obj_scrobble);
Error(u"Json scrobbles scrobble values are not objects."_s, json_object_scrobble);
return;
}
QJsonObject json_obj_artist = json_value_artist.toObject();
QJsonObject json_obj_album = json_value_album.toObject();
QJsonObject json_obj_song = json_value_song.toObject();
const QJsonObject json_object_artist = json_value_artist.toObject();
const QJsonObject json_object_album = json_value_album.toObject();
const QJsonObject json_object_song = json_value_song.toObject();
if (json_obj_artist.isEmpty() || json_obj_album.isEmpty() || json_obj_song.isEmpty()) {
Error(u"Json scrobbles scrobble values objects are empty."_s, json_obj_scrobble);
if (json_object_artist.isEmpty() || json_object_album.isEmpty() || json_object_song.isEmpty()) {
Error(u"Json scrobbles scrobble values objects are empty."_s, json_object_scrobble);
return;
}
if (!json_obj_artist.contains("#text"_L1) || !json_obj_album.contains("#text"_L1) || !json_obj_song.contains("#text"_L1)) {
Error(u"Json scrobbles scrobble values objects are missing #text."_s, json_obj_artist);
if (!json_object_artist.contains("#text"_L1) || !json_object_album.contains("#text"_L1) || !json_object_song.contains("#text"_L1)) {
Error(u"Json scrobbles scrobble values objects are missing #text."_s, json_object_artist);
return;
}
//QString artist = json_obj_artist["#text"].toString();
//QString album = json_obj_album["#text"].toString();
QString song = json_obj_song["#text"_L1].toString();
const QString song = json_object_song["#text"_L1].toString();
int accepted = obj_attr["accepted"_L1].toVariant().toInt();
const int accepted = object_attr["accepted"_L1].toVariant().toInt();
if (accepted == 1) {
qLog(Debug) << name_ << "Scrobble for" << song << "accepted";
}
@@ -868,22 +854,22 @@ void ScrobblingAPI20::LoveRequestFinished(QNetworkReply *reply) {
QObject::disconnect(reply, nullptr, this, nullptr);
reply->deleteLater();
QJsonObject json_obj;
QString error_message;
if (GetJsonObject(reply, json_obj, error_message) != ReplyResult::Success) {
Error(error_message);
const JsonObjectResult json_object_result = ParseJsonObject(reply);
if (json_object_result.error_code != ErrorCode::Success) {
Error(json_object_result.error_message);
return;
}
const QJsonObject &json_object = json_object_result.json_object;
if (json_obj.contains("error"_L1)) {
QJsonValue json_value = json_obj["error"_L1];
if (json_object.contains("error"_L1)) {
const QJsonValue json_value = json_object["error"_L1];
if (!json_value.isObject()) {
Error(u"Error is not on object."_s);
return;
}
QJsonObject json_obj_error = json_value.toObject();
const QJsonObject json_obj_error = json_value.toObject();
if (json_obj_error.isEmpty()) {
Error(u"Received empty json error object."_s, json_obj);
Error(u"Received empty json error object."_s, json_object);
return;
}
if (json_obj_error.contains("code"_L1) && json_obj_error.contains("#text"_L1)) {
@@ -895,12 +881,12 @@ void ScrobblingAPI20::LoveRequestFinished(QNetworkReply *reply) {
}
}
if (json_obj.contains("lfm"_L1)) {
QJsonValue json_value = json_obj["lfm"_L1];
if (json_object.contains("lfm"_L1)) {
const QJsonValue json_value = json_object["lfm"_L1];
if (json_value.isObject()) {
QJsonObject json_obj_lfm = json_value.toObject();
const QJsonObject json_obj_lfm = json_value.toObject();
if (json_obj_lfm.contains("status"_L1)) {
QString status = json_obj_lfm["status"_L1].toString();
const QString status = json_obj_lfm["status"_L1].toString();
qLog(Debug) << name_ << "Received love status:" << status;
return;
}
@@ -918,8 +904,7 @@ void ScrobblingAPI20::AuthError(const QString &error) {
void ScrobblingAPI20::Error(const QString &error, const QVariant &debug) {
qLog(Error) << name_ << error;
if (debug.isValid()) qLog(Debug) << debug;
JsonBaseRequest::Error(error, debug);
if (settings_->show_error_dialog()) {
Q_EMIT ErrorMessage(tr("Scrobbler %1 error: %2").arg(name_, error));
@@ -994,8 +979,7 @@ QString ScrobblingAPI20::ErrorString(const ScrobbleErrorCode error) {
void ScrobblingAPI20::CheckScrobblePrevSong() {
qint64 duration = QDateTime::currentSecsSinceEpoch() - static_cast<qint64>(timestamp_);
if (duration < 0) duration = 0;
const qint64 duration = std::min(0LL, QDateTime::currentSecsSinceEpoch() - static_cast<qint64>(timestamp_));
if (!scrobbled_ && song_playing_.is_metadata_good() && song_playing_.is_radio() && duration > 30) {
Song song(song_playing_);

View File

@@ -1,6 +1,6 @@
/*
* Strawberry Music Player
* Copyright 2018-2023, Jonas Kvinge <jonas@jkvinge.net>
* Copyright 2018-2025, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -22,13 +22,9 @@
#include "config.h"
#include <QtGlobal>
#include <QObject>
#include <QList>
#include <QVariant>
#include <QByteArray>
#include <QString>
#include <QTimer>
#include "includes/shared_ptr.h"
#include "core/song.h"
@@ -36,6 +32,7 @@
#include "scrobblercache.h"
#include "scrobblercacheitem.h"
class QTimer;
class QNetworkReply;
class ScrobblerSettingsService;
@@ -53,15 +50,19 @@ class ScrobblingAPI20 : public ScrobblerService {
void ReloadSettings() override;
void LoadSession();
void ClearSession();
bool enabled() const override { return enabled_; }
bool authentication_required() const override { return true; }
bool authenticated() const override { return !username_.isEmpty() && !session_key_.isEmpty(); }
bool use_authorization_header() const override { return false; }
QByteArray authorization_header() const override { return QByteArray(); }
bool subscriber() const { return subscriber_; }
bool submitted() const override { return submitted_; }
QString username() const { return username_; }
void Authenticate();
void Logout();
void UpdateNowPlaying(const Song &song) override;
void ClearPlaying() override;
void Scrobble(const Song &song) override;
@@ -83,12 +84,6 @@ class ScrobblingAPI20 : public ScrobblerService {
void LoveRequestFinished(QNetworkReply *reply);
private:
enum class ReplyResult {
Success,
ServerError,
APIError
};
enum class ScrobbleErrorCode {
NoError = 1,
InvalidService = 2,
@@ -121,12 +116,11 @@ class ScrobblingAPI20 : public ScrobblerService {
};
QNetworkReply *CreateRequest(const ParamList &request_params);
ReplyResult GetJsonObject(QNetworkReply *reply, QJsonObject &json_obj, QString &error_description);
JsonObjectResult ParseJsonObject(QNetworkReply *reply);
void RequestSession(const QString &token);
void AuthError(const QString &error);
void SendSingleScrobble(ScrobblerCacheItemPtr item);
void Error(const QString &error, const QVariant &debug = QVariant());
void Error(const QString &error, const QVariant &debug = QVariant()) override;
static QString ErrorString(const ScrobbleErrorCode error);
void StartSubmit(const bool initial = false) override;
void CheckScrobblePrevSong();
@@ -140,7 +134,7 @@ class ScrobblingAPI20 : public ScrobblerService {
const SharedPtr<NetworkAccessManager> network_;
ScrobblerCache *cache_;
LocalRedirectServer *server_;
LocalRedirectServer *local_redirect_server_;
bool enabled_;
bool prefer_albumartist_;
@@ -155,9 +149,7 @@ class ScrobblingAPI20 : public ScrobblerService {
quint64 timestamp_;
bool submit_error_;
QTimer timer_submit_;
QList<QNetworkReply*> replies_;
QTimer *timer_submit_;
};
#endif // SCROBBLINGAPI20_H

View File

@@ -1,6 +1,6 @@
/*
* Strawberry Music Player
* Copyright 2018-2021, Jonas Kvinge <jonas@jkvinge.net>
* Copyright 2018-2025, Jonas Kvinge <jonas@jkvinge.net>
* Copyright 2020, Pascal Below <spezifisch@below.fr>
*
* Strawberry is free software: you can redistribute it and/or modify
@@ -43,8 +43,8 @@ namespace {
constexpr char kName[] = "Subsonic";
}
SubsonicScrobbler::SubsonicScrobbler(const SharedPtr<ScrobblerSettingsService> settings, const SharedPtr<SubsonicService> service, QObject *parent)
: ScrobblerService(QLatin1String(kName), settings, parent),
SubsonicScrobbler::SubsonicScrobbler(const SharedPtr<ScrobblerSettingsService> settings, const SharedPtr<NetworkAccessManager> network, const SharedPtr<SubsonicService> service, QObject *parent)
: ScrobblerService(QLatin1String(kName), network, settings, parent),
service_(service),
enabled_(false),
submitted_(false) {

View File

@@ -1,6 +1,6 @@
/*
* Strawberry Music Player
* Copyright 2018-2021, Jonas Kvinge <jonas@jkvinge.net>
* Copyright 2018-2025, Jonas Kvinge <jonas@jkvinge.net>
* Copyright 2020, Pascal Below <spezifisch@below.fr>
*
* Strawberry is free software: you can redistribute it and/or modify
@@ -23,8 +23,6 @@
#include "config.h"
#include <QtGlobal>
#include <QObject>
#include <QDateTime>
#include <QVariant>
#include <QString>
@@ -41,12 +39,15 @@ class SubsonicScrobbler : public ScrobblerService {
Q_OBJECT
public:
explicit SubsonicScrobbler(const SharedPtr<ScrobblerSettingsService> settings, const SharedPtr<SubsonicService> service, QObject *parent = nullptr);
explicit SubsonicScrobbler(const SharedPtr<ScrobblerSettingsService> settings, const SharedPtr<NetworkAccessManager> network, const SharedPtr<SubsonicService> service, QObject *parent = nullptr);
void ReloadSettings() override;
bool enabled() const override { return enabled_; }
bool authentication_required() const override { return true; }
bool authenticated() const override { return true; }
bool use_authorization_header() const override { return false; }
QByteArray authorization_header() const override { return QByteArray(); }
void UpdateNowPlaying(const Song &song) override;
void ClearPlaying() override;

View File

@@ -190,7 +190,7 @@ void ScrobblerSettingsPage::LastFM_Login() {
void ScrobblerSettingsPage::LastFM_Logout() {
lastfmscrobbler_->Logout();
lastfmscrobbler_->ClearSession();
LastFM_RefreshControls(false);
}
@@ -225,7 +225,7 @@ void ScrobblerSettingsPage::LibreFM_Login() {
void ScrobblerSettingsPage::LibreFM_Logout() {
librefmscrobbler_->Logout();
librefmscrobbler_->ClearSession();
LibreFM_RefreshControls(false);
}