Added last.fm support to scrobble data, fixes #138

This commit is contained in:
emeric
2026-06-09 22:56:59 +02:00
parent 709a1509b6
commit f8aa2ed17d
33 changed files with 1548 additions and 45 deletions
+1
View File
@@ -34,6 +34,7 @@ add_library(lmscore STATIC
impl/String.cpp
impl/TraceLogger.cpp
impl/UUID.cpp
impl/Md5.cpp
impl/XxHash3.cpp
${CMAKE_CURRENT_BINARY_DIR}/impl/Version.cpp
)
+35
View File
@@ -0,0 +1,35 @@
/*
* Copyright (C) 2025 Emeric Poupon
*
* This file is part of LMS.
*
* LMS 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.
*
* LMS 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 LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include "core/Md5.hpp"
#include <Wt/Utils.h>
namespace lms::core
{
std::array<std::byte, 16> md5(std::string_view data)
{
const std::string raw{ Wt::Utils::md5(std::string{ data }) };
std::array<std::byte, 16> result;
for (std::size_t i{}; i < 16; ++i)
result[i] = static_cast<std::byte>(static_cast<unsigned char>(raw[i]));
return result;
}
} // namespace lms::core
+16
View File
@@ -462,6 +462,22 @@ namespace lms::core::stringUtils
detail::writeEscapedString(os, str, detail::xmlEscapeChars);
}
std::string urlEncode(std::string_view str)
{
std::ostringstream encoded;
encoded << std::hex << std::uppercase;
for (const unsigned char c : str)
{
if (std::isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~')
encoded << c;
else
encoded << '%' << std::setw(2) << std::setfill('0') << static_cast<int>(c);
}
return encoded.str();
}
std::string escapeString(std::string_view str, std::string_view charsToEscape, char escapeChar)
{
std::string res;
+6 -2
View File
@@ -284,8 +284,12 @@ namespace lms::core::http
LOG(DEBUG, "Remaining messages = " << (remainingCount ? *remainingCount : 0));
if (mustThrottle || (remainingCount && *remainingCount == 0))
{
const auto waitDuration{ headerReadAs<std::chrono::seconds>(msg, "X-RateLimit-Reset-In") };
throttle(waitDuration.value_or(_defaultRetryWaitDuration));
const std::chrono::seconds waitDuration{
headerReadAs<std::chrono::seconds>(msg, "X-RateLimit-Reset-In")
.value_or(headerReadAs<std::chrono::seconds>(msg, "Retry-After")
.value_or(_defaultRetryWaitDuration))
};
throttle(waitDuration);
}
if (!mustThrottle)
+29
View File
@@ -0,0 +1,29 @@
/*
* Copyright (C) 2025 Emeric Poupon
*
* This file is part of LMS.
*
* LMS 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.
*
* LMS 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 LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <array>
#include <cstddef>
#include <string_view>
namespace lms::core
{
std::array<std::byte, 16> md5(std::string_view data);
} // namespace lms::core
+2
View File
@@ -108,6 +108,8 @@ namespace lms::core::stringUtils
[[nodiscard]] std::string xmlEscape(std::string_view str);
void writeXmlEscapedString(std::ostream& os, std::string_view str);
[[nodiscard]] std::string urlEncode(std::string_view str);
[[nodiscard]] std::string escapeString(std::string_view str, std::string_view charsToEscape, char escapeChar);
[[nodiscard]] std::string unescapeString(std::string_view str, char escapeChar);
+9 -1
View File
@@ -35,7 +35,7 @@ namespace lms::db
{
namespace
{
static constexpr Version LMS_DATABASE_VERSION{ 105 };
static constexpr Version LMS_DATABASE_VERSION{ 106 };
}
VersionInfo::VersionInfo()
@@ -1729,6 +1729,13 @@ FROM track)");
utils::executeCommand(*session.getDboSession(), R"(ALTER TABLE "playlist_file" ADD COLUMN "cover_image_file" text NOT NULL DEFAULT '')");
}
void migrateFromV105(Session& session)
{
utils::executeCommand(*session.getDboSession(), R"(ALTER TABLE "user" ADD COLUMN "lastfm_api_key" TEXT NOT NULL DEFAULT '')");
utils::executeCommand(*session.getDboSession(), R"(ALTER TABLE "user" ADD COLUMN "lastfm_api_secret" TEXT NOT NULL DEFAULT '')");
utils::executeCommand(*session.getDboSession(), R"(ALTER TABLE "user" ADD COLUMN "lastfm_session_key" TEXT NOT NULL DEFAULT '')");
}
bool doDbMigration(Session& session)
{
constexpr std::string_view outdatedMsg{ "Outdated database, please rebuild it (delete the .db file and restart)" };
@@ -1810,6 +1817,7 @@ FROM track)");
{ 102, migrateFromV102 },
{ 103, migrateFromV103 },
{ 104, migrateFromV104 },
{ 105, migrateFromV105 },
};
bool migrationPerformed{};
@@ -198,6 +198,7 @@ namespace lms::db
{
Internal = 0,
ListenBrainz = 1,
LastFm = 2,
};
enum class FeedbackBackend
@@ -118,6 +118,9 @@ namespace lms::db
void setFeedbackBackend(FeedbackBackend feedbackBackend) { _feedbackBackend = feedbackBackend; }
void setScrobblingBackend(ScrobblingBackend scrobblingBackend) { _scrobblingBackend = scrobblingBackend; }
void setListenBrainzToken(std::string_view token) { _listenbrainzToken = token; }
void setLastFmApiKey(std::string_view key) { _lastFmApiKey = key; }
void setLastFmApiSecret(std::string_view secret) { _lastFmApiSecret = secret; }
void setLastFmSessionKey(std::string_view key) { _lastFmSessionKey = key; }
// read
bool isAdmin() const { return _type == UserType::ADMIN; }
@@ -134,6 +137,9 @@ namespace lms::db
FeedbackBackend getFeedbackBackend() const { return _feedbackBackend; }
ScrobblingBackend getScrobblingBackend() const { return _scrobblingBackend; }
std::string_view getListenBrainzToken() const { return _listenbrainzToken; }
std::string_view getLastFmApiKey() const { return _lastFmApiKey; }
std::string_view getLastFmApiSecret() const { return _lastFmApiSecret; }
std::string_view getLastFmSessionKey() const { return _lastFmSessionKey; }
template<class Action>
void persist(Action& a)
@@ -155,6 +161,9 @@ namespace lms::db
Wt::Dbo::field(a, _feedbackBackend, "feedback_backend");
Wt::Dbo::field(a, _scrobblingBackend, "scrobbling_backend");
Wt::Dbo::field(a, _listenbrainzToken, "listenbrainz_token");
Wt::Dbo::field(a, _lastFmApiKey, "lastfm_api_key");
Wt::Dbo::field(a, _lastFmApiSecret, "lastfm_api_secret");
Wt::Dbo::field(a, _lastFmSessionKey, "lastfm_session_key");
Wt::Dbo::hasMany(a, _authTokens, Wt::Dbo::ManyToOne, "user");
Wt::Dbo::hasMany(a, _uiStates, Wt::Dbo::ManyToOne, "user");
@@ -176,7 +185,10 @@ namespace lms::db
core::EnumSet<TrackArtistLinkType> _uiInlineArtistRelationships{ TrackArtistLinkType::Composer, TrackArtistLinkType::Performer };
FeedbackBackend _feedbackBackend{ defaultFeedbackBackend };
ScrobblingBackend _scrobblingBackend{ defaultScrobblingBackend };
std::string _listenbrainzToken; // Musicbrainz Identifier
std::string _listenbrainzToken;
std::string _lastFmApiKey;
std::string _lastFmApiSecret;
std::string _lastFmSessionKey;
// Admin defined settings
UserType _type{ UserType::REGULAR };
@@ -1,6 +1,9 @@
add_library(lmsscrobbling STATIC
impl/internal/InternalBackend.cpp
impl/lastfm/LastFmBackend.cpp
impl/lastfm/ScrobblingsSynchronizer.cpp
impl/lastfm/Utils.cpp
impl/listenbrainz/ListenBrainzBackend.cpp
impl/listenbrainz/ListenTypes.cpp
impl/listenbrainz/ListensParser.cpp
@@ -29,6 +29,7 @@
#include "database/objects/User.hpp"
#include "internal/InternalBackend.hpp"
#include "lastfm/LastFmBackend.hpp"
#include "listenbrainz/ListenBrainzBackend.hpp"
namespace lms::scrobbling
@@ -67,6 +68,11 @@ namespace lms::scrobbling
LMS_LOG(SCROBBLING, INFO, "Starting service...");
_scrobblingBackends.emplace(ScrobblingBackend::Internal, std::make_unique<InternalBackend>(_db));
_scrobblingBackends.emplace(ScrobblingBackend::ListenBrainz, std::make_unique<listenBrainz::ListenBrainzBackend>(ioContext, _db));
{
auto backend{ std::make_unique<lastFm::LastFmBackend>(ioContext, _db) };
_lastFmBackend = backend.get();
_scrobblingBackends.emplace(ScrobblingBackend::LastFm, std::move(backend));
}
LMS_LOG(SCROBBLING, INFO, "Service started!");
}
@@ -95,6 +101,28 @@ namespace lms::scrobbling
_scrobblingBackends[*backend]->addTimedListen(listen);
}
void ScrobblingService::initiateLastFmLink(db::UserId userId,
std::string_view apiKey,
std::string_view apiSecret,
std::function<void(std::string_view authUrl)> onSuccess,
std::function<void()> onFailure)
{
if (_lastFmBackend)
_lastFmBackend->initiateLastFmLink(userId, apiKey, apiSecret, std::move(onSuccess), std::move(onFailure));
else
onFailure();
}
void ScrobblingService::continueLastFmLink(db::UserId userId,
std::function<void()> onSuccess,
std::function<void()> onFailure)
{
if (_lastFmBackend)
_lastFmBackend->continueLastFmLink(userId, std::move(onSuccess), std::move(onFailure));
else
onFailure();
}
void ScrobblingService::visitNowPlayingListens(const std::function<void(Clock::time_point startedAt, const Listen&)>& visitor, db::UserId userId)
{
const Clock::time_point now{ Clock::now() };
@@ -28,6 +28,11 @@
#include "IScrobblingBackend.hpp"
namespace lms::scrobbling::lastFm
{
class LastFmBackend;
}
namespace lms::scrobbling
{
class ScrobblingService : public IScrobblingService
@@ -59,12 +64,21 @@ namespace lms::scrobbling
ReleaseContainer getTopReleases(const FindParameters& params) override;
TrackContainer getTopTracks(const FindParameters& params) override;
void initiateLastFmLink(db::UserId userId, std::string_view apiKey, std::string_view apiSecret,
std::function<void(std::string_view authUrl)> onSuccess,
std::function<void()> onFailure) override;
void continueLastFmLink(db::UserId userId,
std::function<void()> onSuccess,
std::function<void()> onFailure) override;
std::optional<db::ScrobblingBackend> getUserBackend(db::UserId userId);
void insertNowPlayingEntry(const Listen& listen);
db::IDb& _db;
std::unordered_map<db::ScrobblingBackend, std::unique_ptr<IScrobblingBackend>> _scrobblingBackends;
lastFm::LastFmBackend* _lastFmBackend{}; // non-owning, owned via _scrobblingBackends
std::shared_mutex _nowPlayingEntriesMutex;
struct NowPlayingEntry
@@ -0,0 +1,191 @@
/*
* Copyright (C) 2025 Emeric Poupon
*
* This file is part of LMS.
*
* LMS 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.
*
* LMS 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 LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include "LastFmBackend.hpp"
#include <map>
#include "core/IConfig.hpp"
#include "core/Service.hpp"
#include "core/http/IClient.hpp"
#include "database/IDb.hpp"
#include "database/Session.hpp"
#include "database/objects/Track.hpp"
#include "database/objects/User.hpp"
#include "Utils.hpp"
namespace lms::scrobbling::lastFm
{
namespace
{
bool canBeScrobbled(db::Session& session, db::TrackId trackId, std::chrono::seconds playedDuration)
{
auto transaction{ session.createReadTransaction() };
const db::Track::pointer track{ db::Track::find(session, trackId) };
if (!track)
return false;
const bool res{ track->getDuration() >= std::chrono::seconds{ 30 } && (playedDuration >= std::chrono::minutes{ 4 } || playedDuration >= track->getDuration() / 2) };
if (!res)
LOG(DEBUG, "Track cannot be scrobbled: played duration too short (" << playedDuration.count() << "s, total = " << std::chrono::duration_cast<std::chrono::seconds>(track->getDuration()).count() << "s)");
return res;
}
} // namespace
LastFmBackend::LastFmBackend(boost::asio::io_context& ioContext, db::IDb& db)
: _db{ db }
, _authBaseUrl{ core::Service<core::IConfig>::get()->getString("lastfm-auth-base-url", "https://www.last.fm") }
, _client{ core::http::createClient(ioContext, core::Service<core::IConfig>::get()->getString("lastfm-api-base-url", "https://ws.audioscrobbler.com")) }
, _synchronizer{ ioContext, db, *_client }
{
LOG(INFO, "Starting Last.fm backend");
}
LastFmBackend::~LastFmBackend()
{
LOG(INFO, "Stopped Last.fm backend");
}
void LastFmBackend::listenStarted(const Listen& listen)
{
_synchronizer.enqueListenNow(listen);
}
void LastFmBackend::listenFinished(const Listen& listen, std::optional<std::chrono::seconds> playedDuration)
{
if (playedDuration && !canBeScrobbled(_db.getTLSSession(), listen.trackId, *playedDuration))
return;
const TimedListen timedListen{ listen, Wt::WDateTime::currentDateTime() };
_synchronizer.enqueListen(timedListen);
}
void LastFmBackend::addTimedListen(const TimedListen& timedListen)
{
_synchronizer.enqueListen(timedListen);
}
void LastFmBackend::initiateLastFmLink(db::UserId userId,
std::string_view apiKey,
std::string_view apiSecret,
std::function<void(std::string_view authUrl)> onSuccess,
std::function<void()> onFailure)
{
const std::string apiKeyStr{ apiKey };
const std::string apiSecretStr{ apiSecret };
const std::map<std::string, std::string> params{
{ "api_key", apiKeyStr },
{ "format", "json" },
{ "method", "auth.getToken" },
};
const std::string sig{ utils::computeApiSig(params, apiSecretStr) };
core::http::ClientGETRequestParameters request;
request.relativeUrl = "/2.0/?method=auth.getToken&api_key=" + apiKeyStr + "&api_sig=" + sig + "&format=json";
request.onSuccessFunc = [this, userId, apiKeyStr, apiSecretStr, onSuccess = std::move(onSuccess), onFailure](const Wt::Http::Message& msg) {
const std::string token{ utils::parseAuthToken(msg.body()) };
if (token.empty())
{
LOG(WARNING, "auth.getToken: failed to parse token");
onFailure();
return;
}
{
std::scoped_lock lock{ _pendingAuthsMutex };
_pendingAuths[userId] = PendingAuth{ .apiKey = apiKeyStr, .apiSecret = apiSecretStr, .token = token };
}
const std::string authUrl{ _authBaseUrl + "/api/auth/?api_key=" + apiKeyStr + "&token=" + token };
onSuccess(authUrl);
};
request.onFailureFunc = [onFailure = std::move(onFailure)] {
LOG(WARNING, "auth.getToken: HTTP request failed");
onFailure();
};
_client->sendGETRequest(std::move(request));
}
void LastFmBackend::continueLastFmLink(db::UserId userId,
std::function<void()> onSuccess,
std::function<void()> onFailure)
{
PendingAuth pending;
{
std::scoped_lock lock{ _pendingAuthsMutex };
auto it{ _pendingAuths.find(userId) };
if (it == _pendingAuths.end())
{
LOG(WARNING, "continueLastFmLink: no pending auth for user");
onFailure();
return;
}
pending = it->second;
}
const std::map<std::string, std::string> params{
{ "api_key", pending.apiKey },
{ "format", "json" },
{ "method", "auth.getSession" },
{ "token", pending.token },
};
const std::string sig{ utils::computeApiSig(params, pending.apiSecret) };
core::http::ClientGETRequestParameters request;
request.relativeUrl = "/2.0/?method=auth.getSession&api_key=" + pending.apiKey + "&token=" + pending.token + "&api_sig=" + sig + "&format=json";
request.onSuccessFunc = [this, userId, pending, onSuccess = std::move(onSuccess), onFailure](const Wt::Http::Message& msg) {
const std::string sessionKey{ utils::parseSessionKey(msg.body()) };
if (sessionKey.empty())
{
LOG(WARNING, "auth.getSession: failed to parse session key");
onFailure();
return;
}
{
db::Session& session{ _db.getTLSSession() };
auto transaction{ session.createWriteTransaction() };
if (db::User::pointer user{ db::User::find(session, userId) })
{
user.modify()->setLastFmApiKey(pending.apiKey);
user.modify()->setLastFmApiSecret(pending.apiSecret);
user.modify()->setLastFmSessionKey(sessionKey);
}
}
{
std::scoped_lock lock{ _pendingAuthsMutex };
_pendingAuths.erase(userId);
}
LOG(INFO, "Last.fm account linked for user " << userId.toString());
onSuccess();
};
request.onFailureFunc = [onFailure = std::move(onFailure)] {
LOG(WARNING, "auth.getSession: HTTP request failed");
onFailure();
};
_client->sendGETRequest(std::move(request));
}
} // namespace lms::scrobbling::lastFm
@@ -0,0 +1,83 @@
/*
* Copyright (C) 2025 Emeric Poupon
*
* This file is part of LMS.
*
* LMS 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.
*
* LMS 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 LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <functional>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include <string_view>
#include <unordered_map>
#include <boost/asio/io_context.hpp>
#include "database/objects/UserId.hpp"
#include "IScrobblingBackend.hpp"
#include "ScrobblingsSynchronizer.hpp"
namespace lms::db
{
class IDb;
}
namespace lms::scrobbling::lastFm
{
class LastFmBackend final : public IScrobblingBackend
{
public:
LastFmBackend(boost::asio::io_context& ioContext, db::IDb& db);
~LastFmBackend() override;
void initiateLastFmLink(db::UserId userId,
std::string_view apiKey,
std::string_view apiSecret,
std::function<void(std::string_view authUrl)> onSuccess,
std::function<void()> onFailure);
void continueLastFmLink(db::UserId userId,
std::function<void()> onSuccess,
std::function<void()> onFailure);
private:
LastFmBackend(const LastFmBackend&) = delete;
LastFmBackend& operator=(const LastFmBackend&) = delete;
void listenStarted(const Listen& listen) override;
void listenFinished(const Listen& listen, std::optional<std::chrono::seconds> playedDuration) override;
void addTimedListen(const TimedListen& listen) override;
struct PendingAuth
{
std::string apiKey;
std::string apiSecret;
std::string token;
};
db::IDb& _db;
const std::string _authBaseUrl;
std::unique_ptr<core::http::IClient> _client;
ScrobblingsSynchronizer _synchronizer;
std::mutex _pendingAuthsMutex;
std::unordered_map<db::UserId, PendingAuth> _pendingAuths;
};
} // namespace lms::scrobbling::lastFm
@@ -0,0 +1,322 @@
/*
* Copyright (C) 2025 Emeric Poupon
*
* This file is part of LMS.
*
* LMS 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.
*
* LMS 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 LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include "ScrobblingsSynchronizer.hpp"
#include <boost/asio/bind_executor.hpp>
#include <boost/asio/post.hpp>
#include "core/IConfig.hpp"
#include "core/Service.hpp"
#include "core/http/IClient.hpp"
#include "database/IDb.hpp"
#include "database/Session.hpp"
#include "database/objects/Artist.hpp"
#include "database/objects/Listen.hpp"
#include "database/objects/Release.hpp"
#include "database/objects/Track.hpp"
#include "database/objects/TrackArtistLink.hpp"
#include "database/objects/User.hpp"
#include "services/scrobbling/Exception.hpp"
#include "Utils.hpp"
namespace lms::scrobbling::lastFm
{
namespace
{
constexpr std::size_t maxBatchSize{ 50 };
struct TrackInfo
{
std::string artistName;
std::string trackName;
std::optional<std::string> albumName;
std::optional<std::chrono::seconds> duration;
};
std::optional<TrackInfo> getTrackInfo(db::Session& session, const scrobbling::Listen& listen)
{
auto transaction{ session.createReadTransaction() };
const db::Track::pointer track{ db::Track::find(session, listen.trackId) };
if (!track)
return std::nullopt;
const std::string artistName{ track->getArtistDisplayName() };
if (artistName.empty())
{
LOG(DEBUG, "Track '" << track->getAbsoluteFilePath() << "' cannot be scrobbled: no artist name");
return std::nullopt;
}
TrackInfo info;
info.artistName = artistName;
info.trackName = track->getName();
if (const auto release{ track->getRelease() })
info.albumName = release->getName();
const auto secs{ std::chrono::duration_cast<std::chrono::seconds>(track->getDuration()) };
if (secs.count() > 0)
info.duration = secs;
return info;
}
std::map<std::string, std::string> buildScrobbleParams(const TrackInfo& info, const Wt::WDateTime& timePoint, std::size_t index)
{
const std::string indexStr{ "[" + std::to_string(index) + "]" };
std::map<std::string, std::string> params;
params["method"] = "track.scrobble";
params["artist" + indexStr] = info.artistName;
params["track" + indexStr] = info.trackName;
if (info.albumName)
params["album" + indexStr] = *info.albumName;
if (info.duration)
params["duration" + indexStr] = std::to_string(info.duration->count());
params["timestamp" + indexStr] = std::to_string(timePoint.toTime_t());
return params;
}
std::map<std::string, std::string> buildNowPlayingParams(const TrackInfo& info)
{
std::map<std::string, std::string> params;
params["method"] = "track.updateNowPlaying";
params["artist"] = info.artistName;
params["track"] = info.trackName;
if (info.albumName)
params["album"] = *info.albumName;
if (info.duration)
params["duration"] = std::to_string(info.duration->count());
return params;
}
} // namespace
ScrobblingsSynchronizer::ScrobblingsSynchronizer(boost::asio::io_context& ioContext, db::IDb& db, core::http::IClient& client)
: _ioContext{ ioContext }
, _db{ db }
, _submitPeriod{ core::Service<core::IConfig>::get()->getULong("lastfm-submit-period-hours", 1) }
, _client{ client }
{
LOG(INFO, "Starting Last.fm scrobblings synchronizer, submit period = " << _submitPeriod.count() << " hours");
if (_submitPeriod.count() > 0)
scheduleSubmit(std::chrono::seconds{ 30 });
}
ScrobblingsSynchronizer::~ScrobblingsSynchronizer() = default;
void ScrobblingsSynchronizer::enqueListen(const TimedListen& listen)
{
assert(listen.listenedAt.isValid());
enqueListen(listen, listen.listenedAt);
}
void ScrobblingsSynchronizer::enqueListenNow(const scrobbling::Listen& listen)
{
enqueListen(listen, {});
}
void ScrobblingsSynchronizer::enqueListen(const scrobbling::Listen& listen, const Wt::WDateTime& timePoint)
{
const utils::LastFmCredentials creds{ utils::getLastFmCredentials(_db.getTLSSession(), listen.userId) };
if (creds.apiKey.empty() || creds.apiSecret.empty() || creds.sessionKey.empty())
{
LOG(DEBUG, "Missing Last.fm credentials for user, skipping");
return;
}
const std::optional<TrackInfo> info{ getTrackInfo(_db.getTLSSession(), listen) };
if (!info)
{
LOG(DEBUG, "Cannot build scrobble params: skipping");
return;
}
std::map<std::string, std::string> params{ timePoint.isValid() ? buildScrobbleParams(*info, timePoint, 0) : buildNowPlayingParams(*info) };
params.emplace("api_key", creds.apiKey);
params.emplace("sk", creds.sessionKey);
params.emplace("format", "json");
params["api_sig"] = utils::computeApiSig(params, creds.apiSecret);
core::http::ClientPOSTRequestParameters request;
request.relativeUrl = "/2.0/";
if (timePoint.isValid())
{
const TimedListen timedListen{ listen, timePoint };
saveListen(timedListen, db::SyncState::PendingAdd);
request.priority = core::http::ClientRequestParameters::Priority::Normal;
request.onSuccessFunc = [this, timedListen](const Wt::Http::Message&) {
boost::asio::post(boost::asio::bind_executor(_strand, [this, timedListen] {
saveListen(timedListen, db::SyncState::Synchronized);
}));
};
}
else
{
request.priority = core::http::ClientRequestParameters::Priority::High;
// "now playing" is fire-and-forget, no retry
}
request.message.addBodyText(utils::buildFormBody(params));
request.message.addHeader("Content-Type", "application/x-www-form-urlencoded");
_client.sendPOSTRequest(std::move(request));
}
bool ScrobblingsSynchronizer::saveListen(const TimedListen& listen, db::SyncState syncState)
{
db::Session& session{ _db.getTLSSession() };
auto transaction{ session.createWriteTransaction() };
db::Listen::pointer dbListen{ db::Listen::find(session, listen.userId, listen.trackId, db::ScrobblingBackend::LastFm, listen.listenedAt) };
if (!dbListen)
{
const db::User::pointer user{ db::User::find(session, listen.userId) };
if (!user)
return false;
const db::Track::pointer track{ db::Track::find(session, listen.trackId) };
if (!track)
return false;
dbListen = session.create<db::Listen>(user, track, db::ScrobblingBackend::LastFm, listen.listenedAt);
dbListen.modify()->setSyncState(syncState);
return true;
}
if (dbListen->getSyncState() == syncState)
return false;
dbListen.modify()->setSyncState(syncState);
return true;
}
void ScrobblingsSynchronizer::enquePendingListens()
{
std::map<db::UserId, std::vector<TimedListen>> pendingByUser;
{
db::Session& session{ _db.getTLSSession() };
auto transaction{ session.createReadTransaction() };
db::Listen::FindParameters params;
params.setScrobblingBackend(db::ScrobblingBackend::LastFm)
.setSyncState(db::SyncState::PendingAdd)
.setRange(db::Range{ 0, maxBatchSize * 10 });
const db::RangeResults results{ db::Listen::find(session, params) };
for (const db::ListenId listenId : results.results)
{
const db::Listen::pointer dbListen{ db::Listen::find(session, listenId) };
TimedListen tl;
tl.listenedAt = dbListen->getDateTime();
tl.userId = dbListen->getUser()->getId();
tl.trackId = dbListen->getTrack()->getId();
pendingByUser[tl.userId].push_back(tl);
}
}
for (auto& [userId, listens] : pendingByUser)
{
const utils::LastFmCredentials creds{ utils::getLastFmCredentials(_db.getTLSSession(), userId) };
if (creds.apiKey.empty() || creds.apiSecret.empty() || creds.sessionKey.empty())
{
LOG(DEBUG, "Missing Last.fm credentials for user, skipping");
continue;
}
for (std::span<const TimedListen> remaining{ listens }; !remaining.empty();)
{
const std::size_t count{ std::min(maxBatchSize, remaining.size()) };
sendScrobbleBatch(creds, remaining.first(count));
remaining = remaining.subspan(count);
}
}
}
void ScrobblingsSynchronizer::sendScrobbleBatch(const utils::LastFmCredentials& creds, std::span<const TimedListen> listens)
{
std::map<std::string, std::string> params;
params["method"] = "track.scrobble";
std::vector<TimedListen> validListens;
db::Session& session{ _db.getTLSSession() };
for (const TimedListen& listen : listens)
{
const std::optional<TrackInfo> info{ getTrackInfo(session, listen) };
if (!info)
continue;
auto trackParams{ buildScrobbleParams(*info, listen.listenedAt, validListens.size()) };
trackParams.erase("method");
params.merge(std::move(trackParams));
validListens.push_back(listen);
}
if (validListens.empty())
return;
LOG(DEBUG, "Sending scrobble batch of " << validListens.size() << " listens");
params["api_key"] = creds.apiKey;
params["sk"] = creds.sessionKey;
params["format"] = "json";
params["api_sig"] = utils::computeApiSig(params, creds.apiSecret);
core::http::ClientPOSTRequestParameters request;
request.relativeUrl = "/2.0/";
request.priority = core::http::ClientRequestParameters::Priority::Normal;
request.message.addBodyText(utils::buildFormBody(params));
request.message.addHeader("Content-Type", "application/x-www-form-urlencoded");
request.onSuccessFunc = [this, validListens](const Wt::Http::Message&) {
boost::asio::post(boost::asio::bind_executor(_strand, [this, validListens] {
for (const TimedListen& listen : validListens)
saveListen(listen, db::SyncState::Synchronized);
}));
};
_client.sendPOSTRequest(std::move(request));
}
void ScrobblingsSynchronizer::scheduleSubmit(std::chrono::seconds fromNow)
{
LOG(DEBUG, "Scheduled pending retry in " << fromNow.count() << " seconds");
_submitTimer.expires_after(fromNow);
_submitTimer.async_wait(boost::asio::bind_executor(_strand, [this](const boost::system::error_code& ec) {
if (ec == boost::asio::error::operation_aborted)
return;
if (ec)
throw Exception{ "Last.fm retry timer failure: " + std::string{ ec.message() } };
if (_submitPeriod.count() > 0)
{
enquePendingListens();
scheduleSubmit(_submitPeriod);
}
}));
}
} // namespace lms::scrobbling::lastFm
@@ -0,0 +1,77 @@
/*
* Copyright (C) 2025 Emeric Poupon
*
* This file is part of LMS.
*
* LMS 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.
*
* LMS 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 LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <span>
#include <boost/asio/io_context.hpp>
#include <boost/asio/io_context_strand.hpp>
#include <boost/asio/steady_timer.hpp>
#include <Wt/WDateTime.h>
#include "database/objects/Types.hpp"
#include "services/scrobbling/Listen.hpp"
#include "Utils.hpp"
namespace lms
{
namespace core::http
{
class IClient;
}
namespace db
{
class IDb;
}
} // namespace lms
namespace lms::scrobbling::lastFm
{
class ScrobblingsSynchronizer
{
public:
ScrobblingsSynchronizer(boost::asio::io_context& ioContext, db::IDb& db, core::http::IClient& client);
~ScrobblingsSynchronizer();
ScrobblingsSynchronizer(const ScrobblingsSynchronizer&) = delete;
ScrobblingsSynchronizer& operator=(const ScrobblingsSynchronizer&) = delete;
void enqueListen(const TimedListen& listen);
void enqueListenNow(const Listen& listen);
private:
void enqueListen(const Listen& listen, const Wt::WDateTime& timePoint);
bool saveListen(const TimedListen& listen, db::SyncState syncState);
void enquePendingListens();
void sendScrobbleBatch(const utils::LastFmCredentials& creds, std::span<const TimedListen> listens);
void scheduleSubmit(std::chrono::seconds fromNow);
boost::asio::io_context& _ioContext;
boost::asio::io_context::strand _strand{ _ioContext };
db::IDb& _db;
std::chrono::hours _submitPeriod;
boost::asio::steady_timer _submitTimer{ _ioContext };
core::http::IClient& _client;
};
} // namespace lms::scrobbling::lastFm
@@ -0,0 +1,124 @@
/*
* Copyright (C) 2025 Emeric Poupon
*
* This file is part of LMS.
*
* LMS 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.
*
* LMS 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 LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include "Utils.hpp"
#include <iomanip>
#include <sstream>
#include <Wt/Json/Object.h>
#include <Wt/Json/Parser.h>
#include "core/Md5.hpp"
#include "core/String.hpp"
#include "database/Session.hpp"
#include "database/objects/User.hpp"
namespace lms::scrobbling::lastFm::utils
{
LastFmCredentials getLastFmCredentials(db::Session& session, db::UserId userId)
{
LastFmCredentials creds;
auto transaction{ session.createReadTransaction() };
if (const db::User::pointer user{ db::User::find(session, userId) })
{
creds.apiKey = user->getLastFmApiKey();
creds.apiSecret = user->getLastFmApiSecret();
creds.sessionKey = user->getLastFmSessionKey();
}
return creds;
}
std::string computeApiSig(const std::map<std::string, std::string>& params, std::string_view secret)
{
std::string payload;
for (const auto& [key, value] : params)
{
if (key == "format" || key == "callback")
continue;
payload += key;
payload += value;
}
payload += secret;
const auto digest{ core::md5(payload) };
std::ostringstream oss;
oss << std::hex << std::setfill('0');
for (const std::byte b : digest)
oss << std::setw(2) << static_cast<int>(b);
return oss.str();
}
std::string buildFormBody(const std::map<std::string, std::string>& params)
{
std::string body;
bool first{ true };
for (const auto& [key, value] : params)
{
if (!first)
body += '&';
first = false;
body += core::stringUtils::urlEncode(key);
body += '=';
body += core::stringUtils::urlEncode(value);
}
return body;
}
std::string parseAuthToken(std::string_view msgBody)
{
Wt::Json::ParseError error;
Wt::Json::Object root;
if (!Wt::Json::parse(std::string{ msgBody }, root, error))
{
LOG(ERROR, "Cannot parse auth.getToken response: " << error.what());
return {};
}
return static_cast<std::string>(root.get("token").orIfNull(""));
}
std::string parseSessionKey(std::string_view msgBody)
{
Wt::Json::ParseError error;
Wt::Json::Object root;
if (!Wt::Json::parse(std::string{ msgBody }, root, error))
{
LOG(ERROR, "Cannot parse auth.getSession response: " << error.what());
return {};
}
try
{
const Wt::Json::Object& session{ static_cast<const Wt::Json::Object&>(root.get("session")) };
return static_cast<std::string>(session.get("key").orIfNull(""));
}
catch (const Wt::WException& e)
{
LOG(ERROR, "Cannot extract session key: " << e.what());
return {};
}
}
} // namespace lms::scrobbling::lastFm::utils
@@ -0,0 +1,52 @@
/*
* Copyright (C) 2025 Emeric Poupon
*
* This file is part of LMS.
*
* LMS 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.
*
* LMS 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 LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <map>
#include <string>
#include <string_view>
#include "core/ILogger.hpp"
#include "database/objects/UserId.hpp"
#define LOG(sev, message) LMS_LOG(SCROBBLING, sev, "[lastfm] " << message)
namespace lms::db
{
class Session;
}
namespace lms::scrobbling::lastFm::utils
{
struct LastFmCredentials
{
std::string apiKey;
std::string apiSecret;
std::string sessionKey;
};
LastFmCredentials getLastFmCredentials(db::Session& session, db::UserId userId);
std::string computeApiSig(const std::map<std::string, std::string>& params, std::string_view secret);
std::string buildFormBody(const std::map<std::string, std::string>& params);
std::string parseAuthToken(std::string_view msgBody);
std::string parseSessionKey(std::string_view msgBody);
} // namespace lms::scrobbling::lastFm::utils
@@ -20,8 +20,10 @@
#pragma once
#include <chrono>
#include <functional>
#include <memory>
#include <optional>
#include <string_view>
#include <Wt/WDateTime.h>
#include <boost/asio/io_context.hpp>
@@ -142,6 +144,18 @@ namespace lms::scrobbling
virtual ArtistContainer getTopArtists(const ArtistFindParameters& params) = 0;
virtual ReleaseContainer getTopReleases(const FindParameters& params) = 0;
virtual TrackContainer getTopTracks(const FindParameters& params) = 0;
virtual void initiateLastFmLink(db::UserId userId,
std::string_view apiKey,
std::string_view apiSecret,
std::function<void(std::string_view authUrl)> onSuccess,
std::function<void()> onFailure)
= 0;
virtual void continueLastFmLink(db::UserId userId,
std::function<void()> onSuccess,
std::function<void()> onFailure)
= 0;
};
std::unique_ptr<IScrobblingService> createScrobblingService(boost::asio::io_context& ioContext, db::IDb& db);
@@ -1,5 +1,6 @@
add_executable(test-scrobbling
LastFmUtils.cpp
Listenbrainz.cpp
Scrobbling.cpp
)
@@ -0,0 +1,147 @@
/*
* Copyright (C) 2025 Emeric Poupon
*
* This file is part of LMS.
*
* LMS 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.
*
* LMS 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 LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include <gtest/gtest.h>
#include "lastfm/Utils.hpp"
namespace lms::scrobbling::lastFm::utils::tests
{
TEST(LastFmUtils, computeApiSig_empty_params)
{
// empty params: payload = secret only
EXPECT_EQ(computeApiSig({}, "mysecret"), "06c219e5bc8378f3a8a3f83b4b7e4649");
}
TEST(LastFmUtils, computeApiSig_basic)
{
const std::map<std::string, std::string> params{ { "api_key", "testkey" } };
// payload = "api_keytestkeymysecret"
EXPECT_EQ(computeApiSig(params, "mysecret"), "20e219ce486aa95fc1e3a72c09f1587c");
}
TEST(LastFmUtils, computeApiSig_format_excluded)
{
const std::map<std::string, std::string> params{
{ "api_key", "testkey" },
{ "format", "json" },
};
// "format" is excluded, same result as without it
EXPECT_EQ(computeApiSig(params, "mysecret"), "20e219ce486aa95fc1e3a72c09f1587c");
}
TEST(LastFmUtils, computeApiSig_callback_excluded)
{
const std::map<std::string, std::string> params{
{ "api_key", "testkey" },
{ "callback", "fn" },
{ "format", "json" },
};
// both "callback" and "format" excluded, same result
EXPECT_EQ(computeApiSig(params, "mysecret"), "20e219ce486aa95fc1e3a72c09f1587c");
}
TEST(LastFmUtils, computeApiSig_params_sorted_by_key)
{
const std::map<std::string, std::string> params{
{ "api_key", "testkey" },
{ "method", "track.love" },
{ "track", "song title" },
};
// payload = "api_keytestkey" + "methodtrack.love" + "tracksong title" + "secret"
EXPECT_EQ(computeApiSig(params, "secret"), "6c6f4099790ab9e72d59fdd0632a8a81");
}
TEST(LastFmUtils, buildFormBody_empty)
{
EXPECT_EQ(buildFormBody({}), "");
}
TEST(LastFmUtils, buildFormBody_single_param)
{
const std::map<std::string, std::string> params{ { "key", "value" } };
EXPECT_EQ(buildFormBody(params), "key=value");
}
TEST(LastFmUtils, buildFormBody_multiple_params_sorted)
{
const std::map<std::string, std::string> params{
{ "a", "1" },
{ "b", "2" },
};
EXPECT_EQ(buildFormBody(params), "a=1&b=2");
}
TEST(LastFmUtils, buildFormBody_encodes_space)
{
const std::map<std::string, std::string> params{ { "track", "hello world" } };
EXPECT_EQ(buildFormBody(params), "track=hello%20world");
}
TEST(LastFmUtils, buildFormBody_encodes_special_chars)
{
const std::map<std::string, std::string> params{ { "q", "foo=bar&baz" } };
EXPECT_EQ(buildFormBody(params), "q=foo%3Dbar%26baz");
}
TEST(LastFmUtils, parseAuthToken_valid)
{
EXPECT_EQ(parseAuthToken(R"({"token":"abc123"})"), "abc123");
}
TEST(LastFmUtils, parseAuthToken_empty_body)
{
EXPECT_EQ(parseAuthToken(""), "");
}
TEST(LastFmUtils, parseAuthToken_invalid_json)
{
EXPECT_EQ(parseAuthToken("not json"), "");
}
TEST(LastFmUtils, parseAuthToken_missing_key)
{
EXPECT_EQ(parseAuthToken(R"({"other":"value"})"), "");
}
TEST(LastFmUtils, parseSessionKey_valid)
{
EXPECT_EQ(parseSessionKey(R"({"session":{"key":"xyz789","name":"user"}})"), "xyz789");
}
TEST(LastFmUtils, parseSessionKey_empty_body)
{
EXPECT_EQ(parseSessionKey(""), "");
}
TEST(LastFmUtils, parseSessionKey_invalid_json)
{
EXPECT_EQ(parseSessionKey("not json"), "");
}
TEST(LastFmUtils, parseSessionKey_missing_session)
{
EXPECT_EQ(parseSessionKey(R"({"other":"value"})"), "");
}
TEST(LastFmUtils, parseSessionKey_missing_key_in_session)
{
EXPECT_EQ(parseSessionKey(R"({"session":{"name":"user"}})"), "");
}
} // namespace lms::scrobbling::lastFm::utils::tests
+7 -2
View File
@@ -562,9 +562,14 @@ namespace lms::ui
}
}
void LmsApplication::post(std::function<void()> func)
void LmsApplication::post(const std::string& sessionId, const std::function<void()>& func)
{
Wt::WServer::instance()->post(LmsApp->sessionId(), std::move(func));
Wt::WServer::instance()->post(sessionId, func);
}
void LmsApplication::post(const std::function<void()>& func)
{
post(sessionId(), func);
}
void LmsApplication::setTitle(const Wt::WString& title)
+2 -1
View File
@@ -78,7 +78,8 @@ namespace lms::ui
AuthenticationBackend getAuthBackend() const { return _authBackend; }
// Utils
void post(std::function<void()> func);
static void post(const std::string& sessionId, const std::function<void()>& func);
void post(const std::function<void()>& func);
void setTitle(const Wt::WString& title = "");
// Used to classify the message sent to the user
+200 -32
View File
@@ -19,17 +19,27 @@
#include "ServicesSettingsView.hpp"
#include <memory>
#include <string>
#include <Wt/WAnchor.h>
#include <Wt/WComboBox.h>
#include <Wt/WContainerWidget.h>
#include <Wt/WFormModel.h>
#include <Wt/WLineEdit.h>
#include <Wt/WPushButton.h>
#include <Wt/WString.h>
#include <Wt/WTemplate.h>
#include <Wt/WTemplateFormView.h>
#include <Wt/WText.h>
#include "core/Service.hpp"
#include "database/Session.hpp"
#include "database/objects/User.hpp"
#include "services/scrobbling/IScrobblingService.hpp"
#include "LmsApplication.hpp"
#include "ModalManager.hpp"
#include "common/MandatoryValidator.hpp"
#include "common/ValueStringModel.hpp"
@@ -58,6 +68,7 @@ namespace lms::ui
_scrobblingBackendModel = std::make_shared<ScrobblingBackendModel>();
_scrobblingBackendModel->add(Wt::WString::tr("Lms.Settings.backend.internal"), db::ScrobblingBackend::Internal);
_scrobblingBackendModel->add(Wt::WString::tr("Lms.Settings.backend.listenbrainz"), db::ScrobblingBackend::ListenBrainz);
_scrobblingBackendModel->add(Wt::WString::tr("Lms.Settings.backend.lastfm"), db::ScrobblingBackend::LastFm);
addField(FeedbackBackendField);
addField(ScrobblingBackendField);
@@ -76,11 +87,11 @@ namespace lms::ui
auto transaction{ LmsApp->getDbSession().createWriteTransaction() };
db::User::pointer user{ LmsApp->getUser() };
if (auto feedbackBackendRow{ _feedbackBackendModel->getRowFromString(valueText(FeedbackBackendField)) })
user.modify()->setFeedbackBackend(_feedbackBackendModel->getValue(*feedbackBackendRow));
if (auto row{ _feedbackBackendModel->getRowFromString(valueText(FeedbackBackendField)) })
user.modify()->setFeedbackBackend(_feedbackBackendModel->getValue(*row));
if (auto scrobblingBackendRow{ _scrobblingBackendModel->getRowFromString(valueText(ScrobblingBackendField)) })
user.modify()->setScrobblingBackend(_scrobblingBackendModel->getValue(*scrobblingBackendRow));
if (auto row{ _scrobblingBackendModel->getRowFromString(valueText(ScrobblingBackendField)) })
user.modify()->setScrobblingBackend(_scrobblingBackendModel->getValue(*row));
user.modify()->setListenBrainzToken(Wt::asString(value(ListenBrainzTokenField)).toUTF8());
}
@@ -90,20 +101,23 @@ namespace lms::ui
auto transaction{ LmsApp->getDbSession().createReadTransaction() };
const db::User::pointer user{ LmsApp->getUser() };
if (auto feedbackBackendRow{ _feedbackBackendModel->getRowFromValue(user->getFeedbackBackend()) })
setValue(FeedbackBackendField, _feedbackBackendModel->getString(*feedbackBackendRow));
if (auto row{ _feedbackBackendModel->getRowFromValue(user->getFeedbackBackend()) })
setValue(FeedbackBackendField, _feedbackBackendModel->getString(*row));
if (auto scrobblingBackendRow{ _scrobblingBackendModel->getRowFromValue(user->getScrobblingBackend()) })
setValue(ScrobblingBackendField, _scrobblingBackendModel->getString(*scrobblingBackendRow));
if (auto row{ _scrobblingBackendModel->getRowFromValue(user->getScrobblingBackend()) })
setValue(ScrobblingBackendField, _scrobblingBackendModel->getString(*row));
if (const auto listenBrainzToken{ user->getListenBrainzToken() }; !listenBrainzToken.empty())
setValue(ListenBrainzTokenField, Wt::WString::fromUTF8(std::string{ listenBrainzToken }));
if (const auto token{ user->getListenBrainzToken() }; !token.empty())
setValue(ListenBrainzTokenField, Wt::WString::fromUTF8(std::string{ token }));
{
const bool usesListenBrainz{ user->getScrobblingBackend() == db::ScrobblingBackend::ListenBrainz || user->getFeedbackBackend() == db::FeedbackBackend::ListenBrainz };
setReadOnly(ServicesSettingsModel::ListenBrainzTokenField, !usesListenBrainz);
validator(ServicesSettingsModel::ListenBrainzTokenField)->setMandatory(usesListenBrainz);
}
updateFieldStates(user->getScrobblingBackend(), user->getFeedbackBackend());
}
void updateFieldStates(db::ScrobblingBackend scrobblingBackend, db::FeedbackBackend feedbackBackend)
{
const bool usesListenBrainz{ scrobblingBackend == db::ScrobblingBackend::ListenBrainz || feedbackBackend == db::FeedbackBackend::ListenBrainz };
setReadOnly(ListenBrainzTokenField, !usesListenBrainz);
validator(ListenBrainzTokenField)->setMandatory(usesListenBrainz);
}
private:
@@ -127,7 +141,12 @@ namespace lms::ui
return;
clear();
refreshFormSection();
refreshLastFmCardSection();
}
void ServicesSettingsView::refreshFormSection()
{
auto* t{ addNew<Wt::WTemplateFormView>(Wt::WString::tr("Lms.Settings.services.template")) };
auto model{ std::make_shared<ServicesSettingsModel>() };
@@ -147,30 +166,179 @@ namespace lms::ui
t->setFormWidget(ServicesSettingsModel::ScrobblingBackendField, std::move(scrobblingBackend));
}
auto listenbrainzToken{ std::make_unique<Wt::WLineEdit>() };
Wt::WLineEdit* listenbrainzTokenPtr{ listenbrainzToken.get() };
listenbrainzTokenPtr->setEchoMode(Wt::EchoMode::Password);
t->setFormWidget(ServicesSettingsModel::ListenBrainzTokenField, std::move(listenbrainzToken));
{
auto tokenEdit{ std::make_unique<Wt::WLineEdit>() };
Wt::WLineEdit* tokenPtr{ tokenEdit.get() };
tokenPtr->setEchoMode(Wt::EchoMode::Password);
t->setFormWidget(ServicesSettingsModel::ListenBrainzTokenField, std::move(tokenEdit));
auto listenbrainzTokenVisibilityBtn{ std::make_unique<Wt::WPushButton>(Wt::WString::tr("Lms.template.toggle-visibility-btn"), Wt::TextFormat::XHTML) };
listenbrainzTokenVisibilityBtn->clicked().connect(this, [listenbrainzTokenPtr] {
listenbrainzTokenPtr->setEchoMode(listenbrainzTokenPtr->echoMode() == Wt::EchoMode::Password ? Wt::EchoMode::Normal : Wt::EchoMode::Password);
});
t->bindWidget("listenbrainz-token-visibility-btn", std::move(listenbrainzTokenVisibilityBtn));
auto visBtn{ std::make_unique<Wt::WPushButton>(Wt::WString::tr("Lms.template.toggle-visibility-btn"), Wt::TextFormat::XHTML) };
visBtn->clicked().connect(this, [tokenPtr] {
tokenPtr->setEchoMode(tokenPtr->echoMode() == Wt::EchoMode::Password ? Wt::EchoMode::Normal : Wt::EchoMode::Password);
});
t->bindWidget("listenbrainz-token-visibility-btn", std::move(visBtn));
}
auto updateListenBrainzTokenField{ [=] {
const bool enable{ model->getFeedbackBackendModel()->getValue(feedbackBackendRaw->currentIndex()) == db::FeedbackBackend::ListenBrainz
|| model->getScrobblingBackendModel()->getValue(scrobblingBackendRaw->currentIndex()) == db::ScrobblingBackend::ListenBrainz };
model->setReadOnly(ServicesSettingsModel::ListenBrainzTokenField, !enable);
model->validator(ServicesSettingsModel::ListenBrainzTokenField)->setMandatory(enable);
auto updateFieldStates{ [=] {
const db::ScrobblingBackend scrobBackend{ model->getScrobblingBackendModel()->getValue(scrobblingBackendRaw->currentIndex()) };
const db::FeedbackBackend feedBackend{ model->getFeedbackBackendModel()->getValue(feedbackBackendRaw->currentIndex()) };
model->updateFieldStates(scrobBackend, feedBackend);
t->updateModel(model.get());
t->updateView(model.get());
} };
feedbackBackendRaw->activated().connect([=] { updateListenBrainzTokenField(); });
scrobblingBackendRaw->activated().connect([=] { updateListenBrainzTokenField(); });
feedbackBackendRaw->activated().connect([=] { updateFieldStates(); });
scrobblingBackendRaw->activated().connect([=] { updateFieldStates(); });
utils::bindSaveDiscardButtons(t, model.get(), [model] { model->saveData(); }, [model] { model->loadData(); });
utils::bindSaveDiscardButtons(t, model.get(), [model, this] { model->saveData(); refreshView(); }, [model] { model->loadData(); });
t->updateView(model.get());
}
void ServicesSettingsView::refreshLastFmCardSection()
{
auto* lastFmCard{ addNew<Wt::WTemplate>(Wt::WString::tr("Lms.Settings.services.lastfm.template.card")) };
lastFmCard->addFunction("tr", &Wt::WTemplate::Functions::tr);
{
auto transaction{ LmsApp->getDbSession().createReadTransaction() };
lastFmCard->setHidden(LmsApp->getUser()->getScrobblingBackend() != db::ScrobblingBackend::LastFm);
}
auto* cardContent{ lastFmCard->bindNew<Wt::WContainerWidget>("card-content") };
cardContent->addStyleClass("d-flex align-items-center gap-2");
const bool linked{ [&] {
auto transaction{ LmsApp->getDbSession().createReadTransaction() };
return !LmsApp->getUser()->getLastFmSessionKey().empty();
}() };
if (linked)
{
lastFmCard->bindNew<Wt::WText>("card-header-badge", "<span class=\"badge text-bg-success\">" + Wt::WString::tr("Lms.Settings.services.lastfm-linked").toUTF8() + "</span>", Wt::TextFormat::UnsafeXHTML);
auto* unlinkBtn{ cardContent->addNew<Wt::WPushButton>(Wt::WString::tr("Lms.Settings.services.lastfm-unlink")) };
unlinkBtn->addStyleClass("btn btn-secondary");
unlinkBtn->clicked().connect(this, [this] {
auto modal{ std::make_unique<Wt::WTemplate>(Wt::WString::tr("Lms.Settings.services.lastfm.template.unlink-confirm")) };
modal->addFunction("tr", &Wt::WTemplate::Functions::tr);
Wt::WTemplate* modalPtr{ modal.get() };
modal->bindNew<Wt::WPushButton>("confirm-btn", Wt::WString::tr("Lms.Settings.services.lastfm-unlink"))
->clicked()
.connect(this, [this, modalPtr] {
{
auto transaction{ LmsApp->getDbSession().createWriteTransaction() };
LmsApp->getUser().modify()->setLastFmSessionKey("");
}
LmsApp->getModalManager().dispose(modalPtr);
refreshView();
});
modal->bindNew<Wt::WPushButton>("cancel-btn", Wt::WString::tr("Lms.cancel"))
->clicked()
.connect([modalPtr] { LmsApp->getModalManager().dispose(modalPtr); });
LmsApp->getModalManager().show(std::move(modal));
});
}
else
{
lastFmCard->bindNew<Wt::WContainerWidget>("card-header-badge");
auto* linkBtn{ cardContent->addNew<Wt::WPushButton>(Wt::WString::tr("Lms.Settings.services.lastfm-link")) };
linkBtn->addStyleClass("btn btn-secondary");
linkBtn->clicked().connect(this, [this] { showLastFmLinkModal(); });
}
}
void ServicesSettingsView::showLastFmLinkModal()
{
auto modal{ std::make_unique<Wt::WTemplate>(Wt::WString::tr("Lms.Settings.services.lastfm.template.link-modal")) };
modal->addFunction("tr", &Wt::WTemplate::Functions::tr);
Wt::WTemplate* modalPtr{ modal.get() };
auto* apiKeyEdit{ modal->bindNew<Wt::WLineEdit>("api-key") };
auto* apiSecretEdit{ modal->bindNew<Wt::WLineEdit>("api-secret") };
apiSecretEdit->setEchoMode(Wt::EchoMode::Password);
// pre-fill from DB if credentials already exist
{
auto transaction{ LmsApp->getDbSession().createReadTransaction() };
const db::User::pointer user{ LmsApp->getUser() };
if (const auto key{ user->getLastFmApiKey() }; !key.empty())
apiKeyEdit->setValueText(Wt::WString::fromUTF8(std::string{ key }));
if (const auto secret{ user->getLastFmApiSecret() }; !secret.empty())
apiSecretEdit->setValueText(Wt::WString::fromUTF8(std::string{ secret }));
}
auto* visBtn{ modal->bindNew<Wt::WPushButton>("api-secret-visibility-btn", Wt::WString::tr("Lms.template.toggle-visibility-btn"), Wt::TextFormat::XHTML) };
visBtn->clicked().connect([apiSecretEdit] {
apiSecretEdit->setEchoMode(apiSecretEdit->echoMode() == Wt::EchoMode::Password ? Wt::EchoMode::Normal : Wt::EchoMode::Password);
});
auto* authAnchorContainer{ modal->bindNew<Wt::WContainerWidget>("auth-anchor-container") };
authAnchorContainer->hide();
auto* authorizeBtn{ modal->bindNew<Wt::WPushButton>("authorize-btn", Wt::WString::tr("Lms.Settings.services.lastfm-authorize")) };
auto* doneBtn{ modal->bindNew<Wt::WPushButton>("done-btn", Wt::WString::tr("Lms.Settings.services.lastfm-done")) };
doneBtn->hide();
auto* cancelBtn{ modal->bindNew<Wt::WPushButton>("cancel-btn", Wt::WString::tr("Lms.cancel")) };
cancelBtn->clicked().connect([modalPtr] {
LmsApp->getModalManager().dispose(modalPtr);
});
authorizeBtn->clicked().connect([=] {
const std::string apiKey{ apiKeyEdit->valueText().toUTF8() };
const std::string apiSecret{ apiSecretEdit->valueText().toUTF8() };
if (apiKey.empty() || apiSecret.empty())
{
LmsApp->notifyMsg(Notification::Type::Warning, Wt::WString::tr("Lms.Settings.services.lastfm-missing-credentials"));
return;
}
const std::string sessionId{ wApp->sessionId() };
const db::UserId userId{ LmsApp->getUserId() };
core::Service<scrobbling::IScrobblingService>::get()->initiateLastFmLink(
userId, apiKey, apiSecret,
[sessionId, authAnchorContainer, authorizeBtn, doneBtn](std::string_view authUrl) {
LmsApplication::post(sessionId, [=, url = std::string{ authUrl }] {
wApp->doJavaScript("window.open('" + url + "', '_blank');");
Wt::WLink link{ url };
link.setTarget(Wt::LinkTarget::NewWindow);
authAnchorContainer->addNew<Wt::WAnchor>(link, Wt::WString::tr("Lms.Settings.services.lastfm-auth-url"));
authAnchorContainer->show();
authorizeBtn->hide();
doneBtn->show();
wApp->triggerUpdate();
});
},
[sessionId] {
LmsApplication::post(sessionId, [] {
LmsApp->notifyMsg(Notification::Type::Warning, Wt::WString::tr("Lms.Settings.services.lastfm-auth-error"));
wApp->triggerUpdate();
});
});
});
doneBtn->clicked().connect([=, this] {
const std::string sessionId{ wApp->sessionId() };
const db::UserId userId{ LmsApp->getUserId() };
core::Service<scrobbling::IScrobblingService>::get()->continueLastFmLink(
userId,
[sessionId, modalPtr, this] {
LmsApplication::post(sessionId, [sessionId, modalPtr, this] {
LmsApp->getModalManager().dispose(modalPtr);
refreshView();
wApp->triggerUpdate();
});
},
[sessionId] {
LmsApplication::post(sessionId, [] {
LmsApp->notifyMsg(Notification::Type::Warning, Wt::WString::tr("Lms.Settings.services.lastfm-auth-error"));
wApp->triggerUpdate();
});
});
});
LmsApp->getModalManager().show(std::move(modal));
}
} // namespace lms::ui
@@ -30,5 +30,8 @@ namespace lms::ui
private:
void refreshView();
void refreshFormSection();
void refreshLastFmCardSection();
void showLastFmLinkModal();
};
} // namespace lms::ui