From f8aa2ed17db35d9cf86d8cae2e2df249266f5a66 Mon Sep 17 00:00:00 2001 From: emeric Date: Tue, 9 Jun 2026 22:56:59 +0200 Subject: [PATCH] Added last.fm support to scrobble data, fixes #138 --- approot/messages.xml | 16 + approot/messages_es.xml | 16 + approot/messages_fr.xml | 28 +- approot/messages_it.xml | 16 + approot/messages_pl.xml | 16 + approot/messages_zh.xml | 16 + approot/settings-services.xml | 60 ++++ conf/lms.conf | 6 + src/libs/core/CMakeLists.txt | 1 + src/libs/core/impl/Md5.cpp | 35 ++ src/libs/core/impl/String.cpp | 16 + src/libs/core/impl/http/SendQueue.cpp | 8 +- src/libs/core/include/core/Md5.hpp | 29 ++ src/libs/core/include/core/String.hpp | 2 + src/libs/database/impl/Migration.cpp | 10 +- .../include/database/objects/Types.hpp | 1 + .../include/database/objects/User.hpp | 14 +- src/libs/services/scrobbling/CMakeLists.txt | 3 + .../scrobbling/impl/ScrobblingService.cpp | 28 ++ .../scrobbling/impl/ScrobblingService.hpp | 14 + .../scrobbling/impl/lastfm/LastFmBackend.cpp | 191 +++++++++++ .../scrobbling/impl/lastfm/LastFmBackend.hpp | 83 +++++ .../impl/lastfm/ScrobblingsSynchronizer.cpp | 322 ++++++++++++++++++ .../impl/lastfm/ScrobblingsSynchronizer.hpp | 77 +++++ .../services/scrobbling/impl/lastfm/Utils.cpp | 124 +++++++ .../services/scrobbling/impl/lastfm/Utils.hpp | 52 +++ .../scrobbling/IScrobblingService.hpp | 14 + .../services/scrobbling/test/CMakeLists.txt | 1 + .../services/scrobbling/test/LastFmUtils.cpp | 147 ++++++++ src/lms/ui/LmsApplication.cpp | 9 +- src/lms/ui/LmsApplication.hpp | 3 +- src/lms/ui/settings/ServicesSettingsView.cpp | 232 +++++++++++-- src/lms/ui/settings/ServicesSettingsView.hpp | 3 + 33 files changed, 1548 insertions(+), 45 deletions(-) create mode 100644 src/libs/core/impl/Md5.cpp create mode 100644 src/libs/core/include/core/Md5.hpp create mode 100644 src/libs/services/scrobbling/impl/lastfm/LastFmBackend.cpp create mode 100644 src/libs/services/scrobbling/impl/lastfm/LastFmBackend.hpp create mode 100644 src/libs/services/scrobbling/impl/lastfm/ScrobblingsSynchronizer.cpp create mode 100644 src/libs/services/scrobbling/impl/lastfm/ScrobblingsSynchronizer.hpp create mode 100644 src/libs/services/scrobbling/impl/lastfm/Utils.cpp create mode 100644 src/libs/services/scrobbling/impl/lastfm/Utils.hpp create mode 100644 src/libs/services/scrobbling/test/LastFmUtils.cpp diff --git a/approot/messages.xml b/approot/messages.xml index 8a8ccaca..4163ae7b 100644 --- a/approot/messages.xml +++ b/approot/messages.xml @@ -343,8 +343,24 @@ Audio These audio settings are local to your browser Internal +Last.fm +Last.fm API key +Last.fm API secret ListenBrainz ListenBrainz API token +Last.fm account +Linked +Link account +Unlink account +Link Last.fm account +Enter your Last.fm API key and shared secret (get them at last.fm/api/account/create), then click Authorize. +Authorize on Last.fm +Done, I've authorized +Didn't open? Click here to authorize on Last.fm +Unlink Last.fm account +Are you sure you want to unlink your Last.fm account? Scrobbling will stop until you link again. +Please enter your Last.fm API key and secret. +Last.fm authentication failed. Check your API key and secret. Change password Release Date (Ascending) Release Date (Descending) diff --git a/approot/messages_es.xml b/approot/messages_es.xml index 67068d3f..7d67101f 100644 --- a/approot/messages_es.xml +++ b/approot/messages_es.xml @@ -345,6 +345,22 @@ Interno ListenBrainz Token de la API de ListenBrainz +Last.fm +Clave API de Last.fm +Secreto de API de Last.fm +Cuenta de Last.fm +Vinculado +Vincular cuenta +Desvincular cuenta +Vincular cuenta de Last.fm +Introduce tu clave API y secreto compartido de Last.fm (obténlos en last.fm/api/account/create), luego haz clic en Autorizar. +Autorizar en Last.fm +Hecho, he autorizado +¿No se abrió? Haz clic aquí para autorizar en Last.fm +Desvincular cuenta de Last.fm +¿Estás seguro de que deseas desvincular tu cuenta de Last.fm? El scrobbling se detendrá hasta que vuelvas a vincularla. +Por favor, introduce tu clave API y secreto de Last.fm. +Error de autenticación de Last.fm. Comprueba tu clave API y secreto. Cambiar contraseña Fecha de publicación (Ascendiente) Fecha de publicación (Descendiente) diff --git a/approot/messages_fr.xml b/approot/messages_fr.xml index 20544b6e..6f9a6166 100644 --- a/approot/messages_fr.xml +++ b/approot/messages_fr.xml @@ -345,6 +345,22 @@ Interne ListenBrainz Jeton d'API ListenBrainz +Last.fm +Clef API Last.fm +Secret API Last.fm +Compte Last.fm +Lié +Lier le compte +Délier le compte +Lier le compte Last.fm +Entrez votre clef API et votre secret partagé Last.fm (obtenez-les sur last.fm/api/account/create), puis cliquez sur Autoriser. +Autoriser sur Last.fm +Terminé, j'ai autorisé +Pas ouvert ? Cliquez ici pour autoriser sur Last.fm +Délier le compte Last.fm +Êtes-vous sûr de vouloir délier votre compte Last.fm ? Le scrobbling s'arrêtera jusqu'à ce que vous le reliiez. +Veuillez entrer votre clef API et votre secret Last.fm. +Échec de l'authentification Last.fm. Vérifiez votre clef API et votre secret. Changement de mot de passe Date de sortie (Croissant) Date de sortie (Décroissant) @@ -366,13 +382,13 @@ Mauvais mot de passe L'ancien mot de passe doit être renseigné Regénérer -Clé API copiée dans le presse-papiers ! +Clef API copiée dans le presse-papiers ! Supprimer -Supprimer la clé déconnectera tous les clients qui l'utilisent actuellement. -Supprimer la clé API ? +Supprimer la clef déconnectera tous les clients qui l'utilisent actuellement. +Supprimer la clef API ? Regénérer -Générer une nouvelle clé invalidera tous les clients utilisant actuellement la clé existante. -Regénérer la clé API ? +Générer une nouvelle clef invalidera tous les clients utilisant actuellement la clef existante. +Regénérer la clef API ? Mode ReplayGain Pas de ReplayGain Automatique @@ -390,7 +406,7 @@ Tous les artistes de piste API Subsonic Clef pour l'API OpenSubsonic -Utilisez cette clé d'API comme mot de passe pour les clients qui ne prennent pas en charge l'extension 'API Key Authentication' +Utilisez cette clef d'API comme mot de passe pour les clients qui ne prennent pas en charge l'extension 'API Key Authentication' Transcodage Appliquer le transcodage Toujours diff --git a/approot/messages_it.xml b/approot/messages_it.xml index c677a12d..06baee9c 100644 --- a/approot/messages_it.xml +++ b/approot/messages_it.xml @@ -345,6 +345,22 @@ Interno ListenBrainz Token API ListenBrainz +Last.fm +Chiave API Last.fm +Segreto API Last.fm +Account Last.fm +Collegato +Collega account +Scollega account +Collega account Last.fm +Inserisci la chiave API e il segreto condiviso di Last.fm (ottenili su last.fm/api/account/create), poi clicca su Autorizza. +Autorizza su Last.fm +Fatto, ho autorizzato +Non si è aperto? Clicca qui per autorizzare su Last.fm +Scollega account Last.fm +Sei sicuro di voler scollegare il tuo account Last.fm? Lo scrobbling si interromperà finché non lo ricolleghi. +Inserisci la chiave API e il segreto Last.fm. +Autenticazione Last.fm fallita. Controlla la chiave API e il segreto. Cambia password Data di uscita (Crescente) Data di uscita (Decrescente) diff --git a/approot/messages_pl.xml b/approot/messages_pl.xml index 144875ea..d89687d5 100644 --- a/approot/messages_pl.xml +++ b/approot/messages_pl.xml @@ -371,6 +371,22 @@ Wewnętrzny ListenBrainz ListenBrainz API token +Last.fm +Klucz API Last.fm +Sekret API Last.fm +Konto Last.fm +Połączono +Połącz konto +Odłącz konto +Połącz konto Last.fm +Wprowadź klucz API i współdzielony sekret Last.fm (uzyskaj je na last.fm/api/account/create), następnie kliknij Autoryzuj. +Autoryzuj na Last.fm +Gotowe, autoryzowałem +Nie otworzyło się? Kliknij tutaj, aby autoryzować na Last.fm +Odłącz konto Last.fm +Czy na pewno chcesz odłączyć swoje konto Last.fm? Scrobbling zostanie zatrzymany do czasu ponownego połączenia. +Wprowadź klucz API i sekret Last.fm. +Uwierzytelnianie Last.fm nie powiodło się. Sprawdź klucz API i sekret. Zmień hasło Data wydania (Rosnąco) Data wydania (Malejąco) diff --git a/approot/messages_zh.xml b/approot/messages_zh.xml index cd558e34..9c525cc4 100644 --- a/approot/messages_zh.xml +++ b/approot/messages_zh.xml @@ -316,6 +316,22 @@ 内部 ListenBrainz ListenBrainz API 令牌 +Last.fm +Last.fm API 密钥 +Last.fm API 共享密钥 +Last.fm 账户 +已关联 +关联账户 +取消关联账户 +关联 Last.fm 账户 +输入您的 Last.fm API 密钥和共享密钥(从 last.fm/api/account/create 获取),然后点击授权。 +在 Last.fm 上授权 +完成,我已授权 +未打开?点击此处在 Last.fm 上授权 +取消关联 Last.fm 账户 +您确定要取消关联 Last.fm 账户吗?在重新关联之前,scrobbling 将停止。 +请输入您的 Last.fm API 密钥和共享密钥。 +Last.fm 认证失败。请检查您的 API 密钥和共享密钥。 更改密码 发行日期(升序) 发行日期(降序) diff --git a/approot/settings-services.xml b/approot/settings-services.xml index 62598371..9d31f847 100644 --- a/approot/settings-services.xml +++ b/approot/settings-services.xml @@ -39,4 +39,64 @@ ${save-btn class="btn btn-primary me-1"}${discard-btn class="btn btn-secondary"} + +
+
+ ${tr:Lms.Settings.services.lastfm-card-title} + ${card-header-badge} +
+
+ ${card-content} +
+
+
+ + + + + + + + + diff --git a/conf/lms.conf b/conf/lms.conf index a4329c4e..e53559b8 100644 --- a/conf/lms.conf +++ b/conf/lms.conf @@ -61,6 +61,12 @@ listenbrainz-max-sync-feedback-count = 1000; # How often to resync feedbacks (0 to disable sync) listenbrainz-sync-feedbacks-period-hours = 1; +# Last.fm API and authorization URLs +lastfm-api-base-url = "https://ws.audioscrobbler.com"; +lastfm-auth-base-url = "https://www.last.fm"; +# How often to submit pending scrobbles (0 to disable) +lastfm-submit-period-hours = 1; + # Acousticbrainz root API acousticbrainz-api-base-url = "https://acousticbrainz.org"; diff --git a/src/libs/core/CMakeLists.txt b/src/libs/core/CMakeLists.txt index 5049c270..65c8826d 100644 --- a/src/libs/core/CMakeLists.txt +++ b/src/libs/core/CMakeLists.txt @@ -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 ) diff --git a/src/libs/core/impl/Md5.cpp b/src/libs/core/impl/Md5.cpp new file mode 100644 index 00000000..d9305767 --- /dev/null +++ b/src/libs/core/impl/Md5.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 . + */ + +#include "core/Md5.hpp" + +#include + +namespace lms::core +{ + std::array md5(std::string_view data) + { + const std::string raw{ Wt::Utils::md5(std::string{ data }) }; + + std::array result; + for (std::size_t i{}; i < 16; ++i) + result[i] = static_cast(static_cast(raw[i])); + return result; + } +} // namespace lms::core diff --git a/src/libs/core/impl/String.cpp b/src/libs/core/impl/String.cpp index 99db1e12..8fec0efb 100644 --- a/src/libs/core/impl/String.cpp +++ b/src/libs/core/impl/String.cpp @@ -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(c); + } + + return encoded.str(); + } + std::string escapeString(std::string_view str, std::string_view charsToEscape, char escapeChar) { std::string res; diff --git a/src/libs/core/impl/http/SendQueue.cpp b/src/libs/core/impl/http/SendQueue.cpp index b2b1a35a..e4c819ec 100644 --- a/src/libs/core/impl/http/SendQueue.cpp +++ b/src/libs/core/impl/http/SendQueue.cpp @@ -284,8 +284,12 @@ namespace lms::core::http LOG(DEBUG, "Remaining messages = " << (remainingCount ? *remainingCount : 0)); if (mustThrottle || (remainingCount && *remainingCount == 0)) { - const auto waitDuration{ headerReadAs(msg, "X-RateLimit-Reset-In") }; - throttle(waitDuration.value_or(_defaultRetryWaitDuration)); + const std::chrono::seconds waitDuration{ + headerReadAs(msg, "X-RateLimit-Reset-In") + .value_or(headerReadAs(msg, "Retry-After") + .value_or(_defaultRetryWaitDuration)) + }; + throttle(waitDuration); } if (!mustThrottle) diff --git a/src/libs/core/include/core/Md5.hpp b/src/libs/core/include/core/Md5.hpp new file mode 100644 index 00000000..721ed1e4 --- /dev/null +++ b/src/libs/core/include/core/Md5.hpp @@ -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 . + */ + +#pragma once + +#include +#include +#include + +namespace lms::core +{ + std::array md5(std::string_view data); +} // namespace lms::core diff --git a/src/libs/core/include/core/String.hpp b/src/libs/core/include/core/String.hpp index 3021cb54..2dd2652c 100644 --- a/src/libs/core/include/core/String.hpp +++ b/src/libs/core/include/core/String.hpp @@ -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); diff --git a/src/libs/database/impl/Migration.cpp b/src/libs/database/impl/Migration.cpp index aadab808..e7b2e54a 100644 --- a/src/libs/database/impl/Migration.cpp +++ b/src/libs/database/impl/Migration.cpp @@ -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{}; diff --git a/src/libs/database/include/database/objects/Types.hpp b/src/libs/database/include/database/objects/Types.hpp index 77fbea2d..f470d9e6 100644 --- a/src/libs/database/include/database/objects/Types.hpp +++ b/src/libs/database/include/database/objects/Types.hpp @@ -198,6 +198,7 @@ namespace lms::db { Internal = 0, ListenBrainz = 1, + LastFm = 2, }; enum class FeedbackBackend diff --git a/src/libs/database/include/database/objects/User.hpp b/src/libs/database/include/database/objects/User.hpp index 29618a7f..79244d51 100644 --- a/src/libs/database/include/database/objects/User.hpp +++ b/src/libs/database/include/database/objects/User.hpp @@ -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 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 _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 }; diff --git a/src/libs/services/scrobbling/CMakeLists.txt b/src/libs/services/scrobbling/CMakeLists.txt index 55b5e825..b99037fb 100644 --- a/src/libs/services/scrobbling/CMakeLists.txt +++ b/src/libs/services/scrobbling/CMakeLists.txt @@ -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 diff --git a/src/libs/services/scrobbling/impl/ScrobblingService.cpp b/src/libs/services/scrobbling/impl/ScrobblingService.cpp index f40ac462..af528d38 100644 --- a/src/libs/services/scrobbling/impl/ScrobblingService.cpp +++ b/src/libs/services/scrobbling/impl/ScrobblingService.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(_db)); _scrobblingBackends.emplace(ScrobblingBackend::ListenBrainz, std::make_unique(ioContext, _db)); + { + auto backend{ std::make_unique(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 onSuccess, + std::function onFailure) + { + if (_lastFmBackend) + _lastFmBackend->initiateLastFmLink(userId, apiKey, apiSecret, std::move(onSuccess), std::move(onFailure)); + else + onFailure(); + } + + void ScrobblingService::continueLastFmLink(db::UserId userId, + std::function onSuccess, + std::function onFailure) + { + if (_lastFmBackend) + _lastFmBackend->continueLastFmLink(userId, std::move(onSuccess), std::move(onFailure)); + else + onFailure(); + } + void ScrobblingService::visitNowPlayingListens(const std::function& visitor, db::UserId userId) { const Clock::time_point now{ Clock::now() }; diff --git a/src/libs/services/scrobbling/impl/ScrobblingService.hpp b/src/libs/services/scrobbling/impl/ScrobblingService.hpp index 96ae5cfe..7202c7c0 100644 --- a/src/libs/services/scrobbling/impl/ScrobblingService.hpp +++ b/src/libs/services/scrobbling/impl/ScrobblingService.hpp @@ -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 onSuccess, + std::function onFailure) override; + + void continueLastFmLink(db::UserId userId, + std::function onSuccess, + std::function onFailure) override; + std::optional getUserBackend(db::UserId userId); void insertNowPlayingEntry(const Listen& listen); db::IDb& _db; std::unordered_map> _scrobblingBackends; + lastFm::LastFmBackend* _lastFmBackend{}; // non-owning, owned via _scrobblingBackends std::shared_mutex _nowPlayingEntriesMutex; struct NowPlayingEntry diff --git a/src/libs/services/scrobbling/impl/lastfm/LastFmBackend.cpp b/src/libs/services/scrobbling/impl/lastfm/LastFmBackend.cpp new file mode 100644 index 00000000..665ffaa8 --- /dev/null +++ b/src/libs/services/scrobbling/impl/lastfm/LastFmBackend.cpp @@ -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 . + */ + +#include "LastFmBackend.hpp" + +#include + +#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(track->getDuration()).count() << "s)"); + + return res; + } + } // namespace + + LastFmBackend::LastFmBackend(boost::asio::io_context& ioContext, db::IDb& db) + : _db{ db } + , _authBaseUrl{ core::Service::get()->getString("lastfm-auth-base-url", "https://www.last.fm") } + , _client{ core::http::createClient(ioContext, core::Service::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 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 onSuccess, + std::function onFailure) + { + const std::string apiKeyStr{ apiKey }; + const std::string apiSecretStr{ apiSecret }; + + const std::map 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 onSuccess, + std::function 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 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 diff --git a/src/libs/services/scrobbling/impl/lastfm/LastFmBackend.hpp b/src/libs/services/scrobbling/impl/lastfm/LastFmBackend.hpp new file mode 100644 index 00000000..ea4cfe49 --- /dev/null +++ b/src/libs/services/scrobbling/impl/lastfm/LastFmBackend.hpp @@ -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 . + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include + +#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 onSuccess, + std::function onFailure); + + void continueLastFmLink(db::UserId userId, + std::function onSuccess, + std::function onFailure); + + private: + LastFmBackend(const LastFmBackend&) = delete; + LastFmBackend& operator=(const LastFmBackend&) = delete; + + void listenStarted(const Listen& listen) override; + void listenFinished(const Listen& listen, std::optional 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 _client; + ScrobblingsSynchronizer _synchronizer; + + std::mutex _pendingAuthsMutex; + std::unordered_map _pendingAuths; + }; +} // namespace lms::scrobbling::lastFm diff --git a/src/libs/services/scrobbling/impl/lastfm/ScrobblingsSynchronizer.cpp b/src/libs/services/scrobbling/impl/lastfm/ScrobblingsSynchronizer.cpp new file mode 100644 index 00000000..e13d7060 --- /dev/null +++ b/src/libs/services/scrobbling/impl/lastfm/ScrobblingsSynchronizer.cpp @@ -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 . + */ + +#include "ScrobblingsSynchronizer.hpp" + +#include +#include + +#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 albumName; + std::optional duration; + }; + + std::optional 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(track->getDuration()) }; + if (secs.count() > 0) + info.duration = secs; + + return info; + } + + std::map buildScrobbleParams(const TrackInfo& info, const Wt::WDateTime& timePoint, std::size_t index) + { + const std::string indexStr{ "[" + std::to_string(index) + "]" }; + + std::map 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 buildNowPlayingParams(const TrackInfo& info) + { + std::map 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::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 info{ getTrackInfo(_db.getTLSSession(), listen) }; + if (!info) + { + LOG(DEBUG, "Cannot build scrobble params: skipping"); + return; + } + + std::map 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(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> 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 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 listens) + { + std::map params; + params["method"] = "track.scrobble"; + + std::vector validListens; + db::Session& session{ _db.getTLSSession() }; + + for (const TimedListen& listen : listens) + { + const std::optional 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 diff --git a/src/libs/services/scrobbling/impl/lastfm/ScrobblingsSynchronizer.hpp b/src/libs/services/scrobbling/impl/lastfm/ScrobblingsSynchronizer.hpp new file mode 100644 index 00000000..69de993c --- /dev/null +++ b/src/libs/services/scrobbling/impl/lastfm/ScrobblingsSynchronizer.hpp @@ -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 . + */ + +#pragma once + +#include + +#include +#include +#include + +#include + +#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 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 diff --git a/src/libs/services/scrobbling/impl/lastfm/Utils.cpp b/src/libs/services/scrobbling/impl/lastfm/Utils.cpp new file mode 100644 index 00000000..746e7bb6 --- /dev/null +++ b/src/libs/services/scrobbling/impl/lastfm/Utils.cpp @@ -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 . + */ + +#include "Utils.hpp" + +#include +#include + +#include +#include + +#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& 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(b); + + return oss.str(); + } + + std::string buildFormBody(const std::map& 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(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(root.get("session")) }; + return static_cast(session.get("key").orIfNull("")); + } + catch (const Wt::WException& e) + { + LOG(ERROR, "Cannot extract session key: " << e.what()); + return {}; + } + } +} // namespace lms::scrobbling::lastFm::utils diff --git a/src/libs/services/scrobbling/impl/lastfm/Utils.hpp b/src/libs/services/scrobbling/impl/lastfm/Utils.hpp new file mode 100644 index 00000000..29eeca7d --- /dev/null +++ b/src/libs/services/scrobbling/impl/lastfm/Utils.hpp @@ -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 . + */ + +#pragma once + +#include +#include +#include + +#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& params, std::string_view secret); + std::string buildFormBody(const std::map& params); + std::string parseAuthToken(std::string_view msgBody); + std::string parseSessionKey(std::string_view msgBody); +} // namespace lms::scrobbling::lastFm::utils diff --git a/src/libs/services/scrobbling/include/services/scrobbling/IScrobblingService.hpp b/src/libs/services/scrobbling/include/services/scrobbling/IScrobblingService.hpp index 94a5f72b..f7af0d49 100644 --- a/src/libs/services/scrobbling/include/services/scrobbling/IScrobblingService.hpp +++ b/src/libs/services/scrobbling/include/services/scrobbling/IScrobblingService.hpp @@ -20,8 +20,10 @@ #pragma once #include +#include #include #include +#include #include #include @@ -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 onSuccess, + std::function onFailure) + = 0; + + virtual void continueLastFmLink(db::UserId userId, + std::function onSuccess, + std::function onFailure) + = 0; }; std::unique_ptr createScrobblingService(boost::asio::io_context& ioContext, db::IDb& db); diff --git a/src/libs/services/scrobbling/test/CMakeLists.txt b/src/libs/services/scrobbling/test/CMakeLists.txt index 3ba0023c..c701d79c 100644 --- a/src/libs/services/scrobbling/test/CMakeLists.txt +++ b/src/libs/services/scrobbling/test/CMakeLists.txt @@ -1,5 +1,6 @@ add_executable(test-scrobbling + LastFmUtils.cpp Listenbrainz.cpp Scrobbling.cpp ) diff --git a/src/libs/services/scrobbling/test/LastFmUtils.cpp b/src/libs/services/scrobbling/test/LastFmUtils.cpp new file mode 100644 index 00000000..98e51e7b --- /dev/null +++ b/src/libs/services/scrobbling/test/LastFmUtils.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 . + */ + +#include + +#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 params{ { "api_key", "testkey" } }; + // payload = "api_keytestkeymysecret" + EXPECT_EQ(computeApiSig(params, "mysecret"), "20e219ce486aa95fc1e3a72c09f1587c"); + } + + TEST(LastFmUtils, computeApiSig_format_excluded) + { + const std::map 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 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 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 params{ { "key", "value" } }; + EXPECT_EQ(buildFormBody(params), "key=value"); + } + + TEST(LastFmUtils, buildFormBody_multiple_params_sorted) + { + const std::map params{ + { "a", "1" }, + { "b", "2" }, + }; + EXPECT_EQ(buildFormBody(params), "a=1&b=2"); + } + + TEST(LastFmUtils, buildFormBody_encodes_space) + { + const std::map params{ { "track", "hello world" } }; + EXPECT_EQ(buildFormBody(params), "track=hello%20world"); + } + + TEST(LastFmUtils, buildFormBody_encodes_special_chars) + { + const std::map 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 diff --git a/src/lms/ui/LmsApplication.cpp b/src/lms/ui/LmsApplication.cpp index 58790df4..36a27d66 100644 --- a/src/lms/ui/LmsApplication.cpp +++ b/src/lms/ui/LmsApplication.cpp @@ -562,9 +562,14 @@ namespace lms::ui } } - void LmsApplication::post(std::function func) + void LmsApplication::post(const std::string& sessionId, const std::function& func) { - Wt::WServer::instance()->post(LmsApp->sessionId(), std::move(func)); + Wt::WServer::instance()->post(sessionId, func); + } + + void LmsApplication::post(const std::function& func) + { + post(sessionId(), func); } void LmsApplication::setTitle(const Wt::WString& title) diff --git a/src/lms/ui/LmsApplication.hpp b/src/lms/ui/LmsApplication.hpp index c54646c4..3294498c 100644 --- a/src/lms/ui/LmsApplication.hpp +++ b/src/lms/ui/LmsApplication.hpp @@ -78,7 +78,8 @@ namespace lms::ui AuthenticationBackend getAuthBackend() const { return _authBackend; } // Utils - void post(std::function func); + static void post(const std::string& sessionId, const std::function& func); + void post(const std::function& func); void setTitle(const Wt::WString& title = ""); // Used to classify the message sent to the user diff --git a/src/lms/ui/settings/ServicesSettingsView.cpp b/src/lms/ui/settings/ServicesSettingsView.cpp index 8fdd2f51..4fc043a0 100644 --- a/src/lms/ui/settings/ServicesSettingsView.cpp +++ b/src/lms/ui/settings/ServicesSettingsView.cpp @@ -19,17 +19,27 @@ #include "ServicesSettingsView.hpp" +#include +#include + +#include #include +#include #include #include #include #include +#include #include +#include +#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->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::WString::tr("Lms.Settings.services.template")) }; auto model{ std::make_shared() }; @@ -147,30 +166,179 @@ namespace lms::ui t->setFormWidget(ServicesSettingsModel::ScrobblingBackendField, std::move(scrobblingBackend)); } - auto listenbrainzToken{ std::make_unique() }; - Wt::WLineEdit* listenbrainzTokenPtr{ listenbrainzToken.get() }; - listenbrainzTokenPtr->setEchoMode(Wt::EchoMode::Password); - t->setFormWidget(ServicesSettingsModel::ListenBrainzTokenField, std::move(listenbrainzToken)); + { + auto tokenEdit{ std::make_unique() }; + Wt::WLineEdit* tokenPtr{ tokenEdit.get() }; + tokenPtr->setEchoMode(Wt::EchoMode::Password); + t->setFormWidget(ServicesSettingsModel::ListenBrainzTokenField, std::move(tokenEdit)); - auto listenbrainzTokenVisibilityBtn{ std::make_unique(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::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::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("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("card-header-badge", "" + Wt::WString::tr("Lms.Settings.services.lastfm-linked").toUTF8() + "", Wt::TextFormat::UnsafeXHTML); + auto* unlinkBtn{ cardContent->addNew(Wt::WString::tr("Lms.Settings.services.lastfm-unlink")) }; + unlinkBtn->addStyleClass("btn btn-secondary"); + unlinkBtn->clicked().connect(this, [this] { + auto modal{ std::make_unique(Wt::WString::tr("Lms.Settings.services.lastfm.template.unlink-confirm")) }; + modal->addFunction("tr", &Wt::WTemplate::Functions::tr); + Wt::WTemplate* modalPtr{ modal.get() }; + modal->bindNew("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("cancel-btn", Wt::WString::tr("Lms.cancel")) + ->clicked() + .connect([modalPtr] { LmsApp->getModalManager().dispose(modalPtr); }); + LmsApp->getModalManager().show(std::move(modal)); + }); + } + else + { + lastFmCard->bindNew("card-header-badge"); + auto* linkBtn{ cardContent->addNew(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::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("api-key") }; + auto* apiSecretEdit{ modal->bindNew("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("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("auth-anchor-container") }; + authAnchorContainer->hide(); + + auto* authorizeBtn{ modal->bindNew("authorize-btn", Wt::WString::tr("Lms.Settings.services.lastfm-authorize")) }; + auto* doneBtn{ modal->bindNew("done-btn", Wt::WString::tr("Lms.Settings.services.lastfm-done")) }; + doneBtn->hide(); + + auto* cancelBtn{ modal->bindNew("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::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(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::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 diff --git a/src/lms/ui/settings/ServicesSettingsView.hpp b/src/lms/ui/settings/ServicesSettingsView.hpp index 51e00503..b39e9e71 100644 --- a/src/lms/ui/settings/ServicesSettingsView.hpp +++ b/src/lms/ui/settings/ServicesSettingsView.hpp @@ -30,5 +30,8 @@ namespace lms::ui private: void refreshView(); + void refreshFormSection(); + void refreshLastFmCardSection(); + void showLastFmLinkModal(); }; } // namespace lms::ui