From 322362b3b978c0e81f9298c1a22f2b728941fb67 Mon Sep 17 00:00:00 2001 From: emeric Date: Sun, 7 Jun 2026 10:08:39 +0200 Subject: [PATCH 01/38] Fake DB generator: added a way to generate fake musicnn embeddings --- src/tools/db-generator/CMakeLists.txt | 1 + src/tools/db-generator/LmsDbGenerator.cpp | 25 ++++++++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/tools/db-generator/CMakeLists.txt b/src/tools/db-generator/CMakeLists.txt index f5e49489..257af67c 100644 --- a/src/tools/db-generator/CMakeLists.txt +++ b/src/tools/db-generator/CMakeLists.txt @@ -4,6 +4,7 @@ add_executable(lms-db-generator ) target_link_libraries(lms-db-generator PRIVATE + lmsaudio lmsdatabase lmscore Boost::program_options diff --git a/src/tools/db-generator/LmsDbGenerator.cpp b/src/tools/db-generator/LmsDbGenerator.cpp index 06b3de29..f1c0a5bf 100644 --- a/src/tools/db-generator/LmsDbGenerator.cpp +++ b/src/tools/db-generator/LmsDbGenerator.cpp @@ -32,6 +32,8 @@ #include "core/Random.hpp" #include "core/Service.hpp" #include "core/SystemPaths.hpp" + +#include "audio/MusicNNEmbeddings.hpp" #include "database/IDb.hpp" #include "database/Session.hpp" #include "database/objects/Artist.hpp" @@ -44,6 +46,7 @@ #include "database/objects/TrackArtistLink.hpp" #include "database/objects/TrackEmbeddedImage.hpp" #include "database/objects/TrackEmbeddedImageLink.hpp" +#include "database/objects/TrackMusicNNEmbeddings.hpp" namespace lms { @@ -59,6 +62,7 @@ namespace lms std::size_t trackEmbeddedImagePerRelease{ 1 }; // usual case: one same image saved on each track std::size_t genreCount{ 50 }; std::size_t moodCount{ 25 }; + bool generateMusicNNEmbeddings{ false }; std::filesystem::path trackPath; }; @@ -131,6 +135,18 @@ namespace lms if (!context.moods.empty()) clusters.push_back(*core::random::pickRandom(context.moods)); track.modify()->setClusters(clusters); + + if (params.generateMusicNNEmbeddings) + { + audio::TrackMusicNNEmbeddings embeddings; + std::normal_distribution embeddingDist{ 0.0F, 1.0F }; + for (auto& v : embeddings.mean.values) + v = embeddingDist(core::random::getRandGenerator()); + std::vector blob(sizeof(audio::TrackMusicNNEmbeddings)); + audio::trackMusicNNEmbeddingsToBlob(embeddings, blob); + TrackMusicNNEmbeddings::pointer entry{ context.session.create(track) }; + entry.modify()->setData(blob); + } } } @@ -204,7 +220,9 @@ int main(int argc, char* argv[]) ("genre-count", program_options::value()->default_value(defaultParams.genreCount), "Number of genres to generate") ("genre-count-per-track", program_options::value()->default_value(defaultParams.genreCountPerTrack), "Number of genres to assign to each track") ("mood-count", program_options::value()->default_value(defaultParams.moodCount), "Number of moods to generate") - ("mood-count-per-track", program_options::value()->default_value(defaultParams.moodCountPerTrack), "Number of moods to assign to each track")("help,h", "produce help message"); + ("mood-count-per-track", program_options::value()->default_value(defaultParams.moodCountPerTrack), "Number of moods to assign to each track") + ("musicnn-embeddings", program_options::bool_switch()->default_value(false), "Generate fake MusicNN embeddings for each track") + ("help,h", "produce help message"); // clang-format on program_options::variables_map vm; @@ -226,6 +244,7 @@ int main(int argc, char* argv[]) genParams.trackCountPerRelease = vm["track-count-per-release"].as(); genParams.compilationRatio = vm["compilation-ratio"].as(); genParams.trackEmbeddedImagePerRelease = vm["track-embedded-image-count"].as(); + genParams.generateMusicNNEmbeddings = vm["musicnn-embeddings"].as(); genParams.trackPath = std::filesystem::path{ vm["track-path"].as() }; if (!std::filesystem::exists(genParams.trackPath)) @@ -234,6 +253,10 @@ int main(int argc, char* argv[]) core::Service config{ core::createConfig(vm["conf"].as()) }; auto db{ db::createDb(config->getPath("working-dir", "/var/lms") / "lms.db") }; db::Session session{ *db }; + session.prepareTablesIfNeeded(); + session.migrateSchemaIfNeeded(); + session.createIndexesIfNeeded(); + std::cout << "Starting generation..." << std::endl; GenerationContext genContext{ session }; From 17ca0390870220ae20cc2e709a99c175af9e09f7 Mon Sep 17 00:00:00 2001 From: emeric Date: Sun, 7 Jun 2026 10:21:57 +0200 Subject: [PATCH 02/38] Speed up audio similarity engine startup for big collections --- .../AudioSimilarityEngine.impl.hpp | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/libs/services/recommendation/impl/audio-similarity/AudioSimilarityEngine.impl.hpp b/src/libs/services/recommendation/impl/audio-similarity/AudioSimilarityEngine.impl.hpp index bba489df..80960b9f 100644 --- a/src/libs/services/recommendation/impl/audio-similarity/AudioSimilarityEngine.impl.hpp +++ b/src/libs/services/recommendation/impl/audio-similarity/AudioSimilarityEngine.impl.hpp @@ -31,7 +31,6 @@ #include "core/ILogger.hpp" #include "core/ITraceLogger.hpp" -#include "core/Random.hpp" #include "database/IDb.hpp" #include "database/Session.hpp" @@ -682,20 +681,26 @@ namespace lms::recommendation LMS_SCOPED_TRACE_DETAILED("AudioSimilarityEngine", "computeTrackDistanceThreshold"); constexpr std::size_t maxSampleCount{ 500 }; + constexpr std::size_t maxCandidateCount{ 10'000 }; constexpr float stdDevMultiplier{ 2.F }; - const std::size_t sampleCount{ std::min(_trackVectors.size(), maxSampleCount) }; - - LOG(INFO, "computing track distance threshold using " << sampleCount << " samples..."); - - // Collect all vector pointers and shuffle for an unbiased random sample. std::vector allVectors; allVectors.reserve(_trackVectors.size()); for (const auto& [id, vec] : _trackVectors) allVectors.push_back(vec); + // move maxCandidateCount random elements to the front + const std::size_t candidateCount{ std::min(allVectors.size(), maxCandidateCount) }; std::minstd_rand randomEngine{ 42 }; - core::random::shuffleContainer(randomEngine, allVectors); + for (std::size_t i{}; i < candidateCount; ++i) + { + std::uniform_int_distribution dist{ i, allVectors.size() - 1 }; + std::swap(allVectors[i], allVectors[dist(randomEngine)]); + } + allVectors.resize(candidateCount); + + const std::size_t sampleCount{ std::min(candidateCount, maxSampleCount) }; + LOG(INFO, "computing track distance threshold using " << sampleCount << " samples on " << candidateCount << " candidates..."); math::StatsAccumulator stats; for (std::size_t i{}; i < sampleCount; ++i) @@ -714,7 +719,8 @@ namespace lms::recommendation minDist = d; } - stats.add(minDist); + if (minDist < std::numeric_limits::max()) + stats.add(minDist); } if (stats.getCount() >= 2) @@ -731,7 +737,7 @@ namespace lms::recommendation LMS_SCOPED_TRACE_DETAILED("AudioSimilarityEngine", "ComputeReleaseDistanceThreshold"); constexpr std::size_t maxSampleCount{ 200 }; - constexpr std::size_t maxCandidateCount{ 1'000 }; + constexpr std::size_t maxCandidateCount{ 2'000 }; constexpr float stdDevMultiplier{ 2.F }; using CosineDistance = math::NormalizedCosineDistance; @@ -784,7 +790,7 @@ namespace lms::recommendation LMS_SCOPED_TRACE_DETAILED("AudioSimilarityEngine", "ComputeArtistDistanceThreshold"); constexpr std::size_t maxSampleCount{ 200 }; - constexpr std::size_t maxCandidateCount{ 1'000 }; + constexpr std::size_t maxCandidateCount{ 2'000 }; constexpr float stdDevMultiplier{ 2.F }; using CosineDistance = math::NormalizedCosineDistance; From 709a1509b6239f09e9e22f660b4ec420e353fafb Mon Sep 17 00:00:00 2001 From: emeric Date: Sun, 7 Jun 2026 17:37:38 +0200 Subject: [PATCH 03/38] Use local time, if available, in the web interface, fixes #427 --- approot/messages.xml | 4 +-- approot/messages_es.xml | 4 +-- approot/messages_fr.xml | 4 +-- approot/messages_it.xml | 4 +-- approot/messages_pl.xml | 4 +-- approot/messages_zh.xml | 4 +-- src/libs/core/impl/Logger.cpp | 43 ++++++++++++++++++++---- src/lms/ui/LmsApplication.cpp | 21 ++++++++++-- src/lms/ui/admin/ScannerController.cpp | 46 ++++++++++++++++++++++---- src/lms/ui/admin/UserView.cpp | 6 +++- 10 files changed, 111 insertions(+), 29 deletions(-) diff --git a/approot/messages.xml b/approot/messages.xml index 000dfef9..8a8ccaca 100644 --- a/approot/messages.xml +++ b/approot/messages.xml @@ -129,7 +129,7 @@ Get report Last scan Not available -{1} files were scanned in {2} at {4} on {3} (UTC) - {5} errors, {6} duplicates +{1} files were scanned in {2} at {4} on {3} - {5} errors, {6} duplicates Missing artist name Unable to find track paths Unable to find the path "{1}" @@ -141,7 +141,7 @@ Scanner Status Not scheduled -Scheduled at {2} on {1} (UTC) +Scheduled at {2} on {1} Scanning: step {1}/{2} Associating artist images: {1}%... Associating external lyrics: {1}%... diff --git a/approot/messages_es.xml b/approot/messages_es.xml index bc9b0183..67068d3f 100644 --- a/approot/messages_es.xml +++ b/approot/messages_es.xml @@ -129,7 +129,7 @@ Informe Último escaneo No disponible -{1} ficheros analizados en {2} el {3} a las {4} (UTC) - {5} errores, {6} duplicados +{1} ficheros analizados en {2} el {3} a las {4} - {5} errores, {6} duplicados Falta el nombre del artista No se pudieron encontrar las rutas de las pistas No se pudo encontrar la ruta "{1}" @@ -141,7 +141,7 @@ Escanear Estado No progamado -Programado el {1} a las {2} (UTC) +Programado el {1} a las {2} Escaneando: etapa {1}/{2} Association des images des artistes: {1}%... Association des paroles externes: {1}%... diff --git a/approot/messages_fr.xml b/approot/messages_fr.xml index 65d2a543..20544b6e 100644 --- a/approot/messages_fr.xml +++ b/approot/messages_fr.xml @@ -129,7 +129,7 @@ Rapport Dernier scan Non disponible -{1} fichiers analysés en {2} à {4} le {3} (UTC) - {5} erreurs, {6} duplicatas +{1} fichiers analysés en {2} à {4} le {3} - {5} erreurs, {6} duplicatas Nom de l'artiste manquant Impossible de trouver les chemins des pistes Impossible de trouver le chemin "{1}" @@ -141,7 +141,7 @@ Scanner Statut Non planifié -Planifié à {2} le {1} (UTC) +Planifié à {2} le {1} En cours de scan : étape {1}/{2} Association des images des artistes: {1}%... Association des paroles externes: {1}%... diff --git a/approot/messages_it.xml b/approot/messages_it.xml index 39051ccb..c677a12d 100644 --- a/approot/messages_it.xml +++ b/approot/messages_it.xml @@ -129,7 +129,7 @@ Scarica un resoconto Ultima scansione Non disponibile -{1} file scansionati in {2} alle {3} il {4} (UTC) - {5} errori, {6} duplicati +{1} file scansionati in {2} alle {3} il {4} - {5} errori, {6} duplicati Nome dell'artista mancante Impossibile trovare i percorsi delle tracce Impossibile trovare il percorso "{1}" @@ -141,7 +141,7 @@ Scanner Stato Non pianificato -Programmato alle {2} del {1} (UTC) +Programmato alle {2} del {1} Scansione: passo {1}/{2} Associando immagini degli artisti: {1}%... Associazione dei testi esterni: {1}%... diff --git a/approot/messages_pl.xml b/approot/messages_pl.xml index 25eba67e..144875ea 100644 --- a/approot/messages_pl.xml +++ b/approot/messages_pl.xml @@ -138,7 +138,7 @@ Pokaż raport Ostatnie skanowanie Niedostępne -Przeskanowano {1} plików w {2} o {4} dnia {3} (UTC) - {5} błędów, {6} duplikatów +Przeskanowano {1} plików w {2} o {4} dnia {3} - {5} błędów, {6} duplikatów Brakuje nazwy artysty Nie można znaleźć ścieżek utworów Nie można znaleźć ścieżki „{1}” @@ -150,7 +150,7 @@ Skaner Status Nie zaplanowano -Zaplanowano o {2} dnia {1} (UTC) +Zaplanowano o {2} dnia {1} Skanowanie: krok {1}/{2} Kojarzenie obrazów artystów: {1}%... Kojarzenie zewnętrznych tekstów: {1}%... diff --git a/approot/messages_zh.xml b/approot/messages_zh.xml index 8c4146d3..cd558e34 100644 --- a/approot/messages_zh.xml +++ b/approot/messages_zh.xml @@ -127,7 +127,7 @@ 生成报告 最后扫描 不可用 -已扫描 {1} 个文件,用时 {2},在 {3} 的 {4} (UTC) - {5} 个错误,{6} 个重复项 +已扫描 {1} 个文件,用时 {2},在 {3} 的 {4} - {5} 个错误,{6} 个重复项 缺少艺术家名称 无法找到曲目路径 无法找到路径 "{1}" @@ -139,7 +139,7 @@ 扫描器 状态 无计划 -计划于 {2} 在 {1} (UTC) +计划于 {2} 在 {1} 扫描中: 阶段 {1}/{2} 关联艺术家图片:{1}%... 关联外部歌词:{1}%... diff --git a/src/libs/core/impl/Logger.cpp b/src/libs/core/impl/Logger.cpp index acffea76..0ae6b682 100644 --- a/src/libs/core/impl/Logger.cpp +++ b/src/libs/core/impl/Logger.cpp @@ -19,15 +19,47 @@ #include "Logger.hpp" -#include - #include +#include +#include +#include #include #include +#include #include #include "core/Exception.hpp" -#include "core/String.hpp" + +namespace +{ + std::string localISO8601TimeString() + { + using Clock = std::chrono::system_clock; + const auto now{ Clock::now() }; + const int ms{ static_cast(std::chrono::duration_cast(now.time_since_epoch()).count() % 1000) }; + const std::time_t tt{ Clock::to_time_t(now) }; + std::tm tm{}; + ::localtime_r(&tt, &tm); + + std::array datetime{}; + std::array offset{}; + std::strftime(datetime.data(), datetime.size(), "%Y-%m-%dT%H:%M:%S", &tm); + std::strftime(offset.data(), offset.size(), "%z", &tm); // "+HHMM" or "-HHMM" + + // Append msecs and offset + const std::string_view offsetView{ offset.data() }; + std::string result{ datetime.data() }; + result += '.'; + result += static_cast('0' + ms / 100); + result += static_cast('0' + (ms / 10) % 10); + result += static_cast('0' + ms % 10); + result += offsetView.substr(0, 3); // sign + HH + result += ':'; + result += offsetView.substr(3); // MM + + return result; + } +} // namespace namespace lms::core::logging { @@ -205,9 +237,8 @@ namespace lms::core::logging { assert(isSeverityActive(severity)); // should have been filtered out by a isSeverityActive call OutputStream* outputStream{ _severityToOutputStreamMap.at(severity) }; - const Wt::WDateTime now{ Wt::WDateTime::currentDateTime() }; std::unique_lock lock{ outputStream->mutex }; - outputStream->stream << stringUtils::toISO8601String(now) << " " << std::this_thread::get_id() << " [" << getSeverityName(severity) << "] [" << getModuleName(module) << "] " << message << std::endl; + outputStream->stream << localISO8601TimeString() << " " << std::this_thread::get_id() << " [" << getSeverityName(severity) << "] [" << getModuleName(module) << "] " << message << std::endl; } -} // namespace lms::core::logging \ No newline at end of file +} // namespace lms::core::logging diff --git a/src/lms/ui/LmsApplication.cpp b/src/lms/ui/LmsApplication.cpp index 5d1a1b58..58790df4 100644 --- a/src/lms/ui/LmsApplication.cpp +++ b/src/lms/ui/LmsApplication.cpp @@ -19,6 +19,8 @@ #include "LmsApplication.hpp" +#include + #include #include #include @@ -117,7 +119,7 @@ namespace lms::ui return res; } - Wt::WLocale createLocale(const std::string& name) + Wt::WLocale createLocale(const std::string& name, [[maybe_unused]] const std::string& timeZoneName) { Wt::WLocale locale{ name }; locale.setDecimalPoint(Wt::WString::tr("Lms.locale.decimal-point").toUTF8()); @@ -126,6 +128,19 @@ namespace lms::ui locale.setTimeFormat(Wt::WString::tr("Lms.locale.time-format").toUTF8()); locale.setDateTimeFormat(Wt::WString::tr("Lms.locale.date-time-format").toUTF8()); +#ifdef WT_DATE_TZ_USE_STD + if (!timeZoneName.empty()) + { + try + { + locale.setTimeZone(std::chrono::locate_zone(timeZoneName)); + } + catch (const std::runtime_error&) + { + // unknown zone, display stays UTC + } + } +#endif return locale; } @@ -224,7 +239,7 @@ namespace lms::ui setTitle(); setLocalizedStrings(getOrCreateMessageBundle()); - setLocale(createLocale(Wt::WLocale::currentLocale().name())); + setLocale(createLocale(Wt::WLocale::currentLocale().name(), environment().timeZoneName())); // Handle Media Scanner events and other session events enableUpdates(true); @@ -325,7 +340,7 @@ namespace lms::ui setUserInfo(userId, strongAuth); - LMS_LOG(UI, INFO, "User '" << getUserLoginName() << "' logged in from '" << environment().clientAddress() << "', user agent = " << environment().userAgent() << ", locale = '" << locale().name() << "'"); + LMS_LOG(UI, INFO, "User '" << getUserLoginName() << "' logged in from '" << environment().clientAddress() << "', user agent = " << environment().userAgent() << ", locale = '" << locale().name() << "', timezone = '" << environment().timeZoneName() << "'"); _appManager.registerApplication(*this); _appManager.applicationRegistered.connect(this, [this](LmsApplication& otherApplication) { diff --git a/src/lms/ui/admin/ScannerController.cpp b/src/lms/ui/admin/ScannerController.cpp index 103b8dd0..ad87ae72 100644 --- a/src/lms/ui/admin/ScannerController.cpp +++ b/src/lms/ui/admin/ScannerController.cpp @@ -21,6 +21,7 @@ #include #include +#include #include #include #include @@ -114,11 +115,26 @@ namespace lms::ui { if (status.lastCompleteScanStats) { + const auto& locale{ Wt::WLocale::currentLocale() }; + const Wt::WDateTime& stopTime{ status.lastCompleteScanStats->stopTime }; + Wt::WString stopDateStr; + Wt::WString stopTimeStr; + if (locale.timeZone()) + { + const Wt::WLocalDateTime local{ stopTime.toLocalTime() }; + stopDateStr = local.date().toString(locale.dateFormat()); + stopTimeStr = local.time().toString(locale.timeFormat()); + } + else + { + stopDateStr = stopTime.date().toString(locale.dateFormat()); + stopTimeStr = stopTime.time().toString(locale.timeFormat()) + " (UTC)"; + } _lastScanStatus->setText(Wt::WString::tr("Lms.Admin.ScannerController.last-scan-status") .arg(status.lastCompleteScanStats->getTotalFileCount()) .arg(durationToString(status.lastCompleteScanStats->startTime, status.lastCompleteScanStats->stopTime)) - .arg(status.lastCompleteScanStats->stopTime.date().toString(Wt::WLocale::currentLocale().dateFormat())) - .arg(status.lastCompleteScanStats->stopTime.time().toString(Wt::WLocale::currentLocale().timeFormat())) + .arg(stopDateStr) + .arg(stopTimeStr) .arg(status.lastCompleteScanStats->errorsCount) .arg(status.lastCompleteScanStats->duplicates.size())); @@ -144,11 +160,27 @@ namespace lms::ui break; case IScannerService::State::Scheduled: - _status->setText(Wt::WString::tr("Lms.Admin.ScannerController.status-scheduled") - .arg(status.nextScheduledScan.date().toString(Wt::WLocale::currentLocale().dateFormat())) - .arg(status.nextScheduledScan.time().toString(Wt::WLocale::currentLocale().timeFormat()))); - _stepStatus->setText(""); - break; + { + const auto& locale{ Wt::WLocale::currentLocale() }; + Wt::WString nextDateStr; + Wt::WString nextTimeStr; + if (locale.timeZone()) + { + const Wt::WLocalDateTime local{ status.nextScheduledScan.toLocalTime() }; + nextDateStr = local.date().toString(locale.dateFormat()); + nextTimeStr = local.time().toString(locale.timeFormat()); + } + else + { + nextDateStr = status.nextScheduledScan.date().toString(locale.dateFormat()); + nextTimeStr = status.nextScheduledScan.time().toString(locale.timeFormat()) + " (UTC)"; + } + _status->setText(Wt::WString::tr("Lms.Admin.ScannerController.status-scheduled") + .arg(nextDateStr) + .arg(nextTimeStr)); + _stepStatus->setText(""); + break; + } case IScannerService::State::InProgress: assert(status.currentScanStepStats); diff --git a/src/lms/ui/admin/UserView.cpp b/src/lms/ui/admin/UserView.cpp index 85a0505d..9af91ce6 100644 --- a/src/lms/ui/admin/UserView.cpp +++ b/src/lms/ui/admin/UserView.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include @@ -229,7 +230,10 @@ namespace lms::ui t->bindString("title", title, Wt::TextFormat::Plain); t->setCondition("if-has-last-login", true); - t->bindString("last-login", user->getLastLogin().toString(), Wt::TextFormat::Plain); + const auto& locale{ Wt::WLocale::currentLocale() }; + const Wt::WDateTime lastLogin{ user->getLastLogin() }; + const Wt::WString lastLoginStr{ locale.timeZone() ? lastLogin.toLocalTime().toString() : lastLogin.toString(locale.dateTimeFormat()) + " (UTC)" }; + t->bindString("last-login", lastLoginStr, Wt::TextFormat::Plain); } else { From f8aa2ed17db35d9cf86d8cae2e2df249266f5a66 Mon Sep 17 00:00:00 2001 From: emeric Date: Tue, 9 Jun 2026 22:56:59 +0200 Subject: [PATCH 04/38] 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 From 9bbe5f53b5bf290ee3b4be3bae3d9516498d8e46 Mon Sep 17 00:00:00 2001 From: emeric Date: Tue, 9 Jun 2026 22:59:34 +0200 Subject: [PATCH 05/38] Bumped version to v3.78.0 --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 2703d876..48432336 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.12) -project(lms VERSION 3.76.0) +project(lms VERSION 3.78.0) set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} ${CMAKE_SOURCE_DIR}/cmake/modules/) From 1d75add3d86e7d969105bb44632f3d7cc0c233f0 Mon Sep 17 00:00:00 2001 From: emeric Date: Tue, 9 Jun 2026 23:00:53 +0200 Subject: [PATCH 06/38] Added missing extension in documentation --- SUBSONIC.md | 1 + 1 file changed, 1 insertion(+) diff --git a/SUBSONIC.md b/SUBSONIC.md index 65f8b8ed..7fd8007f 100644 --- a/SUBSONIC.md +++ b/SUBSONIC.md @@ -75,6 +75,7 @@ The following extra fields are implemented: * [getPodcastEpisode](https://opensubsonic.netlify.app/docs/extensions/getpodcastepisode/) * [HTTP form POST](https://opensubsonic.netlify.app/docs/extensions/formpost/) * [Index based Queue](https://opensubsonic.netlify.app/docs/extensions/indexbasedqueue/) +* [Sonic similarity](https://opensubsonic.netlify.app/docs/extensions/sonicsimilarity/) * [Transcode offset](https://opensubsonic.netlify.app/docs/extensions/transcodeoffset/) * [Song Lyrics](https://opensubsonic.netlify.app/docs/extensions/songlyrics/) * [Transcoding](https://opensubsonic.netlify.app/docs/extensions/transcoding/) From a826a70c688e051794217b07f61e5311c6c8926a Mon Sep 17 00:00:00 2001 From: emeric Date: Wed, 10 Jun 2026 18:38:05 +0200 Subject: [PATCH 07/38] Added distance to previous in the recommendation outputs --- .../AudioSimilarityEngine.impl.hpp | 69 ++++++++++--------- .../impl/clusters/ClustersEngine.cpp | 10 +-- .../include/services/recommendation/Types.hpp | 3 +- src/libs/subsonic/impl/endpoints/Browsing.cpp | 4 +- .../recommendation/LmsRecommendation.cpp | 14 ++-- 5 files changed, 54 insertions(+), 46 deletions(-) diff --git a/src/libs/services/recommendation/impl/audio-similarity/AudioSimilarityEngine.impl.hpp b/src/libs/services/recommendation/impl/audio-similarity/AudioSimilarityEngine.impl.hpp index 80960b9f..92a28801 100644 --- a/src/libs/services/recommendation/impl/audio-similarity/AudioSimilarityEngine.impl.hpp +++ b/src/libs/services/recommendation/impl/audio-similarity/AudioSimilarityEngine.impl.hpp @@ -64,21 +64,27 @@ namespace lms::recommendation { namespace detail { + struct TrackNeighbor + { + db::TrackId id; + float distance{}; + }; + template - TrackResults findNearestNeighbors( + std::vector findNearestNeighbors( const ReducedVector& queryVector, // expected to be normalized const std::unordered_map& trackVectors, std::size_t maxNeighbors, - db::TrackId excludeTrackId) + std::span excludeTrackIds) { const math::NormalizedCosineDistance distFunc{ queryVector }; - TrackResults neighbors; + std::vector neighbors; neighbors.reserve(trackVectors.size()); for (const auto& [trackId, trackVector] : trackVectors) { - if (trackId == excludeTrackId) + if (std::find(std::cbegin(excludeTrackIds), std::cend(excludeTrackIds), trackId) != std::cend(excludeTrackIds)) continue; neighbors.push_back({ .id = trackId, .distance = distFunc(*trackVector) }); @@ -186,27 +192,10 @@ namespace lms::recommendation return res; const ReducedVector& queryVector{ *medoidCalculator.finalize() }; - const math::NormalizedCosineDistance distFunc{ queryVector }; - - using Distance = float; - std::vector> rankedTracks; - rankedTracks.reserve(_trackVectors.size()); - - for (const auto& [trackId, vectors] : _trackVectors) - { - if (std::find(std::cbegin(tracksId), std::cend(tracksId), trackId) != std::cend(tracksId)) - continue; - - rankedTracks.emplace_back(trackId, distFunc(*vectors)); - } // Oversample to give the diversity selection enough candidates to work with static constexpr std::size_t oversamplingFactor{ 5 }; - const std::size_t candidateCount{ std::min(maxCount * oversamplingFactor, rankedTracks.size()) }; - std::partial_sort(std::begin(rankedTracks), std::next(std::begin(rankedTracks), static_cast(candidateCount)), std::end(rankedTracks), [](const auto& lhs, const auto& rhs) { - return lhs.second < rhs.second; - }); - rankedTracks.resize(candidateCount); + auto rankedTracks{ detail::findNearestNeighbors(queryVector, _trackVectors, maxCount * oversamplingFactor, tracksId) }; // Greedy selection: at each step pick the candidate with the lowest penalized score. // Pre-seed selectedTracks with the input tracks so that soft constraints (same release, @@ -216,6 +205,17 @@ namespace lms::recommendation selectedTracks.reserve(selectedTracks.size() + maxCount); res.reserve(maxCount); + const ReducedVector* prevVector{ nullptr }; + for (auto it{ tracksId.rbegin() }; it != tracksId.rend(); ++it) + { + const auto found{ _trackVectors.find(*it) }; + if (found != _trackVectors.cend()) + { + prevVector = found->second; + break; + } + } + while (res.size() < maxCount && !rankedTracks.empty()) { std::optional bestIdx; @@ -223,7 +223,7 @@ namespace lms::recommendation for (std::size_t i{}; i < rankedTracks.size(); ++i) { - const db::TrackId candidateId{ rankedTracks[i].first }; + const db::TrackId candidateId{ rankedTracks[i].id }; const TrackCandidateContext context{ .candidateTrackId = candidateId, @@ -246,8 +246,11 @@ namespace lms::recommendation break; const auto& [selectedId, distanceToQuery]{ rankedTracks[*bestIdx] }; - res.push_back({ .id = selectedId, .distance = distanceToQuery }); + const auto* selectedVector{ _trackVectors.at(selectedId) }; + const float distanceToPrev{ prevVector ? math::NormalizedCosineDistance{ *prevVector }(*selectedVector) : distanceToQuery }; + res.push_back({ .id = selectedId, .distanceToFirst = distanceToQuery, .distanceToPrevious = distanceToPrev }); selectedTracks.push_back(selectedId); + prevVector = selectedVector; rankedTracks.erase(std::begin(rankedTracks) + static_cast(*bestIdx)); } @@ -284,17 +287,17 @@ namespace lms::recommendation auto queryPoint{ startVector + direction * t }; queryPoint.normalizeL2(); - const auto neighbors{ detail::findNearestNeighbors(queryPoint, _trackVectors, NeighborCount, endTrackId) }; + const auto neighbors{ detail::findNearestNeighbors(queryPoint, _trackVectors, NeighborCount, std::span{ &endTrackId, 1 }) }; const db::TrackId stepSeedTrackId{ neighbors.empty() ? startTrackId : neighbors[0].id }; const std::array stepSeedTrackIds{ stepSeedTrackId }; std::optional best; float bestScore{ std::numeric_limits::max() }; - for (const auto& [candidateTrackId, candidateDistance] : neighbors) + for (const auto& neighbor : neighbors) { const TrackCandidateContext context{ - .candidateTrackId = candidateTrackId, + .candidateTrackId = neighbor.id, .selectedTracks = path, .seedTrackIds = stepSeedTrackIds, }; @@ -306,7 +309,7 @@ namespace lms::recommendation if (score < bestScore) { bestScore = score; - best = candidateTrackId; + best = neighbor.id; } } @@ -323,10 +326,14 @@ namespace lms::recommendation results.reserve(path.size()); const math::NormalizedCosineDistance startDistFunc{ startVector }; + const ReducedVector* prevVector{ &startVector }; for (const db::TrackId trackId : path) { const auto* trackVector{ _trackVectors.at(trackId) }; - results.push_back({ .id = trackId, .distance = startDistFunc(*trackVector) }); + const float distToFirst{ startDistFunc(*trackVector) }; + const float distToPrev{ math::NormalizedCosineDistance{ *prevVector }(*trackVector) }; + results.push_back({ .id = trackId, .distanceToFirst = distToFirst, .distanceToPrevious = distToPrev }); + prevVector = trackVector; } return results; @@ -384,7 +391,7 @@ namespace lms::recommendation res.reserve(resultCount); for (std::size_t i{}; i < resultCount; ++i) - res.push_back({ .id = rankedReleases[i].first, .distance = rankedReleases[i].second }); + res.push_back({ .id = rankedReleases[i].first, .distanceToFirst = rankedReleases[i].second }); return res; } @@ -444,7 +451,7 @@ namespace lms::recommendation res.reserve(resultCount); for (std::size_t i{}; i < resultCount; ++i) - res.push_back({ .id = rankedArtists[i].first, .distance = rankedArtists[i].second }); + res.push_back({ .id = rankedArtists[i].first, .distanceToFirst = rankedArtists[i].second }); return res; } diff --git a/src/libs/services/recommendation/impl/clusters/ClustersEngine.cpp b/src/libs/services/recommendation/impl/clusters/ClustersEngine.cpp index 1efee5a5..86540518 100644 --- a/src/libs/services/recommendation/impl/clusters/ClustersEngine.cpp +++ b/src/libs/services/recommendation/impl/clusters/ClustersEngine.cpp @@ -86,7 +86,7 @@ namespace lms::recommendation ResultContainer res; res.reserve(resultCount); for (std::size_t i{}; i < resultCount; ++i) - res.push_back({ .id = overlapCounts[i].first, .distance = {} }); + res.push_back({ .id = overlapCounts[i].first, .distanceToFirst = {}, .distanceToPrevious = {} }); return res; } @@ -295,7 +295,7 @@ namespace lms::recommendation if (!bestIdx) break; - res.push_back({ .id = candidates[*bestIdx], .distance = {} }); + res.push_back({ .id = candidates[*bestIdx], .distanceToFirst = {}, .distanceToPrevious = {} }); selectedTracks.push_back(candidates[*bestIdx]); candidates.erase(std::begin(candidates) + static_cast(*bestIdx)); } @@ -364,7 +364,7 @@ namespace lms::recommendation return {}; if (startTrackId == endTrackId) - return { RecommendationResult{ .id = startTrackId, .distance = {} } }; + return { RecommendationResult{ .id = startTrackId, .distanceToFirst = {}, .distanceToPrevious = {} } }; db::Session& dbSession{ _db.getTLSSession() }; auto transaction{ dbSession.createReadTransaction() }; @@ -376,9 +376,9 @@ namespace lms::recommendation TrackResults res; res.reserve(std::min(maxCount, 2)); - res.push_back({ .id = startTrackId, .distance = {} }); + res.push_back({ .id = startTrackId, .distanceToFirst = {}, .distanceToPrevious = {} }); if (maxCount > 1) - res.push_back({ .id = endTrackId, .distance = {} }); + res.push_back({ .id = endTrackId, .distanceToFirst = {}, .distanceToPrevious = {} }); return res; } diff --git a/src/libs/services/recommendation/include/services/recommendation/Types.hpp b/src/libs/services/recommendation/include/services/recommendation/Types.hpp index bd08ec1e..169872e7 100644 --- a/src/libs/services/recommendation/include/services/recommendation/Types.hpp +++ b/src/libs/services/recommendation/include/services/recommendation/Types.hpp @@ -31,7 +31,8 @@ namespace lms::recommendation struct RecommendationResult { IdType id; - float distance{}; // normalized distance in [0, 1]: 0 = most similar, 1 = least similar + float distanceToFirst{}; // normalized [0, 1]: 0 = most similar, 1 = least similar (distance to seed/query) + float distanceToPrevious{}; // normalized [0, 1]: 0 = most similar, 1 = least similar (distance to preceding result) }; template diff --git a/src/libs/subsonic/impl/endpoints/Browsing.cpp b/src/libs/subsonic/impl/endpoints/Browsing.cpp index 0180043f..5092e535 100644 --- a/src/libs/subsonic/impl/endpoints/Browsing.cpp +++ b/src/libs/subsonic/impl/endpoints/Browsing.cpp @@ -696,7 +696,7 @@ namespace lms::api::subsonic if (track) { Response::Node& sonicMatchNode{ response.createArrayNode("sonicMatch") }; - sonicMatchNode.setAttribute("similarity", 1.0F - similarTrack.distance); + sonicMatchNode.setAttribute("similarity", 1.0F - similarTrack.distanceToFirst); sonicMatchNode.addChild("entry", createSongNode(context, track, context.getUser())); } } @@ -727,7 +727,7 @@ namespace lms::api::subsonic if (track) { Response::Node& sonicMatchNode{ response.createArrayNode("sonicMatch") }; - sonicMatchNode.setAttribute("similarity", 1.0F - pathTrack.distance); + sonicMatchNode.setAttribute("similarity", 1.0F - pathTrack.distanceToFirst); sonicMatchNode.addChild("entry", createSongNode(context, track, context.getUser())); } } diff --git a/src/tools/recommendation/LmsRecommendation.cpp b/src/tools/recommendation/LmsRecommendation.cpp index f24b7a42..825d8d34 100644 --- a/src/tools/recommendation/LmsRecommendation.cpp +++ b/src/tools/recommendation/LmsRecommendation.cpp @@ -86,7 +86,7 @@ namespace lms std::cout << "Processing track " << trackToString(trackId) << std::endl; for (const auto& similarTrack : recommendationService.findSimilarTracks(std::span{ &trackId, 1 }, maxCount)) - std::cout << "\t- " << similarTrack.distance << ", Similar track " << trackToString(similarTrack.id) << std::endl; + std::cout << "\t- " << "Similar track " << trackToString(similarTrack.id) << " (first: " << similarTrack.distanceToFirst << ", prev: " << similarTrack.distanceToPrevious << ")" << std::endl; } } @@ -128,7 +128,7 @@ namespace lms std::cout << "Processing release '" << releaseToString(releaseId) << "'" << std::endl; for (const auto& similarRelease : recommendationService.findSimilarReleases(releaseId, maxCount)) - std::cout << "\t- " << similarRelease.distance << ", Similar release " << releaseToString(similarRelease.id) << std::endl; + std::cout << "\t- " << "Similar release " << releaseToString(similarRelease.id) << " (first: " << similarRelease.distanceToFirst << ", prev: " << similarRelease.distanceToPrevious << ")" << std::endl; } } @@ -163,7 +163,7 @@ namespace lms std::cout << "Processing artist '" << artistToString(artistId) << "'" << std::endl; for (const auto& similarArtist : recommendationService.findSimilarArtists(artistId, { db::TrackArtistLinkType::Artist }, maxCount)) - std::cout << "\t- " << similarArtist.distance << ", Similar artist '" << artistToString(similarArtist.id) << "'" << std::endl; + std::cout << "\t- " << "Similar artist '" << artistToString(similarArtist.id) << "' (first: " << similarArtist.distanceToFirst << ", prev: " << similarArtist.distanceToPrevious << ")" << std::endl; } } @@ -229,7 +229,7 @@ namespace lms } for (const auto& similarTrack : similarTracks) - std::cout << "\t- " << similarTrack.distance << ", Similar track " << trackToString(similarTrack.id) << std::endl; + std::cout << "\t- " << "Similar track " << trackToString(similarTrack.id) << " (first: " << similarTrack.distanceToFirst << ", prev: " << similarTrack.distanceToPrevious << ")" << std::endl; } } @@ -290,7 +290,7 @@ namespace lms } for (const auto& similarRelease : similarReleases) - std::cout << "\t- " << similarRelease.distance << ", Similar release " << releaseToString(similarRelease.id) << std::endl; + std::cout << "\t- " << "Similar release " << releaseToString(similarRelease.id) << " (first: " << similarRelease.distanceToFirst << ", prev: " << similarRelease.distanceToPrevious << ")" << std::endl; } } @@ -345,7 +345,7 @@ namespace lms } for (const auto& similarArtist : similarArtists) - std::cout << "\t- " << similarArtist.distance << ", Similar artist '" << artistToString(similarArtist.id) << "'" << std::endl; + std::cout << "\t- " << "Similar artist '" << artistToString(similarArtist.id) << "' (first: " << similarArtist.distanceToFirst << ", prev: " << similarArtist.distanceToPrevious << ")" << std::endl; } } @@ -436,7 +436,7 @@ namespace lms for (std::size_t i{}; i < path.size(); ++i) { const auto& result{ path[i] }; - std::cout << "\t" << (i + 1) << ". " << trackToString(result.id) << " (distance: " << result.distance << ")" << std::endl; + std::cout << "\t" << (i + 1) << ". " << trackToString(result.id) << " (first: " << result.distanceToFirst << ", prev: " << result.distanceToPrevious << ")" << std::endl; } } } From 986a6e2580b3d53a86458a38800c0b66414b85fa Mon Sep 17 00:00:00 2001 From: emeric Date: Thu, 11 Jun 2026 20:58:52 +0200 Subject: [PATCH 08/38] Fixed release only artists not picked up during search3 calls, fixes #852 --- src/libs/database/impl/objects/Artist.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/libs/database/impl/objects/Artist.cpp b/src/libs/database/impl/objects/Artist.cpp index 781d17e0..6e7cf657 100644 --- a/src/libs/database/impl/objects/Artist.cpp +++ b/src/libs/database/impl/objects/Artist.cpp @@ -240,7 +240,11 @@ namespace lms::db if (library.isValid()) { // Faster than using joins - query.where("EXISTS (SELECT 1 FROM track_artist_link t_a_l JOIN track t ON t.id = t_a_l.track_id WHERE t_a_l.artist_id = a.id AND t.media_library_id = ?)").bind(library); + query.where( + "EXISTS (SELECT 1 FROM track_artist_link t_a_l JOIN track t ON t.id = t_a_l.track_id WHERE t_a_l.artist_id = a.id AND t.media_library_id = ?)" + " OR EXISTS (SELECT 1 FROM release_artist_link r_a_l JOIN release r ON r.id = r_a_l.release_id JOIN track t ON t.release_id = r.id WHERE r_a_l.artist_id = a.id AND t.media_library_id = ?)") + .bind(library) + .bind(library); } utils::forEachQueryResult(query, [&](const Artist::pointer& artist) { From 6825f6130de7812867b0ceaaa99f0dc3f8112fbe Mon Sep 17 00:00:00 2001 From: emeric Date: Thu, 11 Jun 2026 21:25:56 +0200 Subject: [PATCH 09/38] Fixed crash where too many symlinks are hit, fixes #853 --- .../scanner/impl/steps/ScanStepScanFiles.cpp | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/src/libs/services/scanner/impl/steps/ScanStepScanFiles.cpp b/src/libs/services/scanner/impl/steps/ScanStepScanFiles.cpp index 637fe694..7dd0837e 100644 --- a/src/libs/services/scanner/impl/steps/ScanStepScanFiles.cpp +++ b/src/libs/services/scanner/impl/steps/ScanStepScanFiles.cpp @@ -67,25 +67,35 @@ namespace lms::scanner { continueExploring = cb(ec, path, nullptr); } - else if (entry.is_regular_file()) + else { - if (shouldIgnore(path, IgnoreRules::IsDirectory{ false })) + const bool isFile{ entry.is_regular_file(ec) }; + const bool isDir{ !ec && !isFile && entry.is_directory(ec) }; + + if (ec) { - LMS_LOG(DBUPDATER, DEBUG, "Ignoring file " << path << " (matched .lmsignore rule)"); - itPath.increment(ec); - continue; + continueExploring = cb(ec, path, nullptr); } - continueExploring = cb(ec, path, &entry); - } - else if (entry.is_directory()) - { - if (shouldIgnore(path, IgnoreRules::IsDirectory{ true })) + else if (isFile) { - LMS_LOG(DBUPDATER, DEBUG, "Ignoring directory " << path << " (matched .lmsignore rule)"); - itPath.increment(ec); - continue; + if (shouldIgnore(path, IgnoreRules::IsDirectory{ false })) + { + LMS_LOG(DBUPDATER, DEBUG, "Ignoring file " << path << " (matched .lmsignore rule)"); + itPath.increment(ec); + continue; + } + continueExploring = cb(ec, path, &entry); + } + else if (isDir) + { + if (shouldIgnore(path, IgnoreRules::IsDirectory{ true })) + { + LMS_LOG(DBUPDATER, DEBUG, "Ignoring directory " << path << " (matched .lmsignore rule)"); + itPath.increment(ec); + continue; + } + continueExploring = exploreFilesRecursive(path, cb, shouldIgnore); } - continueExploring = exploreFilesRecursive(path, cb, shouldIgnore); } if (!continueExploring) From 309c39a572dbb83688a28c09a7e60961a2008891 Mon Sep 17 00:00:00 2001 From: emeric Date: Thu, 11 Jun 2026 22:09:32 +0200 Subject: [PATCH 10/38] Audio similarity engine: do not treat silent songs as errors --- .../musicnn/MusicNNEmbeddingProvider.cpp | 6 ++-- .../musicnn/MusicNNEmbeddingProvider.hpp | 1 - .../ScanStepExtractMusicNNEmbeddings.cpp | 29 +++++++++++++++---- 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/src/libs/services/recommendation/impl/audio-similarity/musicnn/MusicNNEmbeddingProvider.cpp b/src/libs/services/recommendation/impl/audio-similarity/musicnn/MusicNNEmbeddingProvider.cpp index a817417f..6c846e71 100644 --- a/src/libs/services/recommendation/impl/audio-similarity/musicnn/MusicNNEmbeddingProvider.cpp +++ b/src/libs/services/recommendation/impl/audio-similarity/musicnn/MusicNNEmbeddingProvider.cpp @@ -48,10 +48,10 @@ namespace lms::recommendation session.checkReadTransaction(); const db::TrackMusicNNEmbeddings::pointer embeddings{ db::TrackMusicNNEmbeddings::find(session, trackId) }; - if (embeddings) + if (embeddings && !embeddings->getData().empty()) readEmbeddings(embeddings, vec); - return embeddings; + return embeddings && !embeddings->getData().empty(); } void MusicNNEmbeddingProvider::visitVectors(db::Session& session, const std::function& visitor) @@ -60,6 +60,8 @@ namespace lms::recommendation Vector vec; db::TrackMusicNNEmbeddings::find(session, [&](const db::TrackMusicNNEmbeddings::pointer& embeddings) { + if (embeddings->getData().empty()) + return; readEmbeddings(embeddings, vec); visitor(embeddings->getTrackId(), vec); }); diff --git a/src/libs/services/recommendation/impl/audio-similarity/musicnn/MusicNNEmbeddingProvider.hpp b/src/libs/services/recommendation/impl/audio-similarity/musicnn/MusicNNEmbeddingProvider.hpp index 33d6d521..cc355603 100644 --- a/src/libs/services/recommendation/impl/audio-similarity/musicnn/MusicNNEmbeddingProvider.hpp +++ b/src/libs/services/recommendation/impl/audio-similarity/musicnn/MusicNNEmbeddingProvider.hpp @@ -40,7 +40,6 @@ namespace lms::recommendation static constexpr std::size_t DimCount{ 200 }; using Vector = math::Vector; - static std::size_t getCount(db::Session& session); static bool getVector(db::Session& session, db::TrackId trackId, Vector& vec); static void visitVectors(db::Session& session, const std::function& visitor); }; diff --git a/src/libs/services/scanner/impl/steps/ScanStepExtractMusicNNEmbeddings.cpp b/src/libs/services/scanner/impl/steps/ScanStepExtractMusicNNEmbeddings.cpp index e4499be4..1b55561d 100644 --- a/src/libs/services/scanner/impl/steps/ScanStepExtractMusicNNEmbeddings.cpp +++ b/src/libs/services/scanner/impl/steps/ScanStepExtractMusicNNEmbeddings.cpp @@ -92,6 +92,7 @@ namespace lms::scanner ExtractMusicNNEmbeddingsJob& operator=(const ExtractMusicNNEmbeddingsJob&) = delete; const TrackLocation& getTrackLocation() const { return _trackLocation; } + bool isSuccess() const { return _success; } const audio::TrackMusicNNEmbeddings* getEmbeddings() const { return _embeddings ? &_embeddings.value() : nullptr; } std::string_view getErrorMessage() const { return _errorMessage; } @@ -107,6 +108,8 @@ namespace lms::scanner if (result.patchCount > 0) _embeddings.emplace(result.embeddings); LMS_LOG(DBUPDATER, DEBUG, "MusicNN extraction complete for " << _trackLocation.trackPath << " (" << result.patchCount << " patches)"); + + _success = true; } catch (const audio::Exception& e) { @@ -116,6 +119,7 @@ namespace lms::scanner const audio::IMusicNNEmbeddingExtractor& _extractor; const TrackLocation _trackLocation; + bool _success{}; std::optional _embeddings; std::string _errorMessage; }; @@ -125,10 +129,13 @@ namespace lms::scanner db::Track::pointer track{ db::Track::find(session, assoc.trackId) }; assert(track); - std::vector blob(sizeof(audio::TrackMusicNNEmbeddings)); - audio::trackMusicNNEmbeddingsToBlob(*assoc.embeddings, blob); db::TrackMusicNNEmbeddings::pointer entry{ session.create(track) }; - entry.modify()->setData(blob); + if (assoc.embeddings) + { + std::vector blob(sizeof(audio::TrackMusicNNEmbeddings)); + audio::trackMusicNNEmbeddingsToBlob(*assoc.embeddings, blob); + entry.modify()->setData(blob); + } } void writeEmbeddings(ScanContext& context, db::Session& session, TrackEmbeddingAssociationContainer& pendingAssocs, bool forceFullBatch) @@ -202,10 +209,22 @@ namespace lms::scanner { const auto& extractJob{ static_cast(*job) }; - if (const audio::TrackMusicNNEmbeddings * embeddings{ extractJob.getEmbeddings() }) - pendingAssocs.push_back(TrackEmbeddingAssociation{ .trackId = extractJob.getTrackLocation().track, .embeddings = *embeddings }); + if (extractJob.isSuccess()) + { + if (const audio::TrackMusicNNEmbeddings * embeddings{ extractJob.getEmbeddings() }) + { + pendingAssocs.push_back(TrackEmbeddingAssociation{ .trackId = extractJob.getTrackLocation().track, .embeddings = *embeddings }); + } + else + { + LMS_LOG(DBUPDATER, INFO, "No patch extracted from " << extractJob.getTrackLocation().trackPath); + pendingAssocs.push_back(TrackEmbeddingAssociation{ .trackId = extractJob.getTrackLocation().track, .embeddings = std::nullopt }); + } + } else + { addError(context, extractJob.getTrackLocation().trackPath, extractJob.getErrorMessage()); + } } context.currentStepStats.processedElems += jobs.size(); From af018a91667b5d0e42773dc0396245f33956a420 Mon Sep 17 00:00:00 2001 From: emeric Date: Sat, 13 Jun 2026 16:54:21 +0200 Subject: [PATCH 11/38] Replaced UUID storage from str to array of bytes --- src/libs/core/bench/CMakeLists.txt | 1 + src/libs/core/bench/UUIDBench.cpp | 55 +++++++++++ src/libs/core/impl/UUID.cpp | 99 +++++++++++-------- src/libs/core/include/core/UUID.hpp | 22 +++-- src/libs/core/test/UUID.cpp | 65 +++++++++++- src/libs/database/impl/Migration.cpp | 41 +++++++- src/libs/database/impl/Session.cpp | 1 + src/libs/database/impl/objects/Artist.cpp | 16 +-- src/libs/database/impl/objects/ArtistInfo.cpp | 6 +- src/libs/database/impl/objects/Release.cpp | 7 +- .../impl/objects/ReleaseArtistLink.cpp | 6 +- src/libs/database/impl/objects/Track.cpp | 7 +- .../database/impl/objects/TrackArtistLink.cpp | 6 +- src/libs/database/impl/traits/UUIDTraits.hpp | 64 ++++++++++++ .../include/database/objects/Artist.hpp | 8 +- .../include/database/objects/Release.hpp | 12 +-- .../include/database/objects/Track.hpp | 12 +-- src/libs/database/test/Migration.cpp | 22 ++++- .../impl/listenbrainz/FeedbackTypes.cpp | 2 +- .../listenbrainz/FeedbacksSynchronizer.cpp | 2 +- .../services/podcast/impl/steps/Utils.cpp | 2 +- .../AudioSimilarityEngine.impl.hpp | 2 +- .../impl/clusters/ClustersEngine.cpp | 2 +- .../steps/ScanStepArtistReconciliation.cpp | 2 +- .../steps/ScanStepAssociateArtistImages.cpp | 2 +- .../steps/ScanStepAssociateReleaseImages.cpp | 2 +- .../steps/ScanStepCheckForDuplicatedFiles.cpp | 2 +- .../impl/listenbrainz/ListenTypes.cpp | 4 +- .../impl/listenbrainz/ListensSynchronizer.cpp | 10 +- .../services/scrobbling/test/Listenbrainz.cpp | 12 +-- src/libs/subsonic/impl/endpoints/Browsing.cpp | 4 +- .../subsonic/impl/endpoints/Transcoding.cpp | 2 +- src/libs/subsonic/impl/responses/Album.cpp | 2 +- .../subsonic/impl/responses/AlbumInfo.cpp | 4 +- src/libs/subsonic/impl/responses/Artist.cpp | 2 +- src/libs/subsonic/impl/responses/Song.cpp | 2 +- src/lms/ui/Utils.cpp | 4 +- src/lms/ui/admin/ScannerReportResource.cpp | 2 +- src/lms/ui/admin/UserView.cpp | 2 +- src/lms/ui/explore/ArtistView.cpp | 2 +- src/lms/ui/explore/ReleaseView.cpp | 2 +- src/lms/ui/settings/SubsonicSettingsView.cpp | 2 +- src/tools/db-generator/LmsDbGenerator.cpp | 8 +- 43 files changed, 392 insertions(+), 140 deletions(-) create mode 100644 src/libs/core/bench/UUIDBench.cpp create mode 100644 src/libs/database/impl/traits/UUIDTraits.hpp diff --git a/src/libs/core/bench/CMakeLists.txt b/src/libs/core/bench/CMakeLists.txt index 4ab4c319..7c07b875 100644 --- a/src/libs/core/bench/CMakeLists.txt +++ b/src/libs/core/bench/CMakeLists.txt @@ -2,6 +2,7 @@ add_executable(bench-core Core.cpp TraceLoggerBench.cpp + UUIDBench.cpp ) target_link_libraries(bench-core PRIVATE diff --git a/src/libs/core/bench/UUIDBench.cpp b/src/libs/core/bench/UUIDBench.cpp new file mode 100644 index 00000000..1f22f93b --- /dev/null +++ b/src/libs/core/bench/UUIDBench.cpp @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2026 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 "core/UUID.hpp" + +namespace lms::core::benchs +{ + static void BM_UUID_fromString(benchmark::State& state) + { + for (auto _ : state) + benchmark::DoNotOptimize(UUID::fromString("3f51c839-bee2-4e9d-a7b7-0693e45178fc")); + } + + static void BM_UUID_fromString_invalid(benchmark::State& state) + { + for (auto _ : state) + benchmark::DoNotOptimize(UUID::fromString("not-a-valid-uuid-string-at-all-xx")); + } + + static void BM_UUID_toString(benchmark::State& state) + { + const UUID uuid{ *UUID::fromString("3f51c839-bee2-4e9d-a7b7-0693e45178fc") }; + for (auto _ : state) + benchmark::DoNotOptimize(uuid.toString()); + } + + static void BM_UUID_generate(benchmark::State& state) + { + for (auto _ : state) + benchmark::DoNotOptimize(UUID::generate()); + } + + BENCHMARK(BM_UUID_fromString); + BENCHMARK(BM_UUID_fromString_invalid); + BENCHMARK(BM_UUID_toString); + BENCHMARK(BM_UUID_generate); +} // namespace lms::core::benchs diff --git a/src/libs/core/impl/UUID.cpp b/src/libs/core/impl/UUID.cpp index 756bcb1a..aafe5e4a 100644 --- a/src/libs/core/impl/UUID.cpp +++ b/src/libs/core/impl/UUID.cpp @@ -19,72 +19,93 @@ #include "core/UUID.hpp" -#include -#include -#include -#include +#include +#include +#include +#include #include "core/Random.hpp" -#include "core/String.hpp" namespace lms::core { namespace stringUtils { template<> - std::optional - readAs(std::string_view str) + std::optional readAs(std::string_view str) { return UUID::fromString(str); } } // namespace stringUtils + namespace { - bool stringIsUUID(std::string_view str) - { - static const std::regex re{ R"([0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12})" }; + // Each entry is the str index of the high hex char for that UUID byte + constexpr std::array byteOffsets{ + 0, 2, 4, 6, // group 1 (4 bytes, positions 0-7) + 9, 11, // group 2 (2 bytes, positions 9-12) + 14, 16, // group 3 (2 bytes, positions 14-17) + 19, 21, // group 4 (2 bytes, positions 19-22) + 24, 26, 28, 30, 32, 34 // group 5 (6 bytes, positions 24-35) + }; - return std::regex_match(std::cbegin(str), std::cend(str), re); + bool parseUUID(std::string_view str, std::array& out) + { + if (str.size() != 36 || str[8] != '-' || str[13] != '-' || str[18] != '-' || str[23] != '-') + return false; + + for (std::size_t i{}; i < 16; ++i) + { + unsigned int byte{}; + const char* begin{ str.data() + byteOffsets[i] }; + const auto [ptr, ec]{ std::from_chars(begin, begin + 2, byte, 16) }; + if (ec != std::errc{} || ptr != begin + 2) + return false; + out[i] = static_cast(byte); + } + return true; } } // namespace - UUID::UUID(std::string_view str) - : _value{ stringUtils::stringToLower(str) } + UUID::UUID(std::array bytes) noexcept + : _bytes{ bytes } { } std::optional UUID::fromString(std::string_view str) { - if (!stringIsUUID(str)) + std::array bytes{}; + if (!parseUUID(str, bytes)) return std::nullopt; + return UUID{ bytes }; + } - return UUID{ str }; + UUID UUID::fromBytes(std::span bytes) noexcept + { + std::array arr{}; + std::copy(bytes.begin(), bytes.end(), arr.begin()); + return UUID{ arr }; + } + + std::string UUID::toString() const + { + static constexpr char hex[]{ "0123456789abcdef" }; + std::string s(36, '-'); + for (std::size_t i{}; i < 16; ++i) + { + const auto b{ std::to_integer(_bytes[i]) }; + s[byteOffsets[i]] = hex[b >> 4]; + s[byteOffsets[i] + 1] = hex[b & 0x0F]; + } + return s; } UUID UUID::generate() { - // Form is "123e4567-e89b-12d3-a456-426614174000" - // TODO: store 128 bits and only convert to string when necessary - - std::ostringstream oss; - - auto concatRandomBytes{ [](std::ostream& os, std::size_t byteCount) { - for (std::size_t i{}; i < byteCount; ++i) - os << std::hex << std::setfill('0') << std::setw(2) << static_cast(random::getRandom(0, 255)); - } }; - - concatRandomBytes(oss, 4); - oss << "-"; - concatRandomBytes(oss, 2); - oss << "-"; - concatRandomBytes(oss, 2); - oss << "-"; - concatRandomBytes(oss, 2); - oss << "-"; - concatRandomBytes(oss, 6); - - const auto uuid{ fromString(oss.str()) }; - assert(uuid); - return uuid.value(); + std::uniform_int_distribution dist{ 0, 255 }; + auto& rng{ random::getRandGenerator() }; + std::array bytes{}; + for (auto& b : bytes) + b = static_cast(dist(rng)); + return UUID{ bytes }; } -} // namespace lms::core \ No newline at end of file +} // namespace lms::core diff --git a/src/libs/core/include/core/UUID.hpp b/src/libs/core/include/core/UUID.hpp index 422bba75..c7367ebf 100644 --- a/src/libs/core/include/core/UUID.hpp +++ b/src/libs/core/include/core/UUID.hpp @@ -19,8 +19,11 @@ #pragma once +#include +#include #include #include +#include #include #include @@ -31,24 +34,28 @@ namespace lms::core class UUID { public: + static constexpr std::size_t binarySize{ 16 }; + + UUID() noexcept = default; static std::optional fromString(std::string_view str); + static UUID fromBytes(std::span bytes) noexcept; static UUID generate(); - std::string_view getAsString() const { return _value; } + std::string toString() const; + std::span bytes() const noexcept { return _bytes; } auto operator<=>(const UUID&) const = default; private: - UUID(std::string_view value); - std::string _value; + explicit UUID(std::array bytes) noexcept; + std::array _bytes{}; }; } // namespace lms::core namespace lms::core::stringUtils { template<> - std::optional - readAs(std::string_view str); + std::optional readAs(std::string_view str); } namespace std @@ -56,9 +63,10 @@ namespace std template<> struct hash { - size_t operator()(const lms::core::UUID& str) const + size_t operator()(const lms::core::UUID& uuid) const noexcept { - return hash{}(str.getAsString()); + const auto& b{ uuid.bytes() }; + return hash{}({ static_cast(static_cast(b.data())), b.size() }); } }; } // namespace std diff --git a/src/libs/core/test/UUID.cpp b/src/libs/core/test/UUID.cpp index 9dad6f09..18d8b4d6 100644 --- a/src/libs/core/test/UUID.cpp +++ b/src/libs/core/test/UUID.cpp @@ -17,10 +17,7 @@ * along with LMS. If not, see . */ -#include -#include -#include -#include +#include #include @@ -28,6 +25,19 @@ namespace lms::core { + TEST(UUID, fromString_invalid) + { + EXPECT_FALSE(UUID::fromString("")); + EXPECT_FALSE(UUID::fromString("not-a-uuid")); + EXPECT_FALSE(UUID::fromString("3f51c839-bee2-4e9d-a7b7-0693e45178f")); // too short + EXPECT_FALSE(UUID::fromString("3f51c839-bee2-4e9d-a7b7-0693e45178fcc")); // too long + EXPECT_FALSE(UUID::fromString("3f51c839-bee2-4e9d-a7b7_0693e45178fc")); // wrong separator position 23 + EXPECT_FALSE(UUID::fromString("3f51c839-bee2-4e9d-a7b7-0693e45178gz")); // invalid hex char 'g','z' + EXPECT_FALSE(UUID::fromString("3f51c839Xbee2-4e9d-a7b7-0693e45178fc")); // dash replaced at position 8 + EXPECT_FALSE(UUID::fromString("3f51c839-bee2X4e9d-a7b7-0693e45178fc")); // dash replaced at position 13 + EXPECT_FALSE(UUID::fromString("3f51c839-bee2-4e9dXa7b7-0693e45178fc")); // dash replaced at position 18 + } + TEST(UUID, caseInsensitive) { const std::optional uuid1{ UUID::fromString("3f51c839-bee2-4e9d-a7b7-0693e45178fc") }; @@ -37,4 +47,51 @@ namespace lms::core EXPECT_TRUE(uuid1 >= uuid2); EXPECT_TRUE(uuid1 <= uuid2); } + + TEST(UUID, toString_roundTrip) + { + const std::string str{ "3f51c839-bee2-4e9d-a7b7-0693e45178fc" }; + const std::optional uuid{ UUID::fromString(str) }; + + ASSERT_TRUE(uuid); + EXPECT_EQ(uuid->toString(), str); + } + + TEST(UUID, toString_lowercase) + { + const std::optional uuid{ UUID::fromString("3F51C839-BEE2-4E9D-A7B7-0693E45178FC") }; + + ASSERT_TRUE(uuid); + EXPECT_EQ(uuid->toString(), "3f51c839-bee2-4e9d-a7b7-0693e45178fc"); + } + + TEST(UUID, fromBytes_roundTrip) + { + const std::optional uuid{ UUID::fromString("550e8400-e29b-41d4-a716-446655440000") }; + ASSERT_TRUE(uuid); + + const UUID fromB{ UUID::fromBytes(uuid->bytes()) }; + EXPECT_EQ(fromB, *uuid); + EXPECT_EQ(fromB.toString(), "550e8400-e29b-41d4-a716-446655440000"); + } + + TEST(UUID, bytes_size) + { + const std::optional uuid{ UUID::fromString("3f51c839-bee2-4e9d-a7b7-0693e45178fc") }; + ASSERT_TRUE(uuid); + EXPECT_EQ(uuid->bytes().size(), 16U); + } + + TEST(UUID, generate_validString) + { + const UUID uuid{ UUID::generate() }; + const std::string s{ uuid.toString() }; + + ASSERT_EQ(s.size(), 36U); + EXPECT_EQ(s[8], '-'); + EXPECT_EQ(s[13], '-'); + EXPECT_EQ(s[18], '-'); + EXPECT_EQ(s[23], '-'); + EXPECT_TRUE(UUID::fromString(s).has_value()); + } } // namespace lms::core \ No newline at end of file diff --git a/src/libs/database/impl/Migration.cpp b/src/libs/database/impl/Migration.cpp index e7b2e54a..93e4f5f8 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{ 106 }; + static constexpr Version LMS_DATABASE_VERSION{ 107 }; } VersionInfo::VersionInfo() @@ -1736,6 +1736,44 @@ FROM track)"); utils::executeCommand(*session.getDboSession(), R"(ALTER TABLE "user" ADD COLUMN "lastfm_session_key" TEXT NOT NULL DEFAULT '')"); } + void migrateFromV106(Session& session) + { + dropIndexes(session); + + // Convert the 5 MBID TEXT columns to BLOB (16 raw bytes) + // unhex() returns NULL for non-hex input, so malformed values become NULL + + // artist.mbid + utils::executeCommand(*session.getDboSession(), R"(ALTER TABLE artist ADD COLUMN mbid_new BLOB)"); + utils::executeCommand(*session.getDboSession(), R"(UPDATE artist SET mbid_new = CASE WHEN mbid != '' THEN unhex(replace(mbid, '-', '')) ELSE NULL END)"); + utils::executeCommand(*session.getDboSession(), R"(ALTER TABLE artist DROP COLUMN mbid)"); + utils::executeCommand(*session.getDboSession(), R"(ALTER TABLE artist RENAME COLUMN mbid_new TO mbid)"); + + // release.mbid + utils::executeCommand(*session.getDboSession(), R"(ALTER TABLE release ADD COLUMN mbid_new BLOB)"); + utils::executeCommand(*session.getDboSession(), R"(UPDATE release SET mbid_new = CASE WHEN mbid != '' THEN unhex(replace(mbid, '-', '')) ELSE NULL END)"); + utils::executeCommand(*session.getDboSession(), R"(ALTER TABLE release DROP COLUMN mbid)"); + utils::executeCommand(*session.getDboSession(), R"(ALTER TABLE release RENAME COLUMN mbid_new TO mbid)"); + + // release.group_mbid + utils::executeCommand(*session.getDboSession(), R"(ALTER TABLE release ADD COLUMN group_mbid_new BLOB)"); + utils::executeCommand(*session.getDboSession(), R"(UPDATE release SET group_mbid_new = CASE WHEN group_mbid != '' THEN unhex(replace(group_mbid, '-', '')) ELSE NULL END)"); + utils::executeCommand(*session.getDboSession(), R"(ALTER TABLE release DROP COLUMN group_mbid)"); + utils::executeCommand(*session.getDboSession(), R"(ALTER TABLE release RENAME COLUMN group_mbid_new TO group_mbid)"); + + // track.mbid + utils::executeCommand(*session.getDboSession(), R"(ALTER TABLE track ADD COLUMN mbid_new BLOB)"); + utils::executeCommand(*session.getDboSession(), R"(UPDATE track SET mbid_new = CASE WHEN mbid != '' THEN unhex(replace(mbid, '-', '')) ELSE NULL END)"); + utils::executeCommand(*session.getDboSession(), R"(ALTER TABLE track DROP COLUMN mbid)"); + utils::executeCommand(*session.getDboSession(), R"(ALTER TABLE track RENAME COLUMN mbid_new TO mbid)"); + + // track.recording_mbid + utils::executeCommand(*session.getDboSession(), R"(ALTER TABLE track ADD COLUMN recording_mbid_new BLOB)"); + utils::executeCommand(*session.getDboSession(), R"(UPDATE track SET recording_mbid_new = CASE WHEN recording_mbid != '' THEN unhex(replace(recording_mbid, '-', '')) ELSE NULL END)"); + utils::executeCommand(*session.getDboSession(), R"(ALTER TABLE track DROP COLUMN recording_mbid)"); + utils::executeCommand(*session.getDboSession(), R"(ALTER TABLE track RENAME COLUMN recording_mbid_new TO recording_mbid)"); + } + bool doDbMigration(Session& session) { constexpr std::string_view outdatedMsg{ "Outdated database, please rebuild it (delete the .db file and restart)" }; @@ -1818,6 +1856,7 @@ FROM track)"); { 103, migrateFromV103 }, { 104, migrateFromV104 }, { 105, migrateFromV105 }, + { 106, migrateFromV106 }, }; bool migrationPerformed{}; diff --git a/src/libs/database/impl/Session.cpp b/src/libs/database/impl/Session.cpp index f1c0371f..df51253e 100644 --- a/src/libs/database/impl/Session.cpp +++ b/src/libs/database/impl/Session.cpp @@ -66,6 +66,7 @@ #include "traits/ImageHashTypeTraits.hpp" #include "traits/PartialDateTimeTraits.hpp" #include "traits/PathTraits.hpp" +#include "traits/UUIDTraits.hpp" namespace lms::db { diff --git a/src/libs/database/impl/objects/Artist.cpp b/src/libs/database/impl/objects/Artist.cpp index 6e7cf657..d064e7fc 100644 --- a/src/libs/database/impl/objects/Artist.cpp +++ b/src/libs/database/impl/objects/Artist.cpp @@ -35,6 +35,7 @@ #include "objects/detail/Types.hpp" #include "traits/IdTypeTraits.hpp" #include "traits/StringViewTraits.hpp" +#include "traits/UUIDTraits.hpp" DBO_INSTANTIATE_TEMPLATES(lms::db::Artist) @@ -214,7 +215,7 @@ namespace lms::db } // namespace Artist::Artist(const std::string& name, const std::optional& mbid) - : _mbid{ mbid ? mbid->getAsString() : "" } + : _mbid{ mbid } { setName(name); } @@ -277,7 +278,7 @@ namespace lms::db Artist::pointer Artist::find(Session& session, const core::UUID& mbid) { session.checkReadTransaction(); - return utils::fetchQuerySingleResult(session.getDboSession()->query>("SELECT a FROM artist a").where("a.mbid = ?").bind(std::string{ mbid.getAsString() })); + return utils::fetchQuerySingleResult(session.getDboSession()->query>("SELECT a FROM artist a").where("a.mbid = ?").bind(mbid)); } Artist::pointer Artist::find(Session& session, ArtistId id) @@ -386,17 +387,6 @@ AND NOT EXISTS ( utils::executeCommand(*session.getDboSession(), "UPDATE artist SET preferred_artwork_id = NULL WHERE id = ?", artistId); } - std::optional Artist::getMBID() const - { - return core::UUID::fromString(_mbid); - } - - bool Artist::hasMBID() const - { - // TODO optim this - return getMBID().has_value(); - } - ObjectPtr Artist::getPreferredArtwork() const { return ObjectPtr{ _preferredArtwork }; diff --git a/src/libs/database/impl/objects/ArtistInfo.cpp b/src/libs/database/impl/objects/ArtistInfo.cpp index b7acd5d4..736acbb2 100644 --- a/src/libs/database/impl/objects/ArtistInfo.cpp +++ b/src/libs/database/impl/objects/ArtistInfo.cpp @@ -112,13 +112,13 @@ namespace lms::db query.where("a_i.mbid_matched = FALSE"); if (!allowArtistMBIDFallback) { - query.where("a.mbid <> ''"); + query.where("a.mbid IS NOT NULL"); } else { query.where(R"( - (a.mbid <> '' AND EXISTS (SELECT 1 FROM artist a2 WHERE a2.name = a.name AND a2.mbid <> '' AND a2.mbid <> a.mbid)) - OR (a.mbid = '' AND (SELECT COUNT(*) FROM artist a2 WHERE a2.name = a.name AND a2.mbid <> '') = 1))"); + (a.mbid IS NOT NULL AND EXISTS (SELECT 1 FROM artist a2 WHERE a2.name = a.name AND a2.mbid IS NOT NULL AND a2.mbid <> a.mbid)) + OR (a.mbid IS NULL AND (SELECT COUNT(*) FROM artist a2 WHERE a2.name = a.name AND a2.mbid IS NOT NULL) = 1))"); } utils::applyRange(query, range); diff --git a/src/libs/database/impl/objects/Release.cpp b/src/libs/database/impl/objects/Release.cpp index 1747fe75..8117f978 100644 --- a/src/libs/database/impl/objects/Release.cpp +++ b/src/libs/database/impl/objects/Release.cpp @@ -46,6 +46,7 @@ #include "traits/IdTypeTraits.hpp" #include "traits/PartialDateTimeTraits.hpp" #include "traits/StringViewTraits.hpp" +#include "traits/UUIDTraits.hpp" DBO_INSTANTIATE_TEMPLATES(lms::db::Country) DBO_INSTANTIATE_TEMPLATES(lms::db::Label) @@ -218,7 +219,7 @@ namespace lms::db query.where("t.codec = ?").bind(detail::getDbCodec(params.filters.codec.value())); if (params.releaseGroupMBID) - query.where("group_mbid = ?").bind(params.releaseGroupMBID->getAsString()); + query.where("group_mbid = ?").bind(*params.releaseGroupMBID); switch (params.sortMethod) { @@ -454,7 +455,7 @@ namespace lms::db Release::Release(const std::string& name, const std::optional& MBID) : _name{ std::string(name, 0, _maxNameLength) } - , _MBID{ MBID ? MBID->getAsString() : "" } + , _MBID{ MBID } { } @@ -467,7 +468,7 @@ namespace lms::db { session.checkReadTransaction(); - return utils::fetchQuerySingleResult(session.getDboSession()->query>("SELECT r from release r").where("r.mbid = ?").bind(mbid.getAsString())); + return utils::fetchQuerySingleResult(session.getDboSession()->query>("SELECT r from release r").where("r.mbid = ?").bind(mbid)); } Release::pointer Release::find(Session& session, ReleaseId id) diff --git a/src/libs/database/impl/objects/ReleaseArtistLink.cpp b/src/libs/database/impl/objects/ReleaseArtistLink.cpp index 6c508c4c..c6eeeb7a 100644 --- a/src/libs/database/impl/objects/ReleaseArtistLink.cpp +++ b/src/libs/database/impl/objects/ReleaseArtistLink.cpp @@ -123,13 +123,13 @@ namespace lms::db query.where("r_a_l.artist_mbid_matched = FALSE"); if (!allowArtistMBIDFallback) { - query.where("a.mbid <> ''"); + query.where("a.mbid IS NOT NULL"); } else { query.where(R"( - (a.mbid <> '' AND EXISTS (SELECT 1 FROM artist a2 WHERE a2.name = a.name AND a2.mbid <> '' AND a2.mbid <> a.mbid)) - OR (a.mbid = '' AND (SELECT COUNT(*) FROM artist a2 WHERE a2.name = a.name AND a2.mbid <> '') = 1))"); + (a.mbid IS NOT NULL AND EXISTS (SELECT 1 FROM artist a2 WHERE a2.name = a.name AND a2.mbid IS NOT NULL AND a2.mbid <> a.mbid)) + OR (a.mbid IS NULL AND (SELECT COUNT(*) FROM artist a2 WHERE a2.name = a.name AND a2.mbid IS NOT NULL) = 1))"); } utils::applyRange(query, range); diff --git a/src/libs/database/impl/objects/Track.cpp b/src/libs/database/impl/objects/Track.cpp index 00aa93d5..ff8ad52d 100644 --- a/src/libs/database/impl/objects/Track.cpp +++ b/src/libs/database/impl/objects/Track.cpp @@ -46,6 +46,7 @@ #include "traits/PartialDateTimeTraits.hpp" #include "traits/PathTraits.hpp" #include "traits/StringViewTraits.hpp" +#include "traits/UUIDTraits.hpp" DBO_INSTANTIATE_TEMPLATES(lms::db::Track) @@ -392,21 +393,21 @@ namespace lms::db { session.checkReadTransaction(); - return utils::fetchQueryResults(session.getDboSession()->query>("SELECT t from track t").where("t.mbid = ?").bind(mbid.getAsString())); + return utils::fetchQueryResults(session.getDboSession()->query>("SELECT t from track t").where("t.mbid = ?").bind(mbid)); } std::vector Track::findByRecordingMBID(Session& session, const core::UUID& mbid) { session.checkReadTransaction(); - return utils::fetchQueryResults(session.getDboSession()->query>("SELECT t from track t").where("t.recording_mbid = ?").bind(mbid.getAsString())); + return utils::fetchQueryResults(session.getDboSession()->query>("SELECT t from track t").where("t.recording_mbid = ?").bind(mbid)); } RangeResults Track::findIdsTrackMBIDDuplicates(Session& session, std::optional range) { session.checkReadTransaction(); - auto query{ session.getDboSession()->query("SELECT track.id FROM track WHERE mbid in (SELECT mbid FROM track WHERE mbid <> '' GROUP BY mbid HAVING COUNT (*) > 1)").orderBy("track.release_id,track.mbid") }; + auto query{ session.getDboSession()->query("SELECT track.id FROM track WHERE mbid in (SELECT mbid FROM track WHERE mbid IS NOT NULL GROUP BY mbid HAVING COUNT (*) > 1)").orderBy("track.release_id,track.mbid") }; return utils::execRangeQuery(query, range); } diff --git a/src/libs/database/impl/objects/TrackArtistLink.cpp b/src/libs/database/impl/objects/TrackArtistLink.cpp index 3366d6bc..8ad07317 100644 --- a/src/libs/database/impl/objects/TrackArtistLink.cpp +++ b/src/libs/database/impl/objects/TrackArtistLink.cpp @@ -165,13 +165,13 @@ namespace lms::db query.where("t_a_l.artist_mbid_matched = FALSE"); if (!allowArtistMBIDFallback) { - query.where("a.mbid <> ''"); + query.where("a.mbid IS NOT NULL"); } else { query.where(R"( - (a.mbid <> '' AND EXISTS (SELECT 1 FROM artist a2 WHERE a2.name = a.name AND a2.mbid <> '' AND a2.mbid <> a.mbid)) - OR (a.mbid = '' AND (SELECT COUNT(*) FROM artist a2 WHERE a2.name = a.name AND a2.mbid <> '') = 1))"); + (a.mbid IS NOT NULL AND EXISTS (SELECT 1 FROM artist a2 WHERE a2.name = a.name AND a2.mbid IS NOT NULL AND a2.mbid <> a.mbid)) + OR (a.mbid IS NULL AND (SELECT COUNT(*) FROM artist a2 WHERE a2.name = a.name AND a2.mbid IS NOT NULL) = 1))"); } utils::applyRange(query, range); diff --git a/src/libs/database/impl/traits/UUIDTraits.hpp b/src/libs/database/impl/traits/UUIDTraits.hpp new file mode 100644 index 00000000..25d7b510 --- /dev/null +++ b/src/libs/database/impl/traits/UUIDTraits.hpp @@ -0,0 +1,64 @@ +/* + * 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 "core/UUID.hpp" + +namespace Wt::Dbo +{ + template<> + struct sql_value_traits + { + static constexpr bool specialized{ true }; + using UnderlyingType = std::vector; + + static std::string type(SqlConnection* conn, int size) + { + return sql_value_traits::type(conn, size); + } + + static void bind(const lms::core::UUID& v, SqlStatement* statement, int column, int size) + { + constexpr auto binarySize{ lms::core::UUID::binarySize }; + const auto bytes{ v.bytes() }; + UnderlyingType blob(binarySize); + std::memcpy(blob.data(), bytes.data(), binarySize); + sql_value_traits::bind(blob, statement, column, size); + } + + static bool read(lms::core::UUID& v, SqlStatement* statement, int column, int size) + { + constexpr auto binarySize{ lms::core::UUID::binarySize }; + UnderlyingType buf; + if (!sql_value_traits::read(buf, statement, column, size) || buf.size() != binarySize) + return false; + + v = lms::core::UUID::fromBytes(std::span{ reinterpret_cast(buf.data()), binarySize }); + return true; + } + }; +} // namespace Wt::Dbo diff --git a/src/libs/database/include/database/objects/Artist.hpp b/src/libs/database/include/database/objects/Artist.hpp index 1d3adbda..8786c15f 100644 --- a/src/libs/database/include/database/objects/Artist.hpp +++ b/src/libs/database/include/database/objects/Artist.hpp @@ -141,8 +141,8 @@ namespace lms::db // Accessors const std::string& getName() const { return _name; } const std::string& getSortName() const { return _sortName; } - std::optional getMBID() const; - bool hasMBID() const; + std::optional getMBID() const { return _mbid; } + bool hasMBID() const { return _mbid.has_value(); } ObjectPtr getPreferredArtwork() const; ArtworkId getPreferredArtworkId() const; @@ -152,7 +152,7 @@ namespace lms::db std::vector>> getClusterGroups(std::span clusterTypeIds, std::size_t size) const; void setName(std::string_view name); - void setMBID(const std::optional& mbid) { _mbid = mbid ? mbid->getAsString() : ""; } + void setMBID(const std::optional& mbid) { _mbid = mbid; } void setSortName(std::string_view sortName); void setPreferredArtwork(ObjectPtr artwork); @@ -174,7 +174,7 @@ namespace lms::db std::string _name; std::string _sortName; - std::string _mbid; // Musicbrainz Identifier + std::optional _mbid; Wt::Dbo::ptr _preferredArtwork; }; diff --git a/src/libs/database/include/database/objects/Release.hpp b/src/libs/database/include/database/objects/Release.hpp index 53eec131..95a0d1ae 100644 --- a/src/libs/database/include/database/objects/Release.hpp +++ b/src/libs/database/include/database/objects/Release.hpp @@ -298,8 +298,8 @@ namespace lms::db // Accessors std::string_view getName() const { return _name; } std::string_view getSortName() const { return _sortName; } - std::optional getMBID() const { return core::UUID::fromString(_MBID); } - std::optional getGroupMBID() const { return core::UUID::fromString(_groupMBID); } + std::optional getMBID() const { return _MBID; } + std::optional getGroupMBID() const { return _groupMBID; } std::optional getTotalDisc() const { return _totalDisc; } // the number of discs this release should have if complete std::chrono::milliseconds getDuration() const; Wt::WDateTime getAddedTime() const; @@ -325,8 +325,8 @@ namespace lms::db // Setters void setName(std::string_view name) { _name = name; } void setSortName(std::string_view sortName) { _sortName = sortName; } - void setMBID(const std::optional& mbid) { _MBID = mbid ? mbid->getAsString() : ""; } - void setGroupMBID(const std::optional& mbid) { _groupMBID = mbid ? mbid->getAsString() : ""; } + void setMBID(const std::optional& mbid) { _MBID = mbid; } + void setGroupMBID(const std::optional& mbid) { _groupMBID = mbid; } void setTotalDisc(std::optional totalDisc) { _totalDisc = totalDisc; } void setArtistDisplayName(std::string_view name) { _artistDisplayName = name; } void clearArtistLinks(); @@ -382,8 +382,8 @@ namespace lms::db std::string _name; std::string _sortName; - std::string _MBID; - std::string _groupMBID; + std::optional _MBID; + std::optional _groupMBID; std::optional _totalDisc{}; std::string _artistDisplayName; bool _isCompilation{}; // See https://picard-docs.musicbrainz.org/en/appendices/tag_mapping.html#compilation-itunes-5 diff --git a/src/libs/database/include/database/objects/Track.hpp b/src/libs/database/include/database/objects/Track.hpp index cc06c373..9be95a2a 100644 --- a/src/libs/database/include/database/objects/Track.hpp +++ b/src/libs/database/include/database/objects/Track.hpp @@ -258,8 +258,8 @@ namespace lms::db void setName(std::string_view name); void setDate(const core::PartialDateTime& date) { _date = date; } void setOriginalDate(const core::PartialDateTime& date) { _originalDate = date; } - void setTrackMBID(const std::optional& MBID) { _trackMBID = MBID ? MBID->getAsString() : ""; } - void setRecordingMBID(const std::optional& MBID) { _recordingMBID = MBID ? MBID->getAsString() : ""; } + void setTrackMBID(const std::optional& MBID) { _trackMBID = MBID; } + void setRecordingMBID(const std::optional& MBID) { _recordingMBID = MBID; } void setCopyright(std::string_view copyright); void setCopyrightURL(std::string_view copyrightURL); void setAdvisory(Advisory advisory) { _advisory = advisory; } @@ -308,8 +308,8 @@ namespace lms::db std::optional getOriginalYear() const; const Wt::WDateTime& getLastWriteTime() const { return _fileLastWrite; } bool hasLyrics() const; - std::optional getTrackMBID() const { return core::UUID::fromString(_trackMBID); } - std::optional getRecordingMBID() const { return core::UUID::fromString(_recordingMBID); } + std::optional getTrackMBID() const { return _trackMBID; } + std::optional getRecordingMBID() const { return _recordingMBID; } std::string_view getCopyright() const; std::string_view getCopyrightURL() const; std::string_view getArtistDisplayName() const { return _artistDisplayName; } @@ -413,8 +413,8 @@ namespace lms::db std::string _name; core::PartialDateTime _date; core::PartialDateTime _originalDate; - std::string _trackMBID; - std::string _recordingMBID; + std::optional _trackMBID; + std::optional _recordingMBID; std::string _copyright; std::string _copyrightURL; std::string _artistDisplayName; diff --git a/src/libs/database/test/Migration.cpp b/src/libs/database/test/Migration.cpp index 522e9097..82c4b00c 100644 --- a/src/libs/database/test/Migration.cpp +++ b/src/libs/database/test/Migration.cpp @@ -20,6 +20,7 @@ #include "Common.hpp" #include "core/String.hpp" +#include "core/UUID.hpp" #include "database/objects/Artist.hpp" #include "database/objects/ArtistInfo.hpp" @@ -289,14 +290,14 @@ CREATE INDEX starred_release_user_scrobbler_idx ON starred_release(user_id,scrob CREATE INDEX starred_track_user_scrobbler_idx ON starred_track(user_id,scrobbler);)" }; const std::string_view createDummyData{ R"( --- Inserting artists +-- Inserting artists (Artist A has a valid UUID MBID to verify round-trip through migration) INSERT INTO artist (version, name, sort_name, mbid) VALUES -(1, 'Artist A', 'Artist A', 'mbid_artist_a'), +(1, 'Artist A', 'Artist A', '550e8400-e29b-41d4-a716-446655440000'), (2, 'Artist B', 'Artist B', 'mbid_artist_b'); --- Inserting releases +-- Inserting releases (Release X has a valid UUID MBID to verify round-trip through migration) INSERT INTO release (version, name, mbid) VALUES -(1, 'Release X', 'mbid_release_x'), +(1, 'Release X', '6ba7b810-9dad-11d1-80b4-00c04fd430c8'), (2, 'Release Y', 'mbid_release_y'); -- Inserting tracks without any associated artists or releases (Orphan Tracks) @@ -381,6 +382,19 @@ VALUES EXPECT_FALSE(TrackLyrics::find(session, TrackLyricsId{})); EXPECT_FALSE(UIState::find(session, UIStateId{})); EXPECT_FALSE(User::find(session, UserId{})); + + // Verify UUID MBID round-trip through V107 migration (TEXT → BLOB) + const auto artistMBID{ core::UUID::fromString("550e8400-e29b-41d4-a716-446655440000") }; + ASSERT_TRUE(artistMBID); + const auto artist{ Artist::find(session, *artistMBID) }; + ASSERT_TRUE(artist); + EXPECT_EQ(artist->getMBID(), artistMBID); + + const auto releaseMBID{ core::UUID::fromString("6ba7b810-9dad-11d1-80b4-00c04fd430c8") }; + ASSERT_TRUE(releaseMBID); + const auto release{ Release::find(session, *releaseMBID) }; + ASSERT_TRUE(release); + EXPECT_EQ(release->getMBID(), releaseMBID); } } } // namespace lms::db::tests \ No newline at end of file diff --git a/src/libs/services/feedback/impl/listenbrainz/FeedbackTypes.cpp b/src/libs/services/feedback/impl/listenbrainz/FeedbackTypes.cpp index 5d231141..89380df8 100644 --- a/src/libs/services/feedback/impl/listenbrainz/FeedbackTypes.cpp +++ b/src/libs/services/feedback/impl/listenbrainz/FeedbackTypes.cpp @@ -25,7 +25,7 @@ namespace lms::feedback::listenBrainz { std::ostream& operator<<(std::ostream& os, const Feedback& feedback) { - os << "created = '" << feedback.created.toString() << "', recording MBID = '" << feedback.recordingMBID.getAsString() << "', score = " << static_cast(feedback.score); + os << "created = '" << feedback.created.toString() << "', recording MBID = '" << feedback.recordingMBID.toString() << "', score = " << static_cast(feedback.score); return os; } } // namespace lms::feedback::listenBrainz diff --git a/src/libs/services/feedback/impl/listenbrainz/FeedbacksSynchronizer.cpp b/src/libs/services/feedback/impl/listenbrainz/FeedbacksSynchronizer.cpp index 0ba352a2..2818ae13 100644 --- a/src/libs/services/feedback/impl/listenbrainz/FeedbacksSynchronizer.cpp +++ b/src/libs/services/feedback/impl/listenbrainz/FeedbacksSynchronizer.cpp @@ -127,7 +127,7 @@ namespace lms::feedback::listenBrainz request.message.addHeader("Authorization", "Token " + listenBrainzToken); Wt::Json::Object root; - root["recording_mbid"] = Wt::Json::Value{ std::string{ recordingMBID->getAsString() } }; + root["recording_mbid"] = Wt::Json::Value{ recordingMBID->toString() }; root["score"] = Wt::Json::Value{ static_cast(type) }; request.message.addBodyText(Wt::Json::serialize(root)); diff --git a/src/libs/services/podcast/impl/steps/Utils.cpp b/src/libs/services/podcast/impl/steps/Utils.cpp index f60adaaa..cdf9ae42 100644 --- a/src/libs/services/podcast/impl/steps/Utils.cpp +++ b/src/libs/services/podcast/impl/steps/Utils.cpp @@ -78,7 +78,7 @@ namespace lms::podcast::utils std::string generateRandomFileName() { - return std::string{ core::UUID::generate().getAsString() }; + return core::UUID::generate().toString(); } void removeFile(const std::filesystem::path& filePath) diff --git a/src/libs/services/recommendation/impl/audio-similarity/AudioSimilarityEngine.impl.hpp b/src/libs/services/recommendation/impl/audio-similarity/AudioSimilarityEngine.impl.hpp index 92a28801..c3c869c7 100644 --- a/src/libs/services/recommendation/impl/audio-similarity/AudioSimilarityEngine.impl.hpp +++ b/src/libs/services/recommendation/impl/audio-similarity/AudioSimilarityEngine.impl.hpp @@ -620,7 +620,7 @@ namespace lms::recommendation db::Artist::find(session, db::Artist::FindParameters{}, [&](const db::Artist::pointer& artist) { const auto mbid{ artist->getMBID() }; // skip "Various Artists" to avoid false artist matches - if (mbid && mbid->getAsString() == "89ad4ac3-39f7-470e-963a-56509c546377") + if (mbid && mbid->toString() == "89ad4ac3-39f7-470e-963a-56509c546377") return; std::unordered_set artistTrackIds; diff --git a/src/libs/services/recommendation/impl/clusters/ClustersEngine.cpp b/src/libs/services/recommendation/impl/clusters/ClustersEngine.cpp index 86540518..5e4476a8 100644 --- a/src/libs/services/recommendation/impl/clusters/ClustersEngine.cpp +++ b/src/libs/services/recommendation/impl/clusters/ClustersEngine.cpp @@ -144,7 +144,7 @@ namespace lms::recommendation db::Artist::find(session, db::Artist::FindParameters{}, [&](const db::Artist::pointer& artist) { const auto mbid{ artist->getMBID() }; // skip "Various Artists" to avoid false artist matches - if (mbid && mbid->getAsString() == "89ad4ac3-39f7-470e-963a-56509c546377") + if (mbid && mbid->toString() == "89ad4ac3-39f7-470e-963a-56509c546377") return; std::unordered_set artistTrackIds; diff --git a/src/libs/services/scanner/impl/steps/ScanStepArtistReconciliation.cpp b/src/libs/services/scanner/impl/steps/ScanStepArtistReconciliation.cpp index 2a8ff7c7..83d036b6 100644 --- a/src/libs/services/scanner/impl/steps/ScanStepArtistReconciliation.cpp +++ b/src/libs/services/scanner/impl/steps/ScanStepArtistReconciliation.cpp @@ -46,7 +46,7 @@ namespace lms::scanner { os << "'" << artist->getName() << "'"; if (const auto mbid{ artist->getMBID() }) - os << " [" << mbid->getAsString() << "]"; + os << " [" << mbid->toString() << "]"; return os; } diff --git a/src/libs/services/scanner/impl/steps/ScanStepAssociateArtistImages.cpp b/src/libs/services/scanner/impl/steps/ScanStepAssociateArtistImages.cpp index c5d9bf13..fec80a73 100644 --- a/src/libs/services/scanner/impl/steps/ScanStepAssociateArtistImages.cpp +++ b/src/libs/services/scanner/impl/steps/ScanStepAssociateArtistImages.cpp @@ -94,7 +94,7 @@ namespace lms::scanner db::Image::pointer image; // Find anywhere, since it is supposed to be unique! - db::Image::find(session, db::Image::FindParameters{}.setFileStem(mbid.getAsString()), [&](const db::Image::pointer foundImg) { + db::Image::find(session, db::Image::FindParameters{}.setFileStem(mbid.toString()), [&](const db::Image::pointer foundImg) { if (!image) image = foundImg; }); diff --git a/src/libs/services/scanner/impl/steps/ScanStepAssociateReleaseImages.cpp b/src/libs/services/scanner/impl/steps/ScanStepAssociateReleaseImages.cpp index 341366a7..749d041d 100644 --- a/src/libs/services/scanner/impl/steps/ScanStepAssociateReleaseImages.cpp +++ b/src/libs/services/scanner/impl/steps/ScanStepAssociateReleaseImages.cpp @@ -90,7 +90,7 @@ namespace lms::scanner if (mbid) { // Find anywhere, since it is suppoed to be unique! - db::Image::find(session, db::Image::FindParameters{}.setFileStem(mbid->getAsString()), [&](const db::Image::pointer& image) { + db::Image::find(session, db::Image::FindParameters{}.setFileStem(mbid->toString()), [&](const db::Image::pointer& image) { if (!artwork) artwork = db::Artwork::find(session, image->getId()); }); diff --git a/src/libs/services/scanner/impl/steps/ScanStepCheckForDuplicatedFiles.cpp b/src/libs/services/scanner/impl/steps/ScanStepCheckForDuplicatedFiles.cpp index 3b236cd7..8c3aafae 100644 --- a/src/libs/services/scanner/impl/steps/ScanStepCheckForDuplicatedFiles.cpp +++ b/src/libs/services/scanner/impl/steps/ScanStepCheckForDuplicatedFiles.cpp @@ -50,7 +50,7 @@ namespace lms::scanner const Track::pointer track{ Track::find(session, trackId) }; if (auto trackMBID{ track->getTrackMBID() }) { - LMS_LOG(DBUPDATER, INFO, "Found duplicated track MBID [" << trackMBID->getAsString() << "], file: " << track->getAbsoluteFilePath().string() << " - " << track->getName()); + LMS_LOG(DBUPDATER, INFO, "Found duplicated track MBID [" << trackMBID->toString() << "], file: " << track->getAbsoluteFilePath().string() << " - " << track->getName()); context.stats.duplicates.emplace_back(ScanDuplicate{ track->getId(), DuplicateReason::SameTrackMBID }); context.currentStepStats.processedElems++; _progressCallback(context.currentStepStats); diff --git a/src/libs/services/scrobbling/impl/listenbrainz/ListenTypes.cpp b/src/libs/services/scrobbling/impl/listenbrainz/ListenTypes.cpp index 322ae5f0..d1092c54 100644 --- a/src/libs/services/scrobbling/impl/listenbrainz/ListenTypes.cpp +++ b/src/libs/services/scrobbling/impl/listenbrainz/ListenTypes.cpp @@ -31,9 +31,9 @@ namespace lms::scrobbling::listenBrainz if (listen.trackNumber) os << ", trackNumber = " << *listen.trackNumber; if (listen.trackMBID) - os << ", trackMBID = '" << listen.trackMBID->getAsString() << "'"; + os << ", trackMBID = '" << listen.trackMBID->toString() << "'"; if (listen.recordingMBID) - os << ", recordingMBID = '" << listen.recordingMBID->getAsString() << "'"; + os << ", recordingMBID = '" << listen.recordingMBID->toString() << "'"; return os; } diff --git a/src/libs/services/scrobbling/impl/listenbrainz/ListensSynchronizer.cpp b/src/libs/services/scrobbling/impl/listenbrainz/ListensSynchronizer.cpp index 62da3092..ac271a80 100644 --- a/src/libs/services/scrobbling/impl/listenbrainz/ListensSynchronizer.cpp +++ b/src/libs/services/scrobbling/impl/listenbrainz/ListensSynchronizer.cpp @@ -89,9 +89,9 @@ namespace lms::scrobbling::listenBrainz if (const auto release{ track->getRelease() }) { if (auto MBID{ release->getMBID() }) - additionalInfo["release_mbid"] = Wt::Json::Value{ std::string{ MBID->getAsString() } }; + additionalInfo["release_mbid"] = Wt::Json::Value{ MBID->toString() }; if (auto groupMBID{ release->getGroupMBID() }) - additionalInfo["release_group_mbid"] = Wt::Json::Value{ std::string{ groupMBID->getAsString() } }; + additionalInfo["release_group_mbid"] = Wt::Json::Value{ groupMBID->toString() }; } { @@ -99,7 +99,7 @@ namespace lms::scrobbling::listenBrainz for (const Artist& artist : artists) { if (artist.mbid) - artistMBIDs.push_back(Wt::Json::Value{ std::string{ artist.mbid->getAsString() } }); + artistMBIDs.push_back(Wt::Json::Value{ artist.mbid->toString() }); } if (!artistMBIDs.empty()) @@ -107,10 +107,10 @@ namespace lms::scrobbling::listenBrainz } if (auto MBID{ track->getTrackMBID() }) - additionalInfo["track_mbid"] = Wt::Json::Value{ std::string{ MBID->getAsString() } }; + additionalInfo["track_mbid"] = Wt::Json::Value{ MBID->toString() }; if (auto MBID{ track->getRecordingMBID() }) - additionalInfo["recording_mbid"] = Wt::Json::Value{ std::string{ MBID->getAsString() } }; + additionalInfo["recording_mbid"] = Wt::Json::Value{ MBID->toString() }; if (const std::optional trackNumber{ track->getTrackNumber() }) additionalInfo["tracknumber"] = Wt::Json::Value{ static_cast(*trackNumber) }; diff --git a/src/libs/services/scrobbling/test/Listenbrainz.cpp b/src/libs/services/scrobbling/test/Listenbrainz.cpp index 3b585d0d..8be3a0bc 100644 --- a/src/libs/services/scrobbling/test/Listenbrainz.cpp +++ b/src/libs/services/scrobbling/test/Listenbrainz.cpp @@ -60,18 +60,18 @@ namespace lms::scrobbling::listenBrainz::tests EXPECT_EQ(result.listens[0].releaseName, "Petal"); EXPECT_EQ(result.listens[0].artistName, "Broke For Free"); ASSERT_TRUE(result.listens[0].recordingMBID.has_value()); - EXPECT_EQ(result.listens[0].recordingMBID->getAsString(), "46ae879f-2dbe-46d3-99ad-05c116f97a30"); + EXPECT_EQ(result.listens[0].recordingMBID->toString(), "46ae879f-2dbe-46d3-99ad-05c116f97a30"); ASSERT_TRUE(result.listens[0].releaseMBID.has_value()); - EXPECT_EQ(result.listens[0].releaseMBID->getAsString(), "44915500-fbb9-4060-98ce-59a57a429edc"); + EXPECT_EQ(result.listens[0].releaseMBID->toString(), "44915500-fbb9-4060-98ce-59a57a429edc"); EXPECT_EQ(result.listens[0].trackNumber, 5); EXPECT_EQ(result.listens[1].trackName, "Melt"); EXPECT_EQ(result.listens[1].releaseName, "Petal"); EXPECT_EQ(result.listens[1].artistName, "Broke For Free"); ASSERT_TRUE(result.listens[1].recordingMBID.has_value()); - EXPECT_EQ(result.listens[1].recordingMBID->getAsString(), "d89d042c-8cc1-4526-9080-5bab728ee15f"); + EXPECT_EQ(result.listens[1].recordingMBID->toString(), "d89d042c-8cc1-4526-9080-5bab728ee15f"); ASSERT_TRUE(result.listens[1].releaseMBID.has_value()); - EXPECT_EQ(result.listens[1].releaseMBID->getAsString(), "44915500-fbb9-4060-98ce-59a57a429edc"); + EXPECT_EQ(result.listens[1].releaseMBID->toString(), "44915500-fbb9-4060-98ce-59a57a429edc"); EXPECT_EQ(result.listens[1].trackNumber, 4); } @@ -84,9 +84,9 @@ namespace lms::scrobbling::listenBrainz::tests EXPECT_EQ(result.listens[0].releaseName, "Petal"); EXPECT_EQ(result.listens[0].artistName, "Broke For Free"); ASSERT_TRUE(result.listens[0].recordingMBID.has_value()); - EXPECT_EQ(result.listens[0].recordingMBID->getAsString(), "46ae879f-2dbe-46d3-99ad-05c116f97a30"); + EXPECT_EQ(result.listens[0].recordingMBID->toString(), "46ae879f-2dbe-46d3-99ad-05c116f97a30"); ASSERT_TRUE(result.listens[0].releaseMBID.has_value()); - EXPECT_EQ(result.listens[0].releaseMBID->getAsString(), "44915500-fbb9-4060-98ce-59a57a429edc"); + EXPECT_EQ(result.listens[0].releaseMBID->toString(), "44915500-fbb9-4060-98ce-59a57a429edc"); EXPECT_EQ(result.listens[0].trackNumber, 5); } diff --git a/src/libs/subsonic/impl/endpoints/Browsing.cpp b/src/libs/subsonic/impl/endpoints/Browsing.cpp index 5092e535..98c55b5e 100644 --- a/src/libs/subsonic/impl/endpoints/Browsing.cpp +++ b/src/libs/subsonic/impl/endpoints/Browsing.cpp @@ -561,10 +561,10 @@ namespace lms::api::subsonic switch (context.getResponseFormat()) { case ResponseFormat::json: - artistInfoNode.setAttribute("musicBrainzId", artistMBID->getAsString()); + artistInfoNode.setAttribute("musicBrainzId", artistMBID->toString()); break; case ResponseFormat::xml: - artistInfoNode.createChild("musicBrainzId").setValue(artistMBID->getAsString()); + artistInfoNode.createChild("musicBrainzId").setValue(artistMBID->toString()); break; } } diff --git a/src/libs/subsonic/impl/endpoints/Transcoding.cpp b/src/libs/subsonic/impl/endpoints/Transcoding.cpp index b9112e5b..2ef4bc08 100644 --- a/src/libs/subsonic/impl/endpoints/Transcoding.cpp +++ b/src/libs/subsonic/impl/endpoints/Transcoding.cpp @@ -108,7 +108,7 @@ namespace lms::api::subsonic const core::UUID uuid{ getTranscodeDecisionTracker().add(audioFileId, transcodeRes.targetStreamInfo) }; transcodeNode.addChild("transcodeStream", createStreamDetails(transcodeRes.targetStreamInfo)); - transcodeNode.setAttribute("transcodeParams", uuid.getAsString()); + transcodeNode.setAttribute("transcodeParams", uuid.toString()); }, [&](const detail::FailureResult& failureRes) { transcodeNode.setAttribute("canDirectPlay", false); diff --git a/src/libs/subsonic/impl/responses/Album.cpp b/src/libs/subsonic/impl/responses/Album.cpp index 3baf3bd2..7d7fa1d6 100644 --- a/src/libs/subsonic/impl/responses/Album.cpp +++ b/src/libs/subsonic/impl/responses/Album.cpp @@ -165,7 +165,7 @@ namespace lms::api::subsonic { std::optional mbid{ release->getMBID() }; - albumNode.setAttribute("musicBrainzId", mbid ? mbid->getAsString() : ""); + albumNode.setAttribute("musicBrainzId", mbid ? mbid->toString() : ""); } auto addClusters{ [&](Response::Node::Key field, std::string_view clusterTypeName) { diff --git a/src/libs/subsonic/impl/responses/AlbumInfo.cpp b/src/libs/subsonic/impl/responses/AlbumInfo.cpp index e58b07c7..3831d324 100644 --- a/src/libs/subsonic/impl/responses/AlbumInfo.cpp +++ b/src/libs/subsonic/impl/responses/AlbumInfo.cpp @@ -34,10 +34,10 @@ namespace lms::api::subsonic switch (context.getResponseFormat()) { case ResponseFormat::json: - albumInfo.setAttribute("musicBrainzId", releaseMBID->getAsString()); + albumInfo.setAttribute("musicBrainzId", releaseMBID->toString()); break; case ResponseFormat::xml: - albumInfo.createChild("musicBrainzId").setValue(releaseMBID->getAsString()); + albumInfo.createChild("musicBrainzId").setValue(releaseMBID->toString()); break; } } diff --git a/src/libs/subsonic/impl/responses/Artist.cpp b/src/libs/subsonic/impl/responses/Artist.cpp index 577d97c7..297e6ad0 100644 --- a/src/libs/subsonic/impl/responses/Artist.cpp +++ b/src/libs/subsonic/impl/responses/Artist.cpp @@ -102,7 +102,7 @@ namespace lms::api::subsonic { std::optional mbid{ artist->getMBID() }; - artistNode.setAttribute("musicBrainzId", mbid ? mbid->getAsString() : ""); + artistNode.setAttribute("musicBrainzId", mbid ? mbid->toString() : ""); } artistNode.setAttribute("sortName", artist->getSortName()); diff --git a/src/libs/subsonic/impl/responses/Song.cpp b/src/libs/subsonic/impl/responses/Song.cpp index c20a0de0..fb8c7281 100644 --- a/src/libs/subsonic/impl/responses/Song.cpp +++ b/src/libs/subsonic/impl/responses/Song.cpp @@ -192,7 +192,7 @@ namespace lms::api::subsonic { std::optional mbid{ track->getRecordingMBID() }; - trackResponse.setAttribute("musicBrainzId", mbid ? mbid->getAsString() : ""); + trackResponse.setAttribute("musicBrainzId", mbid ? mbid->toString() : ""); } { diff --git a/src/lms/ui/Utils.cpp b/src/lms/ui/Utils.cpp index a5425d9e..4ac236a5 100644 --- a/src/lms/ui/Utils.cpp +++ b/src/lms/ui/Utils.cpp @@ -59,7 +59,7 @@ namespace lms::ui::utils Wt::WLink createArtistLink(const db::Artist::pointer& artist) { if (const auto mbid{ artist->getMBID() }) - return Wt::WLink{ Wt::LinkType::InternalPath, "/artist/mbid/" + std::string{ mbid->getAsString() } }; + return Wt::WLink{ Wt::LinkType::InternalPath, "/artist/mbid/" + mbid->toString() }; else return Wt::WLink{ Wt::LinkType::InternalPath, "/artist/" + artist->getId().toString() }; } @@ -229,7 +229,7 @@ namespace lms::ui::utils Wt::WLink createReleaseLink(db::Release::pointer release) { if (const auto mbid{ release->getMBID() }) - return Wt::WLink{ Wt::LinkType::InternalPath, "/release/mbid/" + std::string{ mbid->getAsString() } }; + return Wt::WLink{ Wt::LinkType::InternalPath, "/release/mbid/" + mbid->toString() }; return Wt::WLink{ Wt::LinkType::InternalPath, "/release/" + release->getId().toString() }; } diff --git a/src/lms/ui/admin/ScannerReportResource.cpp b/src/lms/ui/admin/ScannerReportResource.cpp index 8a1c369a..acc4bfbd 100644 --- a/src/lms/ui/admin/ScannerReportResource.cpp +++ b/src/lms/ui/admin/ScannerReportResource.cpp @@ -150,7 +150,7 @@ namespace lms::ui response.out() << track->getAbsoluteFilePath().string(); if (auto mbid{ track->getTrackMBID() }) - response.out() << " (Track MBID " << mbid->getAsString() << ")"; + response.out() << " (Track MBID " << mbid->toString() << ")"; response.out() << " - " << duplicateReasonToWString(duplicate.reason).toUTF8() << '\n'; } diff --git a/src/lms/ui/admin/UserView.cpp b/src/lms/ui/admin/UserView.cpp index 9af91ce6..74cbed9f 100644 --- a/src/lms/ui/admin/UserView.cpp +++ b/src/lms/ui/admin/UserView.cpp @@ -107,7 +107,7 @@ namespace lms::ui user.modify()->setType(db::UserType::DEMO); // For demo user, we create the subsonic API auth token now as we have no other mean to create it later - core::Service::get()->createAuthToken("subsonic", user->getId(), core::UUID::generate().getAsString()); + core::Service::get()->createAuthToken("subsonic", user->getId(), core::UUID::generate().toString()); } if (_authPasswordService) diff --git a/src/lms/ui/explore/ArtistView.cpp b/src/lms/ui/explore/ArtistView.cpp index e1bea7e0..8883fbd7 100644 --- a/src/lms/ui/explore/ArtistView.cpp +++ b/src/lms/ui/explore/ArtistView.cpp @@ -393,7 +393,7 @@ namespace lms::ui if (mbid) { setCondition("if-has-mbid", true); - bindString("mbid-link", std::string{ "https://musicbrainz.org/artist/" } + std::string{ mbid->getAsString() }); + bindString("mbid-link", "https://musicbrainz.org/artist/" + mbid->toString()); } } diff --git a/src/lms/ui/explore/ReleaseView.cpp b/src/lms/ui/explore/ReleaseView.cpp index 9f1455d6..d11ed45c 100644 --- a/src/lms/ui/explore/ReleaseView.cpp +++ b/src/lms/ui/explore/ReleaseView.cpp @@ -554,7 +554,7 @@ namespace lms::ui if (mbid) { setCondition("if-has-mbid", true); - bindString("mbid-link", std::string{ "https://musicbrainz.org/release/" } + std::string{ mbid->getAsString() }); + bindString("mbid-link", "https://musicbrainz.org/release/" + mbid->toString()); } } diff --git a/src/lms/ui/settings/SubsonicSettingsView.cpp b/src/lms/ui/settings/SubsonicSettingsView.cpp index 97ad929c..27cc51e5 100644 --- a/src/lms/ui/settings/SubsonicSettingsView.cpp +++ b/src/lms/ui/settings/SubsonicSettingsView.cpp @@ -225,7 +225,7 @@ namespace lms::ui } auto doGenerate{ [subsonicTokenPtr, updateKeyButtonStates] { - const std::string newToken{ core::UUID::generate().getAsString() }; + const std::string newToken{ core::UUID::generate().toString() }; { auto transaction{ LmsApp->getDbSession().createWriteTransaction() }; auto& authService{ *core::Service::get() }; diff --git a/src/tools/db-generator/LmsDbGenerator.cpp b/src/tools/db-generator/LmsDbGenerator.cpp index f1c0a5bf..0d2a8054 100644 --- a/src/tools/db-generator/LmsDbGenerator.cpp +++ b/src/tools/db-generator/LmsDbGenerator.cpp @@ -78,14 +78,14 @@ namespace lms db::Cluster::pointer generateCluster(db::Session& session, db::ClusterType::pointer clusterType) { - const std::string clusterName{ std::string{ clusterType->getName() } + "-" + std::string{ core::UUID::generate().getAsString() } }; + const std::string clusterName{ std::string{ clusterType->getName() } + "-" + core::UUID::generate().toString() }; return session.create(clusterType, clusterName); } db::Artist::pointer generateArtist(db::Session& session) { const core::UUID artistMBID{ core::UUID::generate() }; - const std::string artistName{ "Artist-" + std::string{ core::UUID::generate().getAsString() } }; + const std::string artistName{ "Artist-" + core::UUID::generate().toString() }; return session.create(artistName, artistMBID); } @@ -94,7 +94,7 @@ namespace lms using namespace db; const core::UUID releaseMBID{ core::UUID::generate() }; - const std::string releaseName{ "Release-" + std::string{ core::UUID::generate().getAsString() } }; + const std::string releaseName{ "Release-" + core::UUID::generate().toString() }; Release::pointer release{ context.session.create(releaseName, releaseMBID) }; Medium::pointer medium{ context.session.create(release) }; medium.modify()->setTrackCount(params.trackCountPerRelease); @@ -113,7 +113,7 @@ namespace lms { Track::pointer track{ context.session.create() }; - track.modify()->setName("Track-" + std::string{ core::UUID::generate().getAsString() }); + track.modify()->setName("Track-" + core::UUID::generate().toString()); track.modify()->setMedium(medium); track.modify()->setTrackNumber(i); track.modify()->setDuration(std::chrono::seconds{ core::random::getRandom(30, 300) }); From 845c437364c08f5b18d2f37f9d3c6e4ccd59d442 Mon Sep 17 00:00:00 2001 From: emeric Date: Sat, 13 Jun 2026 23:28:07 +0200 Subject: [PATCH 12/38] Replaced the near similar filter constraint by a duplicate recording mbid one --- .../AudioSimilarityEngine.impl.hpp | 54 +- .../impl/clusters/ClustersEngine.cpp | 21 +- .../impl/clusters/ClustersEngine.hpp | 2 +- .../SameRecordingMBIDConstraint.hpp | 53 ++ .../TrackMetadata.hpp | 8 +- .../recommendation/test/ConstraintsTest.cpp | 736 ++++++++++-------- 6 files changed, 505 insertions(+), 369 deletions(-) create mode 100644 src/libs/services/recommendation/impl/track-selection-constraints/SameRecordingMBIDConstraint.hpp diff --git a/src/libs/services/recommendation/impl/audio-similarity/AudioSimilarityEngine.impl.hpp b/src/libs/services/recommendation/impl/audio-similarity/AudioSimilarityEngine.impl.hpp index c3c869c7..114335ef 100644 --- a/src/libs/services/recommendation/impl/audio-similarity/AudioSimilarityEngine.impl.hpp +++ b/src/libs/services/recommendation/impl/audio-similarity/AudioSimilarityEngine.impl.hpp @@ -50,10 +50,10 @@ #include "InterpolationFitConstraint.hpp" #include "MaxDistanceConstraint.hpp" -#include "NearDuplicateEmbeddingConstraint.hpp" #include "SmoothTransitionConstraint.hpp" #include "track-selection-constraints/DuplicateTrackConstraint.hpp" #include "track-selection-constraints/SameArtistConstraint.hpp" +#include "track-selection-constraints/SameRecordingMBIDConstraint.hpp" #include "track-selection-constraints/SameReleaseConstraint.hpp" #include "Types.hpp" @@ -121,11 +121,9 @@ namespace lms::recommendation constexpr float smoothTransitionWeight{ 0.2F }; constexpr float sameReleaseWeight{ 0.5F }; constexpr float sameArtistWeight{ 0.5F }; - constexpr float nearDuplicateThreshold{ 0.01F }; - _similarityEvaluator = {}; _similarityEvaluator.addHardConstraint(std::make_unique()); - _similarityEvaluator.addHardConstraint(std::make_unique>(_trackVectors, nearDuplicateThreshold)); + _similarityEvaluator.addHardConstraint(std::make_unique(_trackMetadata)); _similarityEvaluator.addHardConstraint(std::make_unique>(_trackVectors, _trackDistanceThreshold)); _similarityEvaluator.addSoftConstraint(std::make_unique>(_trackVectors), interpolationFitWeight); _similarityEvaluator.addSoftConstraint(std::make_unique>(_trackVectors), smoothTransitionWeight); @@ -134,7 +132,7 @@ namespace lms::recommendation _pathEvaluator = {}; _pathEvaluator.addHardConstraint(std::make_unique()); - _pathEvaluator.addHardConstraint(std::make_unique>(_trackVectors, nearDuplicateThreshold)); + _pathEvaluator.addHardConstraint(std::make_unique(_trackMetadata)); _pathEvaluator.addSoftConstraint(std::make_unique>(_trackVectors), interpolationFitWeight); _pathEvaluator.addSoftConstraint(std::make_unique>(_trackVectors), smoothTransitionWeight); _pathEvaluator.addSoftConstraint(std::make_unique(_trackMetadata), sameReleaseWeight); @@ -595,28 +593,29 @@ namespace lms::recommendation _trackVectors.try_emplace(trackId, &reducedVector); }); - db::Release::find(session, db::Release::FindParameters{}, [&](const db::Release::pointer& release) { - std::vector> releaseTrackFeatures; + db::Track::find(session, db::Track::FindParameters{}, [&](const db::Track::pointer& track) { + const auto itVec{ _trackVectors.find(track->getId()) }; + if (itVec == _trackVectors.cend()) + return; - db::Track::FindParameters params; - params.setRelease(release->getId()); + auto& meta{ _trackMetadata[track->getId()] }; + const db::ReleaseId releaseId{ track->getReleaseId() }; + meta.releaseId = releaseId; + meta.recordingMBID = track->getRecordingMBID(); - const auto trackIds{ db::Track::findIds(session, params) }; - for (const db::TrackId trackId : trackIds.results) - { - const auto itFeatures{ _trackVectors.find(trackId) }; - if (itFeatures != std::cend(_trackVectors)) - { - assert(itFeatures->second); - releaseTrackFeatures.emplace_back(*itFeatures->second); - _trackMetadata[trackId].releaseId = release->getId(); - } - } - - if (!releaseTrackFeatures.empty()) - _releaseVectors.try_emplace(release->getId(), std::move(releaseTrackFeatures)); + if (releaseId.isValid()) + _releaseVectors[releaseId].emplace_back(*itVec->second); }); + math::MedoidCalculator calc; + for (const auto& [id, vecs] : _releaseVectors) + { + calc.clear(); + for (const auto& v : vecs) + calc.add(v.get()); + _releaseMedoids.try_emplace(id, calc.finalize()); + } + db::Artist::find(session, db::Artist::FindParameters{}, [&](const db::Artist::pointer& artist) { const auto mbid{ artist->getMBID() }; // skip "Various Artists" to avoid false artist matches @@ -658,15 +657,6 @@ namespace lms::recommendation _artistVectors.try_emplace(artist->getId(), std::move(artistTrackVectors)); }); - math::MedoidCalculator calc; - for (const auto& [id, vecs] : _releaseVectors) - { - calc.clear(); - for (const auto& v : vecs) - calc.add(v.get()); - _releaseMedoids.try_emplace(id, calc.finalize()); - } - for (const auto& [id, vecs] : _artistVectors) { calc.clear(); diff --git a/src/libs/services/recommendation/impl/clusters/ClustersEngine.cpp b/src/libs/services/recommendation/impl/clusters/ClustersEngine.cpp index 5e4476a8..8c01c15c 100644 --- a/src/libs/services/recommendation/impl/clusters/ClustersEngine.cpp +++ b/src/libs/services/recommendation/impl/clusters/ClustersEngine.cpp @@ -23,7 +23,6 @@ #include #include #include -#include #include #include "database/IDb.hpp" @@ -39,6 +38,7 @@ #include "track-selection-constraints/DuplicateTrackConstraint.hpp" #include "track-selection-constraints/SameArtistConstraint.hpp" +#include "track-selection-constraints/SameRecordingMBIDConstraint.hpp" #include "track-selection-constraints/SameReleaseConstraint.hpp" #include "track-selection-constraints/TrackCandidateContext.hpp" @@ -103,6 +103,7 @@ namespace lms::recommendation constexpr float sameReleaseWeight{ 0.5F }; constexpr float sameArtistWeight{ 0.5F }; _trackEvaluator.addHardConstraint(std::make_unique()); + _trackEvaluator.addHardConstraint(std::make_unique(_trackMetadata)); _trackEvaluator.addSoftConstraint(std::make_unique(_trackMetadata), sameReleaseWeight); _trackEvaluator.addSoftConstraint(std::make_unique(_trackMetadata), sameArtistWeight); } @@ -122,8 +123,8 @@ namespace lms::recommendation db::Session& session{ _db.getTLSSession() }; auto transaction{ session.createReadTransaction() }; - buildTrackMetadata(session); buildTrackClusters(session); + buildTrackMetadata(session); buildReleaseClusters(); buildArtistClusters(); @@ -134,11 +135,17 @@ namespace lms::recommendation { LOG(DEBUG, "building track metadata..."); - db::Release::find(session, db::Release::FindParameters{}, [&](const db::Release::pointer& release) { - db::Track::FindParameters params; - params.setRelease(release->getId()); - for (const db::TrackId trackId : db::Track::findIds(session, params).results) - _trackMetadata[trackId].releaseId = release->getId(); + // Ensure cluster tracks with no release/artist have an entry + for (const auto& [trackId, _] : _trackClusters) + _trackMetadata.try_emplace(trackId); + + db::Track::find(session, db::Track::FindParameters{}, [&](const db::Track::pointer& track) { + const auto it{ _trackMetadata.find(track->getId()) }; + if (it != _trackMetadata.cend()) + { + it->second.releaseId = track->getReleaseId(); + it->second.recordingMBID = track->getRecordingMBID(); + } }); db::Artist::find(session, db::Artist::FindParameters{}, [&](const db::Artist::pointer& artist) { diff --git a/src/libs/services/recommendation/impl/clusters/ClustersEngine.hpp b/src/libs/services/recommendation/impl/clusters/ClustersEngine.hpp index f123c15e..bd46f9ac 100644 --- a/src/libs/services/recommendation/impl/clusters/ClustersEngine.hpp +++ b/src/libs/services/recommendation/impl/clusters/ClustersEngine.hpp @@ -54,8 +54,8 @@ namespace lms::recommendation ArtistResults findSimilarArtists(db::ArtistId artistId, core::EnumSet linkTypes, std::size_t maxCount) const override; TrackResults greedySelect(std::vector candidates, std::vector selectedTracks, std::size_t maxCount) const; - void buildTrackMetadata(db::Session& session); void buildTrackClusters(db::Session& session); + void buildTrackMetadata(db::Session& session); void buildReleaseClusters(); void buildArtistClusters(); diff --git a/src/libs/services/recommendation/impl/track-selection-constraints/SameRecordingMBIDConstraint.hpp b/src/libs/services/recommendation/impl/track-selection-constraints/SameRecordingMBIDConstraint.hpp new file mode 100644 index 00000000..9a76edd7 --- /dev/null +++ b/src/libs/services/recommendation/impl/track-selection-constraints/SameRecordingMBIDConstraint.hpp @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2026 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 "ITrackCandidateHardConstraint.hpp" +#include "TrackMetadata.hpp" + +namespace lms::recommendation +{ + class SameRecordingMBIDConstraint : public ITrackCandidateHardConstraint + { + public: + SameRecordingMBIDConstraint(const TrackMetadataMap& trackMetadata) + : _trackMetadata{ trackMetadata } + { + } + + bool rejects(const TrackCandidateContext& context) const override + { + const auto it{ _trackMetadata.find(context.candidateTrackId) }; + if (it == _trackMetadata.cend() || !it->second.recordingMBID) + return false; + + for (const db::TrackId selectedId : context.selectedTracks) + { + const auto itSel{ _trackMetadata.find(selectedId) }; + if (itSel != _trackMetadata.cend() && itSel->second.recordingMBID == it->second.recordingMBID) + return true; + } + return false; + } + + private: + const TrackMetadataMap& _trackMetadata; + }; +} // namespace lms::recommendation diff --git a/src/libs/services/recommendation/impl/track-selection-constraints/TrackMetadata.hpp b/src/libs/services/recommendation/impl/track-selection-constraints/TrackMetadata.hpp index d2a89b43..88393b7a 100644 --- a/src/libs/services/recommendation/impl/track-selection-constraints/TrackMetadata.hpp +++ b/src/libs/services/recommendation/impl/track-selection-constraints/TrackMetadata.hpp @@ -19,9 +19,11 @@ #pragma once +#include #include #include +#include "core/UUID.hpp" #include "database/objects/ArtistId.hpp" #include "database/objects/ReleaseId.hpp" #include "database/objects/TrackId.hpp" @@ -30,9 +32,9 @@ namespace lms::recommendation { struct TrackMetadata { - db::ReleaseId releaseId; // invalid if track has no release - std::vector artistIds; // sorted; album artists only - // Future: db::MediaLibraryId mediaLibraryId; + db::ReleaseId releaseId; // invalid if track has no release + std::vector artistIds; // sorted, album artists only + std::optional recordingMBID; // absent if track has no recording MBID }; using TrackMetadataMap = std::unordered_map; diff --git a/src/libs/services/recommendation/test/ConstraintsTest.cpp b/src/libs/services/recommendation/test/ConstraintsTest.cpp index 4d9cc9b9..f9fc7b38 100644 --- a/src/libs/services/recommendation/test/ConstraintsTest.cpp +++ b/src/libs/services/recommendation/test/ConstraintsTest.cpp @@ -24,390 +24,474 @@ #include "database/objects/TrackId.hpp" #include "math/Vector.hpp" +#include "core/UUID.hpp" + #include "audio-similarity/InterpolationFitConstraint.hpp" #include "audio-similarity/MaxDistanceConstraint.hpp" #include "audio-similarity/NearDuplicateEmbeddingConstraint.hpp" #include "audio-similarity/SmoothTransitionConstraint.hpp" #include "track-selection-constraints/DuplicateTrackConstraint.hpp" #include "track-selection-constraints/SameArtistConstraint.hpp" +#include "track-selection-constraints/SameRecordingMBIDConstraint.hpp" #include "track-selection-constraints/SameReleaseConstraint.hpp" #include "track-selection-constraints/TrackCandidateContext.hpp" #include "track-selection-constraints/TrackCandidateEvaluator.hpp" #include "track-selection-constraints/TrackMetadata.hpp" -using namespace lms; -using namespace lms::recommendation; - -namespace +namespace lms::recommendation::tests { - const db::TrackId T1{ 1 }; - const db::TrackId T2{ 2 }; - const db::TrackId T3{ 3 }; - const db::TrackId T4{ 4 }; + namespace + { + const db::TrackId T1{ 1 }; + const db::TrackId T2{ 2 }; + const db::TrackId T3{ 3 }; + const db::TrackId T4{ 4 }; - const db::ArtistId A1{ 10 }; - const db::ArtistId A2{ 20 }; + const db::ArtistId A1{ 10 }; + const db::ArtistId A2{ 20 }; - const db::ReleaseId R1{ 100 }; - const db::ReleaseId R2{ 200 }; -} // namespace + const db::ReleaseId R1{ 100 }; + const db::ReleaseId R2{ 200 }; + } // namespace -TEST(DuplicateTrackConstraint, acceptsNewCandidate) -{ - const std::vector selected{ T1, T2 }; - const TrackCandidateContext ctx{ .candidateTrackId = T3, .selectedTracks = selected, .seedTrackIds = {} }; - EXPECT_FALSE(DuplicateTrackConstraint{}.rejects(ctx)); -} + TEST(DuplicateTrackConstraint, acceptsNewCandidate) + { + const std::vector selected{ T1, T2 }; + const TrackCandidateContext ctx{ .candidateTrackId = T3, .selectedTracks = selected, .seedTrackIds = {} }; + EXPECT_FALSE(DuplicateTrackConstraint{}.rejects(ctx)); + } -TEST(DuplicateTrackConstraint, rejectsAlreadySelected) -{ - const std::vector selected{ T1, T2, T3 }; - const TrackCandidateContext ctx{ .candidateTrackId = T2, .selectedTracks = selected, .seedTrackIds = {} }; - EXPECT_TRUE(DuplicateTrackConstraint{}.rejects(ctx)); -} + TEST(DuplicateTrackConstraint, rejectsAlreadySelected) + { + const std::vector selected{ T1, T2, T3 }; + const TrackCandidateContext ctx{ .candidateTrackId = T2, .selectedTracks = selected, .seedTrackIds = {} }; + EXPECT_TRUE(DuplicateTrackConstraint{}.rejects(ctx)); + } -TEST(DuplicateTrackConstraint, acceptsWhenSelectionEmpty) -{ - const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = {}, .seedTrackIds = {} }; - EXPECT_FALSE(DuplicateTrackConstraint{}.rejects(ctx)); -} + TEST(DuplicateTrackConstraint, acceptsWhenSelectionEmpty) + { + const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = {}, .seedTrackIds = {} }; + EXPECT_FALSE(DuplicateTrackConstraint{}.rejects(ctx)); + } -TEST(SameArtistConstraint, zeroScoreWhenNoSharedArtist) -{ - const TrackMetadataMap meta{ - { T1, { .releaseId = {}, .artistIds = { A1 } } }, - { T2, { .releaseId = {}, .artistIds = { A2 } } }, - }; - const SameArtistConstraint constraint{ meta }; - const std::vector selected{ T2 }; - const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; - EXPECT_FLOAT_EQ(constraint.computeScore(ctx), 0.F); -} + TEST(SameArtistConstraint, zeroScoreWhenNoSharedArtist) + { + const TrackMetadataMap meta{ + { T1, { .releaseId = {}, .artistIds = { A1 } } }, + { T2, { .releaseId = {}, .artistIds = { A2 } } }, + }; + const SameArtistConstraint constraint{ meta }; + const std::vector selected{ T2 }; + const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; + EXPECT_FLOAT_EQ(constraint.computeScore(ctx), 0.F); + } -TEST(SameArtistConstraint, fullScoreWhenMostRecentMatchesArtist) -{ - TrackMetadataMap meta{ - { T1, { .releaseId = {}, .artistIds = { A1 } } }, - { T2, { .releaseId = {}, .artistIds = { A1 } } }, - }; - const SameArtistConstraint constraint{ meta }; - const std::vector selected{ T2 }; - const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; + TEST(SameArtistConstraint, fullScoreWhenMostRecentMatchesArtist) + { + TrackMetadataMap meta{ + { T1, { .releaseId = {}, .artistIds = { A1 } } }, + { T2, { .releaseId = {}, .artistIds = { A1 } } }, + }; + const SameArtistConstraint constraint{ meta }; + const std::vector selected{ T2 }; + const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; - EXPECT_FLOAT_EQ(constraint.computeScore(ctx), 1.F); -} + EXPECT_FLOAT_EQ(constraint.computeScore(ctx), 1.F); + } -TEST(SameArtistConstraint, halfScoreWhenSecondMostRecentMatchesArtist) -{ - TrackMetadataMap meta{ - { T1, { .releaseId = {}, .artistIds = { A1 } } }, - { T2, { .releaseId = {}, .artistIds = { A2 } } }, - { T3, { .releaseId = {}, .artistIds = { A1 } } }, - }; - const SameArtistConstraint constraint{ meta }; + TEST(SameArtistConstraint, halfScoreWhenSecondMostRecentMatchesArtist) + { + TrackMetadataMap meta{ + { T1, { .releaseId = {}, .artistIds = { A1 } } }, + { T2, { .releaseId = {}, .artistIds = { A2 } } }, + { T3, { .releaseId = {}, .artistIds = { A1 } } }, + }; + const SameArtistConstraint constraint{ meta }; - const std::vector selected{ T3, T2 }; - const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; + const std::vector selected{ T3, T2 }; + const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; - EXPECT_FLOAT_EQ(constraint.computeScore(ctx), 0.5F); -} + EXPECT_FLOAT_EQ(constraint.computeScore(ctx), 0.5F); + } -TEST(SameArtistConstraint, trackOutsideWindowIsIgnored) -{ - TrackMetadataMap meta{ - { T1, { .releaseId = {}, .artistIds = { A1 } } }, - { T2, { .releaseId = {}, .artistIds = { A2 } } }, - { T3, { .releaseId = {}, .artistIds = { A1 } } }, - }; - const SameArtistConstraint constraint{ meta, /*window=*/1 }; + TEST(SameArtistConstraint, trackOutsideWindowIsIgnored) + { + TrackMetadataMap meta{ + { T1, { .releaseId = {}, .artistIds = { A1 } } }, + { T2, { .releaseId = {}, .artistIds = { A2 } } }, + { T3, { .releaseId = {}, .artistIds = { A1 } } }, + }; + const SameArtistConstraint constraint{ meta, /*window=*/1 }; - const std::vector selected{ T3, T2 }; - const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; - EXPECT_FLOAT_EQ(constraint.computeScore(ctx), 0.F); -} + const std::vector selected{ T3, T2 }; + const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; + EXPECT_FLOAT_EQ(constraint.computeScore(ctx), 0.F); + } -TEST(SameArtistConstraint, zeroScoreWhenCandidateNotInMap) -{ - const TrackMetadataMap meta{}; - const SameArtistConstraint constraint{ meta }; - const std::vector selected{ T2 }; - const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; - EXPECT_FLOAT_EQ(constraint.computeScore(ctx), 0.F); -} + TEST(SameArtistConstraint, zeroScoreWhenCandidateNotInMap) + { + const TrackMetadataMap meta{}; + const SameArtistConstraint constraint{ meta }; + const std::vector selected{ T2 }; + const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; + EXPECT_FLOAT_EQ(constraint.computeScore(ctx), 0.F); + } -TEST(SameReleaseConstraint, zeroScoreWhenNoSharedRelease) -{ - TrackMetadataMap meta{ - { T1, { .releaseId = R1, .artistIds = {} } }, - { T2, { .releaseId = R2, .artistIds = {} } }, - }; - const SameReleaseConstraint constraint{ meta }; - const std::vector selected{ T2 }; - const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; - EXPECT_FLOAT_EQ(constraint.computeScore(ctx), 0.F); -} + TEST(SameReleaseConstraint, zeroScoreWhenNoSharedRelease) + { + TrackMetadataMap meta{ + { T1, { .releaseId = R1, .artistIds = {} } }, + { T2, { .releaseId = R2, .artistIds = {} } }, + }; + const SameReleaseConstraint constraint{ meta }; + const std::vector selected{ T2 }; + const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; + EXPECT_FLOAT_EQ(constraint.computeScore(ctx), 0.F); + } -TEST(SameReleaseConstraint, fullScoreWhenMostRecentMatchesRelease) -{ - TrackMetadataMap meta{ - { T1, { .releaseId = R1, .artistIds = {} } }, - { T2, { .releaseId = R1, .artistIds = {} } }, - }; - const SameReleaseConstraint constraint{ meta }; - const std::vector selected{ T2 }; - const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; - EXPECT_FLOAT_EQ(constraint.computeScore(ctx), 1.F); -} + TEST(SameReleaseConstraint, fullScoreWhenMostRecentMatchesRelease) + { + TrackMetadataMap meta{ + { T1, { .releaseId = R1, .artistIds = {} } }, + { T2, { .releaseId = R1, .artistIds = {} } }, + }; + const SameReleaseConstraint constraint{ meta }; + const std::vector selected{ T2 }; + const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; + EXPECT_FLOAT_EQ(constraint.computeScore(ctx), 1.F); + } -TEST(SameReleaseConstraint, zeroScoreWhenCandidateHasNoRelease) -{ - TrackMetadataMap meta{ - { T1, { .releaseId = {}, .artistIds = {} } }, - { T2, { .releaseId = R1, .artistIds = {} } }, - }; - const SameReleaseConstraint constraint{ meta }; - const std::vector selected{ T2 }; - const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; - EXPECT_FLOAT_EQ(constraint.computeScore(ctx), 0.F); -} + TEST(SameReleaseConstraint, zeroScoreWhenCandidateHasNoRelease) + { + TrackMetadataMap meta{ + { T1, { .releaseId = {}, .artistIds = {} } }, + { T2, { .releaseId = R1, .artistIds = {} } }, + }; + const SameReleaseConstraint constraint{ meta }; + const std::vector selected{ T2 }; + const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; + EXPECT_FLOAT_EQ(constraint.computeScore(ctx), 0.F); + } -TEST(TrackCandidateEvaluator, hardConstraintRejects) -{ - TrackCandidateEvaluator evaluator; - evaluator.addHardConstraint(std::make_unique()); + TEST(TrackCandidateEvaluator, hardConstraintRejects) + { + TrackCandidateEvaluator evaluator; + evaluator.addHardConstraint(std::make_unique()); - const std::vector selected{ T1 }; - const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; - EXPECT_TRUE(evaluator.rejects(ctx)); -} + const std::vector selected{ T1 }; + const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; + EXPECT_TRUE(evaluator.rejects(ctx)); + } -TEST(TrackCandidateEvaluator, noHardConstraintDoesNotReject) -{ - TrackCandidateEvaluator evaluator; - const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = {}, .seedTrackIds = {} }; - EXPECT_FALSE(evaluator.rejects(ctx)); -} + TEST(TrackCandidateEvaluator, noHardConstraintDoesNotReject) + { + TrackCandidateEvaluator evaluator; + const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = {}, .seedTrackIds = {} }; + EXPECT_FALSE(evaluator.rejects(ctx)); + } -TEST(TrackCandidateEvaluator, softConstraintScoreIsWeighted) -{ - TrackMetadataMap meta{ - { T1, { .releaseId = R1, .artistIds = {} } }, - { T2, { .releaseId = R1, .artistIds = {} } }, - }; - TrackCandidateEvaluator evaluator; - evaluator.addSoftConstraint(std::make_unique(meta), 2.F); + TEST(TrackCandidateEvaluator, softConstraintScoreIsWeighted) + { + TrackMetadataMap meta{ + { T1, { .releaseId = R1, .artistIds = {} } }, + { T2, { .releaseId = R1, .artistIds = {} } }, + }; + TrackCandidateEvaluator evaluator; + evaluator.addSoftConstraint(std::make_unique(meta), 2.F); - const std::vector selected{ T2 }; - const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; + const std::vector selected{ T2 }; + const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; - EXPECT_FLOAT_EQ(evaluator.score(ctx), 2.F); -} + EXPECT_FLOAT_EQ(evaluator.score(ctx), 2.F); + } -TEST(TrackCandidateEvaluator, multipleSoftConstraintsAreAccumulated) -{ - TrackMetadataMap meta{ - { T1, { .releaseId = R1, .artistIds = { A1 } } }, - { T2, { .releaseId = R1, .artistIds = { A1 } } }, - }; - TrackCandidateEvaluator evaluator; - evaluator.addSoftConstraint(std::make_unique(meta), 1.F); - evaluator.addSoftConstraint(std::make_unique(meta), 1.F); + TEST(TrackCandidateEvaluator, multipleSoftConstraintsAreAccumulated) + { + TrackMetadataMap meta{ + { T1, { .releaseId = R1, .artistIds = { A1 } } }, + { T2, { .releaseId = R1, .artistIds = { A1 } } }, + }; + TrackCandidateEvaluator evaluator; + evaluator.addSoftConstraint(std::make_unique(meta), 1.F); + evaluator.addSoftConstraint(std::make_unique(meta), 1.F); - const std::vector selected{ T2 }; - const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; + const std::vector selected{ T2 }; + const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; - EXPECT_FLOAT_EQ(evaluator.score(ctx), 2.F); -} + EXPECT_FLOAT_EQ(evaluator.score(ctx), 2.F); + } -TEST(TrackCandidateEvaluator, hardConstraintPassesEvenWithSoftConstraints) -{ - TrackMetadataMap meta{ - { T1, { .releaseId = R1, .artistIds = {} } }, - { T2, { .releaseId = R1, .artistIds = {} } }, - }; - TrackCandidateEvaluator evaluator; - evaluator.addHardConstraint(std::make_unique()); - evaluator.addSoftConstraint(std::make_unique(meta), 1.F); + TEST(TrackCandidateEvaluator, hardConstraintPassesEvenWithSoftConstraints) + { + TrackMetadataMap meta{ + { T1, { .releaseId = R1, .artistIds = {} } }, + { T2, { .releaseId = R1, .artistIds = {} } }, + }; + TrackCandidateEvaluator evaluator; + evaluator.addHardConstraint(std::make_unique()); + evaluator.addSoftConstraint(std::make_unique(meta), 1.F); - const std::vector selected{ T2 }; - const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; - EXPECT_FALSE(evaluator.rejects(ctx)); - EXPECT_FLOAT_EQ(evaluator.score(ctx), 1.F); -} + const std::vector selected{ T2 }; + const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; + EXPECT_FALSE(evaluator.rejects(ctx)); + EXPECT_FLOAT_EQ(evaluator.score(ctx), 1.F); + } -namespace -{ - using TestVec = math::Vector<2, float>; - const TestVec v_x{ 1.F, 0.F }; - const TestVec v_y{ 0.F, 1.F }; + namespace + { + using TestVec = math::Vector<2, float>; + const TestVec v_x{ 1.F, 0.F }; + const TestVec v_y{ 0.F, 1.F }; - using TestConstraint = NearDuplicateEmbeddingConstraint<2>; - using TestVectorMap = TestConstraint::TrackVectorMap; -} // namespace + using TestConstraint = NearDuplicateEmbeddingConstraint<2>; + using TestVectorMap = TestConstraint::TrackVectorMap; + } // namespace -TEST(NearDuplicateEmbeddingConstraint, acceptsWhenCandidateNotInMap) -{ - const TestVectorMap trackVectors{ { T1, &v_x } }; - const TestConstraint constraint{ trackVectors, 0.1F }; - const std::vector selected{ T1 }; - const TrackCandidateContext ctx{ .candidateTrackId = T2, .selectedTracks = selected, .seedTrackIds = {} }; - EXPECT_FALSE(constraint.rejects(ctx)); -} + TEST(NearDuplicateEmbeddingConstraint, acceptsWhenCandidateNotInMap) + { + const TestVectorMap trackVectors{ { T1, &v_x } }; + const TestConstraint constraint{ trackVectors, 0.1F }; + const std::vector selected{ T1 }; + const TrackCandidateContext ctx{ .candidateTrackId = T2, .selectedTracks = selected, .seedTrackIds = {} }; + EXPECT_FALSE(constraint.rejects(ctx)); + } -TEST(NearDuplicateEmbeddingConstraint, acceptsWhenSelectionEmpty) -{ - const TestVectorMap trackVectors{ { T1, &v_x } }; - const TestConstraint constraint{ trackVectors, 0.1F }; - const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = {}, .seedTrackIds = {} }; - EXPECT_FALSE(constraint.rejects(ctx)); -} + TEST(NearDuplicateEmbeddingConstraint, acceptsWhenSelectionEmpty) + { + const TestVectorMap trackVectors{ { T1, &v_x } }; + const TestConstraint constraint{ trackVectors, 0.1F }; + const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = {}, .seedTrackIds = {} }; + EXPECT_FALSE(constraint.rejects(ctx)); + } -TEST(NearDuplicateEmbeddingConstraint, acceptsWhenSelectedTrackNotInMap) -{ - const TestVectorMap trackVectors{ { T1, &v_x } }; - const TestConstraint constraint{ trackVectors, 0.1F }; - const std::vector selected{ T2 }; - const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; - EXPECT_FALSE(constraint.rejects(ctx)); -} + TEST(NearDuplicateEmbeddingConstraint, acceptsWhenSelectedTrackNotInMap) + { + const TestVectorMap trackVectors{ { T1, &v_x } }; + const TestConstraint constraint{ trackVectors, 0.1F }; + const std::vector selected{ T2 }; + const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; + EXPECT_FALSE(constraint.rejects(ctx)); + } -TEST(NearDuplicateEmbeddingConstraint, rejectsWhenDistanceBelowThreshold) -{ - const TestVectorMap trackVectors{ { T1, &v_x }, { T2, &v_x } }; - const TestConstraint constraint{ trackVectors, 0.1F }; - const std::vector selected{ T2 }; - const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; - EXPECT_TRUE(constraint.rejects(ctx)); -} + TEST(NearDuplicateEmbeddingConstraint, rejectsWhenDistanceBelowThreshold) + { + const TestVectorMap trackVectors{ { T1, &v_x }, { T2, &v_x } }; + const TestConstraint constraint{ trackVectors, 0.1F }; + const std::vector selected{ T2 }; + const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; + EXPECT_TRUE(constraint.rejects(ctx)); + } -TEST(NearDuplicateEmbeddingConstraint, acceptsWhenDistanceAboveThreshold) -{ - const TestVectorMap trackVectors{ { T1, &v_x }, { T2, &v_y } }; - const TestConstraint constraint{ trackVectors, 0.1F }; - const std::vector selected{ T2 }; - const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; - EXPECT_FALSE(constraint.rejects(ctx)); -} + TEST(NearDuplicateEmbeddingConstraint, acceptsWhenDistanceAboveThreshold) + { + const TestVectorMap trackVectors{ { T1, &v_x }, { T2, &v_y } }; + const TestConstraint constraint{ trackVectors, 0.1F }; + const std::vector selected{ T2 }; + const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; + EXPECT_FALSE(constraint.rejects(ctx)); + } -TEST(NearDuplicateEmbeddingConstraint, rejectsWhenOneOfManySelectedIsNearDuplicate) -{ - const TestVectorMap trackVectors{ { T1, &v_x }, { T3, &v_y }, { T4, &v_x } }; - const TestConstraint constraint{ trackVectors, 0.1F }; - const std::vector selected{ T3, T4 }; - const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; - EXPECT_TRUE(constraint.rejects(ctx)); -} + TEST(NearDuplicateEmbeddingConstraint, rejectsWhenOneOfManySelectedIsNearDuplicate) + { + const TestVectorMap trackVectors{ { T1, &v_x }, { T3, &v_y }, { T4, &v_x } }; + const TestConstraint constraint{ trackVectors, 0.1F }; + const std::vector selected{ T3, T4 }; + const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; + EXPECT_TRUE(constraint.rejects(ctx)); + } -TEST(NearDuplicateEmbeddingConstraint, acceptsWhenAllSelectedAreFarEnough) -{ - const TestVectorMap trackVectors{ { T1, &v_x }, { T2, &v_y }, { T3, &v_y } }; - const TestConstraint constraint{ trackVectors, 0.1F }; - const std::vector selected{ T2, T3 }; - const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; - EXPECT_FALSE(constraint.rejects(ctx)); -} + TEST(NearDuplicateEmbeddingConstraint, acceptsWhenAllSelectedAreFarEnough) + { + const TestVectorMap trackVectors{ { T1, &v_x }, { T2, &v_y }, { T3, &v_y } }; + const TestConstraint constraint{ trackVectors, 0.1F }; + const std::vector selected{ T2, T3 }; + const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; + EXPECT_FALSE(constraint.rejects(ctx)); + } -TEST(InterpolationFitConstraint, zeroScoreWhenNoSeeds) -{ - const TestVectorMap trackVectors{ { T1, &v_x } }; - const InterpolationFitConstraint<2> constraint{ trackVectors }; - const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = {}, .seedTrackIds = {} }; - EXPECT_FLOAT_EQ(constraint.computeScore(ctx), 0.F); -} + TEST(InterpolationFitConstraint, zeroScoreWhenNoSeeds) + { + const TestVectorMap trackVectors{ { T1, &v_x } }; + const InterpolationFitConstraint<2> constraint{ trackVectors }; + const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = {}, .seedTrackIds = {} }; + EXPECT_FLOAT_EQ(constraint.computeScore(ctx), 0.F); + } -TEST(InterpolationFitConstraint, zeroScoreWhenCandidateMatchesSeed) -{ - const TestVectorMap trackVectors{ { T1, &v_x }, { T2, &v_x } }; - const InterpolationFitConstraint<2> constraint{ trackVectors }; - const std::vector seeds{ T2 }; - const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = {}, .seedTrackIds = seeds }; - EXPECT_FLOAT_EQ(constraint.computeScore(ctx), 0.F); -} + TEST(InterpolationFitConstraint, zeroScoreWhenCandidateMatchesSeed) + { + const TestVectorMap trackVectors{ { T1, &v_x }, { T2, &v_x } }; + const InterpolationFitConstraint<2> constraint{ trackVectors }; + const std::vector seeds{ T2 }; + const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = {}, .seedTrackIds = seeds }; + EXPECT_FLOAT_EQ(constraint.computeScore(ctx), 0.F); + } -TEST(InterpolationFitConstraint, halfScoreWhenCandidateOrthogonalToSeed) -{ - const TestVectorMap trackVectors{ { T1, &v_x }, { T2, &v_y } }; - const InterpolationFitConstraint<2> constraint{ trackVectors }; - const std::vector seeds{ T2 }; - const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = {}, .seedTrackIds = seeds }; - EXPECT_FLOAT_EQ(constraint.computeScore(ctx), 0.5F); -} + TEST(InterpolationFitConstraint, halfScoreWhenCandidateOrthogonalToSeed) + { + const TestVectorMap trackVectors{ { T1, &v_x }, { T2, &v_y } }; + const InterpolationFitConstraint<2> constraint{ trackVectors }; + const std::vector seeds{ T2 }; + const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = {}, .seedTrackIds = seeds }; + EXPECT_FLOAT_EQ(constraint.computeScore(ctx), 0.5F); + } -TEST(InterpolationFitConstraint, usesClosestSeed) -{ - const TestVectorMap trackVectors{ { T1, &v_x }, { T2, &v_x }, { T3, &v_y } }; - const InterpolationFitConstraint<2> constraint{ trackVectors }; - const std::vector seeds{ T3, T2 }; - const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = {}, .seedTrackIds = seeds }; - EXPECT_FLOAT_EQ(constraint.computeScore(ctx), 0.F); -} + TEST(InterpolationFitConstraint, usesClosestSeed) + { + const TestVectorMap trackVectors{ { T1, &v_x }, { T2, &v_x }, { T3, &v_y } }; + const InterpolationFitConstraint<2> constraint{ trackVectors }; + const std::vector seeds{ T3, T2 }; + const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = {}, .seedTrackIds = seeds }; + EXPECT_FLOAT_EQ(constraint.computeScore(ctx), 0.F); + } -TEST(MaxDistanceConstraint, acceptsWhenNoSeeds) -{ - const TestVectorMap trackVectors{ { T1, &v_x } }; - const MaxDistanceConstraint<2> constraint{ trackVectors, 0.5F }; - const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = {}, .seedTrackIds = {} }; - EXPECT_FALSE(constraint.rejects(ctx)); -} + TEST(MaxDistanceConstraint, acceptsWhenNoSeeds) + { + const TestVectorMap trackVectors{ { T1, &v_x } }; + const MaxDistanceConstraint<2> constraint{ trackVectors, 0.5F }; + const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = {}, .seedTrackIds = {} }; + EXPECT_FALSE(constraint.rejects(ctx)); + } -TEST(MaxDistanceConstraint, acceptsWhenWithinThresholdOfOneSeed) -{ - const TestVectorMap trackVectors{ { T1, &v_x }, { T2, &v_x } }; - const MaxDistanceConstraint<2> constraint{ trackVectors, 0.5F }; - const std::vector seeds{ T2 }; - const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = {}, .seedTrackIds = seeds }; - EXPECT_FALSE(constraint.rejects(ctx)); -} + TEST(MaxDistanceConstraint, acceptsWhenWithinThresholdOfOneSeed) + { + const TestVectorMap trackVectors{ { T1, &v_x }, { T2, &v_x } }; + const MaxDistanceConstraint<2> constraint{ trackVectors, 0.5F }; + const std::vector seeds{ T2 }; + const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = {}, .seedTrackIds = seeds }; + EXPECT_FALSE(constraint.rejects(ctx)); + } -TEST(MaxDistanceConstraint, rejectsWhenBeyondThresholdFromAllSeeds) -{ - const TestVectorMap trackVectors{ { T1, &v_x }, { T2, &v_y } }; - const MaxDistanceConstraint<2> constraint{ trackVectors, 0.1F }; - const std::vector seeds{ T2 }; - const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = {}, .seedTrackIds = seeds }; - EXPECT_TRUE(constraint.rejects(ctx)); -} + TEST(MaxDistanceConstraint, rejectsWhenBeyondThresholdFromAllSeeds) + { + const TestVectorMap trackVectors{ { T1, &v_x }, { T2, &v_y } }; + const MaxDistanceConstraint<2> constraint{ trackVectors, 0.1F }; + const std::vector seeds{ T2 }; + const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = {}, .seedTrackIds = seeds }; + EXPECT_TRUE(constraint.rejects(ctx)); + } -TEST(MaxDistanceConstraint, acceptsWhenAnyOfManySeedsIsCloseEnough) -{ - const TestVectorMap trackVectors{ { T1, &v_x }, { T2, &v_y }, { T3, &v_x } }; - const MaxDistanceConstraint<2> constraint{ trackVectors, 0.5F }; - const std::vector seeds{ T2, T3 }; - const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = {}, .seedTrackIds = seeds }; - EXPECT_FALSE(constraint.rejects(ctx)); -} + TEST(MaxDistanceConstraint, acceptsWhenAnyOfManySeedsIsCloseEnough) + { + const TestVectorMap trackVectors{ { T1, &v_x }, { T2, &v_y }, { T3, &v_x } }; + const MaxDistanceConstraint<2> constraint{ trackVectors, 0.5F }; + const std::vector seeds{ T2, T3 }; + const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = {}, .seedTrackIds = seeds }; + EXPECT_FALSE(constraint.rejects(ctx)); + } -TEST(SmoothTransitionConstraint, zeroScoreWhenNoSelectedTracks) -{ - const TestVectorMap trackVectors{ { T1, &v_x } }; - const SmoothTransitionConstraint<2> constraint{ trackVectors }; - const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = {}, .seedTrackIds = {} }; - EXPECT_FLOAT_EQ(constraint.computeScore(ctx), 0.F); -} + TEST(SmoothTransitionConstraint, zeroScoreWhenNoSelectedTracks) + { + const TestVectorMap trackVectors{ { T1, &v_x } }; + const SmoothTransitionConstraint<2> constraint{ trackVectors }; + const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = {}, .seedTrackIds = {} }; + EXPECT_FLOAT_EQ(constraint.computeScore(ctx), 0.F); + } -TEST(SmoothTransitionConstraint, zeroScoreWhenPreviousMatchesCandidate) -{ - const TestVectorMap trackVectors{ { T1, &v_x }, { T2, &v_x } }; - const SmoothTransitionConstraint<2> constraint{ trackVectors }; - const std::vector selected{ T2 }; - const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; - EXPECT_FLOAT_EQ(constraint.computeScore(ctx), 0.F); -} + TEST(SmoothTransitionConstraint, zeroScoreWhenPreviousMatchesCandidate) + { + const TestVectorMap trackVectors{ { T1, &v_x }, { T2, &v_x } }; + const SmoothTransitionConstraint<2> constraint{ trackVectors }; + const std::vector selected{ T2 }; + const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; + EXPECT_FLOAT_EQ(constraint.computeScore(ctx), 0.F); + } -TEST(SmoothTransitionConstraint, halfScoreWhenPreviousOrthogonalToCandidate) -{ - const TestVectorMap trackVectors{ { T1, &v_x }, { T2, &v_y } }; - const SmoothTransitionConstraint<2> constraint{ trackVectors }; - const std::vector selected{ T2 }; - const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; - EXPECT_FLOAT_EQ(constraint.computeScore(ctx), 0.5F); -} + TEST(SmoothTransitionConstraint, halfScoreWhenPreviousOrthogonalToCandidate) + { + const TestVectorMap trackVectors{ { T1, &v_x }, { T2, &v_y } }; + const SmoothTransitionConstraint<2> constraint{ trackVectors }; + const std::vector selected{ T2 }; + const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; + EXPECT_FLOAT_EQ(constraint.computeScore(ctx), 0.5F); + } -TEST(SmoothTransitionConstraint, usesMostRecentlySelectedTrack) -{ - const TestVectorMap trackVectors{ { T1, &v_x }, { T2, &v_x }, { T3, &v_y } }; - const SmoothTransitionConstraint<2> constraint{ trackVectors }; - const std::vector selected{ T3, T2 }; - const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; - EXPECT_FLOAT_EQ(constraint.computeScore(ctx), 0.F); -} + TEST(SmoothTransitionConstraint, usesMostRecentlySelectedTrack) + { + const TestVectorMap trackVectors{ { T1, &v_x }, { T2, &v_x }, { T3, &v_y } }; + const SmoothTransitionConstraint<2> constraint{ trackVectors }; + const std::vector selected{ T3, T2 }; + const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; + EXPECT_FLOAT_EQ(constraint.computeScore(ctx), 0.F); + } + + namespace + { + const core::UUID MBID1{ *core::UUID::fromString("3d2508f7-3a7e-4f25-895c-fca079dd71ce") }; + const core::UUID MBID2{ *core::UUID::fromString("8e44e4ea-9bce-47c2-a48c-8aa338242fb2") }; + } // namespace + + TEST(SameRecordingMBIDConstraint, acceptsWhenCandidateNotInMap) + { + const TrackMetadataMap meta{ { T2, { .releaseId = {}, .artistIds = {}, .recordingMBID = MBID1 } } }; + const SameRecordingMBIDConstraint constraint{ meta }; + const std::vector selected{ T2 }; + const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; + EXPECT_FALSE(constraint.rejects(ctx)); + } + + TEST(SameRecordingMBIDConstraint, acceptsWhenCandidateHasNoMBID) + { + const TrackMetadataMap meta{ + { T1, { .releaseId = {}, .artistIds = {}, .recordingMBID = std::nullopt } }, + { T2, { .releaseId = {}, .artistIds = {}, .recordingMBID = MBID1 } }, + }; + const SameRecordingMBIDConstraint constraint{ meta }; + const std::vector selected{ T2 }; + const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; + EXPECT_FALSE(constraint.rejects(ctx)); + } + + TEST(SameRecordingMBIDConstraint, acceptsWhenSelectionEmpty) + { + const TrackMetadataMap meta{ { T1, { .releaseId = {}, .artistIds = {}, .recordingMBID = MBID1 } } }; + const SameRecordingMBIDConstraint constraint{ meta }; + const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = {}, .seedTrackIds = {} }; + EXPECT_FALSE(constraint.rejects(ctx)); + } + + TEST(SameRecordingMBIDConstraint, acceptsWhenNoneOfSelectedHasSameMBID) + { + const TrackMetadataMap meta{ + { T1, { .releaseId = {}, .artistIds = {}, .recordingMBID = MBID1 } }, + { T2, { .releaseId = {}, .artistIds = {}, .recordingMBID = MBID2 } }, + }; + const SameRecordingMBIDConstraint constraint{ meta }; + const std::vector selected{ T2 }; + const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; + EXPECT_FALSE(constraint.rejects(ctx)); + } + + TEST(SameRecordingMBIDConstraint, rejectsWhenOneOfSelectedHasSameMBID) + { + const TrackMetadataMap meta{ + { T1, { .releaseId = {}, .artistIds = {}, .recordingMBID = MBID1 } }, + { T2, { .releaseId = {}, .artistIds = {}, .recordingMBID = MBID2 } }, + { T3, { .releaseId = {}, .artistIds = {}, .recordingMBID = MBID1 } }, + }; + const SameRecordingMBIDConstraint constraint{ meta }; + const std::vector selected{ T2, T3 }; + const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; + EXPECT_TRUE(constraint.rejects(ctx)); + } + + TEST(SameRecordingMBIDConstraint, acceptsWhenSelectedTrackNotInMap) + { + const TrackMetadataMap meta{ { T1, { .releaseId = {}, .artistIds = {}, .recordingMBID = MBID1 } } }; + const SameRecordingMBIDConstraint constraint{ meta }; + const std::vector selected{ T2 }; + const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; + EXPECT_FALSE(constraint.rejects(ctx)); + } + + TEST(SameRecordingMBIDConstraint, acceptsWhenSelectedTrackHasNoMBID) + { + const TrackMetadataMap meta{ + { T1, { .releaseId = {}, .artistIds = {}, .recordingMBID = MBID1 } }, + { T2, { .releaseId = {}, .artistIds = {}, .recordingMBID = std::nullopt } }, + }; + const SameRecordingMBIDConstraint constraint{ meta }; + const std::vector selected{ T2 }; + const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected, .seedTrackIds = {} }; + EXPECT_FALSE(constraint.rejects(ctx)); + } +} // namespace lms::recommendation::tests \ No newline at end of file From 77f9b033ed5f14444e055133bf3169b532a1240d Mon Sep 17 00:00:00 2001 From: emeric Date: Sun, 14 Jun 2026 14:19:19 +0200 Subject: [PATCH 13/38] Removed useless group by --- src/libs/database/impl/objects/Cluster.cpp | 5 ++-- src/libs/database/test/Cluster.cpp | 34 ++++++++++++++++++++++ src/libs/database/test/TrackArtistLink.cpp | 31 ++++++++++++++++++++ 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/src/libs/database/impl/objects/Cluster.cpp b/src/libs/database/impl/objects/Cluster.cpp index 71cedbc9..44e63e45 100644 --- a/src/libs/database/impl/objects/Cluster.cpp +++ b/src/libs/database/impl/objects/Cluster.cpp @@ -51,7 +51,6 @@ namespace lms::db session.checkReadTransaction(); auto query{ session.getDboSession()->query("SELECT " + std::string{ itemToSelect } + " FROM cluster c") }; - query.groupBy("c.id"); if (params.track.isValid() || params.release.isValid()) query.join("track_cluster t_c ON t_c.cluster_id = c.id"); @@ -83,7 +82,9 @@ namespace lms::db break; } - query.groupBy("c.id"); + // track_cluster has a UNIQUE constraint on (track_id, cluster_id), so no duplicates can occur when filtering by track + if (!params.track.isValid()) + query.groupBy("c.id"); return query; } diff --git a/src/libs/database/test/Cluster.cpp b/src/libs/database/test/Cluster.cpp index 4eb6d57e..4936a6a5 100644 --- a/src/libs/database/test/Cluster.cpp +++ b/src/libs/database/test/Cluster.cpp @@ -723,4 +723,38 @@ namespace lms::db::tests } } + TEST_F(DatabaseFixture, Cluster_findByTrackAndTypeName) + { + ScopedTrack track{ session }; + ScopedClusterType genreType{ session, "GENRE" }; + ScopedClusterType moodType{ session, "MOOD" }; + ScopedCluster genre1{ session, genreType.lockAndGet(), "Rock" }; + ScopedCluster genre2{ session, genreType.lockAndGet(), "Pop" }; + ScopedCluster mood{ session, moodType.lockAndGet(), "Happy" }; + + { + auto transaction{ session.createWriteTransaction() }; + genre1.get().modify()->addTrack(track.get()); + genre2.get().modify()->addTrack(track.get()); + mood.get().modify()->addTrack(track.get()); + } + + { + auto transaction{ session.createReadTransaction() }; + + const auto genres{ Cluster::find(session, Cluster::FindParameters{}.setTrack(track.getId()).setClusterTypeName("GENRE")) }; + ASSERT_EQ(genres.results.size(), 2); + + const auto moods{ Cluster::find(session, Cluster::FindParameters{}.setTrack(track.getId()).setClusterTypeName("MOOD")) }; + ASSERT_EQ(moods.results.size(), 1); + EXPECT_EQ(moods.results.front()->getId(), mood.getId()); + + const auto all{ Cluster::find(session, Cluster::FindParameters{}.setTrack(track.getId())) }; + EXPECT_EQ(all.results.size(), 3); + + const auto none{ Cluster::find(session, Cluster::FindParameters{}.setTrack(track.getId()).setClusterTypeName("GROUPING")) }; + EXPECT_EQ(none.results.size(), 0); + } + } + } // namespace lms::db::tests \ No newline at end of file diff --git a/src/libs/database/test/TrackArtistLink.cpp b/src/libs/database/test/TrackArtistLink.cpp index 27d1a487..8e021367 100644 --- a/src/libs/database/test/TrackArtistLink.cpp +++ b/src/libs/database/test/TrackArtistLink.cpp @@ -275,4 +275,35 @@ namespace lms::db::tests EXPECT_EQ(links[0]->getTrack()->getId(), track2.getId()); } } + + TEST_F(DatabaseFixture, Track_getArtists_typeFilterAndOrder) + { + ScopedTrack track{ session }; + ScopedArtist artist1{ session, "Artist1" }; + ScopedArtist artist2{ session, "Artist2" }; + + { + auto transaction{ session.createWriteTransaction() }; + session.create(track.get(), artist1.get(), TrackArtistLinkType::Artist, false); + session.create(track.get(), artist2.get(), TrackArtistLinkType::Artist, false); + session.create(track.get(), artist1.get(), TrackArtistLinkType::Mixer, false); + } + + { + auto transaction{ session.createReadTransaction() }; + + const auto artistLinks{ track->getArtists({ TrackArtistLinkType::Artist }) }; + ASSERT_EQ(artistLinks.size(), 2); + EXPECT_EQ(artistLinks[0]->getId(), artist1.getId()); + EXPECT_EQ(artistLinks[1]->getId(), artist2.getId()); + + const auto mixerLinks{ track->getArtists({ TrackArtistLinkType::Mixer }) }; + ASSERT_EQ(mixerLinks.size(), 1); + EXPECT_EQ(mixerLinks[0]->getId(), artist1.getId()); + + const auto noFilter{ track->getArtists({}) }; + EXPECT_EQ(noFilter.size(), 2); + } + } + } // namespace lms::db::tests \ No newline at end of file From dbd6e57160287405faec4ec1a17aeba3d5825d14 Mon Sep 17 00:00:00 2001 From: emeric Date: Wed, 17 Jun 2026 21:34:29 +0200 Subject: [PATCH 14/38] Extracted genre, mood, language and grouping from generic clusters --- README.md | 2 +- approot/messages.xml | 18 +- approot/messages_es.xml | 18 +- approot/messages_fr.xml | 18 +- approot/messages_it.xml | 18 +- approot/messages_pl.xml | 22 +- approot/messages_zh.xml | 6 +- approot/tracks.xml | 40 +++ src/libs/database/CMakeLists.txt | 4 + src/libs/database/impl/Migration.cpp | 124 +++++++++- src/libs/database/impl/Session.cpp | 13 + src/libs/database/impl/objects/Artist.cpp | 36 +++ src/libs/database/impl/objects/Cluster.cpp | 4 + src/libs/database/impl/objects/Genre.cpp | 187 ++++++++++++++ src/libs/database/impl/objects/Grouping.cpp | 175 +++++++++++++ src/libs/database/impl/objects/Language.cpp | 175 +++++++++++++ src/libs/database/impl/objects/Listen.cpp | 82 +++++- .../database/impl/objects/MediaLibrary.cpp | 4 + src/libs/database/impl/objects/Medium.cpp | 4 + src/libs/database/impl/objects/Mood.cpp | 175 +++++++++++++ .../database/impl/objects/PlayListFile.cpp | 4 + src/libs/database/impl/objects/PlayQueue.cpp | 4 + src/libs/database/impl/objects/RatedTrack.cpp | 4 + src/libs/database/impl/objects/Release.cpp | 36 +++ .../impl/objects/ReleaseArtistLink.cpp | 4 + .../database/impl/objects/StarredTrack.cpp | 4 + src/libs/database/impl/objects/Track.cpp | 116 +++++++++ .../database/impl/objects/TrackArtistLink.cpp | 4 + .../database/impl/objects/TrackBookmark.cpp | 4 + .../impl/objects/TrackEmbeddedImage.cpp | 4 + .../impl/objects/TrackEmbeddedImageLink.cpp | 4 + src/libs/database/impl/objects/TrackList.cpp | 4 + .../database/impl/objects/TrackLyrics.cpp | 4 + .../impl/objects/TrackMusicNNEmbeddings.cpp | 4 + src/libs/database/impl/objects/User.cpp | 4 + .../include/database/objects/Cluster.hpp | 10 - .../include/database/objects/Filters.hpp | 28 +++ .../include/database/objects/Genre.hpp | 123 +++++++++ .../include/database/objects/GenreId.hpp | 24 ++ .../include/database/objects/Grouping.hpp | 110 +++++++++ .../include/database/objects/GroupingId.hpp | 24 ++ .../include/database/objects/Language.hpp | 110 +++++++++ .../include/database/objects/LanguageId.hpp | 24 ++ .../include/database/objects/Mood.hpp | 110 +++++++++ .../include/database/objects/MoodId.hpp | 24 ++ .../include/database/objects/Track.hpp | 28 +++ .../include/database/objects/Types.hpp | 28 +++ src/libs/database/test/CMakeLists.txt | 4 + src/libs/database/test/Common.cpp | 2 - src/libs/database/test/Common.hpp | 4 + src/libs/database/test/Genre.cpp | 233 ++++++++++++++++++ src/libs/database/test/Grouping.cpp | 190 ++++++++++++++ src/libs/database/test/Language.cpp | 190 ++++++++++++++ src/libs/database/test/Mood.cpp | 190 ++++++++++++++ src/libs/database/test/Track.cpp | 33 +++ .../services/recommendation/CMakeLists.txt | 2 +- .../impl/RecommendationService.cpp | 4 +- .../TagsEngine.cpp} | 206 ++++++++++------ .../TagsEngine.hpp} | 62 ++++- src/libs/services/scanner/CMakeLists.txt | 2 +- .../services/scanner/impl/ScannerService.cpp | 4 +- .../audiofile/AudioFileScanOperation.cpp | 70 +++++- ...tats.cpp => ScanStepComputeGenreStats.cpp} | 47 ++-- ...tats.hpp => ScanStepComputeGenreStats.hpp} | 6 +- .../steps/ScanStepRemoveOrphanedDbEntries.cpp | 32 +++ .../steps/ScanStepRemoveOrphanedDbEntries.hpp | 4 + .../include/services/scanner/ScannerStats.hpp | 2 +- .../impl/endpoints/AlbumSongLists.cpp | 29 +-- src/libs/subsonic/impl/endpoints/Browsing.cpp | 12 +- src/libs/subsonic/impl/responses/Album.cpp | 51 ++-- src/libs/subsonic/impl/responses/Genre.cpp | 18 +- src/libs/subsonic/impl/responses/Genre.hpp | 4 +- src/libs/subsonic/impl/responses/Song.cpp | 37 +-- src/lms/ui/Utils.cpp | 48 ++++ src/lms/ui/Utils.hpp | 8 + src/lms/ui/admin/ScannerController.cpp | 4 +- src/lms/ui/explore/ArtistView.cpp | 38 +++ src/lms/ui/explore/Filters.cpp | 217 +++++++++++++++- src/lms/ui/explore/Filters.hpp | 12 + src/lms/ui/explore/ReleaseView.cpp | 40 ++- src/lms/ui/explore/TrackListHelpers.cpp | 65 +++++ src/tools/db-generator/LmsDbGenerator.cpp | 81 ++++-- 82 files changed, 3648 insertions(+), 275 deletions(-) create mode 100644 src/libs/database/impl/objects/Genre.cpp create mode 100644 src/libs/database/impl/objects/Grouping.cpp create mode 100644 src/libs/database/impl/objects/Language.cpp create mode 100644 src/libs/database/impl/objects/Mood.cpp create mode 100644 src/libs/database/include/database/objects/Genre.hpp create mode 100644 src/libs/database/include/database/objects/GenreId.hpp create mode 100644 src/libs/database/include/database/objects/Grouping.hpp create mode 100644 src/libs/database/include/database/objects/GroupingId.hpp create mode 100644 src/libs/database/include/database/objects/Language.hpp create mode 100644 src/libs/database/include/database/objects/LanguageId.hpp create mode 100644 src/libs/database/include/database/objects/Mood.hpp create mode 100644 src/libs/database/include/database/objects/MoodId.hpp create mode 100644 src/libs/database/test/Genre.cpp create mode 100644 src/libs/database/test/Grouping.cpp create mode 100644 src/libs/database/test/Language.cpp create mode 100644 src/libs/database/test/Mood.cpp rename src/libs/services/recommendation/impl/{clusters/ClustersEngine.cpp => tags/TagsEngine.cpp} (59%) rename src/libs/services/recommendation/impl/{clusters/ClustersEngine.hpp => tags/TagsEngine.hpp} (56%) rename src/libs/services/scanner/impl/steps/{ScanStepComputeClusterStats.cpp => ScanStepComputeGenreStats.cpp} (56%) rename src/libs/services/scanner/impl/steps/{ScanStepComputeClusterStats.hpp => ScanStepComputeGenreStats.hpp} (91%) diff --git a/README.md b/README.md index 8c1f3dfe..16e4499a 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ _LMS_ provides several ways to help you find the music you like: * ... __Note__: the recommendation engine supports two modes: -* **Tag-based**: uses metadata tags `genre` and `grouping`. +* **Tag-based**: uses metadata tags `genre`, `mood`, `grouping`, and `language`. * **Audio similarity**: uses MusicNN embeddings. Extraction throughput depends heavily on both the number of scanner threads and the speed of your storage. Typical reported speeds range from ~1k tracks/hour on a Raspberry Pi 4 (3 threads) to ~25k tracks/hour on an Intel Core i5-13500 (10 threads). To speed up extraction, consider tweaking `scanner-thread-count` in `lms.conf` (defaults to half the number of logical CPUs). ## About tags diff --git a/approot/messages.xml b/approot/messages.xml index 4163ae7b..732e805b 100644 --- a/approot/messages.xml +++ b/approot/messages.xml @@ -152,7 +152,7 @@ Checking for duplicate files... {1} files Checking for removed files... {1}% Compacting database... -Computing stats... {1}% +Computing stats... {1}% Extracting MusicNN embeddings: {1} of {2} files ({3}%) Optimizing database... {1}%... Reconciliating artists: {1} entries... @@ -221,7 +221,19 @@ Duration Filter added Filters + + Genre + Genres + + + Grouping + Groupings + Label + + Language + Languages + Lyricist Lyricists @@ -231,6 +243,10 @@ Mixer Mixers + + Mood + Moods + Most played MusicBrainz Artist MusicBrainz Release diff --git a/approot/messages_es.xml b/approot/messages_es.xml index 7d67101f..c14b8491 100644 --- a/approot/messages_es.xml +++ b/approot/messages_es.xml @@ -152,7 +152,7 @@ Comprobando ficheros duplicados ... {1} ficheros Comprobando ficheros eliminados... {1}% Compactando la base de datos... -Calculando estadísticas... {1}% +Calculando estadísticas... {1}% Extracción de embeddings de MusicNN: {1} de {2} archivos ({3}%) Optimizando la base de datos... {1}%... Reconciliando artistas: {1} entradas... @@ -221,7 +221,19 @@ Duración Filtro añadido Filtros + + Género + Géneros + + + Agrupación + Agrupaciones + Sello discográfico + + Idioma + Idiomas + Letrista Letristas @@ -231,6 +243,10 @@ Mezclador Mezcladores + + Estado de ánimo + Estados de ánimo + Reproducidos frecuentemente Artista de MusicBrainz Álbum de MusicBrainz diff --git a/approot/messages_fr.xml b/approot/messages_fr.xml index 6f9a6166..97f392b4 100644 --- a/approot/messages_fr.xml +++ b/approot/messages_fr.xml @@ -152,7 +152,7 @@ Vérification des fichiers dupliqués... {1} fichiers Vérification des fichiers supprimés... {1}% Compactage de la base de données... -Calcul des statistiques... {1}% +Calcul des statistiques... {1}% Extraction des embeddings MusicNN : {1} sur {2} fichiers ({3}%) Optimisation de la base de données... {1}%... Reconciliation des artistes: {1} entrées... @@ -221,7 +221,19 @@ Durée Filtre ajouté Filtres + + Genre + Genres + + + Regroupement + Regroupements + Label + + Langue + Langues + Parolier Paroliers @@ -231,6 +243,10 @@ Mixeur Mixeurs + + Ambiance + Ambiances + Plus joués MusicBrainz Artist MusicBrainz Release diff --git a/approot/messages_it.xml b/approot/messages_it.xml index 06baee9c..da46dd5d 100644 --- a/approot/messages_it.xml +++ b/approot/messages_it.xml @@ -152,7 +152,7 @@ Controllo duplicati... {1} files Controllo file... {1}% Compattazione del database... -Calcolo statistiche... {1}% +Calcolo statistiche... {1}% Estrazione degli embeddings MusicNN: {1} di {2} file ({3}%) Ottimizzazione del database... {1}%... Riconciliazione artisti: {1} voci... @@ -221,7 +221,19 @@ Durata Filtro aggiunto Filtri + + Genere + Generi + + + Raggruppamento + Raggruppamenti + Etichetta + + Lingua + Lingue + Paroliere Parolieri @@ -231,6 +243,10 @@ Tecnico del mixaggio Tecnici del mixaggio + + Umore + Umori + Più ascoltati Artista MusicBrainz Album MusicBrainz diff --git a/approot/messages_pl.xml b/approot/messages_pl.xml index d89687d5..16523ae9 100644 --- a/approot/messages_pl.xml +++ b/approot/messages_pl.xml @@ -165,7 +165,7 @@ Sprawdzanie plików... {1}% Prasowanie bazy danych... -Obliczanie statystyk... {1}% +Obliczanie statystyk... {1}% Ekstrakcja embeddingów MusicNN: {1} z {2} plików ({3}%) Optymalizowanie bazy danych... {1}%... Uzgodnianie artystów: {1} wpisów... @@ -240,7 +240,22 @@ Długość Filtr dodany Filtry + + Gatunek + Gatunki + Gatunków + + + Grupowanie + Grupowania + Grupowań + Wytwórnia + + Język + Języki + Języków + Tekściarz Tekściarze @@ -252,6 +267,11 @@ Mikserzy Mikserów + + Nastrój + Nastroje + Nastrojów + Najczęściej odtwarzane Artysta w MusicBrainz Wydanie w MusicBrainz diff --git a/approot/messages_zh.xml b/approot/messages_zh.xml index 9c525cc4..2da4a425 100644 --- a/approot/messages_zh.xml +++ b/approot/messages_zh.xml @@ -150,7 +150,7 @@ 检查重复文件... {1} 个文件 检查文件中... {1}% 正在压缩数据库... -正在计算统计信息... {1}% +正在计算统计信息... {1}% 正在提取 MusicNN 嵌入:{1} / {2} 个文件({3}%) 正在优化数据库... {1}%... 整理艺术家:{1} 条记录... @@ -213,10 +213,14 @@ 时长 过滤器已添加 过滤器 +流派 +分组 厂牌 +语言 作词者 音乐库 混音师 +情绪 播放最多 MusicBrainz 歌手 MusicBrainz 专辑 diff --git a/approot/tracks.xml b/approot/tracks.xml index d40598ab..76dba9ae 100644 --- a/approot/tracks.xml +++ b/approot/tracks.xml @@ -80,6 +80,46 @@