Remove libre.fm

This commit is contained in:
Jonas Kvinge
2025-09-18 00:22:11 +02:00
parent 1ec6b5582e
commit 4b014253cf
17 changed files with 1068 additions and 1355 deletions

View File

@@ -792,9 +792,7 @@ set(SOURCES
src/scrobbler/scrobblercache.cpp
src/scrobbler/scrobblercacheitem.cpp
src/scrobbler/scrobblemetadata.cpp
src/scrobbler/scrobblingapi20.cpp
src/scrobbler/lastfmscrobbler.cpp
src/scrobbler/librefmscrobbler.cpp
src/scrobbler/listenbrainzscrobbler.cpp
src/scrobbler/lastfmimport.cpp
@@ -1085,9 +1083,7 @@ set(HEADERS
src/scrobbler/scrobblersettingsservice.h
src/scrobbler/scrobblerservice.h
src/scrobbler/scrobblercache.h
src/scrobbler/scrobblingapi20.h
src/scrobbler/lastfmscrobbler.h
src/scrobbler/librefmscrobbler.h
src/scrobbler/listenbrainzscrobbler.h
src/scrobbler/lastfmimport.h

View File

@@ -58,7 +58,7 @@ Funding developers is a way to contribute to open source projects you appreciate
* Audio analyzer
* Audio equalizer
* Transfer music to mass-storage USB players, MTP compatible devices and iPod Nano/Classic
* Scrobbler with support for [Last.fm](https://www.last.fm/), [Libre.fm](https://libre.fm/) and [ListenBrainz](https://listenbrainz.org/)
* Scrobbler with support for [Last.fm](https://www.last.fm/) and [ListenBrainz](https://listenbrainz.org/)
* Streaming from Subsonic compatible servers
* Unofficial Tidal, Spotify and Qobuz integration
* Discord rich presence

2
debian/control vendored
View File

@@ -64,7 +64,7 @@ Description: music player and music collection organizer
- Audio analyzer
- Audio equalizer
- Transfer music to mass-storage USB players, MTP compatible devices and iPod Nano/Classic
- Scrobbler with support for Last.fm, Libre.fm and ListenBrainz
- Scrobbler with support for Last.fm and ListenBrainz
- Streaming support for Subsonic-compatible servers
- Unofficial streaming support for Tidal and Qobuz
.

View File

@@ -34,7 +34,7 @@
<li>Song lyrics from Genius, Musixmatch, ChartLyrics, lyrics.ovh, lololyrics.com, songlyrics.com, azlyrics.com, elyrics.net, letras.mus.br and LyricFind</li>
<li>Audio analyzer and equalizer</li>
<li>Transfer music to mass-storage USB players, MTP compatible devices and iPod Nano/Classic</li>
<li>Scrobbler with support for Last.fm, Libre.fm and ListenBrainz</li>
<li>Scrobbler with support for Last.fm and ListenBrainz</li>
<li>Streaming support for Subsonic-compatible servers</li>
<li>Unofficial streaming support for Tidal, Spotify and Qobuz</li>
</ul>

View File

@@ -39,7 +39,7 @@ Features:
.br
- Transfer music to mass-storage USB players, MTP compatible devices and iPod Nano/Classic
.br
- Scrobbler with support for Last.fm, Libre.fm and ListenBrainz
- Scrobbler with support for Last.fm and ListenBrainz
.br
- Streaming support for Subsonic-compatible servers
.br

View File

@@ -97,7 +97,7 @@ Features:
- Support for multiple backends
- Audio analyzer
- Audio equalizer
- Scrobbler with support for Last.fm, Libre.fm and ListenBrainz
- Scrobbler with support for Last.fm and ListenBrainz
- Transfer music to mass-storage USB players, MTP compatible devices and iPod Nano/Classic
- Streaming support for Subsonic-compatible servers
- Unofficial streaming support for Tidal and Qobuz

View File

@@ -77,7 +77,6 @@
#include "scrobbler/audioscrobbler.h"
#include "scrobbler/lastfmscrobbler.h"
#include "scrobbler/librefmscrobbler.h"
#include "scrobbler/listenbrainzscrobbler.h"
#include "scrobbler/lastfmimport.h"
#ifdef HAVE_SUBSONIC
@@ -206,7 +205,6 @@ class ApplicationImpl {
scrobbler_([app]() {
AudioScrobbler *scrobbler = new AudioScrobbler(app);
scrobbler->AddService(make_shared<LastFMScrobbler>(scrobbler->settings(), app->network()));
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->network(), app->streaming_services()->Service<SubsonicService>(), app));

View File

@@ -45,7 +45,6 @@
#include "lastfmimport.h"
#include "scrobblingapi20.h"
#include "lastfmscrobbler.h"
using namespace Qt::Literals::StringLiterals;
@@ -109,7 +108,7 @@ void LastFMImport::ReloadSettings() {
QNetworkReply *LastFMImport::CreateRequest(const ParamList &request_params) {
ParamList params = ParamList()
<< Param(u"api_key"_s, QLatin1String(ScrobblingAPI20::kApiKey))
<< Param(u"api_key"_s, QLatin1String(LastFMScrobbler::kApiKey))
<< Param(u"user"_s, username_)
<< Param(u"lang"_s, QLocale().name().left(2).toLower())
<< Param(u"format"_s, u"json"_s)

View File

@@ -19,20 +19,968 @@
#include "config.h"
#include <algorithm>
#include <utility>
#include <QApplication>
#include <QDesktopServices>
#include <QLocale>
#include <QClipboard>
#include <QPair>
#include <QVariant>
#include <QByteArray>
#include <QString>
#include <QUrl>
#include <QUrlQuery>
#include <QDateTime>
#include <QTimer>
#include <QCryptographicHash>
#include <QMessageBox>
#include <QSettings>
#include <QNetworkRequest>
#include <QNetworkReply>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <QJsonValue>
#include <QFlags>
#include "includes/shared_ptr.h"
#include "core/networkaccessmanager.h"
#include "core/song.h"
#include "core/logging.h"
#include "core/settings.h"
#include "core/localredirectserver.h"
#include "constants/timeconstants.h"
#include "constants/scrobblersettings.h"
#include "scrobblersettingsservice.h"
#include "scrobblerservice.h"
#include "scrobblercache.h"
#include "scrobblercacheitem.h"
#include "scrobblemetadata.h"
#include "lastfmscrobbler.h"
using namespace Qt::Literals::StringLiterals;
const char *LastFMScrobbler::kName = "Last.fm";
const char *LastFMScrobbler::kSettingsGroup = "LastFM";
const char *LastFMScrobbler::kApiUrl = "https://ws.audioscrobbler.com/2.0/";
const char *LastFMScrobbler::kApiKey = "211990b4c96782c05d1536e7219eb56e";
namespace {
constexpr char kAuthUrl[] = "https://www.last.fm/api/auth/";
constexpr char kSecret[] = "80fd738f49596e9709b1bf9319c444a8";
constexpr int kScrobblesPerRequest = 50;
constexpr char kCacheFile[] = "lastfmscrobbler.cache";
} // namespace
LastFMScrobbler::LastFMScrobbler(const SharedPtr<ScrobblerSettingsService> settings, const SharedPtr<NetworkAccessManager> network, QObject *parent)
: ScrobblingAPI20(QLatin1String(kName), QLatin1String(kSettingsGroup), QLatin1String(kAuthUrl), QLatin1String(kApiUrl), true, QLatin1String(kCacheFile), settings, network, parent) {}
: ScrobblerService(QLatin1String(kName), network, settings, parent),
network_(network),
cache_(new ScrobblerCache(QLatin1String(kCacheFile), this)),
local_redirect_server_(nullptr),
enabled_(false),
prefer_albumartist_(false),
subscriber_(false),
submitted_(false),
scrobbled_(false),
timestamp_(0),
submit_error_(false),
timer_submit_(new QTimer(this)) {
timer_submit_->setSingleShot(true);
QObject::connect(timer_submit_, &QTimer::timeout, this, &LastFMScrobbler::Submit);
LastFMScrobbler::ReloadSettings();
LoadSession();
}
LastFMScrobbler::~LastFMScrobbler() {
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();
}
}
void LastFMScrobbler::ReloadSettings() {
Settings s;
s.beginGroup(kSettingsGroup);
enabled_ = s.value(ScrobblerSettings::kEnabled, false).toBool();
s.endGroup();
s.beginGroup(ScrobblerSettings::kSettingsGroup);
prefer_albumartist_ = s.value(ScrobblerSettings::kAlbumArtist, false).toBool();
s.endGroup();
}
void LastFMScrobbler::LoadSession() {
Settings s;
s.beginGroup(kSettingsGroup);
subscriber_ = s.value("subscriber", false).toBool();
username_ = s.value("username").toString();
session_key_ = s.value("session_key").toString();
s.endGroup();
}
void LastFMScrobbler::ClearSession() {
subscriber_ = false;
username_.clear();
session_key_.clear();
Settings settings;
settings.beginGroup(kSettingsGroup);
settings.remove("subscriber");
settings.remove("username");
settings.remove("session_key");
settings.endGroup();
}
void LastFMScrobbler::Authenticate() {
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(local_redirect_server_, &LocalRedirectServer::Finished, this, &LastFMScrobbler::RedirectArrived);
}
QUrlQuery url_query;
url_query.addQueryItem(u"api_key"_s, QLatin1String(kApiKey));
url_query.addQueryItem(u"cb"_s, local_redirect_server_->url().toString());
QUrl url(QString::fromLatin1(kAuthUrl));
url.setQuery(url_query);
QMessageBox messagebox(QMessageBox::Information, tr("%1 Scrobbler Authentication").arg(name_), tr("Open URL in web browser?") + QStringLiteral("<br /><a href=\"%1\">%1</a><br />").arg(url.toString()) + tr("Press \"Save\" to copy the URL to clipboard and manually open it in a web browser."), QMessageBox::Open|QMessageBox::Save|QMessageBox::Cancel);
messagebox.setTextFormat(Qt::RichText);
int result = messagebox.exec();
switch (result) {
case QMessageBox::Open:{
bool openurl_result = QDesktopServices::openUrl(url);
if (openurl_result) {
break;
}
QMessageBox messagebox_error(QMessageBox::Warning, tr("%1 Scrobbler Authentication").arg(name_), tr("Could not open URL. Please open this URL in your browser") + QStringLiteral(":<br /><a href=\"%1\">%1</a>").arg(url.toString()), QMessageBox::Ok);
messagebox_error.setTextFormat(Qt::RichText);
messagebox_error.exec();
}
[[fallthrough]];
case QMessageBox::Save:
QApplication::clipboard()->setText(url.toString());
break;
case QMessageBox::Cancel:
if (local_redirect_server_) {
local_redirect_server_->close();
local_redirect_server_->deleteLater();
local_redirect_server_ = nullptr;
}
Q_EMIT AuthenticationComplete(false);
break;
default:
break;
}
}
void LastFMScrobbler::RedirectArrived() {
if (!local_redirect_server_) return;
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)) {
QString token = url_query.queryItemValue(u"token"_s);
RequestSession(token);
}
else {
AuthError(tr("Invalid reply from web browser. Missing token."));
}
}
else {
AuthError(tr("Received invalid reply from web browser. Try another browser."));
}
}
else {
AuthError(local_redirect_server_->error());
}
local_redirect_server_->close();
local_redirect_server_->deleteLater();
local_redirect_server_ = nullptr;
}
void LastFMScrobbler::RequestSession(const QString &token) {
QUrl session_url(QString::fromLatin1(kApiUrl));
QUrlQuery session_url_query;
session_url_query.addQueryItem(u"api_key"_s, QLatin1String(kApiKey));
session_url_query.addQueryItem(u"method"_s, u"auth.getSession"_s);
session_url_query.addQueryItem(u"token"_s, token);
QString data_to_sign;
const ParamList params = session_url_query.queryItems();
for (const Param &param : params) {
data_to_sign += param.first + param.second;
}
data_to_sign += QLatin1String(kSecret);
QByteArray const digest = QCryptographicHash::hash(data_to_sign.toUtf8(), QCryptographicHash::Md5);
const QString signature = QString::fromLatin1(digest.toHex()).rightJustified(32, u'0').toLower();
session_url_query.addQueryItem(u"api_sig"_s, signature);
session_url_query.addQueryItem(QString::fromLatin1(QUrl::toPercentEncoding(u"format"_s)), QString::fromLatin1(QUrl::toPercentEncoding(u"json"_s)));
session_url.setQuery(session_url_query);
QNetworkReply *reply = CreateGetRequest(session_url);
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply]() { AuthenticateReplyFinished(reply); });
}
void LastFMScrobbler::AuthenticateReplyFinished(QNetworkReply *reply) {
if (!replies_.contains(reply)) return;
replies_.removeAll(reply);
QObject::disconnect(reply, nullptr, this, nullptr);
reply->deleteLater();
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_object.contains("session"_L1)) {
AuthError(u"Json reply from server is missing session."_s);
return;
}
const QJsonValue json_value_session = json_object["session"_L1];
if (!json_value_session.isObject()) {
AuthError(u"Json session is not an object."_s);
return;
}
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_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_object_session["subscriber"_L1].toBool();
username_ = json_object_session["name"_L1].toString();
session_key_ = json_object_session["key"_L1].toString();
Settings s;
s.beginGroup(kSettingsGroup);
s.setValue("subscriber", subscriber_);
s.setValue("username", username_);
s.setValue("session_key", session_key_);
s.endGroup();
Q_EMIT AuthenticationComplete(true);
StartSubmit();
}
QNetworkReply *LastFMScrobbler::CreateRequest(const ParamList &request_params) {
ParamList params = ParamList()
<< Param(u"api_key"_s, QLatin1String(kApiKey))
<< Param(u"sk"_s, session_key_)
<< Param(u"lang"_s, QLocale().name().left(2).toLower())
<< request_params;
std::sort(params.begin(), params.end());
QUrlQuery url_query;
QString data_to_sign;
for (const Param &param : std::as_const(params)) {
EncodedParam encoded_param(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second));
url_query.addQueryItem(QString::fromLatin1(encoded_param.first), QString::fromLatin1(encoded_param.second));
data_to_sign += param.first + param.second;
}
data_to_sign += QLatin1String(kSecret);
QByteArray const digest = QCryptographicHash::hash(data_to_sign.toUtf8(), QCryptographicHash::Md5);
const QString signature = QString::fromLatin1(digest.toHex()).rightJustified(32, u'0').toLower();
url_query.addQueryItem(u"api_sig"_s, QString::fromLatin1(QUrl::toPercentEncoding(signature)));
url_query.addQueryItem(u"format"_s, u"json"_s);
return CreatePostRequest(QUrl(QString::fromLatin1(kApiUrl)), url_query);
}
JsonBaseRequest::JsonObjectResult LastFMScrobbler::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;
}
void LastFMScrobbler::UpdateNowPlaying(const Song &song) {
CheckScrobblePrevSong();
song_playing_ = song;
timestamp_ = static_cast<quint64>(QDateTime::currentSecsSinceEpoch());
scrobbled_ = false;
if (!authenticated() || !song.is_metadata_good() || settings_->offline()) return;
ParamList params = ParamList()
<< Param(u"method"_s, u"track.updateNowPlaying"_s)
<< Param(u"artist"_s, prefer_albumartist_ ? song.effective_albumartist() : song.artist())
<< Param(u"track"_s, StripTitle(song.title()));
if (!song.album().isEmpty()) {
params << Param(u"album"_s, StripAlbum(song.album()));
}
if (!prefer_albumartist_ && !song.albumartist().isEmpty()) {
params << Param(u"albumArtist"_s, song.albumartist());
}
QNetworkReply *reply = CreateRequest(params);
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply]() { UpdateNowPlayingRequestFinished(reply); });
}
void LastFMScrobbler::UpdateNowPlayingRequestFinished(QNetworkReply *reply) {
if (!replies_.contains(reply)) return;
replies_.removeAll(reply);
QObject::disconnect(reply, nullptr, this, nullptr);
reply->deleteLater();
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_object.contains("nowplaying"_L1)) {
Error(u"Json reply from server is missing nowplaying."_s, json_object);
return;
}
}
void LastFMScrobbler::ClearPlaying() {
CheckScrobblePrevSong();
song_playing_ = Song();
scrobbled_ = false;
timestamp_ = 0;
}
void LastFMScrobbler::Scrobble(const Song &song) {
if (song.id() != song_playing_.id() || song.url() != song_playing_.url() || !song.is_metadata_good()) return;
scrobbled_ = true;
cache_->Add(song, timestamp_);
if (settings_->offline()) return;
if (!authenticated()) {
if (settings_->show_error_dialog()) {
Q_EMIT ErrorMessage(tr("Scrobbler %1 is not authenticated!").arg(name_));
}
return;
}
StartSubmit(true);
}
void LastFMScrobbler::StartSubmit(const bool initial) {
if (!submitted_ && cache_->Count() > 0) {
if (initial && settings_->submit_delay() <= 0 && !submit_error_) {
if (timer_submit_->isActive()) {
timer_submit_->stop();
}
Submit();
}
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();
}
}
}
void LastFMScrobbler::Submit() {
if (!enabled() || !authenticated() || settings_->offline()) return;
qLog(Debug) << name_ << "Submitting scrobbles.";
ParamList params = ParamList() << Param(u"method"_s, u"track.scrobble"_s);
int i = 0;
const ScrobblerCacheItemPtrList all_cache_items = cache_->List();
ScrobblerCacheItemPtrList cache_items_sent;
for (ScrobblerCacheItemPtr cache_item : all_cache_items) {
if (cache_item->sent) continue;
cache_item->sent = true;
cache_items_sent << cache_item;
params << Param(u"%1[%2]"_s.arg(u"artist"_s).arg(i), prefer_albumartist_ ? cache_item->metadata.effective_albumartist() : cache_item->metadata.artist);
params << Param(u"%1[%2]"_s.arg(u"track"_s).arg(i), StripTitle(cache_item->metadata.title));
params << Param(u"%1[%2]"_s.arg(u"timestamp"_s).arg(i), QString::number(cache_item->timestamp));
params << Param(u"%1[%2]"_s.arg(u"duration"_s).arg(i), QString::number(cache_item->metadata.length_nanosec / kNsecPerSec));
if (!cache_item->metadata.album.isEmpty()) {
params << Param(u"%1[%2]"_s.arg("album"_L1).arg(i), StripAlbum(cache_item->metadata.album));
}
if (!prefer_albumartist_ && !cache_item->metadata.albumartist.isEmpty()) {
params << Param(u"%1[%2]"_s.arg("albumArtist"_L1).arg(i), cache_item->metadata.albumartist);
}
if (cache_item->metadata.track > 0) {
params << Param(u"%1[%2]"_s.arg("trackNumber"_L1).arg(i), QString::number(cache_item->metadata.track));
}
++i;
if (cache_items_sent.count() >= kScrobblesPerRequest) break;
}
if (cache_items_sent.count() <= 0) return;
submitted_ = true;
QNetworkReply *reply = CreateRequest(params);
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, cache_items_sent]() { ScrobbleRequestFinished(reply, cache_items_sent); });
}
void LastFMScrobbler::ScrobbleRequestFinished(QNetworkReply *reply, ScrobblerCacheItemPtrList cache_items) {
if (!replies_.contains(reply)) return;
replies_.removeAll(reply);
QObject::disconnect(reply, nullptr, this, nullptr);
reply->deleteLater();
submitted_ = false;
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_object.contains("scrobbles"_L1)) {
Error(u"Json reply from server is missing scrobbles."_s, json_object);
StartSubmit();
return;
}
const QJsonValue value_scrobbles = json_object["scrobbles"_L1];
if (!value_scrobbles.isObject()) {
Error(u"Json scrobbles is not an object."_s, json_object);
StartSubmit();
return;
}
const QJsonObject object_scrobbles = value_scrobbles.toObject();
if (object_scrobbles.isEmpty()) {
Error(u"Json scrobbles object is empty."_s, value_scrobbles);
StartSubmit();
return;
}
if (!object_scrobbles.contains("@attr"_L1) || !object_scrobbles.contains("scrobble"_L1)) {
Error(u"Json scrobbles object is missing values."_s, object_scrobbles);
StartSubmit();
return;
}
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;
}
const QJsonObject object_attr = value_attr.toObject();
if (object_attr.isEmpty()) {
Error(u"Json scrobbles attr is empty."_s, value_attr);
StartSubmit();
return;
}
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 = object_attr["accepted"_L1].toInt();
int ignored = object_attr["ignored"_L1].toInt();
qLog(Debug) << name_ << "Scrobbles accepted:" << accepted << "ignored:" << ignored;
QJsonArray array_scrobble;
const QJsonValue value_scrobble = object_scrobbles["scrobble"_L1];
if (value_scrobble.isObject()) {
QJsonObject obj_scrobble = value_scrobble.toObject();
if (obj_scrobble.isEmpty()) {
Error(u"Json scrobbles scrobble object is empty."_s, obj_scrobble);
StartSubmit();
return;
}
array_scrobble.append(obj_scrobble);
}
else if (value_scrobble.isArray()) {
array_scrobble = value_scrobble.toArray();
if (array_scrobble.isEmpty()) {
Error(u"Json scrobbles scrobble array is empty."_s, value_scrobble);
StartSubmit();
return;
}
}
else {
Error(u"Json scrobbles scrobble is not an object or array."_s, value_scrobble);
StartSubmit();
return;
}
for (const QJsonValue &value : std::as_const(array_scrobble)) {
if (!value.isObject()) {
Error(u"Json scrobbles scrobble array value is not an object."_s);
continue;
}
QJsonObject json_track = value.toObject();
if (json_track.isEmpty()) {
continue;
}
if (!json_track.contains("artist"_L1) ||
!json_track.contains("album"_L1) ||
!json_track.contains("albumArtist"_L1) ||
!json_track.contains("track"_L1) ||
!json_track.contains("timestamp"_L1) ||
!json_track.contains("ignoredMessage"_L1)
) {
Error(u"Json scrobbles scrobble is missing values."_s, json_track);
continue;
}
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;
}
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 (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 (!object_artist.contains("#text"_L1) || !object_album.contains("#text"_L1) || !object_song.contains("#text"_L1)) {
continue;
}
//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));
}
else {
qLog(Debug) << name_ << "Scrobble for" << song << "accepted";
}
}
StartSubmit();
}
void LastFMScrobbler::SendSingleScrobble(ScrobblerCacheItemPtr item) {
ParamList params = ParamList()
<< Param(u"method"_s, u"track.scrobble"_s)
<< Param(u"artist"_s, prefer_albumartist_ ? item->metadata.effective_albumartist() : item->metadata.artist)
<< Param(u"track"_s, StripTitle(item->metadata.title))
<< Param(u"timestamp"_s, QString::number(item->timestamp))
<< Param(u"duration"_s, QString::number(item->metadata.length_nanosec / kNsecPerSec));
if (!item->metadata.album.isEmpty()) {
params << Param(u"album"_s, StripAlbum(item->metadata.album));
}
if (!prefer_albumartist_ && !item->metadata.albumartist.isEmpty()) {
params << Param(u"albumArtist"_s, item->metadata.albumartist);
}
if (item->metadata.track > 0) {
params << Param(u"trackNumber"_s, QString::number(item->metadata.track));
}
QNetworkReply *reply = CreateRequest(params);
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, item]() { SingleScrobbleRequestFinished(reply, item); });
}
void LastFMScrobbler::SingleScrobbleRequestFinished(QNetworkReply *reply, ScrobblerCacheItemPtr cache_item) {
if (!replies_.contains(reply)) return;
replies_.removeAll(reply);
QObject::disconnect(reply, nullptr, this, nullptr);
reply->deleteLater();
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_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);
const QJsonValue value_scrobbles = json_object["scrobbles"_L1];
if (!value_scrobbles.isObject()) {
Error(u"Json scrobbles is not an object."_s, json_object);
return;
}
const QJsonObject object_scrobbles = value_scrobbles.toObject();
if (object_scrobbles.isEmpty()) {
Error(u"Json scrobbles object is empty."_s, value_scrobbles);
return;
}
if (!object_scrobbles.contains("@attr"_L1) || !object_scrobbles.contains("scrobble"_L1)) {
Error(u"Json scrobbles object is missing values."_s, object_scrobbles);
return;
}
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;
}
const QJsonObject object_attr = value_attr.toObject();
if (object_attr.isEmpty()) {
Error(u"Json scrobbles attr is empty."_s, value_attr);
return;
}
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;
}
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 (!object_attr.contains("accepted"_L1) || !object_attr.contains("ignored"_L1)) {
Error(u"Json scrobbles attr is missing values."_s, object_attr);
return;
}
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;
}
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_object_scrobble);
return;
}
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_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_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();
const QString song = json_object_song["#text"_L1].toString();
const int accepted = object_attr["accepted"_L1].toVariant().toInt();
if (accepted == 1) {
qLog(Debug) << name_ << "Scrobble for" << song << "accepted";
}
else {
Error(QStringLiteral("Scrobble for \"%1\" not accepted").arg(song));
}
}
void LastFMScrobbler::Love() {
if (!song_playing_.is_valid() || !song_playing_.is_metadata_good()) return;
if (!authenticated()) {
Q_EMIT OpenSettingsDialog();
return;
}
qLog(Debug) << name_ << "Sending love for song" << song_playing_.artist() << song_playing_.album() << song_playing_.title();
ParamList params = ParamList()
<< Param(u"method"_s, u"track.love"_s)
<< Param(u"artist"_s, prefer_albumartist_ ? song_playing_.effective_albumartist() : song_playing_.artist())
<< Param(u"track"_s, song_playing_.title());
if (!song_playing_.album().isEmpty()) {
params << Param(u"album"_s, song_playing_.album());
}
if (!prefer_albumartist_ && !song_playing_.albumartist().isEmpty()) {
params << Param(u"albumArtist"_s, song_playing_.albumartist());
}
QNetworkReply *reply = CreateRequest(params);
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply] { LoveRequestFinished(reply); });
}
void LastFMScrobbler::LoveRequestFinished(QNetworkReply *reply) {
if (!replies_.contains(reply)) return;
replies_.removeAll(reply);
QObject::disconnect(reply, nullptr, this, nullptr);
reply->deleteLater();
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_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;
}
const QJsonObject json_obj_error = json_value.toObject();
if (json_obj_error.isEmpty()) {
Error(u"Received empty json error object."_s, json_object);
return;
}
if (json_obj_error.contains("code"_L1) && json_obj_error.contains("#text"_L1)) {
int code = json_obj_error["code"_L1].toInt();
QString text = json_obj_error["#text"_L1].toString();
QString error_reason = QStringLiteral("%1 (%2)").arg(text).arg(code);
Error(error_reason);
return;
}
}
if (json_object.contains("lfm"_L1)) {
const QJsonValue json_value = json_object["lfm"_L1];
if (json_value.isObject()) {
const QJsonObject json_obj_lfm = json_value.toObject();
if (json_obj_lfm.contains("status"_L1)) {
const QString status = json_obj_lfm["status"_L1].toString();
qLog(Debug) << name_ << "Received love status:" << status;
return;
}
}
}
}
void LastFMScrobbler::AuthError(const QString &error) {
qLog(Error) << name_ << error;
Q_EMIT AuthenticationComplete(false, error);
}
void LastFMScrobbler::Error(const QString &error, const QVariant &debug) {
JsonBaseRequest::Error(error, debug);
if (settings_->show_error_dialog()) {
Q_EMIT ErrorMessage(tr("Scrobbler %1 error: %2").arg(name_, error));
}
}
QString LastFMScrobbler::ErrorString(const ScrobbleErrorCode error) {
switch (error) {
case ScrobbleErrorCode::NoError:
return u"This error does not exist."_s;
case ScrobbleErrorCode::InvalidService:
return u"Invalid service - This service does not exist."_s;
case ScrobbleErrorCode::InvalidMethod:
return u"Invalid Method - No method with that name in this package."_s;
case ScrobbleErrorCode::AuthenticationFailed:
return u"Authentication Failed - You do not have permissions to access the service."_s;
case ScrobbleErrorCode::InvalidFormat:
return u"Invalid format - This service doesn't exist in that format."_s;
case ScrobbleErrorCode::InvalidParameters:
return u"Invalid parameters - Your request is missing a required parameter."_s;
case ScrobbleErrorCode::InvalidResourceSpecified:
return u"Invalid resource specified"_s;
case ScrobbleErrorCode::OperationFailed:
return u"Operation failed - Most likely the backend service failed. Please try again."_s;
case ScrobbleErrorCode::InvalidSessionKey:
return u"Invalid session key - Please re-authenticate."_s;
case ScrobbleErrorCode::InvalidApiKey:
return u"Invalid API key - You must be granted a valid key by last.fm."_s;
case ScrobbleErrorCode::ServiceOffline:
return u"Service Offline - This service is temporarily offline. Try again later."_s;
case ScrobbleErrorCode::SubscribersOnly:
return u"Subscribers Only - This station is only available to paid last.fm subscribers."_s;
case ScrobbleErrorCode::InvalidMethodSignature:
return u"Invalid method signature supplied."_s;
case ScrobbleErrorCode::UnauthorizedToken:
return u"Unauthorized Token - This token has not been authorized."_s;
case ScrobbleErrorCode::ItemUnavailable:
return u"This item is not available for streaming."_s;
case ScrobbleErrorCode::TemporarilyUnavailable:
return u"The service is temporarily unavailable, please try again."_s;
case ScrobbleErrorCode::LoginRequired:
return u"Login: User requires to be logged in."_s;
case ScrobbleErrorCode::TrialExpired:
return u"Trial Expired - This user has no free radio plays left. Subscription required."_s;
case ScrobbleErrorCode::ErrorDoesNotExist:
return u"This error does not exist."_s;
case ScrobbleErrorCode::NotEnoughContent:
return u"Not Enough Content - There is not enough content to play this station."_s;
case ScrobbleErrorCode::NotEnoughMembers:
return u"Not Enough Members - This group does not have enough members for radio."_s;
case ScrobbleErrorCode::NotEnoughFans:
return u"Not Enough Fans - This artist does not have enough fans for for radio."_s;
case ScrobbleErrorCode::NotEnoughNeighbours:
return u"Not Enough Neighbours - There are not enough neighbours for radio."_s;
case ScrobbleErrorCode::NoPeakRadio:
return u"No Peak Radio - This user is not allowed to listen to radio during peak usage."_s;
case ScrobbleErrorCode::RadioNotFound:
return u"Radio Not Found - Radio station not found."_s;
case ScrobbleErrorCode::APIKeySuspended:
return u"Suspended API key - Access for your account has been suspended, please contact Last.fm"_s;
case ScrobbleErrorCode::Deprecated:
return u"Deprecated - This type of request is no longer supported."_s;
case ScrobbleErrorCode::RateLimitExceeded:
return u"Rate limit exceeded - Your IP has made too many requests in a short period."_s;
}
return u"Unknown error."_s;
}
void LastFMScrobbler::CheckScrobblePrevSong() {
const qint64 duration = std::max(0LL, QDateTime::currentSecsSinceEpoch() - static_cast<qint64>(timestamp_));
if (!scrobbled_ && song_playing_.is_metadata_good() && song_playing_.is_radio() && duration > 30) {
Song song(song_playing_);
song.set_length_nanosec(duration * kNsecPerSec);
Scrobble(song);
}
}

View File

@@ -22,22 +22,131 @@
#include "config.h"
#include <QVariant>
#include <QByteArray>
#include <QString>
#include "includes/shared_ptr.h"
#include "scrobblingapi20.h"
#include "core/song.h"
#include "scrobblerservice.h"
#include "scrobblercache.h"
#include "scrobblercacheitem.h"
class QTimer;
class QNetworkReply;
class ScrobblerSettingsService;
class NetworkAccessManager;
class LocalRedirectServer;
class LastFMScrobbler : public ScrobblingAPI20 {
class LastFMScrobbler : public ScrobblerService {
Q_OBJECT
public:
explicit LastFMScrobbler(const SharedPtr<ScrobblerSettingsService> settings, const SharedPtr<NetworkAccessManager> network, QObject *parent = nullptr);
~LastFMScrobbler() override;
static const char *kName;
static const char *kSettingsGroup;
static const char *kApiUrl;
static const char *kApiKey;
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 UpdateNowPlaying(const Song &song) override;
void ClearPlaying() override;
void Scrobble(const Song &song) override;
void Submit() override;
void Love() override;
Q_SIGNALS:
void AuthenticationComplete(const bool success, const QString &error = QString());
public Q_SLOTS:
void WriteCache() override { cache_->WriteCache(); }
private Q_SLOTS:
void RedirectArrived();
void AuthenticateReplyFinished(QNetworkReply *reply);
void UpdateNowPlayingRequestFinished(QNetworkReply *reply);
void ScrobbleRequestFinished(QNetworkReply *reply, ScrobblerCacheItemPtrList cache_items);
void SingleScrobbleRequestFinished(QNetworkReply *reply, ScrobblerCacheItemPtr cache_item);
void LoveRequestFinished(QNetworkReply *reply);
private:
enum class ScrobbleErrorCode {
NoError = 1,
InvalidService = 2,
InvalidMethod = 3,
AuthenticationFailed = 4,
InvalidFormat = 5,
InvalidParameters = 6,
InvalidResourceSpecified = 7,
OperationFailed = 8,
InvalidSessionKey = 9,
InvalidApiKey = 10,
ServiceOffline = 11,
SubscribersOnly = 12,
InvalidMethodSignature = 13,
UnauthorizedToken = 14,
ItemUnavailable = 15,
TemporarilyUnavailable = 16,
LoginRequired = 17,
TrialExpired = 18,
ErrorDoesNotExist = 19,
NotEnoughContent = 20,
NotEnoughMembers = 21,
NotEnoughFans = 22,
NotEnoughNeighbours = 23,
NoPeakRadio = 24,
RadioNotFound = 25,
APIKeySuspended = 26,
Deprecated = 27,
RateLimitExceeded = 29,
};
QNetworkReply *CreateRequest(const ParamList &request_params);
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()) override;
static QString ErrorString(const ScrobbleErrorCode error);
void StartSubmit(const bool initial = false) override;
void CheckScrobblePrevSong();
protected:
const SharedPtr<NetworkAccessManager> network_;
ScrobblerCache *cache_;
LocalRedirectServer *local_redirect_server_;
bool enabled_;
bool prefer_albumartist_;
bool subscriber_;
QString username_;
QString session_key_;
bool submitted_;
Song song_playing_;
bool scrobbled_;
quint64 timestamp_;
bool submit_error_;
QTimer *timer_submit_;
};
#endif // LASTFMSCROBBLER_H

View File

@@ -1,36 +0,0 @@
/*
* Strawberry Music Player
* 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
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include "config.h"
#include "includes/shared_ptr.h"
#include "core/networkaccessmanager.h"
#include "scrobblersettingsservice.h"
#include "scrobblingapi20.h"
#include "librefmscrobbler.h"
const char *LibreFMScrobbler::kName = "Libre.fm";
const char *LibreFMScrobbler::kSettingsGroup = "LibreFM";
const char *LibreFMScrobbler::kAuthUrl = "https://www.libre.fm/api/auth/";
const char *LibreFMScrobbler::kApiUrl = "https://libre.fm/2.0/";
const char *LibreFMScrobbler::kCacheFile = "librefmscrobbler.cache";
LibreFMScrobbler::LibreFMScrobbler(const SharedPtr<ScrobblerSettingsService> settings, const SharedPtr<NetworkAccessManager> network, QObject *parent)
: ScrobblingAPI20(QLatin1String(kName), QLatin1String(kSettingsGroup), QLatin1String(kAuthUrl), QLatin1String(kApiUrl), false, QLatin1String(kCacheFile), settings, network, parent) {}

View File

@@ -1,46 +0,0 @@
/*
* Strawberry Music Player
* 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
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef LIBREFMSCROBBLER_H
#define LIBREFMSCROBBLER_H
#include "config.h"
#include "includes/shared_ptr.h"
#include "scrobblingapi20.h"
class ScrobblerSettingsService;
class NetworkAccessManager;
class LibreFMScrobbler : public ScrobblingAPI20 {
Q_OBJECT
public:
explicit LibreFMScrobbler(const SharedPtr<ScrobblerSettingsService> settings, const SharedPtr<NetworkAccessManager> network, QObject *parent = nullptr);
static const char *kName;
static const char *kSettingsGroup;
private:
static const char *kAuthUrl;
static const char *kApiUrl;
static const char *kCacheFile;
};
#endif // LIBREFMSCROBBLER_H

View File

@@ -1,990 +0,0 @@
/*
* Strawberry Music Player
* 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
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include "config.h"
#include <algorithm>
#include <utility>
#include <QApplication>
#include <QDesktopServices>
#include <QLocale>
#include <QClipboard>
#include <QPair>
#include <QVariant>
#include <QByteArray>
#include <QString>
#include <QUrl>
#include <QUrlQuery>
#include <QDateTime>
#include <QTimer>
#include <QCryptographicHash>
#include <QMessageBox>
#include <QSettings>
#include <QNetworkRequest>
#include <QNetworkReply>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <QJsonValue>
#include <QFlags>
#include "includes/shared_ptr.h"
#include "core/networkaccessmanager.h"
#include "core/song.h"
#include "core/logging.h"
#include "core/settings.h"
#include "core/localredirectserver.h"
#include "constants/timeconstants.h"
#include "constants/scrobblersettings.h"
#include "scrobblersettingsservice.h"
#include "scrobblerservice.h"
#include "scrobblingapi20.h"
#include "scrobblercache.h"
#include "scrobblercacheitem.h"
#include "scrobblemetadata.h"
using namespace Qt::Literals::StringLiterals;
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, network, settings, parent),
name_(name),
settings_group_(settings_group),
auth_url_(auth_url),
api_url_(api_url),
batch_(batch),
network_(network),
cache_(new ScrobblerCache(cache_file, this)),
local_redirect_server_(nullptr),
enabled_(false),
prefer_albumartist_(false),
subscriber_(false),
submitted_(false),
scrobbled_(false),
timestamp_(0),
submit_error_(false),
timer_submit_(new QTimer(this)) {
timer_submit_->setSingleShot(true);
QObject::connect(timer_submit_, &QTimer::timeout, this, &ScrobblingAPI20::Submit);
ScrobblingAPI20::ReloadSettings();
LoadSession();
}
ScrobblingAPI20::~ScrobblingAPI20() {
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();
}
}
void ScrobblingAPI20::ReloadSettings() {
Settings s;
s.beginGroup(settings_group_);
enabled_ = s.value(ScrobblerSettings::kEnabled, false).toBool();
s.endGroup();
s.beginGroup(ScrobblerSettings::kSettingsGroup);
prefer_albumartist_ = s.value(ScrobblerSettings::kAlbumArtist, false).toBool();
s.endGroup();
}
void ScrobblingAPI20::LoadSession() {
Settings s;
s.beginGroup(settings_group_);
subscriber_ = s.value("subscriber", false).toBool();
username_ = s.value("username").toString();
session_key_ = s.value("session_key").toString();
s.endGroup();
}
void ScrobblingAPI20::ClearSession() {
subscriber_ = false;
username_.clear();
session_key_.clear();
Settings settings;
settings.beginGroup(settings_group_);
settings.remove("subscriber");
settings.remove("username");
settings.remove("session_key");
settings.endGroup();
}
void ScrobblingAPI20::Authenticate() {
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(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, local_redirect_server_->url().toString());
QUrl url(auth_url_);
url.setQuery(url_query);
QMessageBox messagebox(QMessageBox::Information, tr("%1 Scrobbler Authentication").arg(name_), tr("Open URL in web browser?") + QStringLiteral("<br /><a href=\"%1\">%1</a><br />").arg(url.toString()) + tr("Press \"Save\" to copy the URL to clipboard and manually open it in a web browser."), QMessageBox::Open|QMessageBox::Save|QMessageBox::Cancel);
messagebox.setTextFormat(Qt::RichText);
int result = messagebox.exec();
switch (result) {
case QMessageBox::Open:{
bool openurl_result = QDesktopServices::openUrl(url);
if (openurl_result) {
break;
}
QMessageBox messagebox_error(QMessageBox::Warning, tr("%1 Scrobbler Authentication").arg(name_), tr("Could not open URL. Please open this URL in your browser") + QStringLiteral(":<br /><a href=\"%1\">%1</a>").arg(url.toString()), QMessageBox::Ok);
messagebox_error.setTextFormat(Qt::RichText);
messagebox_error.exec();
}
[[fallthrough]];
case QMessageBox::Save:
QApplication::clipboard()->setText(url.toString());
break;
case QMessageBox::Cancel:
if (local_redirect_server_) {
local_redirect_server_->close();
local_redirect_server_->deleteLater();
local_redirect_server_ = nullptr;
}
Q_EMIT AuthenticationComplete(false);
break;
default:
break;
}
}
void ScrobblingAPI20::RedirectArrived() {
if (!local_redirect_server_) return;
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)) {
QString token = url_query.queryItemValue(u"token"_s);
RequestSession(token);
}
else {
AuthError(tr("Invalid reply from web browser. Missing token."));
}
}
else {
AuthError(tr("Received invalid reply from web browser. Try another browser."));
}
}
else {
AuthError(local_redirect_server_->error());
}
local_redirect_server_->close();
local_redirect_server_->deleteLater();
local_redirect_server_ = nullptr;
}
void ScrobblingAPI20::RequestSession(const QString &token) {
QUrl session_url(api_url_);
QUrlQuery session_url_query;
session_url_query.addQueryItem(u"api_key"_s, QLatin1String(kApiKey));
session_url_query.addQueryItem(u"method"_s, u"auth.getSession"_s);
session_url_query.addQueryItem(u"token"_s, token);
QString data_to_sign;
const ParamList params = session_url_query.queryItems();
for (const Param &param : params) {
data_to_sign += param.first + param.second;
}
data_to_sign += QLatin1String(kSecret);
QByteArray const digest = QCryptographicHash::hash(data_to_sign.toUtf8(), QCryptographicHash::Md5);
const QString signature = QString::fromLatin1(digest.toHex()).rightJustified(32, u'0').toLower();
session_url_query.addQueryItem(u"api_sig"_s, signature);
session_url_query.addQueryItem(QString::fromLatin1(QUrl::toPercentEncoding(u"format"_s)), QString::fromLatin1(QUrl::toPercentEncoding(u"json"_s)));
session_url.setQuery(session_url_query);
QNetworkReply *reply = CreateGetRequest(session_url);
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply]() { AuthenticateReplyFinished(reply); });
}
void ScrobblingAPI20::AuthenticateReplyFinished(QNetworkReply *reply) {
if (!replies_.contains(reply)) return;
replies_.removeAll(reply);
QObject::disconnect(reply, nullptr, this, nullptr);
reply->deleteLater();
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_object.contains("session"_L1)) {
AuthError(u"Json reply from server is missing session."_s);
return;
}
const QJsonValue json_value_session = json_object["session"_L1];
if (!json_value_session.isObject()) {
AuthError(u"Json session is not an object."_s);
return;
}
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_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_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_);
s.setValue("subscriber", subscriber_);
s.setValue("username", username_);
s.setValue("session_key", session_key_);
s.endGroup();
Q_EMIT AuthenticationComplete(true);
StartSubmit();
}
QNetworkReply *ScrobblingAPI20::CreateRequest(const ParamList &request_params) {
ParamList params = ParamList()
<< Param(u"api_key"_s, QLatin1String(kApiKey))
<< Param(u"sk"_s, session_key_)
<< Param(u"lang"_s, QLocale().name().left(2).toLower())
<< request_params;
std::sort(params.begin(), params.end());
QUrlQuery url_query;
QString data_to_sign;
for (const Param &param : std::as_const(params)) {
EncodedParam encoded_param(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second));
url_query.addQueryItem(QString::fromLatin1(encoded_param.first), QString::fromLatin1(encoded_param.second));
data_to_sign += param.first + param.second;
}
data_to_sign += QLatin1String(kSecret);
QByteArray const digest = QCryptographicHash::hash(data_to_sign.toUtf8(), QCryptographicHash::Md5);
const QString signature = QString::fromLatin1(digest.toHex()).rightJustified(32, u'0').toLower();
url_query.addQueryItem(u"api_sig"_s, QString::fromLatin1(QUrl::toPercentEncoding(signature)));
url_query.addQueryItem(u"format"_s, u"json"_s);
return CreatePostRequest(QUrl(api_url_), url_query);
}
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;
}
void ScrobblingAPI20::UpdateNowPlaying(const Song &song) {
CheckScrobblePrevSong();
song_playing_ = song;
timestamp_ = static_cast<quint64>(QDateTime::currentSecsSinceEpoch());
scrobbled_ = false;
if (!authenticated() || !song.is_metadata_good() || settings_->offline()) return;
ParamList params = ParamList()
<< Param(u"method"_s, u"track.updateNowPlaying"_s)
<< Param(u"artist"_s, prefer_albumartist_ ? song.effective_albumartist() : song.artist())
<< Param(u"track"_s, StripTitle(song.title()));
if (!song.album().isEmpty()) {
params << Param(u"album"_s, StripAlbum(song.album()));
}
if (!prefer_albumartist_ && !song.albumartist().isEmpty()) {
params << Param(u"albumArtist"_s, song.albumartist());
}
QNetworkReply *reply = CreateRequest(params);
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply]() { UpdateNowPlayingRequestFinished(reply); });
}
void ScrobblingAPI20::UpdateNowPlayingRequestFinished(QNetworkReply *reply) {
if (!replies_.contains(reply)) return;
replies_.removeAll(reply);
QObject::disconnect(reply, nullptr, this, nullptr);
reply->deleteLater();
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_object.contains("nowplaying"_L1)) {
Error(u"Json reply from server is missing nowplaying."_s, json_object);
return;
}
}
void ScrobblingAPI20::ClearPlaying() {
CheckScrobblePrevSong();
song_playing_ = Song();
scrobbled_ = false;
timestamp_ = 0;
}
void ScrobblingAPI20::Scrobble(const Song &song) {
if (song.id() != song_playing_.id() || song.url() != song_playing_.url() || !song.is_metadata_good()) return;
scrobbled_ = true;
cache_->Add(song, timestamp_);
if (settings_->offline()) return;
if (!authenticated()) {
if (settings_->show_error_dialog()) {
Q_EMIT ErrorMessage(tr("Scrobbler %1 is not authenticated!").arg(name_));
}
return;
}
StartSubmit(true);
}
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();
}
Submit();
}
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();
}
}
}
void ScrobblingAPI20::Submit() {
if (!enabled() || !authenticated() || settings_->offline()) return;
qLog(Debug) << name_ << "Submitting scrobbles.";
ParamList params = ParamList() << Param(u"method"_s, u"track.scrobble"_s);
int i = 0;
const ScrobblerCacheItemPtrList all_cache_items = cache_->List();
ScrobblerCacheItemPtrList cache_items_sent;
for (ScrobblerCacheItemPtr cache_item : all_cache_items) {
if (cache_item->sent) continue;
cache_item->sent = true;
if (!batch_) {
SendSingleScrobble(cache_item);
continue;
}
cache_items_sent << cache_item;
params << Param(u"%1[%2]"_s.arg(u"artist"_s).arg(i), prefer_albumartist_ ? cache_item->metadata.effective_albumartist() : cache_item->metadata.artist);
params << Param(u"%1[%2]"_s.arg(u"track"_s).arg(i), StripTitle(cache_item->metadata.title));
params << Param(u"%1[%2]"_s.arg(u"timestamp"_s).arg(i), QString::number(cache_item->timestamp));
params << Param(u"%1[%2]"_s.arg(u"duration"_s).arg(i), QString::number(cache_item->metadata.length_nanosec / kNsecPerSec));
if (!cache_item->metadata.album.isEmpty()) {
params << Param(u"%1[%2]"_s.arg("album"_L1).arg(i), StripAlbum(cache_item->metadata.album));
}
if (!prefer_albumartist_ && !cache_item->metadata.albumartist.isEmpty()) {
params << Param(u"%1[%2]"_s.arg("albumArtist"_L1).arg(i), cache_item->metadata.albumartist);
}
if (cache_item->metadata.track > 0) {
params << Param(u"%1[%2]"_s.arg("trackNumber"_L1).arg(i), QString::number(cache_item->metadata.track));
}
++i;
if (cache_items_sent.count() >= kScrobblesPerRequest) break;
}
if (!batch_ || cache_items_sent.count() <= 0) return;
submitted_ = true;
QNetworkReply *reply = CreateRequest(params);
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, cache_items_sent]() { ScrobbleRequestFinished(reply, cache_items_sent); });
}
void ScrobblingAPI20::ScrobbleRequestFinished(QNetworkReply *reply, ScrobblerCacheItemPtrList cache_items) {
if (!replies_.contains(reply)) return;
replies_.removeAll(reply);
QObject::disconnect(reply, nullptr, this, nullptr);
reply->deleteLater();
submitted_ = false;
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_object.contains("scrobbles"_L1)) {
Error(u"Json reply from server is missing scrobbles."_s, json_object);
StartSubmit();
return;
}
const QJsonValue value_scrobbles = json_object["scrobbles"_L1];
if (!value_scrobbles.isObject()) {
Error(u"Json scrobbles is not an object."_s, json_object);
StartSubmit();
return;
}
const QJsonObject object_scrobbles = value_scrobbles.toObject();
if (object_scrobbles.isEmpty()) {
Error(u"Json scrobbles object is empty."_s, value_scrobbles);
StartSubmit();
return;
}
if (!object_scrobbles.contains("@attr"_L1) || !object_scrobbles.contains("scrobble"_L1)) {
Error(u"Json scrobbles object is missing values."_s, object_scrobbles);
StartSubmit();
return;
}
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;
}
const QJsonObject object_attr = value_attr.toObject();
if (object_attr.isEmpty()) {
Error(u"Json scrobbles attr is empty."_s, value_attr);
StartSubmit();
return;
}
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 = object_attr["accepted"_L1].toInt();
int ignored = object_attr["ignored"_L1].toInt();
qLog(Debug) << name_ << "Scrobbles accepted:" << accepted << "ignored:" << ignored;
QJsonArray array_scrobble;
const QJsonValue value_scrobble = object_scrobbles["scrobble"_L1];
if (value_scrobble.isObject()) {
QJsonObject obj_scrobble = value_scrobble.toObject();
if (obj_scrobble.isEmpty()) {
Error(u"Json scrobbles scrobble object is empty."_s, obj_scrobble);
StartSubmit();
return;
}
array_scrobble.append(obj_scrobble);
}
else if (value_scrobble.isArray()) {
array_scrobble = value_scrobble.toArray();
if (array_scrobble.isEmpty()) {
Error(u"Json scrobbles scrobble array is empty."_s, value_scrobble);
StartSubmit();
return;
}
}
else {
Error(u"Json scrobbles scrobble is not an object or array."_s, value_scrobble);
StartSubmit();
return;
}
for (const QJsonValue &value : std::as_const(array_scrobble)) {
if (!value.isObject()) {
Error(u"Json scrobbles scrobble array value is not an object."_s);
continue;
}
QJsonObject json_track = value.toObject();
if (json_track.isEmpty()) {
continue;
}
if (!json_track.contains("artist"_L1) ||
!json_track.contains("album"_L1) ||
!json_track.contains("albumArtist"_L1) ||
!json_track.contains("track"_L1) ||
!json_track.contains("timestamp"_L1) ||
!json_track.contains("ignoredMessage"_L1)
) {
Error(u"Json scrobbles scrobble is missing values."_s, json_track);
continue;
}
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;
}
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 (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 (!object_artist.contains("#text"_L1) || !object_album.contains("#text"_L1) || !object_song.contains("#text"_L1)) {
continue;
}
//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));
}
else {
qLog(Debug) << name_ << "Scrobble for" << song << "accepted";
}
}
StartSubmit();
}
void ScrobblingAPI20::SendSingleScrobble(ScrobblerCacheItemPtr item) {
ParamList params = ParamList()
<< Param(u"method"_s, u"track.scrobble"_s)
<< Param(u"artist"_s, prefer_albumartist_ ? item->metadata.effective_albumartist() : item->metadata.artist)
<< Param(u"track"_s, StripTitle(item->metadata.title))
<< Param(u"timestamp"_s, QString::number(item->timestamp))
<< Param(u"duration"_s, QString::number(item->metadata.length_nanosec / kNsecPerSec));
if (!item->metadata.album.isEmpty()) {
params << Param(u"album"_s, StripAlbum(item->metadata.album));
}
if (!prefer_albumartist_ && !item->metadata.albumartist.isEmpty()) {
params << Param(u"albumArtist"_s, item->metadata.albumartist);
}
if (item->metadata.track > 0) {
params << Param(u"trackNumber"_s, QString::number(item->metadata.track));
}
QNetworkReply *reply = CreateRequest(params);
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, item]() { SingleScrobbleRequestFinished(reply, item); });
}
void ScrobblingAPI20::SingleScrobbleRequestFinished(QNetworkReply *reply, ScrobblerCacheItemPtr cache_item) {
if (!replies_.contains(reply)) return;
replies_.removeAll(reply);
QObject::disconnect(reply, nullptr, this, nullptr);
reply->deleteLater();
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_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);
const QJsonValue value_scrobbles = json_object["scrobbles"_L1];
if (!value_scrobbles.isObject()) {
Error(u"Json scrobbles is not an object."_s, json_object);
return;
}
const QJsonObject object_scrobbles = value_scrobbles.toObject();
if (object_scrobbles.isEmpty()) {
Error(u"Json scrobbles object is empty."_s, value_scrobbles);
return;
}
if (!object_scrobbles.contains("@attr"_L1) || !object_scrobbles.contains("scrobble"_L1)) {
Error(u"Json scrobbles object is missing values."_s, object_scrobbles);
return;
}
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;
}
const QJsonObject object_attr = value_attr.toObject();
if (object_attr.isEmpty()) {
Error(u"Json scrobbles attr is empty."_s, value_attr);
return;
}
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;
}
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 (!object_attr.contains("accepted"_L1) || !object_attr.contains("ignored"_L1)) {
Error(u"Json scrobbles attr is missing values."_s, object_attr);
return;
}
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;
}
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_object_scrobble);
return;
}
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_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_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();
const QString song = json_object_song["#text"_L1].toString();
const int accepted = object_attr["accepted"_L1].toVariant().toInt();
if (accepted == 1) {
qLog(Debug) << name_ << "Scrobble for" << song << "accepted";
}
else {
Error(QStringLiteral("Scrobble for \"%1\" not accepted").arg(song));
}
}
void ScrobblingAPI20::Love() {
if (!song_playing_.is_valid() || !song_playing_.is_metadata_good()) return;
if (!authenticated()) {
Q_EMIT OpenSettingsDialog();
return;
}
qLog(Debug) << name_ << "Sending love for song" << song_playing_.artist() << song_playing_.album() << song_playing_.title();
ParamList params = ParamList()
<< Param(u"method"_s, u"track.love"_s)
<< Param(u"artist"_s, prefer_albumartist_ ? song_playing_.effective_albumartist() : song_playing_.artist())
<< Param(u"track"_s, song_playing_.title());
if (!song_playing_.album().isEmpty()) {
params << Param(u"album"_s, song_playing_.album());
}
if (!prefer_albumartist_ && !song_playing_.albumartist().isEmpty()) {
params << Param(u"albumArtist"_s, song_playing_.albumartist());
}
QNetworkReply *reply = CreateRequest(params);
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply] { LoveRequestFinished(reply); });
}
void ScrobblingAPI20::LoveRequestFinished(QNetworkReply *reply) {
if (!replies_.contains(reply)) return;
replies_.removeAll(reply);
QObject::disconnect(reply, nullptr, this, nullptr);
reply->deleteLater();
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_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;
}
const QJsonObject json_obj_error = json_value.toObject();
if (json_obj_error.isEmpty()) {
Error(u"Received empty json error object."_s, json_object);
return;
}
if (json_obj_error.contains("code"_L1) && json_obj_error.contains("#text"_L1)) {
int code = json_obj_error["code"_L1].toInt();
QString text = json_obj_error["#text"_L1].toString();
QString error_reason = QStringLiteral("%1 (%2)").arg(text).arg(code);
Error(error_reason);
return;
}
}
if (json_object.contains("lfm"_L1)) {
const QJsonValue json_value = json_object["lfm"_L1];
if (json_value.isObject()) {
const QJsonObject json_obj_lfm = json_value.toObject();
if (json_obj_lfm.contains("status"_L1)) {
const QString status = json_obj_lfm["status"_L1].toString();
qLog(Debug) << name_ << "Received love status:" << status;
return;
}
}
}
}
void ScrobblingAPI20::AuthError(const QString &error) {
qLog(Error) << name_ << error;
Q_EMIT AuthenticationComplete(false, error);
}
void ScrobblingAPI20::Error(const QString &error, const QVariant &debug) {
JsonBaseRequest::Error(error, debug);
if (settings_->show_error_dialog()) {
Q_EMIT ErrorMessage(tr("Scrobbler %1 error: %2").arg(name_, error));
}
}
QString ScrobblingAPI20::ErrorString(const ScrobbleErrorCode error) {
switch (error) {
case ScrobbleErrorCode::NoError:
return u"This error does not exist."_s;
case ScrobbleErrorCode::InvalidService:
return u"Invalid service - This service does not exist."_s;
case ScrobbleErrorCode::InvalidMethod:
return u"Invalid Method - No method with that name in this package."_s;
case ScrobbleErrorCode::AuthenticationFailed:
return u"Authentication Failed - You do not have permissions to access the service."_s;
case ScrobbleErrorCode::InvalidFormat:
return u"Invalid format - This service doesn't exist in that format."_s;
case ScrobbleErrorCode::InvalidParameters:
return u"Invalid parameters - Your request is missing a required parameter."_s;
case ScrobbleErrorCode::InvalidResourceSpecified:
return u"Invalid resource specified"_s;
case ScrobbleErrorCode::OperationFailed:
return u"Operation failed - Most likely the backend service failed. Please try again."_s;
case ScrobbleErrorCode::InvalidSessionKey:
return u"Invalid session key - Please re-authenticate."_s;
case ScrobbleErrorCode::InvalidApiKey:
return u"Invalid API key - You must be granted a valid key by last.fm."_s;
case ScrobbleErrorCode::ServiceOffline:
return u"Service Offline - This service is temporarily offline. Try again later."_s;
case ScrobbleErrorCode::SubscribersOnly:
return u"Subscribers Only - This station is only available to paid last.fm subscribers."_s;
case ScrobbleErrorCode::InvalidMethodSignature:
return u"Invalid method signature supplied."_s;
case ScrobbleErrorCode::UnauthorizedToken:
return u"Unauthorized Token - This token has not been authorized."_s;
case ScrobbleErrorCode::ItemUnavailable:
return u"This item is not available for streaming."_s;
case ScrobbleErrorCode::TemporarilyUnavailable:
return u"The service is temporarily unavailable, please try again."_s;
case ScrobbleErrorCode::LoginRequired:
return u"Login: User requires to be logged in."_s;
case ScrobbleErrorCode::TrialExpired:
return u"Trial Expired - This user has no free radio plays left. Subscription required."_s;
case ScrobbleErrorCode::ErrorDoesNotExist:
return u"This error does not exist."_s;
case ScrobbleErrorCode::NotEnoughContent:
return u"Not Enough Content - There is not enough content to play this station."_s;
case ScrobbleErrorCode::NotEnoughMembers:
return u"Not Enough Members - This group does not have enough members for radio."_s;
case ScrobbleErrorCode::NotEnoughFans:
return u"Not Enough Fans - This artist does not have enough fans for for radio."_s;
case ScrobbleErrorCode::NotEnoughNeighbours:
return u"Not Enough Neighbours - There are not enough neighbours for radio."_s;
case ScrobbleErrorCode::NoPeakRadio:
return u"No Peak Radio - This user is not allowed to listen to radio during peak usage."_s;
case ScrobbleErrorCode::RadioNotFound:
return u"Radio Not Found - Radio station not found."_s;
case ScrobbleErrorCode::APIKeySuspended:
return u"Suspended API key - Access for your account has been suspended, please contact Last.fm"_s;
case ScrobbleErrorCode::Deprecated:
return u"Deprecated - This type of request is no longer supported."_s;
case ScrobbleErrorCode::RateLimitExceeded:
return u"Rate limit exceeded - Your IP has made too many requests in a short period."_s;
}
return u"Unknown error."_s;
}
void ScrobblingAPI20::CheckScrobblePrevSong() {
const qint64 duration = std::max(0LL, QDateTime::currentSecsSinceEpoch() - static_cast<qint64>(timestamp_));
if (!scrobbled_ && song_playing_.is_metadata_good() && song_playing_.is_radio() && duration > 30) {
Song song(song_playing_);
song.set_length_nanosec(duration * kNsecPerSec);
Scrobble(song);
}
}

View File

@@ -1,155 +0,0 @@
/*
* Strawberry Music Player
* 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
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef SCROBBLINGAPI20_H
#define SCROBBLINGAPI20_H
#include "config.h"
#include <QVariant>
#include <QByteArray>
#include <QString>
#include "includes/shared_ptr.h"
#include "core/song.h"
#include "scrobblerservice.h"
#include "scrobblercache.h"
#include "scrobblercacheitem.h"
class QTimer;
class QNetworkReply;
class ScrobblerSettingsService;
class NetworkAccessManager;
class LocalRedirectServer;
class ScrobblingAPI20 : public ScrobblerService {
Q_OBJECT
public:
explicit ScrobblingAPI20(const QString &name, const QString &settings_group, const QString &auth_url, const QString &api_url, const bool batch, const QString &cache_file, const SharedPtr<ScrobblerSettingsService> settings, const SharedPtr<NetworkAccessManager> network, QObject *parent = nullptr);
~ScrobblingAPI20() override;
static const char *kApiKey;
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 UpdateNowPlaying(const Song &song) override;
void ClearPlaying() override;
void Scrobble(const Song &song) override;
void Submit() override;
void Love() override;
Q_SIGNALS:
void AuthenticationComplete(const bool success, const QString &error = QString());
public Q_SLOTS:
void WriteCache() override { cache_->WriteCache(); }
private Q_SLOTS:
void RedirectArrived();
void AuthenticateReplyFinished(QNetworkReply *reply);
void UpdateNowPlayingRequestFinished(QNetworkReply *reply);
void ScrobbleRequestFinished(QNetworkReply *reply, ScrobblerCacheItemPtrList cache_items);
void SingleScrobbleRequestFinished(QNetworkReply *reply, ScrobblerCacheItemPtr cache_item);
void LoveRequestFinished(QNetworkReply *reply);
private:
enum class ScrobbleErrorCode {
NoError = 1,
InvalidService = 2,
InvalidMethod = 3,
AuthenticationFailed = 4,
InvalidFormat = 5,
InvalidParameters = 6,
InvalidResourceSpecified = 7,
OperationFailed = 8,
InvalidSessionKey = 9,
InvalidApiKey = 10,
ServiceOffline = 11,
SubscribersOnly = 12,
InvalidMethodSignature = 13,
UnauthorizedToken = 14,
ItemUnavailable = 15,
TemporarilyUnavailable = 16,
LoginRequired = 17,
TrialExpired = 18,
ErrorDoesNotExist = 19,
NotEnoughContent = 20,
NotEnoughMembers = 21,
NotEnoughFans = 22,
NotEnoughNeighbours = 23,
NoPeakRadio = 24,
RadioNotFound = 25,
APIKeySuspended = 26,
Deprecated = 27,
RateLimitExceeded = 29,
};
QNetworkReply *CreateRequest(const ParamList &request_params);
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()) override;
static QString ErrorString(const ScrobbleErrorCode error);
void StartSubmit(const bool initial = false) override;
void CheckScrobblePrevSong();
protected:
QString name_;
QString settings_group_;
QString auth_url_;
QString api_url_;
bool batch_;
const SharedPtr<NetworkAccessManager> network_;
ScrobblerCache *cache_;
LocalRedirectServer *local_redirect_server_;
bool enabled_;
bool prefer_albumartist_;
bool subscriber_;
QString username_;
QString session_key_;
bool submitted_;
Song song_playing_;
bool scrobbled_;
quint64 timestamp_;
bool submit_error_;
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>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -38,7 +38,6 @@
#include "scrobbler/audioscrobbler.h"
#include "scrobbler/lastfmscrobbler.h"
#include "scrobbler/librefmscrobbler.h"
#include "scrobbler/listenbrainzscrobbler.h"
#include "constants/scrobblersettings.h"
@@ -50,10 +49,8 @@ ScrobblerSettingsPage::ScrobblerSettingsPage(SettingsDialog *dialog, const Share
ui_(new Ui_ScrobblerSettingsPage),
scrobbler_(scrobbler),
lastfmscrobbler_(scrobbler_->Service<LastFMScrobbler>()),
librefmscrobbler_(scrobbler_->Service<LibreFMScrobbler>()),
listenbrainzscrobbler_(scrobbler_->Service<ListenBrainzScrobbler>()),
lastfm_waiting_for_auth_(false),
librefm_waiting_for_auth_(false),
listenbrainz_waiting_for_auth_(false) {
ui_->setupUi(this);
@@ -66,13 +63,6 @@ ScrobblerSettingsPage::ScrobblerSettingsPage(SettingsDialog *dialog, const Share
QObject::connect(ui_->widget_lastfm_login_state, &LoginStateWidget::LogoutClicked, this, &ScrobblerSettingsPage::LastFM_Logout);
ui_->widget_lastfm_login_state->AddCredentialGroup(ui_->widget_lastfm_login);
// Libre.fm
QObject::connect(&*librefmscrobbler_, &LibreFMScrobbler::AuthenticationComplete, this, &ScrobblerSettingsPage::LibreFM_AuthenticationComplete);
QObject::connect(ui_->button_librefm_login, &QPushButton::clicked, this, &ScrobblerSettingsPage::LibreFM_Login);
QObject::connect(ui_->widget_librefm_login_state, &LoginStateWidget::LoginClicked, this, &ScrobblerSettingsPage::LibreFM_Login);
QObject::connect(ui_->widget_librefm_login_state, &LoginStateWidget::LogoutClicked, this, &ScrobblerSettingsPage::LibreFM_Logout);
ui_->widget_librefm_login_state->AddCredentialGroup(ui_->widget_librefm_login);
// ListenBrainz
QObject::connect(&*listenbrainzscrobbler_, &ListenBrainzScrobbler::AuthenticationComplete, this, &ScrobblerSettingsPage::ListenBrainz_AuthenticationComplete);
QObject::connect(ui_->button_listenbrainz_login, &QPushButton::clicked, this, &ScrobblerSettingsPage::ListenBrainz_Login);
@@ -118,9 +108,6 @@ void ScrobblerSettingsPage::Load() {
ui_->checkbox_lastfm_enable->setChecked(lastfmscrobbler_->enabled());
LastFM_RefreshControls(lastfmscrobbler_->authenticated());
ui_->checkbox_librefm_enable->setChecked(librefmscrobbler_->enabled());
LibreFM_RefreshControls(librefmscrobbler_->authenticated());
ui_->checkbox_listenbrainz_enable->setChecked(listenbrainzscrobbler_->enabled());
ui_->lineedit_listenbrainz_user_token->setText(listenbrainzscrobbler_->user_token());
ListenBrainz_RefreshControls(listenbrainzscrobbler_->authenticated());
@@ -167,10 +154,6 @@ void ScrobblerSettingsPage::Save() {
s.setValue(kEnabled, ui_->checkbox_lastfm_enable->isChecked());
s.endGroup();
s.beginGroup(LibreFMScrobbler::kSettingsGroup);
s.setValue(kEnabled, ui_->checkbox_librefm_enable->isChecked());
s.endGroup();
s.beginGroup(ListenBrainzScrobbler::kSettingsGroup);
s.setValue(kEnabled, ui_->checkbox_listenbrainz_enable->isChecked());
s.setValue(kUserToken, ui_->lineedit_listenbrainz_user_token->text());
@@ -215,41 +198,6 @@ void ScrobblerSettingsPage::LastFM_RefreshControls(const bool authenticated) {
ui_->widget_lastfm_login_state->SetLoggedIn(authenticated ? LoginStateWidget::State::LoggedIn : LoginStateWidget::State::LoggedOut, lastfmscrobbler_->username());
}
void ScrobblerSettingsPage::LibreFM_Login() {
librefm_waiting_for_auth_ = true;
ui_->widget_librefm_login_state->SetLoggedIn(LoginStateWidget::State::LoginInProgress);
librefmscrobbler_->Authenticate();
}
void ScrobblerSettingsPage::LibreFM_Logout() {
librefmscrobbler_->ClearSession();
LibreFM_RefreshControls(false);
}
void ScrobblerSettingsPage::LibreFM_AuthenticationComplete(const bool success, const QString &error) {
if (!librefm_waiting_for_auth_) return;
librefm_waiting_for_auth_ = false;
if (success) {
Save();
}
else {
QMessageBox::warning(this, u"Authentication failed"_s, error);
}
LibreFM_RefreshControls(success);
}
void ScrobblerSettingsPage::LibreFM_RefreshControls(const bool authenticated) {
ui_->widget_librefm_login_state->SetLoggedIn(authenticated ? LoginStateWidget::State::LoggedIn : LoginStateWidget::State::LoggedOut, librefmscrobbler_->username());
}
void ScrobblerSettingsPage::ListenBrainz_Login() {
listenbrainz_waiting_for_auth_ = true;

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
@@ -33,7 +33,6 @@ class SettingsDialog;
class Ui_ScrobblerSettingsPage;
class AudioScrobbler;
class LastFMScrobbler;
class LibreFMScrobbler;
class ListenBrainzScrobbler;
class ScrobblerSettingsPage : public SettingsPage {
@@ -50,9 +49,6 @@ class ScrobblerSettingsPage : public SettingsPage {
void LastFM_Login();
void LastFM_Logout();
void LastFM_AuthenticationComplete(const bool success, const QString &error = QString());
void LibreFM_Login();
void LibreFM_Logout();
void LibreFM_AuthenticationComplete(const bool success, const QString &error = QString());
void ListenBrainz_Login();
void ListenBrainz_Logout();
void ListenBrainz_AuthenticationComplete(const bool success, const QString &error = QString());
@@ -62,15 +58,12 @@ class ScrobblerSettingsPage : public SettingsPage {
const SharedPtr<AudioScrobbler> scrobbler_;
const SharedPtr<LastFMScrobbler> lastfmscrobbler_;
const SharedPtr<LibreFMScrobbler> librefmscrobbler_;
const SharedPtr<ListenBrainzScrobbler> listenbrainzscrobbler_;
bool lastfm_waiting_for_auth_;
bool librefm_waiting_for_auth_;
bool listenbrainz_waiting_for_auth_;
void LastFM_RefreshControls(const bool authenticated);
void LibreFM_RefreshControls(const bool authenticated);
void ListenBrainz_RefreshControls(const bool authenticated);
};

View File

@@ -270,55 +270,6 @@
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupbox_librefm">
<property name="title">
<string>Libre.fm</string>
</property>
<layout class="QVBoxLayout" name="layout_librefm">
<item>
<widget class="QCheckBox" name="checkbox_librefm_enable">
<property name="text">
<string>Enable</string>
</property>
</widget>
</item>
<item>
<widget class="LoginStateWidget" name="widget_librefm_login_state" native="true"/>
</item>
<item>
<widget class="QWidget" name="widget_librefm_login" native="true">
<layout class="QVBoxLayout" name="layout_librefm_login">
<item>
<layout class="QHBoxLayout" name="layout_librefm_button_login">
<item>
<widget class="QPushButton" name="button_librefm_login">
<property name="text">
<string>Login</string>
</property>
</widget>
</item>
<item>
<spacer name="spacer_librefm_login">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupbox_listenbrainz">
<property name="title">
@@ -444,8 +395,6 @@
<tabstop>checkbox_source_radioparadise</tabstop>
<tabstop>checkbox_lastfm_enable</tabstop>
<tabstop>button_lastfm_login</tabstop>
<tabstop>checkbox_librefm_enable</tabstop>
<tabstop>button_librefm_login</tabstop>
<tabstop>checkbox_listenbrainz_enable</tabstop>
<tabstop>lineedit_listenbrainz_user_token</tabstop>
<tabstop>button_listenbrainz_login</tabstop>