Added last.fm support to scrobble data, fixes #138
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user