From 0414f36fb0a5e02092f7b40f22cf15f4408a0ddb Mon Sep 17 00:00:00 2001 From: emeric Date: Mon, 15 Mar 2021 13:11:45 +0100 Subject: [PATCH 01/14] Fixed regression on the settings view. fixes #127 --- src/lms/ui/common/PasswordValidator.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/lms/ui/common/PasswordValidator.cpp b/src/lms/ui/common/PasswordValidator.cpp index 881857e3..64cfc2f6 100644 --- a/src/lms/ui/common/PasswordValidator.cpp +++ b/src/lms/ui/common/PasswordValidator.cpp @@ -68,6 +68,9 @@ namespace UserInterface Wt::WValidator::Result PasswordCheckValidator::validate(const Wt::WString& input) const { + if (input.empty()) + return Wt::WValidator::validate(input); + const auto checkResult {Service<::Auth::IPasswordService>::get()->checkUserPassword( LmsApp->getDbSession(), boost::asio::ip::address::from_string(LmsApp->environment().clientAddress()), From 5e61ce92be8543865c6cb1bc9363fb1c72d62bf2 Mon Sep 17 00:00:00 2001 From: Ye61123 <80665692+ye61123@users.noreply.github.com> Date: Wed, 17 Mar 2021 17:51:15 +0800 Subject: [PATCH 02/14] Add Simplified Chinese translation Add preliminary support for Simplified Chinese. --- approot/messages_zh.xml | 222 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 approot/messages_zh.xml diff --git a/approot/messages_zh.xml b/approot/messages_zh.xml new file mode 100644 index 00000000..23e0e3a0 --- /dev/null +++ b/approot/messages_zh.xml @@ -0,0 +1,222 @@ + + + + +添加 +管理 +应用 +撤销 +取消 +新建 +加载中... +登陆 + 注销 +不是一个目录 +密码 +无效的登陆 / 密码组合 +登录已被限制,请稍后再试 +确认密码 +新密码 +旧密码 +密码太弱 +密码不匹配 +另一个会话已打开,重新打开这一个? +保存 +用户 + + +歌手未找到 +发生错误! +返回主页 +发布未找到 +不允许执行此操作 +未找到此用户 + + +每日的 +音乐收藏 +每小时的 +立即扫描! +每月的 + 音乐收藏 +从未 +媒体根目录 +推荐引擎 +基于标签 +基于音频分析 +扫描完成: {1} 总文件, {2} 附加文件, {3} 升级文件, {4} 已删除文件, {5} 副本文件, {6} 错误文件 +扫描已完成! +扫描选项 +新设置已保存! +标签 +更新周期 +更新开始时间 +每周的 + +无法获得音轨时间 +无法解析文件 +无法读取文件 +{1} 备份文件: +{1} 错误: +立即强制全盘扫描 +生成报告 +最后扫描 +不可用 +已扫描 {1} 文件 在 {2} 和 {3} ({4} 错误, {5} 副本) +没有音轨 +复制文件哈希值 +复制 MBID +立即扫描 +扫描器 +状态 +无计划 +计划在 {1} +扫描中: 阶段 {1}/{2} +检查文件中... {1}% +检索文件中: {1} 文件 +Fetching track features from AcousticBrainz: {1}/{2} tracks ({3}%)... +重载相似引擎中 {1}%... +扫描文件中: {1}/{2} 文件 ({3}%)... + + +新建用户 +管理员 +删除 +删除用户 +确认要删除此用户? +演示 +编辑 +用户 + 用户 + + +演示账号 +演示账号已存在! +演示密码必须是登录名! +最后登录 +用户已存在! +新建用户 +新用户已建立! +编辑用户 {1} +用户已更新! + + +管理员账号已建立。请刷新后继续! +新建管理员账号 + + +记住我 +欢迎! + + +添加筛选 +全部 +歌手 +下载 +筛选已添加 +筛选 +链接 +播放最多 +MusicBrainz 歌手 +MusicBrainz 发布 +播放列表 +随机播放 +随机 +最近添加 +最近播放 +专辑 +收藏 +已收藏 +音轨 +类型 +取消收藏 + +群星 + + +相似歌手 + + +所有歌手 +追踪歌手 +作曲 +作词 +混响 +制片人 +专辑歌手 +混音师 + + +相似专辑 +版权所有 +唱片 {1} + + +搜索中... +搜索结果 + + +转码有效 + + +清除 +{1} 追踪 + + 添加 {1} 追踪 + 已添加 {1} 追踪 + +播放队列 +播放队列已满! +广播模式 +循环播放 +随机播放 + + +播放历史 + + +外观 +音频 +这些音频设置取决于您的本地浏览器! +更改密码 +夜间模式 +使用演示账号时不能保存! + 设置 +无效密码 +旧密码必须填写 +ReplayGain 模式 +没有 ReplayGain +自动模式 +音轨 +专辑 +ReplayGain 放大器 +ReplayGain 放大器 (如果没有信息) +歌手列表模式 +所有歌手 +专辑歌手 +追踪歌手 +Subsonic 应用程序接口 +转码 +视频比特率 +开启转码 +转码格式 +Matroska/Opus +MP3 +Ogg/Opus +Ogg/Vorbis +WebM/Vorbis +启动转码 +总是 +仅当格式不支持时使用 +从不 +新设置已保存! + + + + +此字段不能为空 +这个值应该在 {1} 和 {2} + + + From 1ea3a619b9662bd3f0b65690ee6404e7bd2789fc Mon Sep 17 00:00:00 2001 From: emeric Date: Wed, 17 Mar 2021 15:15:36 +0100 Subject: [PATCH 03/14] Made Server::postAll call safer, fixes #126 --- src/lms/main.cpp | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/lms/main.cpp b/src/lms/main.cpp index ac68e93d..71b9b95d 100644 --- a/src/lms/main.cpp +++ b/src/lms/main.cpp @@ -124,9 +124,19 @@ static void proxyScannerEventsToApplication(Scanner::IScanner& scanner, Wt::WServer& server) { + auto postAll {[](Wt::WServer& server, std::function cb) + { + server.postAll([cb = std::move(cb)] + { + // may be nullptr, see https://redmine.webtoolkit.eu/issues/8202 + if (LmsApp) + cb(); + }); + }}; + scanner.getEvents().scanStarted.connect([&] { - server.postAll([] + postAll(server, [] { LmsApp->getScannerEvents().scanStarted.emit(); LmsApp->triggerUpdate(); @@ -135,7 +145,7 @@ proxyScannerEventsToApplication(Scanner::IScanner& scanner, Wt::WServer& server) scanner.getEvents().scanComplete.connect([&] (const Scanner::ScanStats& stats) { - server.postAll([=] + postAll(server, [=] { LmsApp->getScannerEvents().scanComplete.emit(stats); LmsApp->triggerUpdate(); @@ -144,7 +154,7 @@ proxyScannerEventsToApplication(Scanner::IScanner& scanner, Wt::WServer& server) scanner.getEvents().scanInProgress.connect([&] (const Scanner::ScanStepStats& stats) { - server.postAll([=] + postAll(server, [=] { LmsApp->getScannerEvents().scanInProgress.emit(stats); LmsApp->triggerUpdate(); @@ -153,7 +163,7 @@ proxyScannerEventsToApplication(Scanner::IScanner& scanner, Wt::WServer& server) scanner.getEvents().scanScheduled.connect([&] (const Wt::WDateTime dateTime) { - server.postAll([=] + postAll(server, [=] { LmsApp->getScannerEvents().scanScheduled.emit(dateTime); LmsApp->triggerUpdate(); From ad84c888f5e9a078d4c1667fd28f8899e7f3c9ef Mon Sep 17 00:00:00 2001 From: emeric Date: Wed, 17 Mar 2021 15:17:07 +0100 Subject: [PATCH 04/14] Made early exit a bit faster --- src/libs/utils/impl/Path.cpp | 10 ++++++---- src/libs/utils/include/utils/Path.hpp | 3 ++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/libs/utils/impl/Path.cpp b/src/libs/utils/impl/Path.cpp index 1d0b58ae..a2297a37 100644 --- a/src/libs/utils/impl/Path.cpp +++ b/src/libs/utils/impl/Path.cpp @@ -78,7 +78,7 @@ getLastWriteTime(const std::filesystem::path& file) return Wt::WDateTime::fromTime_t(sb.st_mtime); } -void +bool exploreFilesRecursive(const std::filesystem::path& directory, std::function cb) { std::error_code ec; @@ -87,7 +87,7 @@ exploreFilesRecursive(const std::filesystem::path& directory, std::function cb); +// returns false if aborted by user +bool exploreFilesRecursive(const std::filesystem::path& directory, std::function cb); namespace std { From 260e5833d36e58fd40b2424fb73afcb7e4f07d81 Mon Sep 17 00:00:00 2001 From: emeric Date: Sun, 21 Mar 2021 14:32:26 +0100 Subject: [PATCH 05/14] Skipping directories containing a .lmsignore file. fix #120 --- approot/admin-database.xml | 1 + approot/messages.xml | 1 + approot/messages_fr.xml | 1 + src/libs/scanner/impl/Scanner.cpp | 16 +++++++++++----- src/libs/utils/impl/Path.cpp | 15 +++++++++++++-- src/libs/utils/include/utils/Path.hpp | 2 +- 6 files changed, 28 insertions(+), 8 deletions(-) diff --git a/approot/admin-database.xml b/approot/admin-database.xml index 8b6a1173..d455b4fc 100644 --- a/approot/admin-database.xml +++ b/approot/admin-database.xml @@ -16,6 +16,7 @@
${media-directory} ${media-directory-info class="help-block"} + ${tr:Lms.Admin.Database.path-help}
diff --git a/approot/messages.xml b/approot/messages.xml index ee2a721c..06e52b7d 100644 --- a/approot/messages.xml +++ b/approot/messages.xml @@ -42,6 +42,7 @@ Music collection Never Media root directory +Directories containing a .lmsignore file are skipped Recommendation engine Tags based Audio analysis based diff --git a/approot/messages_fr.xml b/approot/messages_fr.xml index 4e4c9af7..c8dbaeb6 100644 --- a/approot/messages_fr.xml +++ b/approot/messages_fr.xml @@ -42,6 +42,7 @@ Collection de musiques Jamais Dossier racine des fichiers de musique +Les dossiers contenant un fichier .lmsignore sont ignorés Moteur de recommandation Basé sur les tags Basé sur l'analyse audio diff --git a/src/libs/scanner/impl/Scanner.cpp b/src/libs/scanner/impl/Scanner.cpp index 064b5a4c..3a95b646 100644 --- a/src/libs/scanner/impl/Scanner.cpp +++ b/src/libs/scanner/impl/Scanner.cpp @@ -43,6 +43,8 @@ using namespace Database; namespace { +const std::filesystem::path excludeDirFileName {".lmsignore"}; + Wt::WDate getNextMonday(Wt::WDate current) { @@ -74,7 +76,7 @@ isFileSupported(const std::filesystem::path& file, const std::unordered_set cb) +exploreFilesRecursive(const std::filesystem::path& directory, std::function cb, const std::filesystem::path& excludeDirFileName) { std::error_code ec; std::filesystem::directory_iterator itPath {directory, std::filesystem::directory_options::follow_directory_symlink, ec}; @@ -90,6 +90,17 @@ exploreFilesRecursive(const std::filesystem::path& directory, std::function cb); +bool exploreFilesRecursive(const std::filesystem::path& directory, std::function cb, const std::filesystem::path& excludeDirFileName = {}); namespace std { From 2c4be7468a25adc6a7544c7f487ec2b388c5fc75 Mon Sep 17 00:00:00 2001 From: emeric Date: Tue, 23 Mar 2021 14:01:09 +0100 Subject: [PATCH 06/14] Added date/time field for tracklist, added a LmsApplicationManager to make scrobbler register for played tracks --- src/libs/database/impl/Session.cpp | 12 ++- .../database/include/database/TrackList.hpp | 11 ++- src/lms/CMakeLists.txt | 2 +- src/lms/main.cpp | 5 +- src/lms/ui/Auth.cpp | 1 + src/lms/ui/LmsApplication.cpp | 59 +++++------- src/lms/ui/LmsApplication.hpp | 25 ++---- src/lms/ui/LmsApplicationGroup.cpp | 89 ------------------- src/lms/ui/LmsApplicationGroup.hpp | 68 -------------- src/lms/ui/LmsApplicationManager.cpp | 48 ++++++++++ src/lms/ui/LmsApplicationManager.hpp | 49 ++++++++++ src/lms/ui/common/PasswordValidator.cpp | 2 + 12 files changed, 151 insertions(+), 220 deletions(-) delete mode 100644 src/lms/ui/LmsApplicationGroup.cpp delete mode 100644 src/lms/ui/LmsApplicationGroup.hpp create mode 100644 src/lms/ui/LmsApplicationManager.cpp create mode 100644 src/lms/ui/LmsApplicationManager.hpp diff --git a/src/libs/database/impl/Session.cpp b/src/libs/database/impl/Session.cpp index 95dd0e16..f5ca6a76 100644 --- a/src/libs/database/impl/Session.cpp +++ b/src/libs/database/impl/Session.cpp @@ -39,10 +39,11 @@ #include "database/TrackFeatures.hpp" #include "database/User.hpp" -namespace Database { +namespace Database +{ using Version = std::size_t; - static constexpr Version LMS_DATABASE_VERSION {29}; + static constexpr Version LMS_DATABASE_VERSION {30}; class VersionInfo { @@ -78,7 +79,7 @@ namespace Database { private: int _version {LMS_DATABASE_VERSION}; -}; + }; void Session::doDatabaseMigrationIfNeeded() @@ -315,6 +316,11 @@ CREATE TABLE "user_backup" ( _session.execute("DROP TABLE user"); _session.execute("ALTER TABLE user_backup RENAME TO user"); } + else if (version == 29) + { + // new field data_time in tracklist_entry (used by async scrobble or to make stats) + _session.execute("ALTER TABLE tracklist_entry ADD date_time TEXT"); + } else { LMS_LOG(DB, ERROR) << "Database version " << version << " cannot be handled using migration"; diff --git a/src/libs/database/include/database/TrackList.hpp b/src/libs/database/include/database/TrackList.hpp index b890391e..a7ba1a8c 100644 --- a/src/libs/database/include/database/TrackList.hpp +++ b/src/libs/database/include/database/TrackList.hpp @@ -22,8 +22,10 @@ #include #include #include +#include #include +#include #include "Types.hpp" @@ -142,13 +144,16 @@ class TrackListEntry : public Wt::Dbo::Dbo template void persist(Action& a) { - Wt::Dbo::belongsTo(a, _track, "track", Wt::Dbo::OnDeleteCascade); - Wt::Dbo::belongsTo(a, _tracklist, "tracklist", Wt::Dbo::OnDeleteCascade); + Wt::Dbo::field(a, _dateTime, "date_time"); + + Wt::Dbo::belongsTo(a, _track, "track", Wt::Dbo::OnDeleteCascade); + Wt::Dbo::belongsTo(a, _tracklist, "tracklist", Wt::Dbo::OnDeleteCascade); } private: - Wt::Dbo::ptr _track; + Wt::WDateTime _dateTime; + Wt::Dbo::ptr _track; Wt::Dbo::ptr _tracklist; }; diff --git a/src/lms/CMakeLists.txt b/src/lms/CMakeLists.txt index a058abfb..d81ae7b4 100644 --- a/src/lms/CMakeLists.txt +++ b/src/lms/CMakeLists.txt @@ -3,7 +3,7 @@ add_executable(lms main.cpp ui/Auth.cpp ui/LmsApplication.cpp - ui/LmsApplicationGroup.cpp + ui/LmsApplicationManager.cpp ui/LmsTheme.cpp ui/MediaPlayer.cpp ui/PlayQueue.cpp diff --git a/src/lms/main.cpp b/src/lms/main.cpp index 71b9b95d..38a450d8 100644 --- a/src/lms/main.cpp +++ b/src/lms/main.cpp @@ -34,6 +34,7 @@ #include "recommendation/IEngine.hpp" #include "subsonic/SubsonicResource.hpp" #include "ui/LmsApplication.hpp" +#include "ui/LmsApplicationManager.hpp" #include "utils/IChildProcessManager.hpp" #include "utils/IConfig.hpp" #include "utils/Service.hpp" @@ -221,7 +222,7 @@ int main(int argc, char* argv[]) session.optimize(); } - UserInterface::LmsApplicationGroupContainer appGroups; + UserInterface::LmsApplicationManager appManager; // Service initialization order is important (reverse-order for deinit) Service childProcessManagerService {createChildProcessManager()}; @@ -268,7 +269,7 @@ int main(int argc, char* argv[]) server.addEntryPoint(Wt::EntryPointType::Application, [&](const Wt::WEnvironment &env) { - return UserInterface::LmsApplication::create(env, database, appGroups); + return UserInterface::LmsApplication::create(env, database, appManager); }); proxyScannerEventsToApplication(*scannerService, server); diff --git a/src/lms/ui/Auth.cpp b/src/lms/ui/Auth.cpp index 4e84eaa6..11a27a0b 100644 --- a/src/lms/ui/Auth.cpp +++ b/src/lms/ui/Auth.cpp @@ -21,6 +21,7 @@ #include +#include #include #include #include diff --git a/src/lms/ui/LmsApplication.cpp b/src/lms/ui/LmsApplication.cpp index f86906ee..a778c796 100644 --- a/src/lms/ui/LmsApplication.cpp +++ b/src/lms/ui/LmsApplication.cpp @@ -53,6 +53,7 @@ #include "resource/CoverResource.hpp" #include "Auth.hpp" #include "LmsApplicationException.hpp" +#include "LmsApplicationManager.hpp" #include "LmsTheme.hpp" #include "MediaPlayer.hpp" #include "PlayQueue.hpp" @@ -63,7 +64,7 @@ namespace UserInterface { static constexpr const char* defaultPath {"/releases"}; std::unique_ptr -LmsApplication::create(const Wt::WEnvironment& env, Database::Db& db, LmsApplicationGroupContainer& appGroups) +LmsApplication::create(const Wt::WEnvironment& env, Database::Db& db, LmsApplicationManager& appManager) { if (auto *authEnvService {Service<::Auth::IEnvService>::get()}) { @@ -75,10 +76,10 @@ LmsApplication::create(const Wt::WEnvironment& env, Database::Db& db, LmsApplica return std::make_unique(env); } - return std::make_unique(env, db, appGroups, checkResult.userId); + return std::make_unique(env, db, appManager, checkResult.userId); } - return std::make_unique(env, db, appGroups); + return std::make_unique(env, db, appManager); } LmsApplication* @@ -102,6 +103,12 @@ LmsApplication::getUser() return Database::User::getById(getDbSession(), _authenticatedUser->userId); } +Database::IdType +LmsApplication::getUserId() +{ + return _authenticatedUser->userId; +} + bool LmsApplication::isUserAuthStrong() const { @@ -134,11 +141,11 @@ LmsApplication::getUserLoginName() LmsApplication::LmsApplication(const Wt::WEnvironment& env, Database::Db& db, - LmsApplicationGroupContainer& appGroups, + LmsApplicationManager& appManager, std::optional userId) : Wt::WApplication {env} , _db {db} -, _appGroups {appGroups} +, _appManager {appManager} , _authenticatedUser {userId ? std::make_optional(UserAuthInfo {*userId, false}) : std::nullopt} { try @@ -261,16 +268,7 @@ void LmsApplication::finalize() { if (_authenticatedUser) - { - LmsApplicationInfo info = LmsApplicationInfo::fromEnvironment(environment()); - - getApplicationGroup().postOthers([info] - { - LmsApp->getEvents().appClosed(info); - }); - - getApplicationGroup().leave(); - } + _appManager.unregisterApplication(*this); preQuit().emit(); } @@ -427,12 +425,6 @@ handlePathChange(Wt::WStackedWidget& stack, bool isAdmin) wApp->setInternalPath(defaultPath, true); } -LmsApplicationGroup& -LmsApplication::getApplicationGroup() -{ - return _appGroups.get(_authenticatedUser->userId); -} - void LmsApplication::logoutUser() { @@ -451,14 +443,19 @@ LmsApplication::onUserLoggedIn() setTheme(); root()->clear(); - const LmsApplicationInfo info {LmsApplicationInfo::fromEnvironment(environment())}; - LMS_LOG(UI, INFO) << "User '" << getUserLoginName() << "' logged in from '" << environment().clientAddress() << "', user agent = " << environment().userAgent(); - getApplicationGroup().join(info); - getApplicationGroup().postOthers([info] + _appManager.registerApplication(*this); + _appManager.applicationRegistered.connect(this, [this] (LmsApplication& otherApplication) { - LmsApp->getEvents().appOpen(info); + // Only one active session by user + if (otherApplication.getUserId() == getUserId()) + { + if (!LmsApp->isUserDemo()) + { + quit(Wt::WString::tr("Lms.quit-other-session")); + } + } }); createHome(); @@ -576,16 +573,6 @@ LmsApplication::createHome() }); } - // Events from Application group - _events.appOpen.connect([=] - { - // Only one active session by user - if (!LmsApp->isUserDemo()) - { - quit(Wt::WString::tr("Lms.quit-other-session")); - } - }); - internalPathChanged().connect([=] { handlePathChange(*mainStack, isUserAdmin()); diff --git a/src/lms/ui/LmsApplication.hpp b/src/lms/ui/LmsApplication.hpp index f6b5217b..37d7cbf7 100644 --- a/src/lms/ui/LmsApplication.hpp +++ b/src/lms/ui/LmsApplication.hpp @@ -25,8 +25,6 @@ #include "scanner/ScannerEvents.hpp" -#include "LmsApplicationGroup.hpp" - namespace Database { class Artist; @@ -47,23 +45,16 @@ class CoverResource; class LmsApplicationException; class MediaPlayer; class PlayQueue; - -// Events that can be listen from anywhere in the application -struct Events -{ - // Events relative to group - Wt::Signal appOpen; - Wt::Signal appClosed; -}; +class LmsApplicationManager; class LmsApplication : public Wt::WApplication { public: - LmsApplication(const Wt::WEnvironment& env, Database::Db& db, LmsApplicationGroupContainer& appGroups, std::optional userId = std::nullopt); + LmsApplication(const Wt::WEnvironment& env, Database::Db& db, LmsApplicationManager& appManager, std::optional userId = std::nullopt); ~LmsApplication(); - static std::unique_ptr create(const Wt::WEnvironment& env, Database::Db& db, LmsApplicationGroupContainer& appGroups); + static std::unique_ptr create(const Wt::WEnvironment& env, Database::Db& db, LmsApplicationManager& appManager); static LmsApplication* instance(); @@ -71,13 +62,14 @@ class LmsApplication : public Wt::WApplication std::shared_ptr getCoverResource() { return _coverResource; } Database::Session& getDbSession(); // always thread safe - Wt::Dbo::ptr getUser(); + Wt::Dbo::ptr getUser(); + Database::IdType getUserId(); bool isUserAuthStrong() const; // user must be logged in prior this call bool isUserAdmin(); // user must be logged in prior this call bool isUserDemo(); // user must be logged in prior this call std::string getUserLoginName(); // user must be logged in prior this call - Events& getEvents() { return _events; } + // Proxified scanner events Scanner::Events& getScannerEvents() { return _scannerEvents; } // Utils @@ -113,8 +105,6 @@ class LmsApplication : public Wt::WApplication void handleException(LmsApplicationException& e); void goHomeAndQuit(); - LmsApplicationGroup& getApplicationGroup(); - // Signal slots void logoutUser(); void onUserLoggedIn(); @@ -126,8 +116,7 @@ class LmsApplication : public Wt::WApplication Database::Db& _db; Wt::Signal<> _preQuit; - LmsApplicationGroupContainer& _appGroups; - Events _events; + LmsApplicationManager& _appManager; Scanner::Events _scannerEvents; struct UserAuthInfo { diff --git a/src/lms/ui/LmsApplicationGroup.cpp b/src/lms/ui/LmsApplicationGroup.cpp deleted file mode 100644 index d9c41199..00000000 --- a/src/lms/ui/LmsApplicationGroup.cpp +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Copyright (C) 2018 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 "LmsApplicationGroup.hpp" - -#include - -#include -#include - - -namespace UserInterface { - -LmsApplicationInfo -LmsApplicationInfo::fromEnvironment(const Wt::WEnvironment& env) -{ - LmsApplicationInfo info = {env.agent()}; - return info; -} - -void -LmsApplicationGroup::join(LmsApplicationInfo info) -{ - std::unique_lock lock {_mutex}; - - _apps.emplace(wApp->sessionId(), std::move(info)); -} - -void -LmsApplicationGroup::leave() -{ - std::unique_lock lock {_mutex}; - - _apps.erase(wApp->sessionId()); -} - -std::vector -LmsApplicationGroup::getOtherSessionIds() const -{ - std::vector res; - - std::unique_lock lock {_mutex}; - for (auto const& app : _apps) - { - if (app.first != wApp->sessionId()) - res.push_back(app.first); - } - - return res; -} - -void -LmsApplicationGroup::postOthers(std::function func) const -{ - for (auto const& sessionId : getOtherSessionIds()) - { - Wt::WServer::instance()->post(sessionId, [=] - { - func(); - wApp->triggerUpdate(); - }); - } -} - -LmsApplicationGroup& -LmsApplicationGroupContainer::get(Database::IdType userId) -{ - std::unique_lock lock {_mutex}; - - return _apps[userId]; -} - -} // UserInterface diff --git a/src/lms/ui/LmsApplicationGroup.hpp b/src/lms/ui/LmsApplicationGroup.hpp deleted file mode 100644 index ae579e91..00000000 --- a/src/lms/ui/LmsApplicationGroup.hpp +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright (C) 2018 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 "database/Types.hpp" - -namespace UserInterface { - - -struct LmsApplicationInfo -{ - Wt::UserAgent userAgent; - - static LmsApplicationInfo fromEnvironment(const Wt::WEnvironment& env); -}; - -// LmsApplication instances are grouped by User -class LmsApplicationGroup -{ - public: - void join(LmsApplicationInfo info); - void leave(); - - void postOthers(std::function func) const; - - private: - - mutable std::mutex _mutex; - - std::vector getOtherSessionIds() const; - - std::map _apps; -}; - -class LmsApplicationGroupContainer -{ - public: - LmsApplicationGroup& get(Database::IdType userId); - - private: - std::map _apps; - std::mutex _mutex; -}; - -} // UserInterface diff --git a/src/lms/ui/LmsApplicationManager.cpp b/src/lms/ui/LmsApplicationManager.cpp new file mode 100644 index 00000000..44390e06 --- /dev/null +++ b/src/lms/ui/LmsApplicationManager.cpp @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2021 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 "LmsApplicationManager.hpp" + +#include "LmsApplication.hpp" + +namespace UserInterface +{ + void + LmsApplicationManager::registerApplication(LmsApplication& application) + { + { + std::scoped_lock lock {_mutex}; + m_applications[application.getUserId()].insert(&application); + } + + applicationRegistered.emit(application); + } + + void + LmsApplicationManager::unregisterApplication(LmsApplication& application) + { + { + std::scoped_lock lock {_mutex}; + m_applications[application.getUserId()].erase(&application); + } + + applicationUnregistered.emit(application); + } + +} // UserInterface diff --git a/src/lms/ui/LmsApplicationManager.hpp b/src/lms/ui/LmsApplicationManager.hpp new file mode 100644 index 00000000..b67e339d --- /dev/null +++ b/src/lms/ui/LmsApplicationManager.hpp @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2021 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 "database/Types.hpp" + +namespace UserInterface +{ + class LmsApplication; + class LmsApplicationManager + { + public: + Wt::Signal applicationRegistered; + Wt::Signal applicationUnregistered; + + private: + + friend class LmsApplication; + + void registerApplication(LmsApplication& application); + void unregisterApplication(LmsApplication& application); + + std::mutex _mutex; + std::unordered_map> m_applications; + }; +} // UserInterface diff --git a/src/lms/ui/common/PasswordValidator.cpp b/src/lms/ui/common/PasswordValidator.cpp index 64cfc2f6..20559f5d 100644 --- a/src/lms/ui/common/PasswordValidator.cpp +++ b/src/lms/ui/common/PasswordValidator.cpp @@ -19,6 +19,8 @@ #include "PasswordValidator.hpp" +#include + #include "auth/IPasswordService.hpp" #include "utils/Service.hpp" #include "LmsApplication.hpp" From ac2fe93e6e8e2aa140bb3020c2b9d2d9801d6b37 Mon Sep 17 00:00:00 2001 From: zorian Date: Tue, 30 Mar 2021 10:59:10 -0500 Subject: [PATCH 07/14] Update INSTALL.md I also required this package for CMake on Debian sid: https://packages.debian.org/sid/libboost-program-options-dev --- INSTALL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/INSTALL.md b/INSTALL.md index 7ff70afc..d0bc5b81 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -44,7 +44,7 @@ __Notes__: * a C++17 compiler is needed * ffmpeg version 4 minimum is required ```sh -apt-get install g++ cmake libboost-system-dev libavutil-dev libavformat-dev libstb-dev libconfig++-dev ffmpeg libtag1-dev libpam0g-dev +apt-get install g++ cmake libboost-program-options-dev libboost-system-dev libavutil-dev libavformat-dev libstb-dev libconfig++-dev ffmpeg libtag1-dev libpam0g-dev ``` __Notes__: * libpam0g-dev is optional (only for using PAM authentication) From cb2af050043dbf086c42c5981ea47f2ee557c7e5 Mon Sep 17 00:00:00 2001 From: emeric Date: Fri, 2 Apr 2021 13:26:27 +0200 Subject: [PATCH 08/14] More robust child process handling --- src/libs/utils/impl/ChildProcess.cpp | 34 ++++++++++++---------------- src/libs/utils/impl/ChildProcess.hpp | 4 +--- 2 files changed, 16 insertions(+), 22 deletions(-) diff --git a/src/libs/utils/impl/ChildProcess.cpp b/src/libs/utils/impl/ChildProcess.cpp index c4eba387..82f319fc 100644 --- a/src/libs/utils/impl/ChildProcess.cpp +++ b/src/libs/utils/impl/ChildProcess.cpp @@ -46,6 +46,10 @@ namespace SystemException(int err, const std::string& errMsg) : ChildProcessException {errMsg + ": " + strerror(err)} {} + + SystemException(boost::system::error_code ec, const std::string& errMsg) + : ChildProcessException {errMsg + ": " + ec.message()} + {} }; } @@ -103,7 +107,7 @@ ChildProcess::ChildProcess(boost::asio::io_context& ioContext, const std::filesy boost::system::error_code assignError; _childStdout.assign(pipe[0], assignError); if (assignError) - LMS_LOG(CHILDPROCESS, ERROR) << "Assign failed: " << assignError.message(); + throw SystemException {assignError, "fork failed!"}; } _childPID = res; } @@ -113,6 +117,7 @@ ChildProcess::~ChildProcess() { if (!_waited) { + LMS_LOG(CHILDPROCESS, DEBUG) << "Closing child process..."; { boost::system::error_code closeError; _childStdout.close(closeError); @@ -136,6 +141,7 @@ ChildProcess::drain() void ChildProcess::kill() { + LMS_LOG(CHILDPROCESS, DEBUG) << "Killing child process..."; ::kill(_childPID, SIGKILL); } @@ -162,36 +168,25 @@ ChildProcess::wait(bool block) void ChildProcess::asyncRead(std::byte* data, std::size_t bufferSize, ReadCallback callback) { + assert(!finished()); + boost::asio::async_read(_childStdout, boost::asio::buffer(data, bufferSize), [this, callback {std::move(callback)}](const boost::system::error_code& error, std::size_t bytesTransferred) { LMS_LOG(CHILDPROCESS, DEBUG) << "Async read cb - ec = '" << error.message() << "', bytesTransferred = " << bytesTransferred; + ReadResult readResult {ReadResult::Success}; if (error) { - if (error == boost::asio::error::operation_aborted) - { - return; - } - - { - boost::system::error_code closeError; - _childStdout.close(closeError); - } + _finished = true; if (error == boost::asio::error::eof) - { - callback(ReadResult::EndOfFile, bytesTransferred); - return; - } + readResult = ReadResult::EndOfFile; else - { - callback(ReadResult::Error, bytesTransferred); return; - } } - callback(ReadResult::Success, bytesTransferred); + callback(readResult, bytesTransferred); }); } @@ -199,6 +194,7 @@ void ChildProcess::asyncWaitForData(WaitCallback cb) { LMS_LOG(CHILDPROCESS, DEBUG) << "Async wait requested"; + assert(!finished()); _childStdout.async_wait(boost::asio::posix::stream_descriptor::wait_read, [cb {std::move(cb)}](const boost::system::error_code& ec) @@ -224,6 +220,6 @@ ChildProcess::readSome(std::byte* data, std::size_t bufferSize) bool ChildProcess::finished() { - return !_childStdout.is_open(); + return _finished; } diff --git a/src/libs/utils/impl/ChildProcess.hpp b/src/libs/utils/impl/ChildProcess.hpp index e3c74f9a..f92b6b25 100644 --- a/src/libs/utils/impl/ChildProcess.hpp +++ b/src/libs/utils/impl/ChildProcess.hpp @@ -20,9 +20,6 @@ #pragma once #include -#include -#pragma once - #include #include @@ -56,5 +53,6 @@ class ChildProcess : public IChildProcess FileDescriptor _childStdout; ::pid_t _childPID {}; bool _waited {}; + bool _finished {}; std::optional _exitCode; }; From acf81e3f1a28c8f060651c7bb482a67c7ab005be Mon Sep 17 00:00:00 2001 From: emeric Date: Tue, 6 Apr 2021 19:59:12 +0200 Subject: [PATCH 09/14] Implemented a Scrobbling service + ListenBrainz scrobbler. fixes #118 --- README.md | 1 + approot/messages.xml | 5 + approot/messages_fr.xml | 5 + approot/settings.xml | 21 + conf/lms.conf | 5 +- docroot/js/mediaplayer.js | 42 +- src/libs/CMakeLists.txt | 1 + src/libs/database/impl/Session.cpp | 11 +- src/libs/database/impl/Track.cpp | 4 +- src/libs/database/impl/TrackList.cpp | 50 +-- src/libs/database/impl/User.cpp | 11 - src/libs/database/include/database/Track.hpp | 14 +- .../database/include/database/TrackList.hpp | 15 +- src/libs/database/include/database/Types.hpp | 19 + src/libs/database/include/database/User.hpp | 28 +- src/libs/metadata/impl/AvFormatParser.cpp | 9 +- src/libs/metadata/impl/TagLibParser.cpp | 5 +- .../metadata/include/metadata/IParser.hpp | 4 +- src/libs/scanner/impl/AcousticBrainzUtils.cpp | 4 +- src/libs/scanner/impl/AcousticBrainzUtils.hpp | 2 +- src/libs/scanner/impl/Scanner.cpp | 23 +- src/libs/scrobbling/CMakeLists.txt | 23 + src/libs/scrobbling/impl/IScrobbler.hpp | 56 +++ src/libs/scrobbling/impl/Scrobbling.cpp | 186 ++++++++ src/libs/scrobbling/impl/Scrobbling.hpp | 89 ++++ .../impl/internal/InternalScrobbler.cpp | 78 ++++ .../impl/internal/InternalScrobbler.hpp | 43 ++ .../listenbrainz/ListenBrainzScrobbler.cpp | 400 ++++++++++++++++++ .../listenbrainz/ListenBrainzScrobbler.hpp | 91 ++++ .../include/scrobbling/IScrobbling.hpp | 101 +++++ .../scrobbling/include/scrobbling/Listen.hpp | 32 ++ src/libs/subsonic/CMakeLists.txt | 1 + src/libs/subsonic/impl/SubsonicResource.cpp | 51 ++- src/libs/utils/impl/Logger.cpp | 1 + src/libs/utils/include/utils/Logger.hpp | 1 + src/lms/CMakeLists.txt | 2 + src/lms/main.cpp | 3 + src/lms/ui/LmsApplication.cpp | 16 + src/lms/ui/MediaPlayer.cpp | 14 +- src/lms/ui/MediaPlayer.hpp | 26 +- src/lms/ui/SettingsView.cpp | 95 ++++- src/lms/ui/admin/ScannerController.cpp | 4 +- src/lms/ui/common/UUIDValidator.cpp | 31 ++ src/lms/ui/common/UUIDValidator.hpp | 28 ++ src/lms/ui/explore/ArtistsView.cpp | 9 +- src/lms/ui/explore/ReleasesView.cpp | 5 +- src/lms/ui/explore/TracksView.cpp | 7 +- src/test/database/DatabaseTest.cpp | 157 ++++++- src/tools/metadata/LmsMetadata.cpp | 8 +- 49 files changed, 1661 insertions(+), 176 deletions(-) create mode 100644 src/libs/scrobbling/CMakeLists.txt create mode 100644 src/libs/scrobbling/impl/IScrobbler.hpp create mode 100644 src/libs/scrobbling/impl/Scrobbling.cpp create mode 100644 src/libs/scrobbling/impl/Scrobbling.hpp create mode 100644 src/libs/scrobbling/impl/internal/InternalScrobbler.cpp create mode 100644 src/libs/scrobbling/impl/internal/InternalScrobbler.hpp create mode 100644 src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.cpp create mode 100644 src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.hpp create mode 100644 src/libs/scrobbling/include/scrobbling/IScrobbling.hpp create mode 100644 src/libs/scrobbling/include/scrobbling/Listen.hpp create mode 100644 src/lms/ui/common/UUIDValidator.cpp create mode 100644 src/lms/ui/common/UUIDValidator.hpp diff --git a/README.md b/README.md index 516f3e5f..5b764349 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ A [demo instance](http://lms.demo.poupon.io) is available. Note the administrati * Multi-value tags: artists, genres, composers, lyricists, moods, ... * Compilation support * [MusicBrainz Identifier](https://musicbrainz.org/doc/MusicBrainz_Identifier) support to handle duplicated artist and release names +* Scrobbling to [ListenBrainz](https://listenbrainz.org) * Disc subtitles support * ReplayGain support * Persistent play queue across sessions diff --git a/approot/messages.xml b/approot/messages.xml index 06e52b7d..bd252296 100644 --- a/approot/messages.xml +++ b/approot/messages.xml @@ -194,6 +194,11 @@ Album ReplayGain preamp ReplayGain preamp (if no info) +Scrobbling +Scrobbler +Internal +ListenBrainz +ListenBrainz token Artist list mode All artists Album artists diff --git a/approot/messages_fr.xml b/approot/messages_fr.xml index c8dbaeb6..52f5f10c 100644 --- a/approot/messages_fr.xml +++ b/approot/messages_fr.xml @@ -194,6 +194,11 @@ Album Pre-amplification ReplayGain Pre-amplification ReplayGain (si pas d'info) +Scrobbling +Scrobbler +Interne +ListenBrainz +Jeton ListenBrainz Mode de listage des artistes Tous les artistes Tous les artistes d'album diff --git a/approot/settings.xml b/approot/settings.xml index c5df89f0..4a3aa7c2 100644 --- a/approot/settings.xml +++ b/approot/settings.xml @@ -131,6 +131,27 @@
${} + ${tr:Lms.Settings.scrobbling} +
+
+ +
+ ${scrobbler} + ${scrobbler-info class="help-block"} +
+
+
+ +
+ ${listenbrainz-token} + ${listenbrainz-token-info class="help-block"} +
+
+
${} ${tr:Lms.Settings.change-password}
diff --git a/conf/lms.conf b/conf/lms.conf index e4c00109..5ec9768d 100644 --- a/conf/lms.conf +++ b/conf/lms.conf @@ -34,7 +34,10 @@ deploy-path = "/"; # Number of threads to be used to dispatch http requests (0 means auto detect) http-server-thread-count = 0; -# Acoustic brainz's root API +# ListenBrainz root API +listenbrainz-api-url = "https://api.listenbrainz.org/1/"; + +# Acousticbrainz root API acousticbrainz-api-url = "https://acousticbrainz.org/api/v1/"; # Authentication diff --git a/docroot/js/mediaplayer.js b/docroot/js/mediaplayer.js index cd4f7da9..6023a9ca 100644 --- a/docroot/js/mediaplayer.js +++ b/docroot/js/mediaplayer.js @@ -20,12 +20,15 @@ LMS.mediaplayer = function () { var _root = {}; var _elems = {}; var _offset = 0; + var _trackId = null; var _duration = 0; var _audioNativeSrc; var _audioTranscodeSrc; var _settings = {}; var _audioCtx = new (window.AudioContext || window.webkitAudioContext)(); var _gainNode = _audioCtx.createGain(); + var _playedDuration = 0; + var _lastStartPlaying = null; var _updateControls = function() { if (_elems.audio.paused) { @@ -38,16 +41,35 @@ LMS.mediaplayer = function () { } } + var _startTimer = function() { + if (_lastStartPlaying == null) + Wt.emit(_root, "scrobbleListenNow", _trackId); + _lastStartPlaying = Date.now(); + } + + var _stopTimer = function() { + if (_lastStartPlaying != null) { + _playedDuration += Date.now() - _lastStartPlaying; + } + } + + var _resetTimer = function() { + if (_lastStartPlaying != null) + Wt.emit(_root, "scrobbleListenFinished", _trackId, _playedDuration); + _playedDuration = 0; + _lastStartPlaying = null; + } + var _durationToString = function (duration) { - var minutes = parseInt(duration / 60, 10); - var seconds = parseInt(duration, 10) % 60; + var minutes = parseInt(duration / 60, 10); + var seconds = parseInt(duration, 10) % 60; - var res = ""; + var res = ""; - res += minutes + ":"; - res += (seconds < 10 ? "0" + seconds : seconds); + res += minutes + ":"; + res += (seconds < 10 ? "0" + seconds : seconds); - return res; + return res; } var _playTrack = function() { @@ -201,6 +223,10 @@ LMS.mediaplayer = function () { _elems.audio.addEventListener("playing", _updateControls); _elems.audio.addEventListener("pause", _updateControls); + _elems.audio.addEventListener("pause", _stopTimer); + _elems.audio.addEventListener("playing", _startTimer); + _elems.audio.addEventListener("waiting", _stopTimer); + _elems.audio.addEventListener("timeupdate", function() { _elems.progress.style.width = "" + ((_offset + _elems.audio.currentTime) / _duration) * 100 + "%"; _elems.curtime.innerHTML = _durationToString(_offset + _elems.audio.currentTime); @@ -293,6 +319,10 @@ LMS.mediaplayer = function () { } var loadTrack = function(params, autoplay) { + _stopTimer(); + _resetTimer(); + + _trackId = params.trackId; _offset = 0; _duration = params.duration; _audioNativeSrc = params.nativeResource; diff --git a/src/libs/CMakeLists.txt b/src/libs/CMakeLists.txt index 7302b579..e8c1413c 100644 --- a/src/libs/CMakeLists.txt +++ b/src/libs/CMakeLists.txt @@ -6,6 +6,7 @@ add_subdirectory(database) add_subdirectory(metadata) add_subdirectory(recommendation) add_subdirectory(scanner) +add_subdirectory(scrobbling) add_subdirectory(som) add_subdirectory(subsonic) add_subdirectory(utils) diff --git a/src/libs/database/impl/Session.cpp b/src/libs/database/impl/Session.cpp index f5ca6a76..5141f6d6 100644 --- a/src/libs/database/impl/Session.cpp +++ b/src/libs/database/impl/Session.cpp @@ -318,8 +318,16 @@ CREATE TABLE "user_backup" ( } else if (version == 29) { - // new field data_time in tracklist_entry (used by async scrobble or to make stats) _session.execute("ALTER TABLE tracklist_entry ADD date_time TEXT"); + _session.execute("ALTER TABLE user ADD listenbrainz_token TEXT"); + _session.execute("ALTER TABLE user ADD scrobbler INTEGER NOT NULL DEFAULT(" + std::to_string(static_cast(User::defaultScrobbler)) + ")"); + _session.execute("ALTER TABLE track ADD recording_mbid TEXT"); + + _session.execute("DELETE from tracklist WHERE name = ?").bind("__played_tracks__"); + + // MBID changes + // Just increment the scan version of the settings to make the next scheduled scan rescan everything + ScanSettings::get(*this).modify()->incScanVersion(); } else { @@ -432,6 +440,7 @@ Session::prepareTables() _session.execute("CREATE INDEX IF NOT EXISTS track_name_idx ON track(name)"); _session.execute("CREATE INDEX IF NOT EXISTS track_name_nocase_idx ON track(name COLLATE NOCASE)"); _session.execute("CREATE INDEX IF NOT EXISTS track_mbid_idx ON track(mbid)"); + _session.execute("CREATE INDEX IF NOT EXISTS track_recording_mbid_idx ON track(recording_mbid)"); _session.execute("CREATE INDEX IF NOT EXISTS track_release_idx ON track(release_id)"); _session.execute("CREATE INDEX IF NOT EXISTS track_year_idx ON track(year)"); _session.execute("CREATE INDEX IF NOT EXISTS track_original_year_idx ON track(original_year)"); diff --git a/src/libs/database/impl/Track.cpp b/src/libs/database/impl/Track.cpp index 13ec9cf6..604e7062 100644 --- a/src/libs/database/impl/Track.cpp +++ b/src/libs/database/impl/Track.cpp @@ -229,13 +229,13 @@ Track::getLastWritten(Session& session, std::optional after, cons } std::vector -Track::getAllWithMBIDAndMissingFeatures(Session& session) +Track::getAllWithRecordingMBIDAndMissingFeatures(Session& session) { session.checkSharedLocked(); Wt::Dbo::collection res = session.getDboSession().query ("SELECT t FROM track t") - .where("LENGTH(t.mbid) > 0") + .where("LENGTH(t.recording_mbid) > 0") .where("NOT EXISTS (SELECT * FROM track_features t_f WHERE t_f.track_id = t.id)"); return std::vector(res.begin(), res.end()); } diff --git a/src/libs/database/impl/TrackList.cpp b/src/libs/database/impl/TrackList.cpp index 39cff1a8..54b685e3 100644 --- a/src/libs/database/impl/TrackList.cpp +++ b/src/libs/database/impl/TrackList.cpp @@ -29,10 +29,11 @@ #include "database/User.hpp" #include "database/Track.hpp" #include "SqlQuery.hpp" +#include "StringViewTraits.hpp" namespace Database { -TrackList::TrackList(const std::string& name, Type type, bool isPublic, Wt::Dbo::ptr user) +TrackList::TrackList(std::string_view name, Type type, bool isPublic, Wt::Dbo::ptr user) : _name {name}, _type {type}, _isPublic {isPublic}, @@ -42,7 +43,7 @@ TrackList::TrackList(const std::string& name, Type type, bool isPublic, Wt::Dbo: } TrackList::pointer -TrackList::create(Session& session, const std::string& name, Type type, bool isPublic, Wt::Dbo::ptr user) +TrackList::create(Session& session, std::string_view name, Type type, bool isPublic, Wt::Dbo::ptr user) { session.checkUniqueLocked(); assert(user); @@ -54,7 +55,7 @@ TrackList::create(Session& session, const std::string& name, Type type, bool isP } TrackList::pointer -TrackList::get(Session& session, const std::string& name, Type type, Wt::Dbo::ptr user) +TrackList::get(Session& session, std::string_view name, Type type, Wt::Dbo::ptr user) { session.checkSharedLocked(); assert(user); @@ -147,22 +148,6 @@ TrackList::getEntries(std::optional offset, std::optional>(entries.begin(), entries.end()); } -std::vector> -TrackList::getEntriesReverse(std::optional offset, std::optional size) const -{ - assert(session()); - assert(IdIsValid(self()->id())); - - Wt::Dbo::collection> entries = - session()->find() - .where("tracklist_id = ?").bind(self().id()) - .orderBy("id DESC") - .limit(size ? static_cast(*size) : -1) - .offset(offset ? static_cast(*offset) : -1); - - return std::vector>(entries.begin(), entries.end()); -} - static Wt::Dbo::Query createArtistsQuery(Wt::Dbo::Session& session, const std::string& queryStr, IdType tracklistId, const std::set& clusterIds, std::optional linkType) @@ -275,8 +260,9 @@ TrackList::getArtistsReverse(const std::set& clusterIds, std::optionalid())); - Wt::Dbo::collection collection = createArtistsQuery(*session(), "SELECT DISTINCT a from artist a", self()->id(), clusterIds, linkType) - .orderBy("p_e.id DESC") + Wt::Dbo::collection collection = createArtistsQuery(*session(), "SELECT a from artist a", self()->id(), clusterIds, linkType) + .groupBy("a.id").having("p_e.date_time = MAX(p_e.date_time)") + .orderBy("p_e.date_time DESC") .limit(range ? static_cast(range->limit) + 1 : -1) .offset(range ? static_cast(range->offset) : -1); @@ -298,8 +284,9 @@ TrackList::getReleasesReverse(const std::set& clusterIds, std::optional< assert(session()); assert(IdIsValid(self()->id())); - Wt::Dbo::collection collection = createReleasesQuery(*session(), "SELECT DISTINCT r from release r", self()->id(), clusterIds) - .orderBy("p_e.id DESC") + Wt::Dbo::collection collection = createReleasesQuery(*session(), "SELECT r from release r", self()->id(), clusterIds) + .groupBy("r.id").having("p_e.date_time = MAX(p_e.date_time)") + .orderBy("p_e.date_time DESC") .limit(range ? static_cast(range->limit) + 1 : -1) .offset(range ? static_cast(range->offset) : -1); @@ -322,8 +309,8 @@ TrackList::getTracksReverse(const std::set& clusterIds, std::optionalid())); Wt::Dbo::collection collection = createTracksQuery(*session(), self()->id(), clusterIds) - .orderBy("p_e.id DESC") - .groupBy("t.id") + .groupBy("t.id").having("p_e.date_time = MAX(p_e.date_time)") + .orderBy("p_e.date_time DESC") .limit(range ? static_cast(range->limit) + 1 : -1) .offset(range ? static_cast(range->offset) : -1); @@ -498,21 +485,22 @@ TrackList::getTopTracks(const std::set& clusterIds, std::optional return res; } -TrackListEntry::TrackListEntry(Wt::Dbo::ptr track, Wt::Dbo::ptr tracklist) -: _track(track), - _tracklist(tracklist) +TrackListEntry::TrackListEntry(Wt::Dbo::ptr track, Wt::Dbo::ptr tracklist, const Wt::WDateTime& dateTime) +: _dateTime {Wt::WDateTime::fromTime_t(dateTime.toTime_t())} // force second resolution +, _track {track} +, _tracklist {tracklist} { - + assert(_dateTime.isValid()); } TrackListEntry::pointer -TrackListEntry::create(Session& session, Wt::Dbo::ptr track, Wt::Dbo::ptr tracklist) +TrackListEntry::create(Session& session, Wt::Dbo::ptr track, Wt::Dbo::ptr tracklist, const Wt::WDateTime& dateTime) { session.checkUniqueLocked(); assert(track); assert(tracklist); - auto res = session.getDboSession().add( std::make_unique( track, tracklist) ); + auto res = session.getDboSession().add(std::make_unique( track, tracklist, dateTime)); session.getDboSession().flush(); return res; diff --git a/src/libs/database/impl/User.cpp b/src/libs/database/impl/User.cpp index 055fcac0..0e3b09bd 100644 --- a/src/libs/database/impl/User.cpp +++ b/src/libs/database/impl/User.cpp @@ -68,7 +68,6 @@ AuthToken::getByValue(Session& session, const std::string& value) .where("value = ?").bind(value); } -static const std::string playedListName {"__played_tracks__"}; static const std::string queuedListName {"__queued_tracks__"}; User::User(std::string_view loginName) @@ -109,7 +108,6 @@ User::create(Session& session, std::string_view loginName) User::pointer user {session.getDboSession().add(std::make_unique(loginName))}; - TrackList::create(session, playedListName, TrackList::Type::Internal, false, user); TrackList::create(session, queuedListName, TrackList::Type::Internal, false, user); session.getDboSession().flush(); @@ -143,15 +141,6 @@ User::clearAuthTokens() _authTokens.clear(); } -Wt::Dbo::ptr -User::getPlayedTrackList(Session& session) const -{ - assert(self()); - session.checkSharedLocked(); - - return TrackList::get(session, playedListName, TrackList::Type::Internal, self()); -} - Wt::Dbo::ptr User::getQueuedTrackList(Session& session) const { diff --git a/src/libs/database/include/database/Track.hpp b/src/libs/database/include/database/Track.hpp index 22e97643..119b7d75 100644 --- a/src/libs/database/include/database/Track.hpp +++ b/src/libs/database/include/database/Track.hpp @@ -81,7 +81,7 @@ class Track : public Wt::Dbo::Dbo static std::vector> getAllPaths(Session& session, std::optional offset = std::nullopt, std::optional size = std::nullopt); static std::vector getMBIDDuplicates(Session& session); static std::vector getLastWritten(Session& session, std::optional after, const std::set& clusters, std::optional range, bool& moreResults); - static std::vector getAllWithMBIDAndMissingFeatures(Session& session); + static std::vector getAllWithRecordingMBIDAndMissingFeatures(Session& session); static std::vector getAllIdsWithFeatures(Session& session, std::optional limit = {}); static std::vector getAllIdsWithClusters(Session& session, std::optional limit = {}); static std::vector getStarred(Session& session, @@ -106,7 +106,8 @@ class Track : public Wt::Dbo::Dbo void setYear(int year) { _year = year; } void setOriginalYear(int year) { _originalYear = year; } void setHasCover(bool hasCover) { _hasCover = hasCover; } - void setMBID(const std::optional& MBID) { _MBID = MBID ? MBID->getAsString() : ""; } + void setTrackMBID(const std::optional& MBID) { _trackMBID = MBID ? MBID->getAsString() : ""; } + void setRecordingMBID(const std::optional& MBID) { _recordingMBID = MBID ? MBID->getAsString() : ""; } void setCopyright(const std::string& copyright) { _copyright = std::string(copyright, 0, _maxCopyrightLength); } void setCopyrightURL(const std::string& copyrightURL) { _copyrightURL = std::string(copyrightURL, 0, _maxCopyrightURLLength); } void setTrackReplayGain(float replayGain) { _trackReplayGain = replayGain; } @@ -131,7 +132,8 @@ class Track : public Wt::Dbo::Dbo Wt::WDateTime getLastWriteTime() const { return _fileLastWrite; } Wt::WDateTime getAddedTime() const { return _fileAdded; } bool hasCover() const { return _hasCover; } - std::optional getMBID() const { return UUID::fromString(_MBID); } + std::optional getTrackMBID() const { return UUID::fromString(_trackMBID); } + std::optional getRecordingMBID() const { return UUID::fromString(_recordingMBID); } std::optional getCopyright() const; std::optional getCopyrightURL() const; std::optional getTrackReplayGain() const { return _trackReplayGain; } @@ -166,7 +168,8 @@ class Track : public Wt::Dbo::Dbo Wt::Dbo::field(a, _fileLastWrite, "file_last_write"); Wt::Dbo::field(a, _fileAdded, "file_added"); Wt::Dbo::field(a, _hasCover, "has_cover"); - Wt::Dbo::field(a, _MBID, "mbid"); + Wt::Dbo::field(a, _trackMBID, "mbid"); + Wt::Dbo::field(a, _recordingMBID, "recording_mbid"); Wt::Dbo::field(a, _copyright, "copyright"); Wt::Dbo::field(a, _copyrightURL, "copyright_url"); Wt::Dbo::field(a, _trackReplayGain, "track_replay_gain"); @@ -201,7 +204,8 @@ class Track : public Wt::Dbo::Dbo Wt::WDateTime _fileLastWrite; Wt::WDateTime _fileAdded; bool _hasCover {}; - std::string _MBID; // Musicbrainz Identifier + std::string _trackMBID; + std::string _recordingMBID; std::string _copyright; std::string _copyrightURL; std::optional _trackReplayGain; diff --git a/src/libs/database/include/database/TrackList.hpp b/src/libs/database/include/database/TrackList.hpp index a7ba1a8c..7e7099da 100644 --- a/src/libs/database/include/database/TrackList.hpp +++ b/src/libs/database/include/database/TrackList.hpp @@ -47,11 +47,11 @@ class TrackList : public Wt::Dbo::Dbo enum class Type { Playlist, // user controlled playlists - Internal, // current playqueue, history + Internal, // internal usage (current playqueue, history, ...) }; TrackList() = default; - TrackList(const std::string& name, Type type, bool isPublic, Wt::Dbo::ptr user); + TrackList(std::string_view name, Type type, bool isPublic, Wt::Dbo::ptr user); // Stats utility std::vector> getTopArtists(const std::set& clusterIds, std::optional linkType, std::optional range, bool& moreResults) const; @@ -59,14 +59,14 @@ class TrackList : public Wt::Dbo::Dbo std::vector> getTopTracks(const std::set& clusterIds, std::optional range, bool& moreResults) const; // Search utility - static pointer get(Session& session, const std::string& name, Type type, Wt::Dbo::ptr user); + static pointer get(Session& session, std::string_view name, Type type, Wt::Dbo::ptr user); static pointer getById(Session& session, IdType tracklistId); static std::vector getAll(Session& session); static std::vector getAll(Session& session, Wt::Dbo::ptr user); static std::vector getAll(Session& session, Wt::Dbo::ptr user, Type type); // Create utility - static pointer create(Session& session, const std::string& name, Type type, bool isPublic, Wt::Dbo::ptr user); + static pointer create(Session& session, std::string_view name, Type type, bool isPublic, Wt::Dbo::ptr user); // Accessors std::string getName() const { return _name; } @@ -84,7 +84,6 @@ class TrackList : public Wt::Dbo::Dbo std::size_t getCount() const; Wt::Dbo::ptr getEntry(std::size_t pos) const; std::vector> getEntries(std::optional offset = {}, std::optional size = {}) const; - std::vector> getEntriesReverse(std::optional offset = {}, std::optional size = {}) const; std::vector> getArtistsReverse(const std::set& clusterIds, std::optional linkType, std::optional range, bool& moreResults) const; std::vector> getReleasesReverse(const std::set& clusterIds, std::optional range, bool& moreResults) const; @@ -131,15 +130,17 @@ class TrackListEntry : public Wt::Dbo::Dbo using pointer = Wt::Dbo::ptr; TrackListEntry() = default; - TrackListEntry(Wt::Dbo::ptr track, Wt::Dbo::ptr tracklist); + TrackListEntry(Wt::Dbo::ptr track, Wt::Dbo::ptr tracklist, const Wt::WDateTime& dateTime); + // find utility static pointer getById(Session& session, IdType id); // Create utility - static pointer create(Session& session, Wt::Dbo::ptr track, Wt::Dbo::ptr tracklist); + static pointer create(Session& session, Wt::Dbo::ptr track, Wt::Dbo::ptr tracklist, const Wt::WDateTime& dateTime = Wt::WDateTime::currentDateTime()); // Accessors Wt::Dbo::ptr getTrack() const { return _track; } + const Wt::WDateTime& getDateTime() const { return _dateTime; } template void persist(Action& a) diff --git a/src/libs/database/include/database/Types.hpp b/src/libs/database/include/database/Types.hpp index 15fdb0b3..080075f1 100644 --- a/src/libs/database/include/database/Types.hpp +++ b/src/libs/database/include/database/Types.hpp @@ -19,6 +19,7 @@ #pragma once +#include #include namespace Database @@ -51,5 +52,23 @@ namespace Database Writer, }; + // User selectable audio file formats + // Do not change values + enum class AudioFormat + { + MP3 = 1, + OGG_OPUS = 2, + OGG_VORBIS = 3, + WEBM_VORBIS = 4, + MATROSKA_OPUS = 5, + }; + using Bitrate = std::uint32_t; + + // Do not change enum values! + enum class Scrobbler + { + Internal = 0, + ListenBrainz = 1, + }; } diff --git a/src/libs/database/include/database/User.hpp b/src/libs/database/include/database/User.hpp index 52932a88..0f5b5758 100644 --- a/src/libs/database/include/database/User.hpp +++ b/src/libs/database/include/database/User.hpp @@ -26,6 +26,7 @@ #include #include +#include "utils/UUID.hpp" #include "Types.hpp" namespace Database { @@ -37,19 +38,6 @@ class Session; class TrackList; class Track; -// User selectable audio file formats -// Do not change values -enum class AudioFormat -{ - MP3 = 1, - OGG_OPUS = 2, - OGG_VORBIS = 3, - WEBM_VORBIS = 4, - MATROSKA_OPUS = 5, -}; - -using Bitrate = std::size_t; - class User; class AuthToken { @@ -139,6 +127,7 @@ class User : public Wt::Dbo::Dbo static inline const Bitrate defaultSubsonicTranscodeBitrate {128000}; static inline const UITheme defaultUITheme {UITheme::Dark}; static inline const SubsonicArtistListMode defaultSubsonicArtistListMode {SubsonicArtistListMode::AllArtists}; + static inline const Scrobbler defaultScrobbler {Scrobbler::Internal}; User() = default; @@ -172,6 +161,8 @@ class User : public Wt::Dbo::Dbo void setUITheme(UITheme uiTheme) { _uiTheme = uiTheme; } void clearAuthTokens(); void setSubsonicArtistListMode(SubsonicArtistListMode mode) { _subsonicArtistListMode = mode; } + void setScrobbler(Scrobbler scrobbler) { _scrobbler = scrobbler; } + void setListenBrainzToken(const std::optional& MBID) { _listenbrainzToken = MBID ? MBID->getAsString() : ""; } // read bool isAdmin() const { return _type == Type::ADMIN; } @@ -184,8 +175,9 @@ class User : public Wt::Dbo::Dbo bool isRadioSet() const { return _radio; } UITheme getUITheme() const { return _uiTheme; } SubsonicArtistListMode getSubsonicArtistListMode() const { return _subsonicArtistListMode; } + Scrobbler getScrobbler() const { return _scrobbler; } + std::optional getListenBrainzToken() const { return UUID::fromString(_listenbrainzToken); } - Wt::Dbo::ptr getPlayedTrackList(Session& session) const; Wt::Dbo::ptr getQueuedTrackList(Session& session) const; void starArtist(Wt::Dbo::ptr artist); @@ -214,10 +206,14 @@ class User : public Wt::Dbo::Dbo Wt::Dbo::field(a, _subsonicTranscodeBitrate, "subsonic_transcode_bitrate"); Wt::Dbo::field(a, _subsonicArtistListMode, "subsonic_artist_list_mode"); Wt::Dbo::field(a, _uiTheme, "ui_theme"); - // User's dynamic data + Wt::Dbo::field(a, _scrobbler, "scrobbler"); + Wt::Dbo::field(a, _listenbrainzToken, "listenbrainz_token"); + + // UI settings Wt::Dbo::field(a, _curPlayingTrackPos, "cur_playing_track_pos"); Wt::Dbo::field(a, _repeatAll, "repeat_all"); Wt::Dbo::field(a, _radio, "radio"); + Wt::Dbo::hasMany(a, _tracklists, Wt::Dbo::ManyToOne, "user"); Wt::Dbo::hasMany(a, _starredArtists, Wt::Dbo::ManyToMany, "user_artist_starred", "", Wt::Dbo::OnDeleteCascade); Wt::Dbo::hasMany(a, _starredReleases, Wt::Dbo::ManyToMany, "user_release_starred", "", Wt::Dbo::OnDeleteCascade); @@ -232,6 +228,8 @@ class User : public Wt::Dbo::Dbo std::string _passwordHash; Wt::WDateTime _lastLogin; UITheme _uiTheme {defaultUITheme}; + Scrobbler _scrobbler {defaultScrobbler}; + std::string _listenbrainzToken; // Musicbrainz Identifier // Admin defined settings Type _type {Type::REGULAR}; diff --git a/src/libs/metadata/impl/AvFormatParser.cpp b/src/libs/metadata/impl/AvFormatParser.cpp index 3bde19ab..164f4c3f 100644 --- a/src/libs/metadata/impl/AvFormatParser.cpp +++ b/src/libs/metadata/impl/AvFormatParser.cpp @@ -200,11 +200,14 @@ AvFormatParser::parse(const std::filesystem::path& p, bool debug) track.acoustID = UUID::fromString(value); } else if (tag == "MUSICBRAINZ RELEASE TRACK ID" - || tag == "MUSICBRAINZ_RELEASETRACKID" - || tag == "MUSICBRAINZ_TRACKID" + || tag == "MUSICBRAINZ_RELEASETRACKID") + { + track.trackMBID = UUID::fromString(value); + } + else if (tag == "MUSICBRAINZ_TRACKID" || tag == "MUSICBRAINZ/TRACK ID") { - track.musicBrainzTrackID = UUID::fromString(value); + track.recordingMBID = UUID::fromString(value); } else if (tag == "TSST" || tag == "DISCSUBTITLE" diff --git a/src/libs/metadata/impl/TagLibParser.cpp b/src/libs/metadata/impl/TagLibParser.cpp index 8b7d70c3..227fc4ed 100644 --- a/src/libs/metadata/impl/TagLibParser.cpp +++ b/src/libs/metadata/impl/TagLibParser.cpp @@ -149,7 +149,6 @@ void TagLibParser::processTag(Track& track, const std::string& tag, const TagLib::StringList& values, bool debug) { - // TODO validate MBID format if (debug) { std::vector strs; @@ -168,11 +167,11 @@ TagLibParser::processTag(Track& track, const std::string& tag, const TagLib::Str else if (tag == "MUSICBRAINZ_RELEASETRACKID" || tag == "MUSICBRAINZ RELEASE TRACK ID") { - track.musicBrainzTrackID = UUID::fromString(value); + track.trackMBID = UUID::fromString(value); } else if (tag == "MUSICBRAINZ_TRACKID" || tag == "MUSICBRAINZ TRACK ID") - track.musicBrainzRecordID = UUID::fromString(value); + track.recordingMBID = UUID::fromString(value); else if (tag == "ACOUSTID_ID") track.acoustID = UUID::fromString(value); else if (tag == "TRACKTOTAL") diff --git a/src/libs/metadata/include/metadata/IParser.hpp b/src/libs/metadata/include/metadata/IParser.hpp index 06a521bf..61244066 100644 --- a/src/libs/metadata/include/metadata/IParser.hpp +++ b/src/libs/metadata/include/metadata/IParser.hpp @@ -59,8 +59,8 @@ namespace MetaData std::vector artists; std::vector albumArtists; std::string title; - std::optional musicBrainzTrackID; - std::optional musicBrainzRecordID; + std::optional trackMBID; + std::optional recordingMBID; std::optional album; Clusters clusters; std::chrono::milliseconds duration; diff --git a/src/libs/scanner/impl/AcousticBrainzUtils.cpp b/src/libs/scanner/impl/AcousticBrainzUtils.cpp index 918b4d13..37f06166 100644 --- a/src/libs/scanner/impl/AcousticBrainzUtils.cpp +++ b/src/libs/scanner/impl/AcousticBrainzUtils.cpp @@ -79,9 +79,9 @@ getJsonData(const UUID& mbid) } std::string -extractLowLevelFeatures(const UUID& mbid) +extractLowLevelFeatures(const UUID& recordingMBID) { - return getJsonData(mbid); + return getJsonData(recordingMBID); } } // namespace Scanner::AcousticBrainz diff --git a/src/libs/scanner/impl/AcousticBrainzUtils.hpp b/src/libs/scanner/impl/AcousticBrainzUtils.hpp index cdd73d77..07787d59 100644 --- a/src/libs/scanner/impl/AcousticBrainzUtils.hpp +++ b/src/libs/scanner/impl/AcousticBrainzUtils.hpp @@ -25,6 +25,6 @@ class UUID; namespace AcousticBrainz { - std::string extractLowLevelFeatures(const UUID& MBID); + std::string extractLowLevelFeatures(const UUID& recordingMBID); } diff --git a/src/libs/scanner/impl/Scanner.cpp b/src/libs/scanner/impl/Scanner.cpp index 3a95b646..f1b1fe3f 100644 --- a/src/libs/scanner/impl/Scanner.cpp +++ b/src/libs/scanner/impl/Scanner.cpp @@ -543,15 +543,15 @@ Scanner::scan(bool forceScan) } bool -Scanner::fetchTrackFeatures(Database::IdType trackId, const UUID& MBID) +Scanner::fetchTrackFeatures(Database::IdType trackId, const UUID& recordingMBID) { std::map features; - LMS_LOG(DBUPDATER, INFO) << "Fetching low level features for track '" << MBID.getAsString() << "'"; - const std::string data {AcousticBrainz::extractLowLevelFeatures(MBID)}; + LMS_LOG(DBUPDATER, INFO) << "Fetching low level features for recording '" << recordingMBID.getAsString() << "'"; + const std::string data {AcousticBrainz::extractLowLevelFeatures(recordingMBID)}; if (data.empty()) { - LMS_LOG(DBUPDATER, ERROR) << "Track " << trackId << ", MBID = '" << MBID.getAsString() << "': cannot extract features using AcousticBrainz"; + LMS_LOG(DBUPDATER, ERROR) << "Track " << trackId << ", recording MBID = '" << recordingMBID.getAsString() << "': cannot extract features using AcousticBrainz"; return false; } @@ -581,7 +581,7 @@ Scanner::fetchTrackFeatures(ScanStats& stats) struct TrackInfo { Database::IdType id; - UUID mbid; + UUID recordingMBID; }; const auto tracksToFetch {[&]() @@ -590,9 +590,9 @@ Scanner::fetchTrackFeatures(ScanStats& stats) auto transaction {_dbSession.createSharedTransaction()}; - auto tracks {Database::Track::getAllWithMBIDAndMissingFeatures(_dbSession)}; + auto tracks {Database::Track::getAllWithRecordingMBIDAndMissingFeatures(_dbSession)}; for (const auto& track : tracks) - res.emplace_back(TrackInfo {track.id(), *track->getMBID()}); + res.emplace_back(TrackInfo {track.id(), *track->getRecordingMBID()}); return res; }()}; @@ -607,7 +607,7 @@ Scanner::fetchTrackFeatures(ScanStats& stats) if (_abortScan) return; - if (fetchTrackFeatures(trackToFetch.id, trackToFetch.mbid)) + if (fetchTrackFeatures(trackToFetch.id, trackToFetch.recordingMBID)) stats.featuresFetched++; stepStats.processedElems++; @@ -824,7 +824,8 @@ Scanner::scanAudioFile(const std::filesystem::path& file, bool forceScan, ScanSt if (!trackInfo->year && trackInfo->originalYear) track.modify()->setYear(*trackInfo->originalYear); - track.modify()->setMBID(trackInfo->musicBrainzRecordID); + track.modify()->setRecordingMBID(trackInfo->recordingMBID); + track.modify()->setTrackMBID(trackInfo->trackMBID); track.modify()->setFeatures({}); // TODO: only if MBID changed? track.modify()->setHasCover(trackInfo->hasCover); track.modify()->setCopyright(trackInfo->copyright); @@ -1022,9 +1023,9 @@ Scanner::checkDuplicatedAudioFiles(ScanStats& stats) const std::vector tracks = Database::Track::getMBIDDuplicates(_dbSession); for (const Track::pointer& track : tracks) { - if (track->getMBID()) + if (auto trackMBID {track->getTrackMBID()}) { - LMS_LOG(DBUPDATER, INFO) << "Found duplicated MBID [" << track->getMBID()->getAsString() << "], file: " << track->getPath().string() << " - " << track->getName(); + LMS_LOG(DBUPDATER, INFO) << "Found duplicated Track MBID [" << trackMBID->getAsString() << "], file: " << track->getPath().string() << " - " << track->getName(); stats.duplicates.emplace_back(ScanDuplicate {track.id(), DuplicateReason::SameMBID}); } } diff --git a/src/libs/scrobbling/CMakeLists.txt b/src/libs/scrobbling/CMakeLists.txt new file mode 100644 index 00000000..162f1c9c --- /dev/null +++ b/src/libs/scrobbling/CMakeLists.txt @@ -0,0 +1,23 @@ + +add_library(lmsscrobbling SHARED + impl/internal/InternalScrobbler.cpp + impl/listenbrainz/ListenBrainzScrobbler.cpp + impl/Scrobbling.cpp + ) + +target_include_directories(lmsscrobbling INTERFACE + include + ) + +target_include_directories(lmsscrobbling PRIVATE + include + impl + ) + +target_link_libraries(lmsscrobbling PUBLIC + lmsdatabase + lmsutils + ) + +install(TARGETS lmsscrobbling DESTINATION lib) + diff --git a/src/libs/scrobbling/impl/IScrobbler.hpp b/src/libs/scrobbling/impl/IScrobbler.hpp new file mode 100644 index 00000000..9359a071 --- /dev/null +++ b/src/libs/scrobbling/impl/IScrobbler.hpp @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2021 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 "scrobbling/Listen.hpp" + +namespace Database +{ + class Db; + class Session; + class TrackList; + class User; +} + +namespace Scrobbling +{ + + class IScrobbler + { + public: + virtual ~IScrobbler() = default; + + virtual void listenStarted(const Listen& listen) = 0; + virtual void listenFinished(const Listen& listen, std::chrono::seconds duration) = 0; + + virtual void addListen(const Listen& listen, const Wt::WDateTime& timePoint) = 0; + + virtual Wt::Dbo::ptr getListensTrackList(Database::Session& session, Wt::Dbo::ptr user) = 0; + }; + + std::unique_ptr createScrobbler(std::string_view backendName); + +} // ns Scrobbling + diff --git a/src/libs/scrobbling/impl/Scrobbling.cpp b/src/libs/scrobbling/impl/Scrobbling.cpp new file mode 100644 index 00000000..6b755f8e --- /dev/null +++ b/src/libs/scrobbling/impl/Scrobbling.cpp @@ -0,0 +1,186 @@ +/* + * Copyright (C) 2021 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 "Scrobbling.hpp" + +#include "database/Db.hpp" +#include "database/Session.hpp" +#include "database/TrackList.hpp" +#include "database/User.hpp" + +#include "internal/InternalScrobbler.hpp" +#include "listenbrainz/ListenBrainzScrobbler.hpp" + +namespace Scrobbling +{ + std::unique_ptr + createScrobbling(Database::Db& db) + { + return std::make_unique(db); + } + + Scrobbling::Scrobbling(Database::Db& db) + : _db {db} + { + _scrobblers.emplace(Database::Scrobbler::Internal, std::make_unique(_db)); + _scrobblers.emplace(Database::Scrobbler::ListenBrainz, std::make_unique(_db)); + } + + void + Scrobbling::listenStarted(const Listen& listen) + { + if (auto scrobbler {getUserScrobbler(listen.userId)}) + _scrobblers[*scrobbler]->listenStarted(listen); + } + + void + Scrobbling::listenFinished(const Listen& listen, std::chrono::seconds duration) + { + if (auto scrobbler {getUserScrobbler(listen.userId)}) + _scrobblers[*scrobbler]->listenFinished(listen, duration); + } + + void + Scrobbling::addListen(const Listen& listen, Wt::WDateTime timePoint) + { + if (auto scrobbler {getUserScrobbler(listen.userId)}) + _scrobblers[*scrobbler]->addListen(listen, timePoint); + } + + std::optional + Scrobbling::getUserScrobbler(Database::IdType userId) + { + std::optional scrobbler; + + Database::Session& session {_db.getTLSSession()}; + auto transaction {session.createSharedTransaction()}; + if (const Database::User::pointer user {Database::User::getById(session, userId)}) + scrobbler = user->getScrobbler(); + + return scrobbler; + } + + std::vector> + Scrobbling::getRecentArtists(Database::Session& session, + Wt::Dbo::ptr user, + const std::set& clusterIds, + std::optional linkType, + std::optional range, + bool& moreResults) + { + const Wt::Dbo::ptr history {getListensTrackList(session, user)}; + + std::vector> res; + if (history) + res = history->getArtistsReverse(clusterIds, linkType, range, moreResults); + + return res; + } + + std::vector> + Scrobbling::getRecentReleases(Database::Session& session, + Wt::Dbo::ptr user, + const std::set& clusterIds, + std::optional range, + bool& moreResults) + { + const Wt::Dbo::ptr history {getListensTrackList(session, user)}; + + std::vector> res; + if (history) + res = history->getReleasesReverse(clusterIds, range, moreResults); + + return res; + } + + std::vector> + Scrobbling::getRecentTracks(Database::Session& session, + Wt::Dbo::ptr user, + const std::set& clusterIds, + std::optional range, + bool& moreResults) + { + const Wt::Dbo::ptr history {getListensTrackList(session, user)}; + + std::vector> res; + if (history) + res = history->getTracksReverse(clusterIds, range, moreResults); + + return res; + } + + + // Top + std::vector> + Scrobbling::getTopArtists(Database::Session& session, + Wt::Dbo::ptr user, + const std::set& clusterIds, + std::optional linkType, + std::optional range, + bool& moreResults) + { + const Wt::Dbo::ptr history {getListensTrackList(session, user)}; + + std::vector> res; + if (history) + res = history->getTopArtists(clusterIds, linkType, range, moreResults); + + return res; + } + + std::vector> + Scrobbling::getTopReleases(Database::Session& session, + Wt::Dbo::ptr user, + const std::set& clusterIds, + std::optional range, + bool& moreResults) + { + const Wt::Dbo::ptr history {getListensTrackList(session, user)}; + + std::vector> res; + if (history) + res = history->getTopReleases(clusterIds, range, moreResults); + + return res; + } + + std::vector> + Scrobbling::getTopTracks(Database::Session& session, + Wt::Dbo::ptr user, + const std::set& clusterIds, + std::optional range, + bool& moreResults) + { + const Wt::Dbo::ptr history {getListensTrackList(session, user)}; + + std::vector> res; + if (history) + res = history->getTopTracks(clusterIds, range, moreResults); + + return res; + } + + Wt::Dbo::ptr + Scrobbling::getListensTrackList(Database::Session& session, Wt::Dbo::ptr user) + { + return _scrobblers[user->getScrobbler()]->getListensTrackList(session, user); + } + +} // ns Scrobbling + diff --git a/src/libs/scrobbling/impl/Scrobbling.hpp b/src/libs/scrobbling/impl/Scrobbling.hpp new file mode 100644 index 00000000..e4ac4256 --- /dev/null +++ b/src/libs/scrobbling/impl/Scrobbling.hpp @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2021 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 "scrobbling/IScrobbling.hpp" +#include "IScrobbler.hpp" + +namespace Scrobbling +{ + class Scrobbling : public IScrobbling + { + public: + + Scrobbling(Database::Db& db); + + private: + void listenStarted(const Listen& listen) override; + void listenFinished(const Listen& listen, std::chrono::seconds duration) override; + void addListen(const Listen& listen, Wt::WDateTime timePoint) override; + + std::vector> getRecentArtists(Database::Session& session, + Wt::Dbo::ptr user, + const std::set& clusterIds, + std::optional linkType, + std::optional range, + bool& moreResults) override; + + std::vector> getRecentReleases(Database::Session& session, + Wt::Dbo::ptr user, + const std::set& clusterIds, + std::optional range, + bool& moreResults) override; + + std::vector> getRecentTracks(Database::Session& session, + Wt::Dbo::ptr user, + const std::set& clusterIds, + std::optional range, + bool& moreResults) override; + + std::vector> getTopArtists(Database::Session& session, + Wt::Dbo::ptr user, + const std::set& clusterIds, + std::optional linkType, + std::optional range, + bool& moreResults) override; + + std::vector> getTopReleases(Database::Session& session, + Wt::Dbo::ptr user, + const std::set& clusterIds, + std::optional range, + bool& moreResults) override; + + std::vector> getTopTracks(Database::Session& session, + Wt::Dbo::ptr user, + const std::set& clusterIds, + std::optional range, + bool& moreResults) override; + + Wt::Dbo::ptr getListensTrackList(Database::Session& session, Wt::Dbo::ptr user); + + std::optional getUserScrobbler(Database::IdType userId); + + Database::Db& _db; + std::unordered_map> _scrobblers; + }; + +} // ns Scrobbling + diff --git a/src/libs/scrobbling/impl/internal/InternalScrobbler.cpp b/src/libs/scrobbling/impl/internal/InternalScrobbler.cpp new file mode 100644 index 00000000..cd0ff9d2 --- /dev/null +++ b/src/libs/scrobbling/impl/internal/InternalScrobbler.cpp @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2021 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 "InternalScrobbler.hpp" + +#include "database/Db.hpp" +#include "database/Session.hpp" +#include "database/Track.hpp" +#include "database/TrackList.hpp" +#include "database/User.hpp" +#include "utils/Logger.hpp" + +namespace Scrobbling +{ + static const std::string historyTracklistName {"__scrobbler_internal_history__"}; + + InternalScrobbler::InternalScrobbler(Database::Db& db) + : _db {db} + {} + + void + InternalScrobbler::listenStarted(const Listen& listen) + { + addListen(listen, Wt::WDateTime::currentDateTime()); + } + + void + InternalScrobbler::listenFinished(const Listen& /*event*/, std::chrono::seconds /* duration */) + { + // nothing to do + } + + void + InternalScrobbler::addListen(const Listen& listen, const Wt::WDateTime& timePoint) + { + Database::Session& session {_db.getTLSSession()}; + + auto transaction {session.createUniqueTransaction()}; + + const Database::User::pointer user {Database::User::getById(session, listen.userId)}; + if (!user) + return; + + Wt::Dbo::ptr tracklist {getListensTrackList(session, user)}; + if (!tracklist) + tracklist = Database::TrackList::create(session, historyTracklistName, Database::TrackList::Type::Internal, false, user); + + const Database::Track::pointer track {Database::Track::getById(session, listen.trackId)}; + if (!track) + return; + + Database::TrackListEntry::create(session, track, getListensTrackList(session, user), timePoint); + } + + Wt::Dbo::ptr + InternalScrobbler::getListensTrackList(Database::Session& session, Wt::Dbo::ptr user) + { + return Database::TrackList::get(session, historyTracklistName, Database::TrackList::Type::Internal, user); + } + +} // Scrobbling + diff --git a/src/libs/scrobbling/impl/internal/InternalScrobbler.hpp b/src/libs/scrobbling/impl/internal/InternalScrobbler.hpp new file mode 100644 index 00000000..062c3f95 --- /dev/null +++ b/src/libs/scrobbling/impl/internal/InternalScrobbler.hpp @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2021 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 "IScrobbler.hpp" + +namespace Scrobbling +{ + class InternalScrobbler final : public IScrobbler + { + public: + InternalScrobbler(Database::Db& db); + + private: + + void listenStarted(const Listen& listen) override; + void listenFinished(const Listen& listen, std::chrono::seconds duration) override; + + void addListen(const Listen& listen, const Wt::WDateTime& timePoint) override; + + Wt::Dbo::ptr getListensTrackList(Database::Session& session, Wt::Dbo::ptr user) override; + + Database::Db& _db; + }; +} // Scrobbling + diff --git a/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.cpp b/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.cpp new file mode 100644 index 00000000..c1f86aeb --- /dev/null +++ b/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.cpp @@ -0,0 +1,400 @@ +/* + * Copyright (C) 2021 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 "ListenBrainzScrobbler.hpp" + +#include +#include +#include +#include + +#include "database/Artist.hpp" +#include "database/Db.hpp" +#include "database/Release.hpp" +#include "database/Session.hpp" +#include "database/Track.hpp" +#include "database/TrackList.hpp" +#include "database/User.hpp" +#include "utils/IConfig.hpp" +#include "utils/Logger.hpp" +#include "utils/Service.hpp" + +#define LOG(sev) LMS_LOG(SCROBBLING, sev) << "[listenbrainz] - " + +namespace StringUtils +{ + template<> + std::optional + readAs(const std::string& str) + { + std::optional res; + + if (const std::optional value {StringUtils::readAs(str)}) + res = std::chrono::seconds {*value}; + + return res; + } +} + +namespace +{ + std::optional + getListenBrainzToken(Database::Session& session, Database::IdType userId) + { + auto transaction {session.createSharedTransaction()}; + + const Database::User::pointer user {Database::User::getById(session, userId)}; + if (!user) + return std::nullopt; + + if (user->getScrobbler() != Database::Scrobbler::ListenBrainz) + return std::nullopt; + + return user->getListenBrainzToken(); + } + + bool + canBeScrobbled(Database::Session& session, Database::IdType trackId, std::chrono::seconds duration) + { + auto transaction {session.createSharedTransaction()}; + + const Database::Track::pointer track {Database::Track::getById(session, trackId)}; + if (!track) + return false; + + const bool res {duration >= std::chrono::minutes(4) || (duration >= track->getDuration() / 2)}; + if (!res) + LOG(DEBUG) << "Track cannot be scrobbled since played duration is too short: " << duration.count() << "s, total duration = " << std::chrono::duration_cast(track->getDuration()).count() << "s"; + + return res; + } + + std::optional + listenToJsonPayload(Database::Session& session, const Scrobbling::Listen& listen, const Wt::WDateTime& timePoint) + { + auto transaction {session.createSharedTransaction()}; + + const Database::Track::pointer track {Database::Track::getById(session, listen.trackId)}; + if (!track) + return std::nullopt; + + auto artists {track->getArtists({Database::TrackArtistLinkType::Artist})}; + if (artists.empty()) + artists = track->getArtists({Database::TrackArtistLinkType::ReleaseArtist}); + + Wt::Json::Object additionalInfo; + additionalInfo["listening_from"] = "LMS"; + if (track->getRelease()) + { + if (auto MBID {track->getRelease()->getMBID()}) + additionalInfo["release_mbid"] = Wt::Json::Value {std::string {MBID->getAsString()}}; + } + + if (!artists.empty()) + { + Wt::Json::Array artistMBIDs; + for (const Database::Artist::pointer& artist : artists) + { + if (auto MBID {artist->getMBID()}) + artistMBIDs.push_back(Wt::Json::Value {std::string {MBID->getAsString()}}); + } + + if (!artistMBIDs.empty()) + additionalInfo["artist_mbids"] = std::move(artistMBIDs); + } + + if (auto MBID {track->getTrackMBID()}) + additionalInfo["track_mbid"] = Wt::Json::Value {std::string {MBID->getAsString()}}; + + if (auto MBID {track->getRecordingMBID()}) + additionalInfo["recording_mbid"] = Wt::Json::Value {std::string {MBID->getAsString()}}; + + if (const std::optional trackNumber {track->getTrackNumber()}) + additionalInfo["tracknumber"] = Wt::Json::Value {static_cast(*trackNumber)}; + + Wt::Json::Object trackMetadata; + trackMetadata["additional_info"] = std::move(additionalInfo); + + if (!artists.empty()) + trackMetadata["artist_name"] = Wt::Json::Value {artists.front()->getName()}; + + trackMetadata["track_name"] = Wt::Json::Value {track->getName()}; + if (track->getRelease()) + trackMetadata["release_name"] = Wt::Json::Value {track->getRelease()->getName()}; + + Wt::Json::Object payload; + payload["track_metadata"] = std::move(trackMetadata); + if (timePoint.isValid()) + payload["listened_at"] = Wt::Json::Value {static_cast(timePoint.toTime_t())}; + + return payload; + } + + std::string + listenToJsonString(Database::Session& session, const Scrobbling::Listen& listen, const Wt::WDateTime& timePoint, std::string_view listenType) + { + std::string res; + + std::optional payload {listenToJsonPayload(session, listen, timePoint)}; + if (!payload) + return res; + + Wt::Json::Object root; + root["listen_type"] = Wt::Json::Value {std::string {listenType}}; + root["payload"] = Wt::Json::Array {std::move(*payload)}; + + res = Wt::Json::serialize(root); + return res; + } + + template + std::optional + headerReadAs(const Wt::Http::Message& msg, std::string_view headerName) + { + std::optional res; + + if (const std::string* headerValue {msg.getHeader(std::string {headerName})}) + res = StringUtils::readAs(*headerValue); + + return res; + } +} + +namespace Scrobbling +{ + static const std::string historyTracklistName {"__scrobbler_listenbrainz_history__"}; + + ListenBrainzScrobbler::ListenBrainzScrobbler(Database::Db& db) + : _apiEndpoint {Service::get()->getString("listenbrainz-api-url", "https://api.listenbrainz.org/1/")} + , _db {db} + { + LOG(INFO) << "Starting ListenBrainz scrobbler... API endpoint = '" << _apiEndpoint << "'"; + + _client.done().connect([this](Wt::AsioWrapper::error_code ec, Wt::Http::Message msg) + { + onClientDone(ec, msg); + }); + + _ioService.setThreadCount(1); + _ioService.start(); + } + + ListenBrainzScrobbler::~ListenBrainzScrobbler() + { + _ioService.stop(); + + LOG(INFO) << "Stopped ListenBrainz scrobbler"; + } + + void + ListenBrainzScrobbler::listenStarted(const Listen& listen) + { + _ioService.post([=] + { + enqueListen(listen, Wt::WDateTime {}); + }); + } + + void + ListenBrainzScrobbler::listenFinished(const Listen& listen, std::chrono::seconds duration) + { + if (!canBeScrobbled(_db.getTLSSession(), listen.trackId, duration)) + return; + + Listen timedListen {listen}; + const Wt::WDateTime now {Wt::WDateTime::currentDateTime().addSecs(-duration.count())}; + + _ioService.post([=] + { + enqueListen(timedListen, now); + }); + } + + void + ListenBrainzScrobbler::addListen(const Listen& listen, const Wt::WDateTime& timePoint) + { + assert(timePoint.isValid()); + + _ioService.post([=] + { + enqueListen(listen, timePoint); + }); + } + + Wt::Dbo::ptr + ListenBrainzScrobbler::getListensTrackList(Database::Session& session, Wt::Dbo::ptr user) + { + return Database::TrackList::get(session, historyTracklistName, Database::TrackList::Type::Internal, user); + } + + void + ListenBrainzScrobbler::enqueListen(const Listen& listen, const Wt::WDateTime& timePoint) + { + if (!timePoint.isValid()) + { + // If we are currently throttled, just replace the entry if it has no timePoint + // in order to only report the newest track listened to + // If not throttled, just search past the next current first message as it is being sent + + const std::size_t offset {_state == State::Throttled ? std::size_t {0} : std::size_t {1}}; + if (_sendQueue.size() > offset) + { + _sendQueue.erase(std::remove_if(std::next(std::begin(_sendQueue), offset), std::end(_sendQueue), + [&](const QueuedListen& queuedListen) { return queuedListen.listen.userId == listen.userId && !queuedListen.timePoint.isValid(); }), std::end(_sendQueue)); + } + } + + _sendQueue.emplace_back(QueuedListen {listen, timePoint}); + + if (_state == State::Idle) + sendNextQueuedListen(); + } + + void + ListenBrainzScrobbler::sendNextQueuedListen() + { + assert(_state == State::Idle); + if (_sendQueue.empty()) + return; + + sendListen(_sendQueue.front().listen, _sendQueue.front().timePoint); + _state = State::Sending; + } + + void + ListenBrainzScrobbler::sendListen(const Listen& listen, const Wt::WDateTime& timePoint) + { + Database::Session& session {_db.getTLSSession()}; + + const std::optional listenBrainzToken {getListenBrainzToken(session, listen.userId)}; + if (!listenBrainzToken) + return; + + std::string payload {listenToJsonString(session, listen, timePoint, timePoint.isValid() ? "single" : "playing_now")}; + if (payload.empty()) + { + LOG(DEBUG) << "Cannot convert listen to json: skipping"; + return; + } + + // now send this + Wt::Http::Message message; + message.addHeader("Authorization", "Token " + std::string {listenBrainzToken->getAsString()}); + message.addBodyText(payload); + + const std::string endPoint {_apiEndpoint + "submit-listens"}; + if (!_client.post(endPoint, message)) + LOG(ERROR) << "Cannot post to '" << endPoint << "': invalid scheme or URL?"; + + LOG(DEBUG) << "POST done to '" << endPoint << "'"; + } + + void + ListenBrainzScrobbler::onClientDone(Wt::AsioWrapper::error_code ec, const Wt::Http::Message& msg) + { + assert(!_sendQueue.empty()); + QueuedListen& queuedListen {_sendQueue.front()}; + + _state = State::Idle; + + LOG(DEBUG) << "POST done. status = " << msg.status() << ", msg = '" << msg.body() << "'"; + if (ec) + { + LOG(ERROR) << "Client error: " << ec.message(); + // may be a network error, try again later + if (++queuedListen.retryCount > _maxRetryCount) + _sendQueue.pop_front(); + + throttle(_defaultRetryWaitDuration); + return; + } + + bool mustThrottle{}; + + switch (msg.status()) + { + case 429: + mustThrottle = true; + break; + + case 200: + if (queuedListen.timePoint.isValid()) + cacheListen(queuedListen.listen, queuedListen.timePoint); + _sendQueue.pop_front(); + break; + + default: + LOG(ERROR) << "Submit error: '" << msg.body() << "'"; + _sendQueue.pop_front(); + break; + } + + const auto remainingCount {headerReadAs(msg, "X-RateLimit-Remaining")}; + 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)); + } + else + { + sendNextQueuedListen(); + } + } + + void + ListenBrainzScrobbler::throttle(std::chrono::seconds requestedDuration) + { + assert(_state == State::Idle); + + const std::chrono::seconds duration {requestedDuration.count() > 0 ? requestedDuration : std::chrono::seconds {1}}; + LOG(DEBUG) << "Throttling for " << duration.count() << " seconds"; + + _ioService.schedule(duration, [this] + { + _state = State::Idle; + sendNextQueuedListen(); + }); + _state = State::Throttled; + } + + void + ListenBrainzScrobbler::cacheListen(const Listen& listen, const Wt::WDateTime& timePoint) + { + Database::Session& session {_db.getTLSSession()}; + + auto transaction {session.createUniqueTransaction()}; + + const Database::User::pointer user {Database::User::getById(session, listen.userId)}; + if (!user) + return; + + const Database::Track::pointer track {Database::Track::getById(session, listen.trackId)}; + if (!track) + return; + + Database::TrackList::pointer tracklist {getListensTrackList(session, user)}; + if (!tracklist) + tracklist = Database::TrackList::create(session, historyTracklistName, Database::TrackList::Type::Internal, false, user); + + Database::TrackListEntry::create(session, track, getListensTrackList(session, user), timePoint); + } + +} // Scrobbling + diff --git a/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.hpp b/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.hpp new file mode 100644 index 00000000..98611bc3 --- /dev/null +++ b/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.hpp @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2021 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 "IScrobbler.hpp" + +namespace Database +{ + class Db; + class Session; + class TrackList; +} + +namespace Scrobbling +{ + class ListenBrainzScrobbler final : public IScrobbler + { + public: + ListenBrainzScrobbler(Database::Db& db); + ~ListenBrainzScrobbler(); + + ListenBrainzScrobbler(const ListenBrainzScrobbler&) = delete; + ListenBrainzScrobbler(const ListenBrainzScrobbler&&) = delete; + ListenBrainzScrobbler& operator=(const ListenBrainzScrobbler&) = delete; + ListenBrainzScrobbler& operator=(const ListenBrainzScrobbler&&) = delete; + + private: + void listenStarted(const Listen& listen) override; + void listenFinished(const Listen& listen, std::chrono::seconds duration) override; + void addListen(const Listen& listen, const Wt::WDateTime& timePoint) override; + + Wt::Dbo::ptr getListensTrackList(Database::Session& session, Wt::Dbo::ptr user) override; + + void enqueListen(const Listen& listen, const Wt::WDateTime& timePoint); + void sendNextQueuedListen(); + void sendListen(const Listen& listen, const Wt::WDateTime& timePoint); + void onClientDone(Wt::AsioWrapper::error_code ec, const Wt::Http::Message& msg); + void throttle(std::chrono::seconds duration); + + void cacheListen(const Listen& listen, const Wt::WDateTime& timePoint); + + enum class State + { + Idle, + Throttled, + Sending, + }; + State _state {State::Idle}; + + const std::string _apiEndpoint; + const std::size_t _maxRetryCount {2}; + const std::chrono::seconds _defaultRetryWaitDuration {30}; + + Database::Db& _db; + Wt::WIOService _ioService; + Wt::Http::Client _client {_ioService}; + + struct QueuedListen + { + Listen listen; + Wt::WDateTime timePoint; + std::size_t retryCount {}; + }; + std::deque _sendQueue; + }; +} // Scrobbling + diff --git a/src/libs/scrobbling/include/scrobbling/IScrobbling.hpp b/src/libs/scrobbling/include/scrobbling/IScrobbling.hpp new file mode 100644 index 00000000..3a5a3e0f --- /dev/null +++ b/src/libs/scrobbling/include/scrobbling/IScrobbling.hpp @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2021 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 "scrobbling/Listen.hpp" + +namespace Database +{ + class Artist; + class Db; + class Release; + class Session; + class Track; + class User; +} + +namespace Scrobbling +{ + + class IScrobbling + { + public: + virtual ~IScrobbling() = default; + + // Scrobbling + virtual void listenStarted(const Listen& listen) = 0; + virtual void listenFinished(const Listen& listen, std::chrono::seconds duration) = 0; + + virtual void addListen(const Listen& listen, Wt::WDateTime timePoint) = 0; + + // Stats + // From most recent to oldest + virtual std::vector> getRecentArtists(Database::Session& session, + Wt::Dbo::ptr user, + const std::set& clusterIds, + std::optional linkType, + std::optional range, + bool& moreResults) = 0; + + virtual std::vector> getRecentReleases(Database::Session& session, + Wt::Dbo::ptr user, + const std::set& clusterIds, + std::optional range, + bool& moreResults) = 0; + + virtual std::vector> getRecentTracks(Database::Session& session, + Wt::Dbo::ptr user, + const std::set& clusterIds, + std::optional range, + bool& moreResults) = 0; + + // Top + virtual std::vector> getTopArtists(Database::Session& session, + Wt::Dbo::ptr user, + const std::set& clusterIds, + std::optional linkType, + std::optional range, + bool& moreResults) = 0; + + virtual std::vector> getTopReleases(Database::Session& session, + Wt::Dbo::ptr user, + const std::set& clusterIds, + std::optional range, + bool& moreResults) = 0; + + virtual std::vector> getTopTracks(Database::Session& session, + Wt::Dbo::ptr user, + const std::set& clusterIds, + std::optional range, + bool& moreResults) = 0; + }; + + std::unique_ptr createScrobbling(Database::Db& db); + +} // ns Scrobbling + diff --git a/src/libs/scrobbling/include/scrobbling/Listen.hpp b/src/libs/scrobbling/include/scrobbling/Listen.hpp new file mode 100644 index 00000000..48c10456 --- /dev/null +++ b/src/libs/scrobbling/include/scrobbling/Listen.hpp @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2021 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 "database/Types.hpp" + +namespace Scrobbling +{ + struct Listen + { + Database::IdType userId {}; + Database::IdType trackId {}; + }; +} // ns Scrobbling + diff --git a/src/libs/subsonic/CMakeLists.txt b/src/libs/subsonic/CMakeLists.txt index e11f5814..e4aeeb0f 100644 --- a/src/libs/subsonic/CMakeLists.txt +++ b/src/libs/subsonic/CMakeLists.txt @@ -23,6 +23,7 @@ target_link_libraries(lmssubsonic PRIVATE lmsdatabase lmsrecommendation lmsscanner + lmsscrobbling lmsutils std::filesystem ) diff --git a/src/libs/subsonic/impl/SubsonicResource.cpp b/src/libs/subsonic/impl/SubsonicResource.cpp index 70ba40ba..854e8cf8 100644 --- a/src/libs/subsonic/impl/SubsonicResource.cpp +++ b/src/libs/subsonic/impl/SubsonicResource.cpp @@ -38,6 +38,7 @@ #include "database/TrackList.hpp" #include "database/User.hpp" #include "recommendation/IEngine.hpp" +#include "scrobbling/IScrobbling.hpp" #include "utils/Logger.hpp" #include "utils/Random.hpp" #include "utils/Service.hpp" @@ -815,7 +816,7 @@ handleGetAlbumListRequestCommon(const RequestContext& context, bool id3) else if (type == "frequent") { bool moreResults {}; - releases = user->getPlayedTrackList(context.dbSession)->getTopReleases({}, range, moreResults); + releases = Service::get()->getTopReleases(context.dbSession, user, {}, range, moreResults); } else if (type == "newest") { @@ -830,7 +831,7 @@ handleGetAlbumListRequestCommon(const RequestContext& context, bool id3) else if (type == "recent") { bool moreResults {}; - releases = user->getPlayedTrackList(context.dbSession)->getReleasesReverse({}, range, moreResults); + releases = Service::get()->getRecentReleases(context.dbSession, user, {}, range, moreResults); } else if (type == "starred") { @@ -1643,26 +1644,52 @@ Response handleScrobble(RequestContext& context) { const std::vector ids {getMandatoryMultiParametersAs(context.parameters, "id")}; - // TODO handle time in some way (need underlying refacto) + const std::vector times {getMandatoryMultiParametersAs(context.parameters, "time")}; if (!std::all_of(std::cbegin(ids), std::cend(ids), [](const Id& id) { return id.type == Id::Type::Track; })) throw BadParameterGenericError {"id"}; - auto transaction {context.dbSession.createUniqueTransaction()}; + if (ids.size() != times.size()) + throw BadParameterGenericError {"time"}; - User::pointer user {User::getById(context.dbSession, context.userId)}; - if (!user) - throw RequestedDataNotFoundError {}; + struct Scrobble + { + Scrobbling::Listen listen; + Wt::WDateTime timePoint; + }; + + std::vector scrobbles; + scrobbles.reserve(ids.size()); - for (Id id : ids) { - Track::pointer track {Track::getById(context.dbSession, id.value)}; - if (!track) - continue; + auto transaction {context.dbSession.createSharedTransaction()}; - TrackListEntry::create(context.dbSession, track, user->getPlayedTrackList(context.dbSession)); + User::pointer user {User::getById(context.dbSession, context.userId)}; + if (!user) + throw RequestedDataNotFoundError {}; + + Scrobble scrobble; + scrobble.listen.userId = context.userId; + + for (std::size_t i {}; i < ids.size(); ++i) + { + const Id id {ids[i]}; + const unsigned long time {times[i]}; + + const Track::pointer track {Track::getById(context.dbSession, id.value)}; + if (!track) + continue; + + scrobble.listen.trackId = id.value; + scrobble.timePoint.setTime_t(static_cast(time / 1000)); + + scrobbles.emplace_back(scrobble); + } } + for (const Scrobble& scrobble : scrobbles) + Service::get()->addListen(scrobble.listen, scrobble.timePoint); + return Response::createOkResponse(context); } diff --git a/src/libs/utils/impl/Logger.cpp b/src/libs/utils/impl/Logger.cpp index 1f3dbca4..3c0d6668 100644 --- a/src/libs/utils/impl/Logger.cpp +++ b/src/libs/utils/impl/Logger.cpp @@ -34,6 +34,7 @@ const char* getModuleName(Module mod) case Module::MAIN: return "MAIN"; case Module::METADATA: return "METADATA"; case Module::REMOTE: return "REMOTE"; + case Module::SCROBBLING: return "SCROBBLING"; case Module::SERVICE: return "SERVICE"; case Module::RECOMMENDATION: return "RECOMMENDATION"; case Module::TRANSCODE: return "TRANSCODE"; diff --git a/src/libs/utils/include/utils/Logger.hpp b/src/libs/utils/include/utils/Logger.hpp index 8e9be839..4da7f4ba 100644 --- a/src/libs/utils/include/utils/Logger.hpp +++ b/src/libs/utils/include/utils/Logger.hpp @@ -46,6 +46,7 @@ enum class Module MAIN, METADATA, REMOTE, + SCROBBLING, SERVICE, RECOMMENDATION, TRANSCODE, diff --git a/src/lms/CMakeLists.txt b/src/lms/CMakeLists.txt index d81ae7b4..1dada5af 100644 --- a/src/lms/CMakeLists.txt +++ b/src/lms/CMakeLists.txt @@ -19,6 +19,7 @@ add_executable(lms ui/common/LoginNameValidator.cpp ui/common/MandatoryValidator.cpp ui/common/PasswordValidator.cpp + ui/common/UUIDValidator.cpp ui/explore/ArtistListHelpers.cpp ui/explore/ArtistView.cpp ui/explore/ArtistsView.cpp @@ -49,6 +50,7 @@ target_link_libraries(lms PRIVATE lmsdatabase lmsrecommendation lmsscanner + lmsscrobbling lmssubsonic lmsutils Wt::Wt diff --git a/src/lms/main.cpp b/src/lms/main.cpp index 38a450d8..15f04cdc 100644 --- a/src/lms/main.cpp +++ b/src/lms/main.cpp @@ -33,6 +33,7 @@ #include "scanner/IScanner.hpp" #include "recommendation/IEngine.hpp" #include "subsonic/SubsonicResource.hpp" +#include "scrobbling/IScrobbling.hpp" #include "ui/LmsApplication.hpp" #include "ui/LmsApplicationManager.hpp" #include "utils/IChildProcessManager.hpp" @@ -259,6 +260,8 @@ int main(int argc, char* argv[]) coverArtService->flushCache(); }); + Service scrobblingService {Scrobbling::createScrobbling(database)}; + API::Subsonic::SubsonicResource subsonicResource {database}; // bind API resources diff --git a/src/lms/ui/LmsApplication.cpp b/src/lms/ui/LmsApplication.cpp index a778c796..6662d59a 100644 --- a/src/lms/ui/LmsApplication.cpp +++ b/src/lms/ui/LmsApplication.cpp @@ -37,6 +37,7 @@ #include "database/Release.hpp" #include "database/Session.hpp" #include "database/User.hpp" +#include "scrobbling/IScrobbling.hpp" #include "utils/Logger.hpp" #include "utils/Service.hpp" #include "utils/String.hpp" @@ -544,6 +545,21 @@ LmsApplication::createHome() { _playQueue->playPrevious(); }); + + _mediaPlayer->scrobbleListenNow.connect([this](Database::IdType trackId) + { + LMS_LOG(UI, DEBUG) << "Received ScrobbleListenNow from player for trackId = " << trackId; + const Scrobbling::Listen listen {getUserId(), trackId}; + Service::get()->listenStarted(listen); + }); + _mediaPlayer->scrobbleListenFinished.connect([this](Database::IdType trackId, unsigned durationMs) + { + LMS_LOG(UI, DEBUG) << "Received ScrobbleListenFinished from player for trackId = " << trackId << ", duration = " << (durationMs / 1000) << "s"; + const std::chrono::milliseconds duration {durationMs}; + const Scrobbling::Listen listen {getUserId(), trackId}; + Service::get()->listenFinished(listen, std::chrono::duration_cast(duration)); + }); + _mediaPlayer->playbackEnded.connect([this] { _playQueue->playNext(); diff --git a/src/lms/ui/MediaPlayer.cpp b/src/lms/ui/MediaPlayer.cpp index cb771f2b..5095c5a7 100644 --- a/src/lms/ui/MediaPlayer.cpp +++ b/src/lms/ui/MediaPlayer.cpp @@ -193,9 +193,11 @@ static MediaPlayer::Settings settingsfromJSString(const std::string& strSettings MediaPlayer::MediaPlayer() : Wt::WTemplate {Wt::WString::tr("Lms.MediaPlayer.template")} -, playbackEnded {this, "playbackEnded"} , playPrevious {this, "playPrevious"} , playNext {this, "playNext"} +, scrobbleListenNow {this, "scrobbleListenNow"} +, scrobbleListenFinished {this, "scrobbleListenFinished"} +, playbackEnded {this, "playbackEnded"} , _settingsLoaded {this, "settingsLoaded"} { addFunction("tr", &Wt::WTemplate::Functions::tr); @@ -250,6 +252,7 @@ MediaPlayer::loadTrack(Database::IdType trackId, bool play, float replayGain) oss << "var params = {" + << " trackId :\"" << trackId << "\"," << " nativeResource: \"" << nativeResource << "\"," << " transcodeResource: \"" << transcodeResource << "\"," << " duration: " << std::chrono::duration_cast(track->getDuration()).count() << "," @@ -295,15 +298,6 @@ MediaPlayer::loadTrack(Database::IdType trackId, bool play, float replayGain) LMS_LOG(UI, DEBUG) << "Running js = '" << oss.str() << "'"; wApp->doJavaScript(oss.str()); - { - auto transaction {LmsApp->getDbSession().createUniqueTransaction()}; - - const Database::Track::pointer track {Database::Track::getById(LmsApp->getDbSession(), trackId)}; - if (track) - Database::TrackListEntry::create(LmsApp->getDbSession(), track, LmsApp->getUser()->getPlayedTrackList(LmsApp->getDbSession())); - - }; - _trackIdLoaded = trackId; trackLoaded.emit(*_trackIdLoaded); } diff --git a/src/lms/ui/MediaPlayer.hpp b/src/lms/ui/MediaPlayer.hpp index af2374b9..033b53be 100644 --- a/src/lms/ui/MediaPlayer.hpp +++ b/src/lms/ui/MediaPlayer.hpp @@ -19,13 +19,15 @@ #pragma once +#include +#include + #include #include #include #include #include "database/Types.hpp" -#include "database/User.hpp" namespace UserInterface { @@ -101,13 +103,26 @@ class MediaPlayer : public Wt::WTemplate void setSettings(const Settings& settings); // Signals - Wt::JSignal<> playbackEnded; Wt::JSignal<> playPrevious; Wt::JSignal<> playNext; Wt::Signal trackLoaded; Wt::Signal<> settingsLoaded; + Wt::JSignal scrobbleListenNow; + Wt::JSignal scrobbleListenFinished; + + Wt::JSignal<> playbackEnded; + private: + + enum class State + { + Playing, + Stopped, + }; + std::chrono::steady_clock::time_point _lastStateTimePoint; + State _state {State::Stopped}; + std::unique_ptr _audioFileResource; std::unique_ptr _audioTranscodeResource; @@ -115,9 +130,10 @@ class MediaPlayer : public Wt::WTemplate std::optional _settings; Wt::JSignal _settingsLoaded; - Wt::WText* _title; - Wt::WAnchor* _release; - Wt::WAnchor* _artist; + + Wt::WText* _title {}; + Wt::WAnchor* _release {}; + Wt::WAnchor* _artist {}; }; } // namespace UserInterface diff --git a/src/lms/ui/SettingsView.cpp b/src/lms/ui/SettingsView.cpp index 7662f385..05d56242 100644 --- a/src/lms/ui/SettingsView.cpp +++ b/src/lms/ui/SettingsView.cpp @@ -31,6 +31,7 @@ #include "common/PasswordValidator.hpp" #include "common/MandatoryValidator.hpp" +#include "common/UUIDValidator.hpp" #include "common/ValueStringModel.hpp" #include "auth/IPasswordService.hpp" @@ -62,12 +63,15 @@ class SettingsModel : public Wt::WFormModel static inline const Field SubsonicTranscodeEnableField {"subsonic-transcode-enable"}; static inline const Field SubsonicTranscodeFormatField {"subsonic-transcode-format"}; static inline const Field SubsonicTranscodeBitrateField {"subsonic-transcode-bitrate"}; + static inline const Field ScrobblerField {"scrobbler"}; + static inline const Field ListenBrainzTokenField {"listenbrainz-token"}; static inline const Field PasswordOldField {"password-old"}; static inline const Field PasswordField {"password"}; static inline const Field PasswordConfirmField {"password-confirm"}; using TranscodeModeModel = ValueStringModel; using ReplayGainModeModel = ValueStringModel; + using ScrobblerModel = ValueStringModel; SettingsModel(::Auth::IPasswordService* authPasswordService, bool withOldPassword) : _authPasswordService {authPasswordService} @@ -85,6 +89,9 @@ class SettingsModel : public Wt::WFormModel addField(SubsonicTranscodeEnableField); addField(SubsonicTranscodeBitrateField); addField(SubsonicTranscodeFormatField); + addField(ScrobblerField); + addField(ListenBrainzTokenField); + setValidator(ListenBrainzTokenField, createUUIDValidator()); if (_authPasswordService) { @@ -103,7 +110,6 @@ class SettingsModel : public Wt::WFormModel setValidator(TranscodeBitrateField, createMandatoryValidator()); setValidator(TranscodeFormatField, createMandatoryValidator()); setValidator(ReplayGainModeField, createMandatoryValidator()); - auto createPreAmpValidator = [] { auto preampGainValidator {std::make_unique()}; @@ -119,11 +125,12 @@ class SettingsModel : public Wt::WFormModel loadData(); } - std::shared_ptr getTranscodeModeModel() { return _transcodeModeModel; } - std::shared_ptr getTranscodeBitrateModel() { return _transcodeBitrateModel; } - std::shared_ptr getTranscodeFormatModel() { return _transcodeFormatModel; } - std::shared_ptr getReplayGainModeModel() { return _replayGainModeModel; } - std::shared_ptr getSubsonicArtistListModeModel() { return _subsonicArtistListModeModel; } + std::shared_ptr getTranscodeModeModel() { return _transcodeModeModel; } + std::shared_ptr getTranscodeBitrateModel() { return _transcodeBitrateModel; } + std::shared_ptr getTranscodeFormatModel() { return _transcodeFormatModel; } + std::shared_ptr getReplayGainModeModel() { return _replayGainModeModel; } + std::shared_ptr getSubsonicArtistListModeModel() { return _subsonicArtistListModeModel; } + std::shared_ptr getScrobblerModel() { return _scrobblerModel; } void saveData() { @@ -174,11 +181,19 @@ class SettingsModel : public Wt::WFormModel auto subsonicTranscodeFormatRow {_transcodeFormatModel->getRowFromString(valueText(SubsonicTranscodeFormatField))}; if (subsonicTranscodeFormatRow) user.modify()->setSubsonicTranscodeFormat(_transcodeFormatModel->getValue(*subsonicTranscodeFormatRow)); + + auto subsonicArtistListModeRow {_subsonicArtistListModeModel->getRowFromString(valueText(SubsonicArtistListModeField))}; + if (subsonicArtistListModeRow) + user.modify()->setSubsonicArtistListMode(_subsonicArtistListModeModel->getValue(*subsonicArtistListModeRow)); + } - auto subsonicArtistListModeRow {_subsonicArtistListModeModel->getRowFromString(valueText(SubsonicArtistListModeField))}; - if (subsonicArtistListModeRow) - user.modify()->setSubsonicArtistListMode(_subsonicArtistListModeModel->getValue(*subsonicArtistListModeRow)); + { + if (auto scrobblerRow {_scrobblerModel->getRowFromString(valueText(ScrobblerField))}) + user.modify()->setScrobbler(_scrobblerModel->getValue(*scrobblerRow)); + + user.modify()->setListenBrainzToken(UUID::fromString(Wt::asString(value(ListenBrainzTokenField)).toUTF8())); + } if (_authPasswordService && !valueText(PasswordField).empty()) { @@ -225,17 +240,30 @@ class SettingsModel : public Wt::WFormModel setReadOnly(SubsonicTranscodeBitrateField, true); } - auto subsonicTranscodeBitrateRow {_transcodeBitrateModel->getRowFromValue(user->getSubsonicTranscodeBitrate())}; - if (subsonicTranscodeBitrateRow) - setValue(SubsonicTranscodeBitrateField, _transcodeBitrateModel->getString(*subsonicTranscodeBitrateRow)); + { + auto subsonicTranscodeBitrateRow {_transcodeBitrateModel->getRowFromValue(user->getSubsonicTranscodeBitrate())}; + if (subsonicTranscodeBitrateRow) + setValue(SubsonicTranscodeBitrateField, _transcodeBitrateModel->getString(*subsonicTranscodeBitrateRow)); + + auto subsonicTranscodeFormatRow {_transcodeFormatModel->getRowFromValue(user->getSubsonicTranscodeFormat())}; + if (subsonicTranscodeFormatRow) + setValue(SubsonicTranscodeFormatField, _transcodeFormatModel->getString(*subsonicTranscodeFormatRow)); + + auto subsonicArtistListModeRow {_subsonicArtistListModeModel->getRowFromValue(user->getSubsonicArtistListMode())}; + if (subsonicArtistListModeRow) + setValue(SubsonicArtistListModeField, _subsonicArtistListModeModel->getString(*subsonicArtistListModeRow)); + } - auto subsonicTranscodeFormatRow {_transcodeFormatModel->getRowFromValue(user->getSubsonicTranscodeFormat())}; - if (subsonicTranscodeFormatRow) - setValue(SubsonicTranscodeFormatField, _transcodeFormatModel->getString(*subsonicTranscodeFormatRow)); + { + if (auto scrobblerRow {_scrobblerModel->getRowFromValue(user->getScrobbler())}) + setValue(ScrobblerField, _scrobblerModel->getString(*scrobblerRow)); - auto subsonicArtistListModeRow {_subsonicArtistListModeModel->getRowFromValue(user->getSubsonicArtistListMode())}; - if (subsonicArtistListModeRow) - setValue(SubsonicArtistListModeField, _subsonicArtistListModeModel->getString(*subsonicArtistListModeRow)); + if (auto listenBrainzToken {user->getListenBrainzToken()}) + { + LMS_LOG(UI, DEBUG) << "Read listenBrainzToken! value = " << listenBrainzToken->getAsString(); + setValue(ListenBrainzTokenField, Wt::WString::fromUTF8( std::string {listenBrainzToken->getAsString()})); + } + } } private: @@ -309,16 +337,21 @@ class SettingsModel : public Wt::WFormModel _subsonicArtistListModeModel->add(Wt::WString::tr("Lms.Settings.subsonic-artist-list-mode.all-artists"), User::SubsonicArtistListMode::AllArtists); _subsonicArtistListModeModel->add(Wt::WString::tr("Lms.Settings.subsonic-artist-list-mode.release-artists"), User::SubsonicArtistListMode::ReleaseArtists); _subsonicArtistListModeModel->add(Wt::WString::tr("Lms.Settings.subsonic-artist-list-mode.track-artists"), User::SubsonicArtistListMode::TrackArtists); + + _scrobblerModel = std::make_shared>(); + _scrobblerModel->add(Wt::WString::tr("Lms.Settings.scrobbling.scrobbler.internal"), Scrobbler::Internal); + _scrobblerModel->add(Wt::WString::tr("Lms.Settings.scrobbling.scrobbler.listenbrainz"), Scrobbler::ListenBrainz); } ::Auth::IPasswordService* _authPasswordService {}; bool _withOldPassword {}; - std::shared_ptr _transcodeModeModel; + std::shared_ptr _transcodeModeModel; std::shared_ptr> _transcodeBitrateModel; - std::shared_ptr> _transcodeFormatModel; + std::shared_ptr> _transcodeFormatModel; std::shared_ptr _replayGainModeModel; std::shared_ptr> _subsonicArtistListModeModel; + std::shared_ptr _scrobblerModel; }; SettingsView::SettingsView() @@ -473,11 +506,11 @@ SettingsView::refreshView() t->setFormWidget(SettingsModel::SubsonicTranscodeBitrateField, std::move(transcodeBitrate)); // Artist list mode - auto artistListMode = std::make_unique(); + auto artistListMode {std::make_unique()}; artistListMode->setModel(model->getSubsonicArtistListModeModel()); t->setFormWidget(SettingsModel::SubsonicArtistListModeField, std::move(artistListMode)); - transcodeRaw->changed().connect([=]() + transcodeRaw->changed().connect([=] { const bool enable {transcodeRaw->checkState() == Wt::CheckState::Checked}; model->setReadOnly(SettingsModel::SubsonicTranscodeFormatField, !enable); @@ -487,6 +520,24 @@ SettingsView::refreshView() }); } + // Scrobbling + { + auto scrobbler {std::make_unique()}; + scrobbler->setModel(model->getScrobblerModel()); + auto* scrobblerRaw {scrobbler.get()}; + t->setFormWidget(SettingsModel::ScrobblerField, std::move(scrobbler)); + + auto listenbrainzToken {std::make_unique()}; + t->setFormWidget(SettingsModel::ListenBrainzTokenField, std::move(listenbrainzToken)); + scrobblerRaw->activated().connect([=](int row) + { + const bool enable {model->getScrobblerModel()->getValue(row) == Scrobbler::ListenBrainz}; + model->setReadOnly(SettingsModel::ListenBrainzTokenField, !enable); + t->updateModel(model.get()); + t->updateView(model.get()); + }); + } + // Buttons Wt::WPushButton *saveBtn {t->bindWidget("apply-btn", std::make_unique(Wt::WString::tr("Lms.apply")))}; Wt::WPushButton *discardBtn {t->bindWidget("discard-btn", std::make_unique(Wt::WString::tr("Lms.discard")))}; diff --git a/src/lms/ui/admin/ScannerController.cpp b/src/lms/ui/admin/ScannerController.cpp index d19b4974..f01cfc71 100644 --- a/src/lms/ui/admin/ScannerController.cpp +++ b/src/lms/ui/admin/ScannerController.cpp @@ -95,8 +95,8 @@ class ReportResource : public Wt::WResource continue; response.out() << track->getPath().string(); - if (auto mbid {track->getMBID()}) - response.out() << " (MBID " << mbid->getAsString() << ")"; + if (auto mbid {track->getTrackMBID()}) + response.out() << " (Track MBID " << mbid->getAsString() << ")"; response.out() << " - " << duplicateReasonToWString(duplicate.reason).toUTF8() << '\n'; } diff --git a/src/lms/ui/common/UUIDValidator.cpp b/src/lms/ui/common/UUIDValidator.cpp new file mode 100644 index 00000000..a333c7be --- /dev/null +++ b/src/lms/ui/common/UUIDValidator.cpp @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2021 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 "UUIDValidator.hpp" + +#include + +namespace UserInterface +{ + std::shared_ptr + createUUIDValidator() + { + return std::make_unique("[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"); + } +} // namespace UserInterface diff --git a/src/lms/ui/common/UUIDValidator.hpp b/src/lms/ui/common/UUIDValidator.hpp new file mode 100644 index 00000000..e6120e5d --- /dev/null +++ b/src/lms/ui/common/UUIDValidator.hpp @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2021 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 + +namespace UserInterface +{ + std::shared_ptr createUUIDValidator(); +} // namespace UserInterface + diff --git a/src/lms/ui/explore/ArtistsView.cpp b/src/lms/ui/explore/ArtistsView.cpp index 25f34307..83e6316f 100644 --- a/src/lms/ui/explore/ArtistsView.cpp +++ b/src/lms/ui/explore/ArtistsView.cpp @@ -29,6 +29,7 @@ #include "database/User.hpp" #include "database/TrackArtistLink.hpp" #include "database/TrackList.hpp" +#include "scrobbling/IScrobbling.hpp" #include "utils/Logger.hpp" #include "common/LoadingIndicator.hpp" @@ -212,15 +213,15 @@ Artists::getArtists(std::optional range, bool& moreResults) break; case Mode::RecentlyPlayed: - artists = LmsApp->getUser()->getPlayedTrackList(LmsApp->getDbSession()) - ->getArtistsReverse(_filters->getClusterIds(), + artists = Service::get()->getRecentArtists(LmsApp->getDbSession(), LmsApp->getUser(), + _filters->getClusterIds(), linkType, range, moreResults); break; case Mode::MostPlayed: - artists = LmsApp->getUser()->getPlayedTrackList(LmsApp->getDbSession()) - ->getTopArtists(_filters->getClusterIds(), + artists = Service::get()->getTopArtists(LmsApp->getDbSession(), LmsApp->getUser(), + _filters->getClusterIds(), linkType, range, moreResults); break; diff --git a/src/lms/ui/explore/ReleasesView.cpp b/src/lms/ui/explore/ReleasesView.cpp index 84ac60d0..b56a5acf 100644 --- a/src/lms/ui/explore/ReleasesView.cpp +++ b/src/lms/ui/explore/ReleasesView.cpp @@ -31,6 +31,7 @@ #include "database/Session.hpp" #include "database/TrackList.hpp" #include "database/User.hpp" +#include "scrobbling/IScrobbling.hpp" #include "utils/Logger.hpp" #include "utils/String.hpp" @@ -205,11 +206,11 @@ Releases::getReleases(std::optional range, bool& moreResults) break; case Mode::RecentlyPlayed: - releases = LmsApp->getUser()->getPlayedTrackList(LmsApp->getDbSession())->getReleasesReverse(_filters->getClusterIds(), range, moreResults); + releases = Service::get()->getRecentReleases(LmsApp->getDbSession(), LmsApp->getUser(), _filters->getClusterIds(), range, moreResults); break; case Mode::MostPlayed: - releases = LmsApp->getUser()->getPlayedTrackList(LmsApp->getDbSession())->getTopReleases(_filters->getClusterIds(), range, moreResults); + releases = Service::get()->getTopReleases(LmsApp->getDbSession(), LmsApp->getUser(), _filters->getClusterIds(), range, moreResults); break; case Mode::RecentlyAdded: diff --git a/src/lms/ui/explore/TracksView.cpp b/src/lms/ui/explore/TracksView.cpp index 6093b45d..683dd563 100644 --- a/src/lms/ui/explore/TracksView.cpp +++ b/src/lms/ui/explore/TracksView.cpp @@ -30,7 +30,8 @@ #include "database/Session.hpp" #include "database/Track.hpp" #include "database/TrackList.hpp" - +#include "database/User.hpp" +#include "scrobbling/IScrobbling.hpp" #include "utils/Logger.hpp" #include "utils/String.hpp" @@ -192,11 +193,11 @@ Tracks::getTracks(std::optional range, bool& moreResults) break; case Mode::RecentlyPlayed: - tracks = LmsApp->getUser()->getPlayedTrackList(LmsApp->getDbSession())->getTracksReverse(_filters->getClusterIds(), range, moreResults); + tracks = Service::get()->getRecentTracks(LmsApp->getDbSession(), LmsApp->getUser(), _filters->getClusterIds(), range, moreResults); break; case Mode::MostPlayed: - tracks = LmsApp->getUser()->getPlayedTrackList(LmsApp->getDbSession())->getTopTracks(_filters->getClusterIds(), range, moreResults); + tracks = Service::get()->getTopTracks(LmsApp->getDbSession(), LmsApp->getUser(), _filters->getClusterIds(), range, moreResults); break; case Mode::RecentlyAdded: diff --git a/src/test/database/DatabaseTest.cpp b/src/test/database/DatabaseTest.cpp index 6569f1a4..56443d25 100644 --- a/src/test/database/DatabaseTest.cpp +++ b/src/test/database/DatabaseTest.cpp @@ -1289,11 +1289,6 @@ testSingleUser(Session& session) { auto transaction {session.createSharedTransaction()}; - bool hasMore {}; - CHECK(user->getPlayedTrackList(session)->getCount() == 0); - CHECK(user->getPlayedTrackList(session)->getTopTracks({}, Range {0, 1}, hasMore).empty()); - CHECK(user->getPlayedTrackList(session)->getTopArtists({}, std::nullopt, Range {0, 1}, hasMore).empty()); - CHECK(user->getPlayedTrackList(session)->getTopReleases({}, Range {0, 1}, hasMore).empty()); CHECK(user->getQueuedTrackList(session)->getCount() == 0); } } @@ -1423,6 +1418,34 @@ testSingleTrackListMultipleTrack(Session& session) } } +void +testSingleTrackListMultipleTrackDateTime(Session& session) +{ + ScopedUser user {session, "MyUser"}; + ScopedTrackList trackList {session, "MytrackList", TrackList::Type::Playlist, false, user.lockAndGet()}; + ScopedTrack track1 {session, "MyTrack1"}; + ScopedTrack track2 {session, "MyTrack2"}; + ScopedTrack track3 {session, "MyTrack3"}; + + { + Wt::WDateTime now {Wt::WDateTime::currentDateTime()}; + auto transaction {session.createUniqueTransaction()}; + TrackListEntry::create(session, track1.get(), trackList.get(), now); + TrackListEntry::create(session, track2.get(), trackList.get(), now.addSecs(-1)); + TrackListEntry::create(session, track3.get(), trackList.get(), now.addSecs(1)); + } + + { + auto transaction {session.createSharedTransaction()}; + + bool moreResults; + const auto tracks {trackList.get()->getTracksReverse({}, std::nullopt, moreResults)}; + CHECK(tracks.size() == 3); + CHECK(tracks.front().id() == track3.getId()); + CHECK(tracks.back().id() == track2.getId()); + } +} + static void testSingleTrackListMultipleTrackSingleCluster(Session& session) @@ -1512,6 +1535,115 @@ testSingleTrackListMultipleTrackMultiClusters(Session& session) } } +static +void +testSingleTrackListMultipleTrackRecentlyPlayed(Session& session) +{ + ScopedUser user {session, "MyUser"}; + ScopedTrackList trackList {session, "MyTrackList", TrackList::Type::Playlist, false, user.lockAndGet()}; + ScopedClusterType clusterType {session, "MyClusterType"}; + ScopedTrack track1 {session, "MyTrack1"}; + ScopedTrack track2 {session, "MyTrack1"}; + ScopedArtist artist1 {session, "MyArtist1"}; + ScopedArtist artist2 {session, "MyArtist2"}; + ScopedRelease release1 {session, "MyRelease1"}; + ScopedRelease release2 {session, "MyRelease2"}; + + const Wt::WDateTime now {Wt::WDateTime::currentDateTime()}; + + { + auto transaction {session.createUniqueTransaction()}; + + track1.get().modify()->setRelease(release1.get()); + track2.get().modify()->setRelease(release2.get()); + TrackArtistLink::create(session, track1.get(), artist1.get(), TrackArtistLinkType::Artist); + TrackArtistLink::create(session, track2.get(), artist2.get(), TrackArtistLinkType::Artist); + } + { + + auto transaction {session.createSharedTransaction()}; + + bool moreResults {}; + CHECK(trackList->getArtistsReverse({}, std::nullopt, std::nullopt, moreResults).empty()); + CHECK(trackList->getReleasesReverse({}, std::nullopt, moreResults).empty()); + CHECK(trackList->getTracksReverse({}, std::nullopt, moreResults).empty()); + } + + { + auto transaction {session.createUniqueTransaction()}; + + TrackListEntry::create(session, track1.get(), trackList.get(), now); + } + + { + auto transaction {session.createSharedTransaction()}; + + bool moreResults {}; + const auto artists {trackList->getArtistsReverse({}, std::nullopt, std::nullopt, moreResults)}; + CHECK(artists.size() == 1); + CHECK(artists.front().id() == artist1.getId()); + + const auto releases {trackList->getReleasesReverse({}, std::nullopt, moreResults)}; + CHECK(releases.size() == 1); + CHECK(releases.front().id() == release1.getId()); + + const auto tracks {trackList->getTracksReverse({}, std::nullopt, moreResults)}; + CHECK(tracks.size() == 1); + } + + { + auto transaction {session.createUniqueTransaction()}; + + TrackListEntry::create(session, track2.get(), trackList.get(), now.addSecs(1)); + } + + { + auto transaction {session.createSharedTransaction()}; + + bool moreResults {}; + const auto artists {trackList->getArtistsReverse({}, std::nullopt, std::nullopt, moreResults)}; + CHECK(artists.size() == 2); + CHECK(artists[0].id() == artist2.getId()); + CHECK(artists[1].id() == artist1.getId()); + + const auto releases {trackList->getReleasesReverse({}, std::nullopt, moreResults)}; + CHECK(releases.size() == 2); + CHECK(releases[0].id() == release2.getId()); + CHECK(releases[1].id() == release1.getId()); + + const auto tracks {trackList->getTracksReverse({}, std::nullopt, moreResults)}; + CHECK(tracks.size() == 2); + CHECK(tracks[0].id() == track2.getId()); + CHECK(tracks[1].id() == track1.getId()); + } + + { + auto transaction {session.createUniqueTransaction()}; + + TrackListEntry::create(session, track1.get(), trackList.get(), now.addSecs(2)); + } + + { + auto transaction {session.createSharedTransaction()}; + + bool moreResults {}; + const auto artists {trackList->getArtistsReverse({}, std::nullopt, std::nullopt, moreResults)}; + CHECK(artists.size() == 2); + CHECK(artists[0].id() == artist1.getId()); + CHECK(artists[1].id() == artist2.getId()); + + const auto releases {trackList->getReleasesReverse({}, std::nullopt, moreResults)}; + CHECK(releases.size() == 2); + CHECK(releases[0].id() == release1.getId()); + CHECK(releases[1].id() == release2.getId()); + + const auto tracks {trackList->getTracksReverse({}, std::nullopt, moreResults)}; + CHECK(tracks.size() == 2); + CHECK(tracks[0].id() == track1.getId()); + CHECK(tracks[1].id() == track2.getId()); + } +} + static void testSingleTrackListMultipleTrackMultiClustersRecentlyPlayed(Session& session) @@ -1529,6 +1661,8 @@ testSingleTrackListMultipleTrackMultiClustersRecentlyPlayed(Session& session) ScopedRelease release1 {session, "MyRelease1"}; ScopedRelease release2 {session, "MyRelease2"}; + const Wt::WDateTime now {Wt::WDateTime::currentDateTime()}; + { auto transaction {session.createUniqueTransaction()}; @@ -1555,7 +1689,7 @@ testSingleTrackListMultipleTrackMultiClustersRecentlyPlayed(Session& session) { auto transaction {session.createUniqueTransaction()}; - TrackListEntry::create(session, track1.get(), trackList.get()); + TrackListEntry::create(session, track1.get(), trackList.get(), now); } { @@ -1641,7 +1775,7 @@ testSingleTrackListMultipleTrackMultiClustersRecentlyPlayed(Session& session) { auto transaction {session.createUniqueTransaction()}; - TrackListEntry::create(session, track2.get(), trackList.get()); + TrackListEntry::create(session, track2.get(), trackList.get(), now.addSecs(1)); } { @@ -1721,7 +1855,7 @@ testSingleTrackListMultipleTrackMultiClustersRecentlyPlayed(Session& session) { auto transaction {session.createUniqueTransaction()}; - TrackListEntry::create(session, track1.get(), trackList.get()); + TrackListEntry::create(session, track1.get(), trackList.get(), now.addSecs(2)); } { @@ -1750,9 +1884,8 @@ testSingleTrackListMultipleTrackMultiClustersRecentlyPlayed(Session& session) bool moreResults {}; const auto artists {trackList->getArtistsReverse({cluster3.getId()}, std::nullopt, std::nullopt, moreResults)}; CHECK(artists.size() == 2); - // TODO investigate - // CHECK(artists[0].id() == artist1.getId()); - // CHECK(artists[1].id() == artist2.getId()); + CHECK(artists[0].id() == artist1.getId()); + CHECK(artists[1].id() == artist2.getId()); const auto releases {trackList->getReleasesReverse({cluster3.getId()}, std::nullopt, moreResults)}; CHECK(releases.size() == 2); @@ -2036,8 +2169,10 @@ int main() RUN_TEST(testSingleTrackList); RUN_TEST(testSingleTrackListMultipleTrack); + RUN_TEST(testSingleTrackListMultipleTrackDateTime); RUN_TEST(testSingleTrackListMultipleTrackSingleCluster); RUN_TEST(testSingleTrackListMultipleTrackMultiClusters); + RUN_TEST(testSingleTrackListMultipleTrackRecentlyPlayed); RUN_TEST(testSingleTrackListMultipleTrackMultiClustersRecentlyPlayed); RUN_TEST(testMultipleTracksMultipleArtistsMultiClusters); RUN_TEST(testMultipleTracksMultipleReleasesMultiClusters); diff --git a/src/tools/metadata/LmsMetadata.cpp b/src/tools/metadata/LmsMetadata.cpp index f7103ee1..04d92a1f 100644 --- a/src/tools/metadata/LmsMetadata.cpp +++ b/src/tools/metadata/LmsMetadata.cpp @@ -102,11 +102,11 @@ void parse(MetaData::IParser& parser, const std::filesystem::path& file) std::cout << "Title: " << track->title << std::endl; - if (track->musicBrainzTrackID) - std::cout << "MB TrackID = " << track->musicBrainzTrackID->getAsString() << std::endl; + if (track->trackMBID) + std::cout << "track MBID = " << track->trackMBID->getAsString() << std::endl; - if (track->musicBrainzRecordID) - std::cout << "MB RecordID = " << track->musicBrainzRecordID->getAsString() << std::endl; + if (track->recordingMBID) + std::cout << "recording MBID = " << track->recordingMBID->getAsString() << std::endl; for (const auto& cluster : track->clusters) { From 38037d8741eb383eeff40daf7420f6455afe85d8 Mon Sep 17 00:00:00 2001 From: emeric Date: Tue, 6 Apr 2021 22:35:46 +0200 Subject: [PATCH 10/14] Codefactor review --- src/libs/scrobbling/impl/Scrobbling.hpp | 1 - src/libs/scrobbling/impl/internal/InternalScrobbler.hpp | 1 - .../impl/listenbrainz/ListenBrainzScrobbler.cpp | 4 ++-- .../impl/listenbrainz/ListenBrainzScrobbler.hpp | 2 ++ src/lms/ui/LmsApplicationManager.hpp | 1 - src/lms/ui/MediaPlayer.hpp | 9 --------- src/lms/ui/SettingsView.cpp | 1 - src/test/database/DatabaseTest.cpp | 1 - 8 files changed, 4 insertions(+), 16 deletions(-) diff --git a/src/libs/scrobbling/impl/Scrobbling.hpp b/src/libs/scrobbling/impl/Scrobbling.hpp index e4ac4256..6babe3f7 100644 --- a/src/libs/scrobbling/impl/Scrobbling.hpp +++ b/src/libs/scrobbling/impl/Scrobbling.hpp @@ -31,7 +31,6 @@ namespace Scrobbling class Scrobbling : public IScrobbling { public: - Scrobbling(Database::Db& db); private: diff --git a/src/libs/scrobbling/impl/internal/InternalScrobbler.hpp b/src/libs/scrobbling/impl/internal/InternalScrobbler.hpp index 062c3f95..64cfb87b 100644 --- a/src/libs/scrobbling/impl/internal/InternalScrobbler.hpp +++ b/src/libs/scrobbling/impl/internal/InternalScrobbler.hpp @@ -29,7 +29,6 @@ namespace Scrobbling InternalScrobbler(Database::Db& db); private: - void listenStarted(const Listen& listen) override; void listenFinished(const Listen& listen, std::chrono::seconds duration) override; diff --git a/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.cpp b/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.cpp index c1f86aeb..4169a6c3 100644 --- a/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.cpp +++ b/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.cpp @@ -316,7 +316,7 @@ namespace Scrobbling LOG(DEBUG) << "POST done. status = " << msg.status() << ", msg = '" << msg.body() << "'"; if (ec) { - LOG(ERROR) << "Client error: " << ec.message(); + LOG(ERROR) << "Retry " << queuedListen.retryCount << ", client error: '" << ec.message() << "'"; // may be a network error, try again later if (++queuedListen.retryCount > _maxRetryCount) _sendQueue.pop_front(); @@ -363,7 +363,7 @@ namespace Scrobbling { assert(_state == State::Idle); - const std::chrono::seconds duration {requestedDuration.count() > 0 ? requestedDuration : std::chrono::seconds {1}}; + const std::chrono::seconds duration {clamp(requestedDuration, _minRetryWaitDuration, _maxRetryWaitDuration)}; LOG(DEBUG) << "Throttling for " << duration.count() << " seconds"; _ioService.schedule(duration, [this] diff --git a/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.hpp b/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.hpp index 98611bc3..37952516 100644 --- a/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.hpp +++ b/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.hpp @@ -74,6 +74,8 @@ namespace Scrobbling const std::string _apiEndpoint; const std::size_t _maxRetryCount {2}; const std::chrono::seconds _defaultRetryWaitDuration {30}; + const std::chrono::seconds _minRetryWaitDuration {1}; + const std::chrono::seconds _maxRetryWaitDuration {300}; Database::Db& _db; Wt::WIOService _ioService; diff --git a/src/lms/ui/LmsApplicationManager.hpp b/src/lms/ui/LmsApplicationManager.hpp index b67e339d..2e3459cd 100644 --- a/src/lms/ui/LmsApplicationManager.hpp +++ b/src/lms/ui/LmsApplicationManager.hpp @@ -37,7 +37,6 @@ namespace UserInterface Wt::Signal applicationUnregistered; private: - friend class LmsApplication; void registerApplication(LmsApplication& application); diff --git a/src/lms/ui/MediaPlayer.hpp b/src/lms/ui/MediaPlayer.hpp index 033b53be..a5be6bd5 100644 --- a/src/lms/ui/MediaPlayer.hpp +++ b/src/lms/ui/MediaPlayer.hpp @@ -114,15 +114,6 @@ class MediaPlayer : public Wt::WTemplate Wt::JSignal<> playbackEnded; private: - - enum class State - { - Playing, - Stopped, - }; - std::chrono::steady_clock::time_point _lastStateTimePoint; - State _state {State::Stopped}; - std::unique_ptr _audioFileResource; std::unique_ptr _audioTranscodeResource; diff --git a/src/lms/ui/SettingsView.cpp b/src/lms/ui/SettingsView.cpp index 05d56242..c900db34 100644 --- a/src/lms/ui/SettingsView.cpp +++ b/src/lms/ui/SettingsView.cpp @@ -308,7 +308,6 @@ class SettingsModel : public Wt::WFormModel void initializeModels() { - _transcodeModeModel = std::make_shared(); _transcodeModeModel->add(Wt::WString::tr("Lms.Settings.transcode-mode.always"), MediaPlayer::Settings::Transcode::Mode::Always); _transcodeModeModel->add(Wt::WString::tr("Lms.Settings.transcode-mode.never"), MediaPlayer::Settings::Transcode::Mode::Never); diff --git a/src/test/database/DatabaseTest.cpp b/src/test/database/DatabaseTest.cpp index 56443d25..bd612339 100644 --- a/src/test/database/DatabaseTest.cpp +++ b/src/test/database/DatabaseTest.cpp @@ -2098,7 +2098,6 @@ testDatabaseEmpty(Session& session) int main() { - try { // log to stdout From aa5f33e9eea82c4aabd26c291d29a0ee842e21b3 Mon Sep 17 00:00:00 2001 From: emeric Date: Wed, 7 Apr 2021 13:26:24 +0200 Subject: [PATCH 11/14] Do not report tracks with no artist + correctly skip on error --- .../listenbrainz/ListenBrainzScrobbler.cpp | 40 +++++++++++++------ .../listenbrainz/ListenBrainzScrobbler.hpp | 2 +- 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.cpp b/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.cpp index 4169a6c3..bcb719ae 100644 --- a/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.cpp +++ b/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.cpp @@ -98,6 +98,12 @@ namespace if (artists.empty()) artists = track->getArtists({Database::TrackArtistLinkType::ReleaseArtist}); + if (artists.empty()) + { + LOG(DEBUG) << "Track cannot be scrobbled since it does not have any artist"; + return std::nullopt; + } + Wt::Json::Object additionalInfo; additionalInfo["listening_from"] = "LMS"; if (track->getRelease()) @@ -106,7 +112,6 @@ namespace additionalInfo["release_mbid"] = Wt::Json::Value {std::string {MBID->getAsString()}}; } - if (!artists.empty()) { Wt::Json::Array artistMBIDs; for (const Database::Artist::pointer& artist : artists) @@ -130,10 +135,7 @@ namespace Wt::Json::Object trackMetadata; trackMetadata["additional_info"] = std::move(additionalInfo); - - if (!artists.empty()) - trackMetadata["artist_name"] = Wt::Json::Value {artists.front()->getName()}; - + trackMetadata["artist_name"] = Wt::Json::Value {artists.front()->getName()}; trackMetadata["track_name"] = Wt::Json::Value {track->getName()}; if (track->getRelease()) trackMetadata["release_name"] = Wt::Json::Value {track->getRelease()->getName()}; @@ -262,6 +264,8 @@ namespace Scrobbling _sendQueue.emplace_back(QueuedListen {listen, timePoint}); + LOG(DEBUG) << "listen queue size = " << _sendQueue.size(); + if (_state == State::Idle) sendNextQueuedListen(); } @@ -270,27 +274,33 @@ namespace Scrobbling ListenBrainzScrobbler::sendNextQueuedListen() { assert(_state == State::Idle); - if (_sendQueue.empty()) - return; - sendListen(_sendQueue.front().listen, _sendQueue.front().timePoint); - _state = State::Sending; + while (!_sendQueue.empty()) + { + if (sendListen(_sendQueue.front().listen, _sendQueue.front().timePoint)) + { + _state = State::Sending; + break; + } + + _sendQueue.pop_front(); + } } - void + bool ListenBrainzScrobbler::sendListen(const Listen& listen, const Wt::WDateTime& timePoint) { Database::Session& session {_db.getTLSSession()}; const std::optional listenBrainzToken {getListenBrainzToken(session, listen.userId)}; if (!listenBrainzToken) - return; + return false; std::string payload {listenToJsonString(session, listen, timePoint, timePoint.isValid() ? "single" : "playing_now")}; if (payload.empty()) { LOG(DEBUG) << "Cannot convert listen to json: skipping"; - return; + return false; } // now send this @@ -300,9 +310,13 @@ namespace Scrobbling const std::string endPoint {_apiEndpoint + "submit-listens"}; if (!_client.post(endPoint, message)) + { LOG(ERROR) << "Cannot post to '" << endPoint << "': invalid scheme or URL?"; + return false; + } - LOG(DEBUG) << "POST done to '" << endPoint << "'"; + LOG(DEBUG) << "Listen POST done to '" << endPoint << "'"; + return true; } void diff --git a/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.hpp b/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.hpp index 37952516..d053a81d 100644 --- a/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.hpp +++ b/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.hpp @@ -57,7 +57,7 @@ namespace Scrobbling void enqueListen(const Listen& listen, const Wt::WDateTime& timePoint); void sendNextQueuedListen(); - void sendListen(const Listen& listen, const Wt::WDateTime& timePoint); + bool sendListen(const Listen& listen, const Wt::WDateTime& timePoint); void onClientDone(Wt::AsioWrapper::error_code ec, const Wt::Http::Message& msg); void throttle(std::chrono::seconds duration); From 8b9e45654f9abc0000099a26584bb940a2904262 Mon Sep 17 00:00:00 2001 From: emeric Date: Wed, 7 Apr 2021 20:07:53 +0200 Subject: [PATCH 12/14] Set Content-Type for listenbrainz submissions --- src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.cpp b/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.cpp index bcb719ae..50cb4fb3 100644 --- a/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.cpp +++ b/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.cpp @@ -306,6 +306,7 @@ namespace Scrobbling // now send this Wt::Http::Message message; message.addHeader("Authorization", "Token " + std::string {listenBrainzToken->getAsString()}); + message.addHeader("Content-Type", "application/json"); message.addBodyText(payload); const std::string endPoint {_apiEndpoint + "submit-listens"}; From 4cf60f7b8b3f3445e409db7ce9eaa9860d367809 Mon Sep 17 00:00:00 2001 From: emeric Date: Wed, 7 Apr 2021 23:46:39 +0200 Subject: [PATCH 13/14] Fixed bad settings state handling in UI --- src/lms/ui/SettingsView.cpp | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/src/lms/ui/SettingsView.cpp b/src/lms/ui/SettingsView.cpp index c900db34..f0a84ac7 100644 --- a/src/lms/ui/SettingsView.cpp +++ b/src/lms/ui/SettingsView.cpp @@ -225,6 +225,12 @@ class SettingsModel : public Wt::WFormModel if (transcodeBitrateRow) setValue(TranscodeBitrateField, _transcodeBitrateModel->getString(*transcodeBitrateRow)); + { + const bool usesTranscode {settings.transcode.mode != MediaPlayer::Settings::Transcode::Mode::Never}; + setReadOnly(SettingsModel::TranscodeFormatField, !usesTranscode); + setReadOnly(SettingsModel::TranscodeBitrateField, !usesTranscode); + } + auto replayGainModeRow {_replayGainModeModel->getRowFromValue(settings.replayGain.mode)}; if (replayGainModeRow) setValue(ReplayGainModeField, _replayGainModeModel->getString(*replayGainModeRow)); @@ -234,10 +240,10 @@ class SettingsModel : public Wt::WFormModel } setValue(SubsonicTranscodeEnableField, LmsApp->getUser()->getSubsonicTranscodeEnable()); - if (!LmsApp->getUser()->getSubsonicTranscodeEnable()) { - setReadOnly(SubsonicTranscodeFormatField, true); - setReadOnly(SubsonicTranscodeBitrateField, true); + const bool usesTranscode {LmsApp->getUser()->getSubsonicTranscodeEnable()}; + setReadOnly(SubsonicTranscodeFormatField, !usesTranscode); + setReadOnly(SubsonicTranscodeBitrateField, !usesTranscode); } { @@ -263,11 +269,16 @@ class SettingsModel : public Wt::WFormModel LMS_LOG(UI, DEBUG) << "Read listenBrainzToken! value = " << listenBrainzToken->getAsString(); setValue(ListenBrainzTokenField, Wt::WString::fromUTF8( std::string {listenBrainzToken->getAsString()})); } + + { + const bool usesListenBrainz {user->getScrobbler() == Scrobbler::ListenBrainz}; + setReadOnly(SettingsModel::ListenBrainzTokenField, !usesListenBrainz); + validator(SettingsModel::ListenBrainzTokenField)->setMandatory(usesListenBrainz); + } } } private: - bool validateField(Field field) { Wt::WString error; @@ -448,11 +459,6 @@ SettingsView::refreshView() t->updateModel(model.get()); t->updateView(model.get()); }); - if (LmsApp->getMediaPlayer().getSettings()->transcode.mode == MediaPlayer::Settings::Transcode::Mode::Never) - { - model->setReadOnly(SettingsModel::TranscodeFormatField, true); - model->setReadOnly(SettingsModel::TranscodeBitrateField, true); - } // Replay gain mode auto replayGainMode {std::make_unique()}; @@ -532,8 +538,9 @@ SettingsView::refreshView() { const bool enable {model->getScrobblerModel()->getValue(row) == Scrobbler::ListenBrainz}; model->setReadOnly(SettingsModel::ListenBrainzTokenField, !enable); - t->updateModel(model.get()); - t->updateView(model.get()); + model->validator(SettingsModel::ListenBrainzTokenField)->setMandatory(enable); + t->updateModel(model.get()); + t->updateView(model.get()); }); } From 42e9403b550eee71e7f0fa950e49f2df0ceb6601 Mon Sep 17 00:00:00 2001 From: emeric Date: Thu, 8 Apr 2021 14:11:09 +0200 Subject: [PATCH 14/14] Fixed subsonic scrobble command + internal scrobbler: do not record listen that have been played for less than 5s --- src/libs/scrobbling/impl/IScrobbler.hpp | 2 +- src/libs/scrobbling/impl/Scrobbling.cpp | 2 +- src/libs/scrobbling/impl/Scrobbling.hpp | 2 +- .../impl/internal/InternalScrobbler.cpp | 12 ++-- .../impl/internal/InternalScrobbler.hpp | 2 +- .../listenbrainz/ListenBrainzScrobbler.cpp | 6 +- .../listenbrainz/ListenBrainzScrobbler.hpp | 2 +- .../include/scrobbling/IScrobbling.hpp | 2 +- src/libs/subsonic/impl/SubsonicResource.cpp | 60 +++++++++---------- 9 files changed, 44 insertions(+), 46 deletions(-) diff --git a/src/libs/scrobbling/impl/IScrobbler.hpp b/src/libs/scrobbling/impl/IScrobbler.hpp index 9359a071..7587ce0e 100644 --- a/src/libs/scrobbling/impl/IScrobbler.hpp +++ b/src/libs/scrobbling/impl/IScrobbler.hpp @@ -43,7 +43,7 @@ namespace Scrobbling virtual ~IScrobbler() = default; virtual void listenStarted(const Listen& listen) = 0; - virtual void listenFinished(const Listen& listen, std::chrono::seconds duration) = 0; + virtual void listenFinished(const Listen& listen, std::optional duration) = 0; virtual void addListen(const Listen& listen, const Wt::WDateTime& timePoint) = 0; diff --git a/src/libs/scrobbling/impl/Scrobbling.cpp b/src/libs/scrobbling/impl/Scrobbling.cpp index 6b755f8e..14f84c7e 100644 --- a/src/libs/scrobbling/impl/Scrobbling.cpp +++ b/src/libs/scrobbling/impl/Scrobbling.cpp @@ -50,7 +50,7 @@ namespace Scrobbling } void - Scrobbling::listenFinished(const Listen& listen, std::chrono::seconds duration) + Scrobbling::listenFinished(const Listen& listen, std::optional duration) { if (auto scrobbler {getUserScrobbler(listen.userId)}) _scrobblers[*scrobbler]->listenFinished(listen, duration); diff --git a/src/libs/scrobbling/impl/Scrobbling.hpp b/src/libs/scrobbling/impl/Scrobbling.hpp index 6babe3f7..26f3cbb1 100644 --- a/src/libs/scrobbling/impl/Scrobbling.hpp +++ b/src/libs/scrobbling/impl/Scrobbling.hpp @@ -35,7 +35,7 @@ namespace Scrobbling private: void listenStarted(const Listen& listen) override; - void listenFinished(const Listen& listen, std::chrono::seconds duration) override; + void listenFinished(const Listen& listen, std::optional duration) override; void addListen(const Listen& listen, Wt::WDateTime timePoint) override; std::vector> getRecentArtists(Database::Session& session, diff --git a/src/libs/scrobbling/impl/internal/InternalScrobbler.cpp b/src/libs/scrobbling/impl/internal/InternalScrobbler.cpp index cd0ff9d2..14a86adf 100644 --- a/src/libs/scrobbling/impl/internal/InternalScrobbler.cpp +++ b/src/libs/scrobbling/impl/internal/InternalScrobbler.cpp @@ -35,15 +35,19 @@ namespace Scrobbling {} void - InternalScrobbler::listenStarted(const Listen& listen) + InternalScrobbler::listenStarted(const Listen& /*listen*/) { - addListen(listen, Wt::WDateTime::currentDateTime()); + // nothing to do } void - InternalScrobbler::listenFinished(const Listen& /*event*/, std::chrono::seconds /* duration */) + InternalScrobbler::listenFinished(const Listen& listen, std::optional duration) { - // nothing to do + // record tracks that have been played for at least of few seconds... + if (duration && *duration < std::chrono::seconds {5}) + return; + + addListen(listen, Wt::WDateTime::currentDateTime()); } void diff --git a/src/libs/scrobbling/impl/internal/InternalScrobbler.hpp b/src/libs/scrobbling/impl/internal/InternalScrobbler.hpp index 64cfb87b..e11ff43e 100644 --- a/src/libs/scrobbling/impl/internal/InternalScrobbler.hpp +++ b/src/libs/scrobbling/impl/internal/InternalScrobbler.hpp @@ -30,7 +30,7 @@ namespace Scrobbling private: void listenStarted(const Listen& listen) override; - void listenFinished(const Listen& listen, std::chrono::seconds duration) override; + void listenFinished(const Listen& listen, std::optional duration) override; void addListen(const Listen& listen, const Wt::WDateTime& timePoint) override; diff --git a/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.cpp b/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.cpp index 50cb4fb3..c7f1bd1a 100644 --- a/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.cpp +++ b/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.cpp @@ -214,13 +214,13 @@ namespace Scrobbling } void - ListenBrainzScrobbler::listenFinished(const Listen& listen, std::chrono::seconds duration) + ListenBrainzScrobbler::listenFinished(const Listen& listen, std::optional duration) { - if (!canBeScrobbled(_db.getTLSSession(), listen.trackId, duration)) + if (duration && !canBeScrobbled(_db.getTLSSession(), listen.trackId, *duration)) return; Listen timedListen {listen}; - const Wt::WDateTime now {Wt::WDateTime::currentDateTime().addSecs(-duration.count())}; + const Wt::WDateTime now {Wt::WDateTime::currentDateTime()}; _ioService.post([=] { diff --git a/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.hpp b/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.hpp index d053a81d..80c50fb2 100644 --- a/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.hpp +++ b/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.hpp @@ -50,7 +50,7 @@ namespace Scrobbling private: void listenStarted(const Listen& listen) override; - void listenFinished(const Listen& listen, std::chrono::seconds duration) override; + void listenFinished(const Listen& listen, std::optional duration) override; void addListen(const Listen& listen, const Wt::WDateTime& timePoint) override; Wt::Dbo::ptr getListensTrackList(Database::Session& session, Wt::Dbo::ptr user) override; diff --git a/src/libs/scrobbling/include/scrobbling/IScrobbling.hpp b/src/libs/scrobbling/include/scrobbling/IScrobbling.hpp index 3a5a3e0f..7b46436b 100644 --- a/src/libs/scrobbling/include/scrobbling/IScrobbling.hpp +++ b/src/libs/scrobbling/include/scrobbling/IScrobbling.hpp @@ -49,7 +49,7 @@ namespace Scrobbling // Scrobbling virtual void listenStarted(const Listen& listen) = 0; - virtual void listenFinished(const Listen& listen, std::chrono::seconds duration) = 0; + virtual void listenFinished(const Listen& listen, std::optional playedDuration = std::nullopt) = 0; virtual void addListen(const Listen& listen, Wt::WDateTime timePoint) = 0; diff --git a/src/libs/subsonic/impl/SubsonicResource.cpp b/src/libs/subsonic/impl/SubsonicResource.cpp index 854e8cf8..a9caad89 100644 --- a/src/libs/subsonic/impl/SubsonicResource.cpp +++ b/src/libs/subsonic/impl/SubsonicResource.cpp @@ -1644,52 +1644,46 @@ Response handleScrobble(RequestContext& context) { const std::vector ids {getMandatoryMultiParametersAs(context.parameters, "id")}; - const std::vector times {getMandatoryMultiParametersAs(context.parameters, "time")}; + const std::vector times {getMultiParametersAs(context.parameters, "time")}; + const bool submission{getParameterAs(context.parameters, "submission").value_or(true)}; + // only for tracks if (!std::all_of(std::cbegin(ids), std::cend(ids), [](const Id& id) { return id.type == Id::Type::Track; })) throw BadParameterGenericError {"id"}; - if (ids.size() != times.size()) + // playing now => no time to be provided + if (!submission && !times.empty()) throw BadParameterGenericError {"time"}; - struct Scrobble - { - Scrobbling::Listen listen; - Wt::WDateTime timePoint; - }; + // playing now => only one at a time + if (!submission && ids.size() > 1) + throw BadParameterGenericError {"id"}; - std::vector scrobbles; - scrobbles.reserve(ids.size()); + // if multiple submissions, must have times + if (ids.size() > 1 && ids.size() != times.size()) + throw BadParameterGenericError {"time"}; + if (!submission) { - auto transaction {context.dbSession.createSharedTransaction()}; - - User::pointer user {User::getById(context.dbSession, context.userId)}; - if (!user) - throw RequestedDataNotFoundError {}; - - Scrobble scrobble; - scrobble.listen.userId = context.userId; - - for (std::size_t i {}; i < ids.size(); ++i) + Service::get()->listenStarted({context.userId, ids.front().value}); + } + else + { + if (times.empty()) { - const Id id {ids[i]}; - const unsigned long time {times[i]}; - - const Track::pointer track {Track::getById(context.dbSession, id.value)}; - if (!track) - continue; - - scrobble.listen.trackId = id.value; - scrobble.timePoint.setTime_t(static_cast(time / 1000)); - - scrobbles.emplace_back(scrobble); + Service::get()->listenFinished({context.userId, ids.front().value}); + } + else + { + for (std::size_t i {}; i < ids.size(); ++i) + { + const Database::IdType trackId {ids[i].value}; + const unsigned long time {times[i]}; + Service::get()->addListen({context.userId, trackId}, Wt::WDateTime::fromTime_t(static_cast(time / 1000))); + } } } - for (const Scrobble& scrobble : scrobbles) - Service::get()->addListen(scrobble.listen, scrobble.timePoint); - return Response::createOkResponse(context); }