diff --git a/src/core/application.cpp b/src/core/application.cpp index e3c2afd6e..4da9a6896 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -3,7 +3,7 @@ * This file was part of Clementine. * Copyright 2012, David Sansome * Copyright 2012, 2014, John Maguire - * Copyright 2018-2024, Jonas Kvinge + * Copyright 2018-2025, Jonas Kvinge * * Strawberry is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -208,7 +208,7 @@ class ApplicationImpl { scrobbler->AddService(make_shared(scrobbler->settings(), app->network())); scrobbler->AddService(make_shared(scrobbler->settings(), app->network())); #ifdef HAVE_SUBSONIC - scrobbler->AddService(make_shared(scrobbler->settings(), app->streaming_services()->Service(), app)); + scrobbler->AddService(make_shared(scrobbler->settings(), app->network(), app->streaming_services()->Service(), app)); #endif return scrobbler; }), diff --git a/src/scrobbler/audioscrobbler.cpp b/src/scrobbler/audioscrobbler.cpp index 85032918b..4cd17ae7a 100644 --- a/src/scrobbler/audioscrobbler.cpp +++ b/src/scrobbler/audioscrobbler.cpp @@ -1,6 +1,6 @@ /* * Strawberry Music Player - * Copyright 2018-2021, Jonas Kvinge + * Copyright 2018-2025, Jonas Kvinge * * Strawberry is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by diff --git a/src/scrobbler/audioscrobbler.h b/src/scrobbler/audioscrobbler.h index 3726080ff..55cc53eb8 100644 --- a/src/scrobbler/audioscrobbler.h +++ b/src/scrobbler/audioscrobbler.h @@ -1,6 +1,6 @@ /* * Strawberry Music Player - * Copyright 2018-2021, Jonas Kvinge + * Copyright 2018-2025, Jonas Kvinge * * Strawberry is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by diff --git a/src/scrobbler/lastfmimport.cpp b/src/scrobbler/lastfmimport.cpp index fd164e9f4..ca52dc6ce 100644 --- a/src/scrobbler/lastfmimport.cpp +++ b/src/scrobbler/lastfmimport.cpp @@ -1,6 +1,6 @@ /* * Strawberry Music Player - * Copyright 2020-2021, Jonas Kvinge + * Copyright 2020-2025, Jonas Kvinge * * Strawberry is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -55,7 +55,7 @@ constexpr int kRequestsDelay = 2000; } LastFMImport::LastFMImport(const SharedPtr 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 ¶m : 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; diff --git a/src/scrobbler/lastfmimport.h b/src/scrobbler/lastfmimport.h index 8fb0a847f..c4e54ead5 100644 --- a/src/scrobbler/lastfmimport.h +++ b/src/scrobbler/lastfmimport.h @@ -1,6 +1,6 @@ /* * Strawberry Music Player - * Copyright 2020-2021, Jonas Kvinge + * Copyright 2020-2025, Jonas Kvinge * * Strawberry is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -22,8 +22,6 @@ #include "config.h" -#include -#include #include #include #include @@ -31,20 +29,28 @@ #include #include +#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 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 recent_tracks_requests_; QQueue top_tracks_requests_; - QList replies_; }; #endif // LASTFMIMPORT_H diff --git a/src/scrobbler/lastfmscrobbler.cpp b/src/scrobbler/lastfmscrobbler.cpp index 5500c8d40..d16bd02fc 100644 --- a/src/scrobbler/lastfmscrobbler.cpp +++ b/src/scrobbler/lastfmscrobbler.cpp @@ -1,6 +1,6 @@ /* * Strawberry Music Player - * Copyright 2018-2023, Jonas Kvinge + * Copyright 2018-2025, Jonas Kvinge * * Strawberry is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -19,8 +19,6 @@ #include "config.h" -#include - #include "includes/shared_ptr.h" #include "core/networkaccessmanager.h" diff --git a/src/scrobbler/lastfmscrobbler.h b/src/scrobbler/lastfmscrobbler.h index 3acb760b8..86850b9ba 100644 --- a/src/scrobbler/lastfmscrobbler.h +++ b/src/scrobbler/lastfmscrobbler.h @@ -1,6 +1,6 @@ /* * Strawberry Music Player - * Copyright 2018-2023, Jonas Kvinge + * Copyright 2018-2025, Jonas Kvinge * * Strawberry is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -22,9 +22,6 @@ #include "config.h" -#include -#include - #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 diff --git a/src/scrobbler/librefmscrobbler.cpp b/src/scrobbler/librefmscrobbler.cpp index 67e944500..2d7cb468f 100644 --- a/src/scrobbler/librefmscrobbler.cpp +++ b/src/scrobbler/librefmscrobbler.cpp @@ -1,6 +1,6 @@ /* * Strawberry Music Player - * Copyright 2018-2023, Jonas Kvinge + * Copyright 2018-2025, Jonas Kvinge * * Strawberry is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -19,8 +19,6 @@ #include "config.h" -#include - #include "includes/shared_ptr.h" #include "core/networkaccessmanager.h" diff --git a/src/scrobbler/librefmscrobbler.h b/src/scrobbler/librefmscrobbler.h index eefb6d90f..82a6b4b79 100644 --- a/src/scrobbler/librefmscrobbler.h +++ b/src/scrobbler/librefmscrobbler.h @@ -1,6 +1,6 @@ /* * Strawberry Music Player - * Copyright 2018-2023, Jonas Kvinge + * Copyright 2018-2025, Jonas Kvinge * * Strawberry is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -22,9 +22,6 @@ #include "config.h" -#include -#include - #include "includes/shared_ptr.h" #include "scrobblingapi20.h" diff --git a/src/scrobbler/listenbrainzscrobbler.cpp b/src/scrobbler/listenbrainzscrobbler.cpp index 5df25e960..4365f3505 100644 --- a/src/scrobbler/listenbrainzscrobbler.cpp +++ b/src/scrobbler/listenbrainzscrobbler.cpp @@ -1,6 +1,6 @@ /* * Strawberry Music Player - * Copyright 2018-2023, Jonas Kvinge + * Copyright 2018-2025, Jonas Kvinge * * Strawberry is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -23,18 +23,12 @@ #include #include -#include -#include #include #include #include #include -#include #include #include -#include -#include -#include #include #include #include @@ -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 settings, const SharedPtr 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(login_time_)); - if (time < 6) time = 6; - refresh_login_timer_.setInterval(static_cast(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(":
%1").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 ¶m : 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(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(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)); } } diff --git a/src/scrobbler/listenbrainzscrobbler.h b/src/scrobbler/listenbrainzscrobbler.h index 6e474e8d8..32cb666dc 100644 --- a/src/scrobbler/listenbrainzscrobbler.h +++ b/src/scrobbler/listenbrainzscrobbler.h @@ -1,6 +1,6 @@ /* * Strawberry Music Player - * Copyright 2018-2023, Jonas Kvinge + * Copyright 2018-2025, Jonas Kvinge * * Strawberry is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -22,15 +22,11 @@ #include "config.h" -#include -#include -#include #include #include #include #include #include -#include #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 settings, const SharedPtr 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 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 replies_; }; #endif // LISTENBRAINZSCROBBLER_H diff --git a/src/scrobbler/scrobblerservice.cpp b/src/scrobbler/scrobblerservice.cpp index 50441827f..1893f788f 100644 --- a/src/scrobbler/scrobblerservice.cpp +++ b/src/scrobbler/scrobblerservice.cpp @@ -1,6 +1,6 @@ /* * Strawberry Music Player - * Copyright 2018-2023, Jonas Kvinge + * Copyright 2018-2025, Jonas Kvinge * * Strawberry is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -19,37 +19,14 @@ #include "config.h" -#include -#include #include -#include -#include -#include #include "scrobblerservice.h" #include "scrobblersettingsservice.h" #include "core/song.h" -ScrobblerService::ScrobblerService(const QString &name, const SharedPtr 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 network, const SharedPtr settings, QObject *parent) : JsonBaseRequest(network, parent), name_(name), settings_(settings) {} QString ScrobblerService::StripAlbum(const QString &album) const { diff --git a/src/scrobbler/scrobblerservice.h b/src/scrobbler/scrobblerservice.h index 54d805872..c29b1b423 100644 --- a/src/scrobbler/scrobblerservice.h +++ b/src/scrobbler/scrobblerservice.h @@ -1,6 +1,6 @@ /* * Strawberry Music Player - * Copyright 2018-2023, Jonas Kvinge + * Copyright 2018-2025, Jonas Kvinge * * Strawberry is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -22,31 +22,29 @@ #include "config.h" -#include #include -#include -#include #include #include -#include #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 settings, QObject *parent); + explicit ScrobblerService(const QString &name, const SharedPtr network, const SharedPtr 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; - using ParamList = QList; using EncodedParam = QPair; - bool ExtractJsonObj(const QByteArray &data, QJsonObject &json_obj, QString &error_description); - QString StripAlbum(const QString &album) const; QString StripTitle(const QString &title) const; diff --git a/src/scrobbler/scrobblingapi20.cpp b/src/scrobbler/scrobblingapi20.cpp index d9e857605..34f069708 100644 --- a/src/scrobbler/scrobblingapi20.cpp +++ b/src/scrobbler/scrobblingapi20.cpp @@ -1,6 +1,6 @@ /* * Strawberry Music Player - * Copyright 2018-2023, Jonas Kvinge + * Copyright 2018-2025, Jonas Kvinge * * Strawberry is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -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 settings, const SharedPtr 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(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(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(timestamp_); - if (duration < 0) duration = 0; + const qint64 duration = std::min(0LL, QDateTime::currentSecsSinceEpoch() - static_cast(timestamp_)); if (!scrobbled_ && song_playing_.is_metadata_good() && song_playing_.is_radio() && duration > 30) { Song song(song_playing_); diff --git a/src/scrobbler/scrobblingapi20.h b/src/scrobbler/scrobblingapi20.h index 441bd488d..46f6157c9 100644 --- a/src/scrobbler/scrobblingapi20.h +++ b/src/scrobbler/scrobblingapi20.h @@ -1,6 +1,6 @@ /* * Strawberry Music Player - * Copyright 2018-2023, Jonas Kvinge + * Copyright 2018-2025, Jonas Kvinge * * Strawberry is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -22,13 +22,9 @@ #include "config.h" -#include -#include -#include #include #include #include -#include #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 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 replies_; + QTimer *timer_submit_; }; #endif // SCROBBLINGAPI20_H diff --git a/src/scrobbler/subsonicscrobbler.cpp b/src/scrobbler/subsonicscrobbler.cpp index 7060e7121..6fe37b197 100644 --- a/src/scrobbler/subsonicscrobbler.cpp +++ b/src/scrobbler/subsonicscrobbler.cpp @@ -1,6 +1,6 @@ /* * Strawberry Music Player - * Copyright 2018-2021, Jonas Kvinge + * Copyright 2018-2025, Jonas Kvinge * Copyright 2020, Pascal Below * * Strawberry is free software: you can redistribute it and/or modify @@ -43,8 +43,8 @@ namespace { constexpr char kName[] = "Subsonic"; } -SubsonicScrobbler::SubsonicScrobbler(const SharedPtr settings, const SharedPtr service, QObject *parent) - : ScrobblerService(QLatin1String(kName), settings, parent), +SubsonicScrobbler::SubsonicScrobbler(const SharedPtr settings, const SharedPtr network, const SharedPtr service, QObject *parent) + : ScrobblerService(QLatin1String(kName), network, settings, parent), service_(service), enabled_(false), submitted_(false) { diff --git a/src/scrobbler/subsonicscrobbler.h b/src/scrobbler/subsonicscrobbler.h index f7065ac31..fe257ee52 100644 --- a/src/scrobbler/subsonicscrobbler.h +++ b/src/scrobbler/subsonicscrobbler.h @@ -1,6 +1,6 @@ /* * Strawberry Music Player - * Copyright 2018-2021, Jonas Kvinge + * Copyright 2018-2025, Jonas Kvinge * Copyright 2020, Pascal Below * * Strawberry is free software: you can redistribute it and/or modify @@ -23,8 +23,6 @@ #include "config.h" -#include -#include #include #include #include @@ -41,12 +39,15 @@ class SubsonicScrobbler : public ScrobblerService { Q_OBJECT public: - explicit SubsonicScrobbler(const SharedPtr settings, const SharedPtr service, QObject *parent = nullptr); + explicit SubsonicScrobbler(const SharedPtr settings, const SharedPtr network, const SharedPtr 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; diff --git a/src/settings/scrobblersettingspage.cpp b/src/settings/scrobblersettingspage.cpp index 5894973b4..0e09a8b92 100644 --- a/src/settings/scrobblersettingspage.cpp +++ b/src/settings/scrobblersettingspage.cpp @@ -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); }