From a6398738bb0ddee3e3764e6235f5f7bc8fd04897 Mon Sep 17 00:00:00 2001 From: emeric Date: Fri, 30 Apr 2021 17:41:03 +0200 Subject: [PATCH 01/29] listenbrainz: added a listens synchronizer. fixes #142 --- conf/lms.conf | 8 +- src/libs/database/impl/Track.cpp | 17 +- src/libs/database/impl/TrackList.cpp | 12 + src/libs/database/impl/User.cpp | 9 + src/libs/database/include/database/Track.hpp | 30 +- .../database/include/database/TrackList.hpp | 3 + src/libs/database/include/database/User.hpp | 1 + src/libs/scanner/impl/AcousticBrainzUtils.cpp | 4 +- src/libs/scrobbling/CMakeLists.txt | 8 +- src/libs/scrobbling/impl/IScrobbler.hpp | 2 +- src/libs/scrobbling/impl/Scrobbling.cpp | 18 +- src/libs/scrobbling/impl/Scrobbling.hpp | 4 +- .../impl/internal/InternalScrobbler.cpp | 6 +- .../impl/internal/InternalScrobbler.hpp | 2 +- .../listenbrainz/ListenBrainzScrobbler.cpp | 282 ++-------- .../listenbrainz/ListenBrainzScrobbler.hpp | 64 +-- .../impl/listenbrainz/ListensSynchronizer.cpp | 514 ++++++++++++++++++ .../impl/listenbrainz/ListensSynchronizer.hpp | 98 ++++ .../impl/listenbrainz/SendQueue.cpp | 228 ++++++++ .../impl/listenbrainz/SendQueue.hpp | 118 ++++ .../scrobbling/impl/listenbrainz/Utils.cpp | 63 +++ .../scrobbling/impl/listenbrainz/Utils.hpp | 38 ++ .../include/scrobbling/IScrobbling.hpp | 6 +- .../scrobbling/include/scrobbling/Listen.hpp | 7 + src/libs/subsonic/impl/SubsonicResource.cpp | 2 +- src/libs/utils/CMakeLists.txt | 1 + src/libs/utils/impl/ChildProcessManager.cpp | 36 +- src/libs/utils/impl/ChildProcessManager.hpp | 12 +- src/libs/utils/impl/IOContextRunner.cpp | 49 ++ .../include/utils/IChildProcessManager.hpp | 7 +- .../utils/include/utils/IOContextRunner.hpp | 43 ++ src/lms/main.cpp | 11 +- src/test/database/DatabaseTest.cpp | 19 + 33 files changed, 1359 insertions(+), 363 deletions(-) create mode 100644 src/libs/scrobbling/impl/listenbrainz/ListensSynchronizer.cpp create mode 100644 src/libs/scrobbling/impl/listenbrainz/ListensSynchronizer.hpp create mode 100644 src/libs/scrobbling/impl/listenbrainz/SendQueue.cpp create mode 100644 src/libs/scrobbling/impl/listenbrainz/SendQueue.hpp create mode 100644 src/libs/scrobbling/impl/listenbrainz/Utils.cpp create mode 100644 src/libs/scrobbling/impl/listenbrainz/Utils.hpp create mode 100644 src/libs/utils/impl/IOContextRunner.cpp create mode 100644 src/libs/utils/include/utils/IOContextRunner.hpp diff --git a/conf/lms.conf b/conf/lms.conf index 5ec9768d..f62d8d83 100644 --- a/conf/lms.conf +++ b/conf/lms.conf @@ -35,10 +35,14 @@ deploy-path = "/"; http-server-thread-count = 0; # ListenBrainz root API -listenbrainz-api-url = "https://api.listenbrainz.org/1/"; +listenbrainz-api-base-url = "https://api.listenbrainz.org"; +# How many listens to retrieve when syncing (0 disables sync) +listenbrainz-max-sync-listen-count = 1000; +# How often to resync listens (0 disables sync) +listenbrainz-sync-listens-period-hours = 1; # Acousticbrainz root API -acousticbrainz-api-url = "https://acousticbrainz.org/api/v1/"; +acousticbrainz-api-base-url = "https://acousticbrainz.org/api"; # Authentication # Available backends: "internal", "PAM", "http-headers" diff --git a/src/libs/database/impl/Track.cpp b/src/libs/database/impl/Track.cpp index 604e7062..860e4ccb 100644 --- a/src/libs/database/impl/Track.cpp +++ b/src/libs/database/impl/Track.cpp @@ -30,6 +30,7 @@ #include "utils/Logger.hpp" #include "SqlQuery.hpp" +#include "StringViewTraits.hpp" namespace Database { @@ -151,12 +152,12 @@ Track::getById(Session& session, IdType id) } Track::pointer -Track::getByMBID(Session& session, const UUID& mbid) +Track::getByRecordingMBID(Session& session, const UUID& mbid) { session.checkSharedLocked(); return session.getDboSession().find() - .where("mbid = ?").bind(std::string {mbid.getAsString()}); + .where("recording_mbid = ?").bind(std::string {mbid.getAsString()}); } Track::pointer @@ -354,6 +355,18 @@ Track::getByFilter(Session& session, return res; } +std::vector +Track::getByNameAndReleaseName(Session& session, std::string_view trackName, std::string_view releaseName) +{ + session.checkSharedLocked(); + Wt::Dbo::collection collection = session.getDboSession().query("SELECT t from track t") + .join("release r ON t.release_id = r.id") + .where("t.name = ?").bind(trackName) + .where("r.name = ?").bind(releaseName); + + return std::vector(collection.begin(), collection.end()); +} + std::vector Track::getSimilarTracks(Session& session, const std::unordered_set& tracks, diff --git a/src/libs/database/impl/TrackList.cpp b/src/libs/database/impl/TrackList.cpp index 54b685e3..73c1067a 100644 --- a/src/libs/database/impl/TrackList.cpp +++ b/src/libs/database/impl/TrackList.cpp @@ -148,6 +148,18 @@ TrackList::getEntries(std::optional offset, std::optional>(entries.begin(), entries.end()); } +Wt::Dbo::ptr +TrackList::getEntryByTrackAndDateTime(Wt::Dbo::ptr track, const Wt::WDateTime& dateTime) const +{ + assert(session()); + assert(IdIsValid(self()->id())); + + return session()->find() + .where("tracklist_id = ?").bind(self().id()) + .where("track_id = ?").bind(track.id()) + .where("date_time = ?").bind(Wt::WDateTime::fromTime_t(dateTime.toTime_t())); +} + static Wt::Dbo::Query createArtistsQuery(Wt::Dbo::Session& session, const std::string& queryStr, IdType tracklistId, const std::set& clusterIds, std::optional linkType) diff --git a/src/libs/database/impl/User.cpp b/src/libs/database/impl/User.cpp index 0e3b09bd..b0dc3f01 100644 --- a/src/libs/database/impl/User.cpp +++ b/src/libs/database/impl/User.cpp @@ -84,6 +84,15 @@ User::getAll(Session& session) return std::vector(res.begin(), res.end()); } +std::vector +User::getAllIds(Session& session) +{ + session.checkSharedLocked(); + + Wt::Dbo::collection res = session.getDboSession().query("SELECT id FROM user"); + return std::vector(res.begin(), res.end()); +} + User::pointer User::getDemo(Session& session) { diff --git a/src/libs/database/include/database/Track.hpp b/src/libs/database/include/database/Track.hpp index 119b7d75..fc8055ae 100644 --- a/src/libs/database/include/database/Track.hpp +++ b/src/libs/database/include/database/Track.hpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include @@ -61,7 +62,7 @@ class Track : public Wt::Dbo::Dbo static std::size_t getCount(Session& session); static pointer getByPath(Session& session, const std::filesystem::path& p); static pointer getById(Session& session, IdType id); - static pointer getByMBID(Session& session, const UUID& MBID); + static pointer getByRecordingMBID(Session& session, const UUID& MBID); static std::vector getSimilarTracks(Session& session, const std::unordered_set& trackIds, std::optional offset = {}, @@ -73,6 +74,7 @@ class Track : public Wt::Dbo::Dbo const std::vector& keywords, // if non empty, name must match all of these keywords std::optional range, bool& moreExpected); + static std::vector getByNameAndReleaseName(Session& session, std::string_view trackName, std::string_view releaseName); static std::vector getAll(Session& session, std::optional limit = std::nullopt); static std::vector getAllRandom(Session& session, const std::set& clusters, std::optional limit = std::nullopt); @@ -188,28 +190,28 @@ class Track : public Wt::Dbo::Dbo static const std::size_t _maxCopyrightLength = 128; static const std::size_t _maxCopyrightURLLength = 128; - int _scanVersion {}; - int _trackNumber {}; - int _discNumber {}; - std::string _discSubtitle; - int _totalTrack {}; - int _totalDisc {}; + int _scanVersion {}; + int _trackNumber {}; + int _discNumber {}; + std::string _discSubtitle; + int _totalTrack {}; + int _totalDisc {}; std::string _name; std::string _artistName; std::string _releaseName; - std::chrono::duration _duration; - int _year {}; - int _originalYear {}; + std::chrono::duration _duration {}; + int _year {}; + int _originalYear {}; std::string _filePath; - Wt::WDateTime _fileLastWrite; - Wt::WDateTime _fileAdded; + Wt::WDateTime _fileLastWrite; + Wt::WDateTime _fileAdded; bool _hasCover {}; std::string _trackMBID; std::string _recordingMBID; std::string _copyright; std::string _copyrightURL; - std::optional _trackReplayGain; - std::optional _releaseReplayGain; + std::optional _trackReplayGain; + std::optional _releaseReplayGain; Wt::Dbo::ptr _release; Wt::Dbo::collection> _trackArtistLinks; diff --git a/src/libs/database/include/database/TrackList.hpp b/src/libs/database/include/database/TrackList.hpp index 7e7099da..d8505d23 100644 --- a/src/libs/database/include/database/TrackList.hpp +++ b/src/libs/database/include/database/TrackList.hpp @@ -84,6 +84,9 @@ 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; + Wt::Dbo::ptr getEntryByTrackAndDateTime(Wt::Dbo::ptr track, const Wt::WDateTime& dateTime) const; + + // Get track bya 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; diff --git a/src/libs/database/include/database/User.hpp b/src/libs/database/include/database/User.hpp index 0f5b5758..82980188 100644 --- a/src/libs/database/include/database/User.hpp +++ b/src/libs/database/include/database/User.hpp @@ -139,6 +139,7 @@ class User : public Wt::Dbo::Dbo static pointer getById(Session& session, IdType id); static pointer getByLoginName(Session& session, std::string_view loginName); static std::vector getAll(Session& session); + static std::vector getAllIds(Session& session); static pointer getDemo(Session& session); static std::size_t getCount(Session& session); diff --git a/src/libs/scanner/impl/AcousticBrainzUtils.cpp b/src/libs/scanner/impl/AcousticBrainzUtils.cpp index 37f06166..64980664 100644 --- a/src/libs/scanner/impl/AcousticBrainzUtils.cpp +++ b/src/libs/scanner/impl/AcousticBrainzUtils.cpp @@ -38,9 +38,9 @@ static std::string getJsonData(const UUID& mbid) { - static constexpr std::string_view defaultAPIURL {"https://acousticbrainz.org/api/v1/"}; + static constexpr std::string_view defaultAPIURL {"https://acousticbrainz.org/api"}; - const std::string url {std::string {Service::get()->getString("acousticbrainz-api-url", defaultAPIURL)} + std::string {mbid.getAsString()} + "/low-level"}; + const std::string url {std::string {Service::get()->getString("acousticbrainz-api-base-url", defaultAPIURL)} + std::string {mbid.getAsString()} + "/low-level"}; boost::asio::io_service ioService; diff --git a/src/libs/scrobbling/CMakeLists.txt b/src/libs/scrobbling/CMakeLists.txt index 162f1c9c..bd8a0960 100644 --- a/src/libs/scrobbling/CMakeLists.txt +++ b/src/libs/scrobbling/CMakeLists.txt @@ -2,6 +2,9 @@ add_library(lmsscrobbling SHARED impl/internal/InternalScrobbler.cpp impl/listenbrainz/ListenBrainzScrobbler.cpp + impl/listenbrainz/ListensSynchronizer.cpp + impl/listenbrainz/SendQueue.cpp + impl/listenbrainz/Utils.cpp impl/Scrobbling.cpp ) @@ -14,9 +17,12 @@ target_include_directories(lmsscrobbling PRIVATE impl ) +target_link_libraries(lmsscrobbling PRIVATE + lmsutils + ) + 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 index 840d24ef..01fae716 100644 --- a/src/libs/scrobbling/impl/IScrobbler.hpp +++ b/src/libs/scrobbling/impl/IScrobbler.hpp @@ -46,7 +46,7 @@ namespace Scrobbling virtual void listenStarted(const Listen& listen) = 0; virtual void listenFinished(const Listen& listen, std::optional duration) = 0; - virtual void addListen(const Listen& listen, const Wt::WDateTime& timePoint) = 0; + virtual void addTimedListen(const TimedListen& listen) = 0; virtual Wt::Dbo::ptr getListensTrackList(Database::Session& session, Wt::Dbo::ptr user) = 0; }; diff --git a/src/libs/scrobbling/impl/Scrobbling.cpp b/src/libs/scrobbling/impl/Scrobbling.cpp index 14f84c7e..65df227d 100644 --- a/src/libs/scrobbling/impl/Scrobbling.cpp +++ b/src/libs/scrobbling/impl/Scrobbling.cpp @@ -30,37 +30,37 @@ namespace Scrobbling { std::unique_ptr - createScrobbling(Database::Db& db) + createScrobbling(boost::asio::io_context& ioContext, Database::Db& db) { - return std::make_unique(db); + return std::make_unique(ioContext, db); } - Scrobbling::Scrobbling(Database::Db& db) + Scrobbling::Scrobbling(boost::asio::io_context& ioContext, Database::Db& db) : _db {db} { _scrobblers.emplace(Database::Scrobbler::Internal, std::make_unique(_db)); - _scrobblers.emplace(Database::Scrobbler::ListenBrainz, std::make_unique(_db)); + _scrobblers.emplace(Database::Scrobbler::ListenBrainz, std::make_unique(ioContext, _db)); } void Scrobbling::listenStarted(const Listen& listen) { - if (auto scrobbler {getUserScrobbler(listen.userId)}) + if (std::optional scrobbler {getUserScrobbler(listen.userId)}) _scrobblers[*scrobbler]->listenStarted(listen); } void Scrobbling::listenFinished(const Listen& listen, std::optional duration) { - if (auto scrobbler {getUserScrobbler(listen.userId)}) + if (std::optional scrobbler {getUserScrobbler(listen.userId)}) _scrobblers[*scrobbler]->listenFinished(listen, duration); } void - Scrobbling::addListen(const Listen& listen, Wt::WDateTime timePoint) + Scrobbling::addTimedListen(const TimedListen& listen) { - if (auto scrobbler {getUserScrobbler(listen.userId)}) - _scrobblers[*scrobbler]->addListen(listen, timePoint); + if (std::optional scrobbler {getUserScrobbler(listen.userId)}) + _scrobblers[*scrobbler]->addTimedListen(listen); } std::optional diff --git a/src/libs/scrobbling/impl/Scrobbling.hpp b/src/libs/scrobbling/impl/Scrobbling.hpp index 26f3cbb1..613335c8 100644 --- a/src/libs/scrobbling/impl/Scrobbling.hpp +++ b/src/libs/scrobbling/impl/Scrobbling.hpp @@ -31,12 +31,12 @@ namespace Scrobbling class Scrobbling : public IScrobbling { public: - Scrobbling(Database::Db& db); + Scrobbling(boost::asio::io_context& ioContext, Database::Db& db); private: void listenStarted(const Listen& listen) override; void listenFinished(const Listen& listen, std::optional duration) override; - void addListen(const Listen& listen, Wt::WDateTime timePoint) override; + void addTimedListen(const TimedListen& listen) override; std::vector> getRecentArtists(Database::Session& session, Wt::Dbo::ptr user, diff --git a/src/libs/scrobbling/impl/internal/InternalScrobbler.cpp b/src/libs/scrobbling/impl/internal/InternalScrobbler.cpp index 14a86adf..019759f2 100644 --- a/src/libs/scrobbling/impl/internal/InternalScrobbler.cpp +++ b/src/libs/scrobbling/impl/internal/InternalScrobbler.cpp @@ -47,11 +47,11 @@ namespace Scrobbling if (duration && *duration < std::chrono::seconds {5}) return; - addListen(listen, Wt::WDateTime::currentDateTime()); + addTimedListen({listen, Wt::WDateTime::currentDateTime()}); } void - InternalScrobbler::addListen(const Listen& listen, const Wt::WDateTime& timePoint) + InternalScrobbler::addTimedListen(const TimedListen& listen) { Database::Session& session {_db.getTLSSession()}; @@ -69,7 +69,7 @@ namespace Scrobbling if (!track) return; - Database::TrackListEntry::create(session, track, getListensTrackList(session, user), timePoint); + Database::TrackListEntry::create(session, track, getListensTrackList(session, user), listen.listenedAt); } Wt::Dbo::ptr diff --git a/src/libs/scrobbling/impl/internal/InternalScrobbler.hpp b/src/libs/scrobbling/impl/internal/InternalScrobbler.hpp index e11ff43e..4d3323ea 100644 --- a/src/libs/scrobbling/impl/internal/InternalScrobbler.hpp +++ b/src/libs/scrobbling/impl/internal/InternalScrobbler.hpp @@ -32,7 +32,7 @@ namespace Scrobbling void listenStarted(const Listen& listen) override; void listenFinished(const Listen& listen, std::optional duration) override; - void addListen(const Listen& listen, const Wt::WDateTime& timePoint) override; + void addTimedListen(const TimedListen& listen) override; Wt::Dbo::ptr getListensTrackList(Database::Session& session, Wt::Dbo::ptr user) override; diff --git a/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.cpp b/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.cpp index 39a05c4a..0cdb8443 100644 --- a/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.cpp +++ b/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.cpp @@ -34,41 +34,12 @@ #include "utils/IConfig.hpp" #include "utils/Logger.hpp" #include "utils/Service.hpp" +#include "Utils.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) { @@ -164,252 +135,105 @@ namespace 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 +namespace Scrobbling::ListenBrainz { - 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/")} + Scrobbler::Scrobbler(boost::asio::io_context& ioContext, Database::Db& db) + : _ioContext {ioContext} , _db {db} + , _sendQueue {_ioContext, Service::get()->getString("listenbrainz-api-base-url", "https://api.listenbrainz.org")} + , _listensSynchronizer {_ioContext, db, _sendQueue} { - LOG(INFO) << "Starting ListenBrainz scrobbler... API endpoint = '" << _apiEndpoint << "'"; - - _client.done().connect([this](Wt::AsioWrapper::error_code ec, const Wt::Http::Message& msg) - { - onClientDone(ec, msg); - }); - - _ioService.setThreadCount(1); - _ioService.start(); + LOG(INFO) << "Starting ListenBrainz scrobbler... API endpoint = '" << _sendQueue.getAPIBaseURL(); } - ListenBrainzScrobbler::~ListenBrainzScrobbler() + Scrobbler::~Scrobbler() { - _ioService.stop(); - - LOG(INFO) << "Stopped ListenBrainz scrobbler"; + LOG(INFO) << "Stopped ListenBrainz scrobbler!"; } void - ListenBrainzScrobbler::listenStarted(const Listen& listen) + Scrobbler::listenStarted(const Listen& listen) { - _ioService.post([=] - { - enqueListen(listen, Wt::WDateTime {}); - }); + enqueListen(listen, Wt::WDateTime {}); } void - ListenBrainzScrobbler::listenFinished(const Listen& listen, std::optional duration) + Scrobbler::listenFinished(const Listen& listen, std::optional duration) { if (duration && !canBeScrobbled(_db.getTLSSession(), listen.trackId, *duration)) return; - Listen timedListen {listen}; + const Listen timedListen {listen}; const Wt::WDateTime now {Wt::WDateTime::currentDateTime()}; - _ioService.post([=] - { - enqueListen(timedListen, now); - }); + enqueListen(timedListen, now); } void - ListenBrainzScrobbler::addListen(const Listen& listen, const Wt::WDateTime& timePoint) + Scrobbler::addTimedListen(const TimedListen& listen) { - assert(timePoint.isValid()); - - _ioService.post([=] - { - enqueListen(listen, timePoint); - }); + assert(listen.listenedAt.isValid()); + enqueListen(listen, listen.listenedAt); } - Wt::Dbo::ptr - ListenBrainzScrobbler::getListensTrackList(Database::Session& session, Wt::Dbo::ptr user) + Database::TrackList::pointer + Scrobbler::getListensTrackList(Database::Session& session, Database::User::pointer user) { - return Database::TrackList::get(session, historyTracklistName, Database::TrackList::Type::Internal, user); + return Utils::getListensTrackList(session, user); } void - ListenBrainzScrobbler::enqueListen(const Listen& listen, const Wt::WDateTime& timePoint) + Scrobbler::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}); - - LOG(DEBUG) << "listen queue size = " << _sendQueue.size(); - - if (_state == State::Idle) - sendNextQueuedListen(); - } - - void - ListenBrainzScrobbler::sendNextQueuedListen() - { - assert(_state == State::Idle); - - while (!_sendQueue.empty()) - { - if (sendListen(_sendQueue.front().listen, _sendQueue.front().timePoint)) - { - _state = State::Sending; - break; - } - - _sendQueue.pop_front(); - } - } - - 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 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 false; - } - - // 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"}; - if (!_client.post(endPoint, message)) - { - LOG(ERROR) << "Cannot post to '" << endPoint << "': invalid scheme or URL?"; - return false; - } - - LOG(DEBUG) << "Listen POST done to '" << endPoint << "'"; - return true; - } - - 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) << "Retry " << queuedListen.retryCount << ", client error: '" << ec.message() << "'"; - // may be a network error, try again later - if (++queuedListen.retryCount > _maxRetryCount) - _sendQueue.pop_front(); - - throttle(_defaultRetryWaitDuration); + std::optional requestData {createSubmitListenRequestData(listen, timePoint)}; + if (!requestData) return; - } - bool mustThrottle{}; - - switch (msg.status()) + SendQueue::Request submitListen {std::move(*requestData)}; + if (timePoint.isValid()) { - 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)); + submitListen.setPriority(SendQueue::Request::Priority::Normal); + submitListen.setOnSuccessFunc([=](std::string_view) + { + _listensSynchronizer.saveListen(TimedListen {listen, timePoint}); + }); } else { - sendNextQueuedListen(); + // We want "listen now" to appear as soon as possible + submitListen.setPriority(SendQueue::Request::Priority::High); } + + _sendQueue.enqueueRequest(std::move(submitListen)); } - void - ListenBrainzScrobbler::throttle(std::chrono::seconds requestedDuration) - { - assert(_state == State::Idle); - - const std::chrono::seconds duration {clamp(requestedDuration, _minRetryWaitDuration, _maxRetryWaitDuration)}; - 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) + std::optional + Scrobbler::createSubmitListenRequestData(const Listen& listen, const Wt::WDateTime& timePoint) { Database::Session& session {_db.getTLSSession()}; - auto transaction {session.createUniqueTransaction()}; + const std::optional listenBrainzToken {Utils::getListenBrainzToken(session, listen.userId)}; + if (!listenBrainzToken) + return std::nullopt; - const Database::User::pointer user {Database::User::getById(session, listen.userId)}; - if (!user) - return; + SendQueue::RequestData requestData; + requestData.endpoint = "/1/submit-listens"; + requestData.type = SendQueue::RequestData::Type::POST; - const Database::Track::pointer track {Database::Track::getById(session, listen.trackId)}; - if (!track) - return; + std::string bodyText {listenToJsonString(session, listen, timePoint, timePoint.isValid() ? "single" : "playing_now")}; + if (bodyText.empty()) + { + LOG(DEBUG) << "Cannot convert listen to json: skipping"; + return std::nullopt; + } - Database::TrackList::pointer tracklist {getListensTrackList(session, user)}; - if (!tracklist) - tracklist = Database::TrackList::create(session, historyTracklistName, Database::TrackList::Type::Internal, false, user); + requestData.message.addBodyText(bodyText); + requestData.message.addHeader("Authorization", "Token " + std::string {listenBrainzToken->getAsString()}); + requestData.message.addHeader("Content-Type", "application/json"); - Database::TrackListEntry::create(session, track, getListensTrackList(session, user), timePoint); + return requestData; } - -} // Scrobbling +} // namespace Scrobbling::ListenBrainz diff --git a/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.hpp b/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.hpp index 690a7afc..ea6e5d1d 100644 --- a/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.hpp +++ b/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.hpp @@ -19,12 +19,12 @@ #pragma once -#include - -#include -#include +#include +#include #include "IScrobbler.hpp" +#include "ListensSynchronizer.hpp" +#include "SendQueue.hpp" namespace Database { @@ -33,59 +33,33 @@ namespace Database class TrackList; } -namespace Scrobbling +namespace Scrobbling::ListenBrainz { - class ListenBrainzScrobbler final : public IScrobbler + class Scrobbler final : public IScrobbler { public: - ListenBrainzScrobbler(Database::Db& db); - ~ListenBrainzScrobbler(); + Scrobbler(boost::asio::io_context& ioContext, Database::Db& db); + ~Scrobbler(); - ListenBrainzScrobbler(const ListenBrainzScrobbler&) = delete; - ListenBrainzScrobbler(const ListenBrainzScrobbler&&) = delete; - ListenBrainzScrobbler& operator=(const ListenBrainzScrobbler&) = delete; - ListenBrainzScrobbler& operator=(const ListenBrainzScrobbler&&) = delete; + Scrobbler(const Scrobbler&) = delete; + Scrobbler(const Scrobbler&&) = delete; + Scrobbler& operator=(const Scrobbler&) = delete; + Scrobbler& operator=(const Scrobbler&&) = delete; private: void listenStarted(const Listen& listen) override; void listenFinished(const Listen& listen, std::optional duration) override; - void addListen(const Listen& listen, const Wt::WDateTime& timePoint) override; - + void addTimedListen(const TimedListen& listen) override; Wt::Dbo::ptr getListensTrackList(Database::Session& session, Wt::Dbo::ptr user) override; + // Submit listens void enqueListen(const Listen& listen, const Wt::WDateTime& timePoint); - void sendNextQueuedListen(); - 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); - - 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}; - const std::chrono::seconds _minRetryWaitDuration {1}; - const std::chrono::seconds _maxRetryWaitDuration {300}; + std::optional createSubmitListenRequestData(const Listen& listen, const Wt::WDateTime& timePoint); + boost::asio::io_context& _ioContext; 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; + SendQueue _sendQueue; + ListensSynchronizer _listensSynchronizer; }; -} // Scrobbling +} // Scrobbling::ListenBrainz diff --git a/src/libs/scrobbling/impl/listenbrainz/ListensSynchronizer.cpp b/src/libs/scrobbling/impl/listenbrainz/ListensSynchronizer.cpp new file mode 100644 index 00000000..03395ae0 --- /dev/null +++ b/src/libs/scrobbling/impl/listenbrainz/ListensSynchronizer.cpp @@ -0,0 +1,514 @@ +/* + * 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 + +#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" + +#include "Utils.hpp" + +#define LOG(sev) LMS_LOG(SCROBBLING, sev) << "[listenbrainz Synchronizer] - " + +namespace +{ + using namespace Scrobbling::ListenBrainz; + + SendQueue::RequestData + createValidateTokenRequestData(std::string_view authToken) + { + SendQueue::RequestData requestData; + requestData.type = SendQueue::RequestData::Type::GET; + requestData.endpoint = "/1/validate-token"; + requestData.headers = { {"Authorization", "Token " + std::string {authToken}} }; + + return requestData; + } + + std::string + parseValidateToken(std::string_view msgBody) + { + std::string listenBrainzUserName; + + Wt::Json::ParseError error; + Wt::Json::Object root; + if (!Wt::Json::parse(std::string {msgBody}, root, error)) + { + LOG(ERROR) << "Cannot parse 'validate-token' result: " << error.what(); + return listenBrainzUserName; + } + + if (!root.get("valid").orIfNull(false)) + { + LOG(INFO) << "Invalid listenbrainz user"; + return listenBrainzUserName; + } + + listenBrainzUserName = root.get("user_name").orIfNull(""); + return listenBrainzUserName; + } + + SendQueue::RequestData + createListenCountRequestData(std::string_view listenBrainzUserName) + { + LOG(DEBUG) << "Getting listen count for listenbrainz user '" << listenBrainzUserName << "'"; + + SendQueue::RequestData requestData; + requestData.type = SendQueue::RequestData::Type::GET; + requestData.endpoint = "/1/user/" + std::string {listenBrainzUserName} + "/listen-count"; + + return requestData; + } + + std::optional + parseListenCount(std::string_view msgBody) + { + try + { + Wt::Json::Object root; + Wt::Json::parse(std::string {msgBody}, root); + + const Wt::Json::Object& payload {static_cast(root.get("payload"))}; + return static_cast(payload.get("count")); + } + catch (const Wt::WException& e) + { + LOG(ERROR) << "Cannot parse listen count response: " << e.what(); + return std::nullopt; + } + } + + SendQueue::RequestData + createGetListensRequestData(std::string_view listenBrainzUserName, const Wt::WDateTime& maxDateTime) + { + LOG(DEBUG) << "Getting listens for listenbrainz user '" << listenBrainzUserName << "' with max_ts = " << maxDateTime.toString(); + + SendQueue::RequestData requestData; + requestData.type = SendQueue::RequestData::Type::GET; + requestData.endpoint = "/1/user/" + std::string {listenBrainzUserName} + "/listens?max_ts=" + std::to_string(maxDateTime.toTime_t()); + + return requestData; + } + + Database::Track::pointer + tryMatchListen(Database::Session& session, const Wt::Json::Object& metadata) + { + Database::Track::pointer track; + + // first try to get the associated track using MBIDs, and then fallback on names + if (metadata.type("additional_info") == Wt::Json::Type::Object) + { + const Wt::Json::Object& additionalInfo = metadata.get("additional_info"); + if (std::optional recordingMBID {UUID::fromString(additionalInfo.get("recording_mbid").orIfNull(""))}) + track = Database::Track::getByRecordingMBID(session, *recordingMBID); + } + + if (track) + return track; + + // these fields are mandatory + const std::string trackName {static_cast(metadata.get("track_name"))}; + const std::string releaseName {static_cast(metadata.get("release_name"))}; + + auto tracks {Database::Track::getByNameAndReleaseName(session, trackName, releaseName)}; + if (tracks.size() > 1) + { + tracks.erase(std::remove_if(std::begin(tracks), std::end(tracks), + [&](const Database::Track::pointer track) + { + if (std::string artistName {metadata.get("artist_name").orIfNull("")}; !artistName.empty()) + { + const auto& artists {track->getArtists({Database::TrackArtistLinkType::Artist})}; + if (std::none_of(std::begin(artists), std::end(artists), [&](const Database::Artist::pointer& artist) { return artist->getName() == artistName; })) + return true; + } + if (metadata.type("additional_info") == Wt::Json::Type::Object) + { + const Wt::Json::Object& additionalInfo = metadata.get("additional_info"); + if (track->getTrackNumber()) + { + int otherTrackNumber {additionalInfo.get("tracknumber").orIfNull(-1)}; + if (otherTrackNumber > 0 && static_cast(otherTrackNumber) != *track->getTrackNumber()) + return true; + } + + if (auto releaseMBID {track->getRelease()->getMBID()}) + { + if (std::optional otherReleaseMBID {UUID::fromString(additionalInfo.get("release_mbid").orIfNull(""))}) + { + if (otherReleaseMBID->getAsString() != releaseMBID->getAsString()) + return true; + } + } + } + + return false; + }), std::end(tracks)); + } + + if (tracks.size() == 1) + track = tracks.front(); + + return track; + } + + struct ParseGetListensResult + { + Wt::WDateTime oldestEntry; + std::size_t listenCount{}; + std::vector matchedListens; + }; + ParseGetListensResult + parseGetListens(Database::Session& session, std::string_view msgBody, Database::IdType userId) + { + ParseGetListensResult result; + + try + { + Wt::Json::Object root; + Wt::Json::parse(std::string {msgBody}, root); + + const Wt::Json::Object& payload = root.get("payload"); + const Wt::Json::Array& listens = payload.get("listens"); + + LOG(DEBUG) << "Got " << listens.size() << " listens"; + + if (listens.empty()) + return result; + + auto transaction {session.createSharedTransaction()}; + + for (const Wt::Json::Value& value : listens) + { + const Wt::Json::Object& listen = value; + const Wt::WDateTime listenedAt {Wt::WDateTime::fromTime_t(static_cast(listen.get("listened_at")))}; + const Wt::Json::Object& metadata = listen.get("track_metadata"); + + if (!listenedAt.isValid()) + { + LOG(ERROR) << "bad listened_at field!"; + continue; + } + + result.listenCount++; + if (!result.oldestEntry.isValid()) + result.oldestEntry = listenedAt; + else if (listenedAt < result.oldestEntry) + result.oldestEntry = listenedAt; + + if (const Database::Track::pointer track {tryMatchListen(session, metadata)}) + result.matchedListens.emplace_back(Scrobbling::TimedListen {userId, track.id(), listenedAt}); + } + } + catch (const Wt::WException& error) + { + LOG(ERROR) << "Cannot parse 'get-listens' result: " << error.what(); + } + + return result; + } +} + +namespace Scrobbling::ListenBrainz +{ + ListensSynchronizer::ListensSynchronizer(boost::asio::io_context& ioContext, Database::Db& db, SendQueue& sendQueue) + : _ioContext {ioContext} + , _db {db} + , _sendQueue {sendQueue} + , _maxSyncListenCount {Service::get()->getULong("listenbrainz-max-sync-listen-count", 1000)} + , _syncListensPeriod {Service::get()->getULong("listenbrainz-sync-listens-period-hours", 1)} + { + LOG(INFO) << "Starting Listens synchronizer, maxSyncListenCount = " << _maxSyncListenCount << ", _syncListensPeriod = " << _syncListensPeriod.count() << " hours"; + + scheduleGetListens(std::chrono::seconds {30}); + } + + void + ListensSynchronizer::saveListen(const TimedListen& listen) + { + _strand.dispatch([=] + { + 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::TrackListEntry::create(session, track, Utils::getOrCreateListensTrackList(session, user), listen.listenedAt); + + UserContext& context {getUserContext(listen.userId)}; + if (context.listenCount) + (*context.listenCount)++; + }); + } + + ListensSynchronizer::UserContext& + ListensSynchronizer::getUserContext(Database::IdType userId) + { + auto itContext {_userContexts.find(userId)}; + if (itContext == std::cend(_userContexts)) + { + auto [itNewContext, inserted] {_userContexts.emplace(userId, userId)}; + itContext = itNewContext; + } + + return itContext->second; + } + + bool + ListensSynchronizer::isFetching() const + { + return std::any_of(std::cbegin(_userContexts), std::cend(_userContexts), [](const auto& contextEntry) + { + const auto& [userId, context] {contextEntry}; + return context.fetching; + }); + } + + void + ListensSynchronizer::scheduleGetListens(std::chrono::seconds fromNow) + { + if (_syncListensPeriod.count() == 0 || _maxSyncListenCount == 0) + return; + + LOG(DEBUG) << "Scheduled sync in " << fromNow.count() << " seconds..."; + _getListensTimer.expires_after(fromNow); + _getListensTimer.async_wait(boost::asio::bind_executor(_strand, [this] (const boost::system::error_code& ec) + { + if (ec == boost::asio::error::operation_aborted) + { + LOG(DEBUG) << "getListens aborted"; + return; + } + + startGetListens(); + })); + } + + void + ListensSynchronizer::startGetListens() + { + LOG(DEBUG) << "GetListens started!!!"; + + assert(!isFetching()); + + std::vector userIds; + { + Database::Session& session {_db.getTLSSession()}; + auto transaction {session.createSharedTransaction()}; + userIds = Database::User::getAllIds(_db.getTLSSession()); + } + + for (const Database::IdType userId : userIds) + { + if (Utils::getListenBrainzToken(_db.getTLSSession(), userId)) + startGetListens(getUserContext(userId)); + } + + if (!isFetching()) + scheduleGetListens(_syncListensPeriod); + } + + void + ListensSynchronizer::startGetListens(UserContext& context) + { + context.fetching = true; + context.listenBrainzUserName = ""; + context.maxDateTime = {}; + context.fetchedListenCount = 0; + context.matchedListenCount = 0; + context.importedListenCount = 0; + + enqueValidateToken(context); + } + + void + ListensSynchronizer::onGetListensEnded(UserContext& context) + { + _strand.dispatch([this, &context] + { + LOG(DEBUG) << "Fetch done for user " << context.userId << ", fetched: " << context.fetchedListenCount << ", matched: " << context.matchedListenCount << ", imported: " << context.importedListenCount; + context.fetching = false; + + if (!isFetching()) + scheduleGetListens(_syncListensPeriod); + }); + } + + void + ListensSynchronizer::enqueValidateToken(UserContext& context) + { + assert(context.listenBrainzUserName.empty()); + + std::optional requestData {createValidateTokenRequestData(context.userId)}; + if (!requestData) + { + onGetListensEnded(context); + return; + } + + SendQueue::Request validateTokenRequest {std::move(*requestData)}; + validateTokenRequest.setOnSuccessFunc([this, &context] (std::string_view msgBody) + { + context.listenBrainzUserName = parseValidateToken(msgBody); + if (context.listenBrainzUserName.empty()) + { + onGetListensEnded(context); + return; + } + enqueGetListenCount(context); + }); + validateTokenRequest.setOnFailureFunc([this, &context] + { + onGetListensEnded(context); + }); + + validateTokenRequest.setPriority(SendQueue::Request::Priority::Low); + _sendQueue.enqueueRequest(std::move(validateTokenRequest)); + } + + void + ListensSynchronizer::enqueGetListenCount(UserContext& context) + { + assert(!context.listenBrainzUserName.empty()); + + SendQueue::Request getListenCountRequest {createListenCountRequestData(context.listenBrainzUserName)}; + getListenCountRequest.setOnSuccessFunc([=, &context] (std::string_view msgBody) + { + const auto listenCount = parseListenCount(msgBody); + if (listenCount) + LOG(DEBUG) << "Listen count for listenbrainz user '" << context.listenBrainzUserName << "' = " << *listenCount; + + bool needSync {listenCount && (!context.listenCount || *context.listenCount != *listenCount)}; + context.listenCount = listenCount; + + if (!needSync) + { + onGetListensEnded(context); + return; + } + + context.maxDateTime = Wt::WDateTime::currentDateTime(); + enqueGetListens(context); + }); + getListenCountRequest.setOnFailureFunc([this, &context] + { + onGetListensEnded(context); + }); + + getListenCountRequest.setPriority(SendQueue::Request::Priority::Low); + _sendQueue.enqueueRequest(std::move(getListenCountRequest)); + } + + void + ListensSynchronizer::enqueGetListens(UserContext& context) + { + assert(!context.listenBrainzUserName.empty()); + + SendQueue::Request getListensRequest {::createGetListensRequestData(context.listenBrainzUserName, context.maxDateTime)}; + getListensRequest.setOnSuccessFunc([=, &context] (std::string_view msgBody) + { + processGetListensResponse(msgBody, context); + if (context.fetchedListenCount >= _maxSyncListenCount || !context.maxDateTime.isValid()) + { + onGetListensEnded(context); + return; + } + + enqueGetListens(context); + }); + getListensRequest.setOnFailureFunc([=, &context] + { + onGetListensEnded(context); + }); + + getListensRequest.setPriority(SendQueue::Request::Priority::Low); + _sendQueue.enqueueRequest(std::move(getListensRequest)); + } + + std::optional + ListensSynchronizer::createValidateTokenRequestData(Database::IdType userId) + { + Database::Session& session {_db.getTLSSession()}; + + const std::optional listenBrainzToken {Utils::getListenBrainzToken(session, userId)}; + if (!listenBrainzToken) + return std::nullopt; + + return ::createValidateTokenRequestData(listenBrainzToken->getAsString()); + } + + void + ListensSynchronizer::processGetListensResponse(std::string_view msgBody, UserContext& context) + { + Database::Session& session {_db.getTLSSession()}; + + const ParseGetListensResult parseResult {parseGetListens(session, msgBody, context.userId)}; + + context.fetchedListenCount += parseResult.listenCount; + context.matchedListenCount += parseResult.matchedListens.size(); + context.maxDateTime = parseResult.oldestEntry; + + if (parseResult.matchedListens.empty()) + return; + + auto transaction {session.createUniqueTransaction()}; + + Database::User::pointer user {Database::User::getById(session, context.userId)}; + if (!user) + return; + + Database::TrackList::pointer tracklist {Utils::getOrCreateListensTrackList(session, user)}; + + for (const TimedListen& listen : parseResult.matchedListens) + { + const Database::Track::pointer track {Database::Track::getById(session, listen.trackId)}; + if (!track) + continue; + + if (!tracklist->getEntryByTrackAndDateTime(track, listen.listenedAt)) + { + context.importedListenCount++; + Database::TrackListEntry::create(session, track, tracklist, listen.listenedAt); + } + } + } + +} // namespace Scrobbling::ListenBrainz + diff --git a/src/libs/scrobbling/impl/listenbrainz/ListensSynchronizer.hpp b/src/libs/scrobbling/impl/listenbrainz/ListensSynchronizer.hpp new file mode 100644 index 00000000..1a49ed98 --- /dev/null +++ b/src/libs/scrobbling/impl/listenbrainz/ListensSynchronizer.hpp @@ -0,0 +1,98 @@ +/* + * 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 "database/Types.hpp" +#include "scrobbling/Listen.hpp" +#include "SendQueue.hpp" + +namespace Database +{ + class Db; + class Session; + class TrackList; + class User; +} + +namespace Scrobbling::ListenBrainz +{ + class ListensSynchronizer + { + public: + ListensSynchronizer(boost::asio::io_context& ioContext, Database::Db& db, SendQueue& sendQueue); + + void saveListen(const TimedListen& listen); + + private: + struct UserContext + { + UserContext(Database::IdType id) : userId {id} {} + + UserContext(const UserContext&) = delete; + UserContext(UserContext&&) = delete; + UserContext& operator=(const UserContext&) = delete; + UserContext& operator=(UserContext&&) = delete; + + const Database::IdType userId; + bool fetching {}; + std::optional listenCount {}; + + // resetted at each fetch + std::string listenBrainzUserName; // need to be resolved first + Wt::WDateTime maxDateTime; + std::size_t fetchedListenCount{}; + std::size_t matchedListenCount{}; + std::size_t importedListenCount{}; + + }; + + UserContext& getUserContext(Database::IdType userId); + bool isFetching() const; + void scheduleGetListens(std::chrono::seconds fromNow); + void startGetListens(); + void startGetListens(UserContext& context); + void onGetListensEnded(UserContext& context); + void enqueValidateToken(UserContext& context); + void enqueGetListenCount(UserContext& context); + void enqueGetListens(UserContext& context); + std::optional createValidateTokenRequestData(Database::IdType userId); + std::optional createGetListensRequestData(std::string_view listenBrainzUserName, const Wt::WDateTime& maxDateTime); + void processGetListensResponse(std::string_view body, UserContext& context); + + boost::asio::io_context& _ioContext; + boost::asio::io_context::strand _strand {_ioContext}; + Database::Db& _db; + SendQueue& _sendQueue; + boost::asio::steady_timer _getListensTimer {_ioContext}; + + std::unordered_map _userContexts; + + const std::size_t _maxSyncListenCount; + const std::chrono::hours _syncListensPeriod; + }; +} // Scrobbling::ListenBrainz + diff --git a/src/libs/scrobbling/impl/listenbrainz/SendQueue.cpp b/src/libs/scrobbling/impl/listenbrainz/SendQueue.cpp new file mode 100644 index 00000000..dbede5e0 --- /dev/null +++ b/src/libs/scrobbling/impl/listenbrainz/SendQueue.cpp @@ -0,0 +1,228 @@ +/* + * 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 "SendQueue.hpp" + +#include + +#include "utils/Logger.hpp" +#include "utils/String.hpp" + +#define LOG(sev) LMS_LOG(SCROBBLING, sev) << "[listenbrainz SendQueue] - " + +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 +{ + 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::ListenBrainz +{ + SendQueue::SendQueue(boost::asio::io_context& ioContext, std::string_view apiBaseURL) + : _ioContext {ioContext} + , _apiBaseURL {apiBaseURL} + { + _client.done().connect([this](Wt::AsioWrapper::error_code ec, const Wt::Http::Message& msg) + { + _strand.dispatch([=, msg = std::move(msg)] + { + onClientDone(ec, msg); + }); + }); + } + + SendQueue::~SendQueue() + { + _client.abort(); + } + + void + SendQueue::enqueueRequest(Request request) + { + _strand.dispatch([this, request = std::move(request)]() + { + _sendQueue[request._priority].emplace_back(std::move(request)); + + if (_state == State::Idle) + sendNextQueuedRequest(); + }); + } + + void + SendQueue::sendNextQueuedRequest() + { + assert(_state == State::Idle); + + for (auto& [prio, requests] : _sendQueue) + { + LOG(DEBUG) << "Processing prio " << static_cast(prio) << ", request count = " << requests.size(); + while (!requests.empty()) + { + Request request {std::move(requests.front())}; + requests.pop_front(); + + if (!sendRequest(request._requestData)) + continue; + + _state = State::Sending; + _currentRequest = std::move(request); + return; + } + } + } + + bool + SendQueue::sendRequest(const RequestData& requestData) + { + const std::string url {_apiBaseURL + requestData.endpoint}; + + LOG(DEBUG) << "Sending request type " << (requestData.type == RequestData::Type::GET ? "GET" : "POST") << " to url '" << url << "'"; + + bool res{}; + switch (requestData.type) + { + case RequestData::Type::GET: + res = _client.get(url, requestData.headers); + break; + case RequestData::Type::POST: + res = _client.post(url, requestData.message); + break; + } + + if (!res) + LOG(ERROR) << "Send failed, bad url or unsupported scheme?"; + + return res; + } + + void + SendQueue::onClientDone(Wt::AsioWrapper::error_code ec, const Wt::Http::Message& msg) + { + if (ec == boost::asio::error::operation_aborted) + { + LOG(DEBUG) << "SendQueue: client aborted"; + return; + } + + assert(_currentRequest); + Request request {std::move(*_currentRequest)}; + _state = State::Idle; + + LOG(DEBUG) << "Client done. status = " << msg.status(); + if (ec) + { + LOG(ERROR) << "Retry " << request._retryCount << ", client error: '" << ec.message() << "'"; + + // may be a network error, try again later + throttle(_defaultRetryWaitDuration); + + if (request._retryCount++ < _maxRetryCount) + { + _sendQueue[request._priority].emplace_front(std::move(request)); + } + else + { + LOG(ERROR) << "Too many retries, giving up operation and throttle"; + if (request._onFailureFunc) + request._onFailureFunc(); + } + return; + } + + bool mustThrottle{}; + if (msg.status() == 429) + _sendQueue[request._priority].emplace_front(std::move(request)); + + 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)); + } + + if (!mustThrottle) + { + if (msg.status() == 200) + { + if (request._onSuccessFunc) + request._onSuccessFunc(msg.body()); + } + else + { + LOG(ERROR) << "Send error: '" << msg.body() << "'"; + if (request._onFailureFunc) + request._onFailureFunc(); + } + } + + if (_state == State::Idle) + sendNextQueuedRequest(); + } + + void + SendQueue::throttle(std::chrono::seconds requestedDuration) + { + assert(_state == State::Idle); + + const std::chrono::seconds duration {clamp(requestedDuration, _minRetryWaitDuration, _maxRetryWaitDuration)}; + LOG(DEBUG) << "Throttling for " << duration.count() << " seconds"; + + _throttleTimer.expires_after(duration); + _throttleTimer.async_wait([this](const boost::system::error_code& ec) + { + if (ec == boost::asio::error::operation_aborted) + { + LOG(DEBUG) << "SendQueue: throttle aborted"; + return; + } + + if (ec) + LOG(ERROR) << "async_wait failed:" << ec.message(); + + _state = State::Idle; + sendNextQueuedRequest(); + }); + _state = State::Throttled; + } +} // namespace Scrobbling::ListenBrainz diff --git a/src/libs/scrobbling/impl/listenbrainz/SendQueue.hpp b/src/libs/scrobbling/impl/listenbrainz/SendQueue.hpp new file mode 100644 index 00000000..7345212e --- /dev/null +++ b/src/libs/scrobbling/impl/listenbrainz/SendQueue.hpp @@ -0,0 +1,118 @@ +/* + * 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 + +namespace Scrobbling::ListenBrainz +{ + class SendQueue + { + public: + SendQueue(boost::asio::io_context& ioContext, std::string_view apiBaseURL); + ~SendQueue(); + + SendQueue(const SendQueue&) = delete; + SendQueue(const SendQueue&&) = delete; + SendQueue& operator=(const SendQueue&) = delete; + SendQueue& operator=(const SendQueue&&) = delete; + + // generic queue operations + struct RequestData + { + enum class Type + { + GET, + POST, + }; + + Type type; + std::string endpoint; // relative URL to the base API + std::vector headers; // used by GET + Wt::Http::Message message; // used by POST + }; + + class Request + { + public: + + enum class Priority + { + High, + Normal, + Low, + }; + + Request(RequestData requestData) : _requestData {std::move(requestData)} {} + + using OnSuccessFunc = std::function; + using OnFailureFunc = std::function; + + void setOnSuccessFunc(OnSuccessFunc onSuccessFunc) { _onSuccessFunc = onSuccessFunc; } + void setOnFailureFunc(OnFailureFunc onFailureFunc) { _onFailureFunc = onFailureFunc; } + void setPriority(Priority priority) { _priority = priority; } + + private: + friend class SendQueue; + RequestData _requestData; + Priority _priority {Priority::Normal}; + std::size_t _retryCount {}; + OnSuccessFunc _onSuccessFunc; + OnFailureFunc _onFailureFunc; + }; + + std::string_view getAPIBaseURL() const { return _apiBaseURL; } + void enqueueRequest(Request request); + + private: + void sendNextQueuedRequest(); + bool sendRequest(const RequestData& request); + void onClientDone(Wt::AsioWrapper::error_code ec, const Wt::Http::Message& msg); + void throttle(std::chrono::seconds duration); + + const std::size_t _maxRetryCount {2}; + const std::chrono::seconds _defaultRetryWaitDuration {30}; + const std::chrono::seconds _minRetryWaitDuration {1}; + const std::chrono::seconds _maxRetryWaitDuration {300}; + + enum class State + { + Idle, + Throttled, + Sending, + }; + boost::asio::io_context& _ioContext; + boost::asio::io_context::strand _strand {_ioContext}; + boost::asio::steady_timer _throttleTimer {_ioContext}; + std::string _apiBaseURL; + State _state {State::Idle}; + Wt::Http::Client _client {_ioContext}; + std::map> _sendQueue; + std::optional _currentRequest; + }; + +} // namespace Scrobbling::ListenBrainz + diff --git a/src/libs/scrobbling/impl/listenbrainz/Utils.cpp b/src/libs/scrobbling/impl/listenbrainz/Utils.cpp new file mode 100644 index 00000000..47cf901c --- /dev/null +++ b/src/libs/scrobbling/impl/listenbrainz/Utils.cpp @@ -0,0 +1,63 @@ +/* + * 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 "Utils.hpp" + +#include + +#include "database/Session.hpp" +#include "database/TrackList.hpp" +#include "database/User.hpp" + +static constexpr std::string_view historyTracklistName {"__scrobbler_listenbrainz_history__"}; + +namespace Scrobbling::ListenBrainz::Utils +{ + 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(); + } + + Database::TrackList::pointer + getListensTrackList(Database::Session& session, Database::User::pointer user) + { + return Database::TrackList::get(session, historyTracklistName, Database::TrackList::Type::Internal, user); + } + + Database::TrackList::pointer + getOrCreateListensTrackList(Database::Session& session, Database::User::pointer user) + { + Database::TrackList::pointer tracklist {getListensTrackList(session, user)}; + if (!tracklist) + tracklist = Database::TrackList::create(session, historyTracklistName, Database::TrackList::Type::Internal, false, user); + + return tracklist; + } + +} diff --git a/src/libs/scrobbling/impl/listenbrainz/Utils.hpp b/src/libs/scrobbling/impl/listenbrainz/Utils.hpp new file mode 100644 index 00000000..84074086 --- /dev/null +++ b/src/libs/scrobbling/impl/listenbrainz/Utils.hpp @@ -0,0 +1,38 @@ +/* + * 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 "utils/UUID.hpp" +#include "database/Types.hpp" + +namespace Database +{ + class Session; + class TrackList; + class User; +} + +namespace Scrobbling::ListenBrainz::Utils +{ + std::optional getListenBrainzToken(Database::Session& session, Database::IdType userId); + Wt::Dbo::ptr getOrCreateListensTrackList(Database::Session& session, Wt::Dbo::ptr user); + Wt::Dbo::ptr getListensTrackList(Database::Session& session, Wt::Dbo::ptr user); +} diff --git a/src/libs/scrobbling/include/scrobbling/IScrobbling.hpp b/src/libs/scrobbling/include/scrobbling/IScrobbling.hpp index 376ad085..e1fc5ce9 100644 --- a/src/libs/scrobbling/include/scrobbling/IScrobbling.hpp +++ b/src/libs/scrobbling/include/scrobbling/IScrobbling.hpp @@ -19,6 +19,8 @@ #pragma once +#include + #include #include #include @@ -51,7 +53,7 @@ namespace Scrobbling virtual void listenStarted(const Listen& listen) = 0; virtual void listenFinished(const Listen& listen, std::optional playedDuration = std::nullopt) = 0; - virtual void addListen(const Listen& listen, Wt::WDateTime timePoint) = 0; + virtual void addTimedListen(const TimedListen& listen) = 0; // Stats // From most recent to oldest @@ -95,7 +97,7 @@ namespace Scrobbling bool& moreResults) = 0; }; - std::unique_ptr createScrobbling(Database::Db& db); + std::unique_ptr createScrobbling(boost::asio::io_service& ioService, Database::Db& db); } // ns Scrobbling diff --git a/src/libs/scrobbling/include/scrobbling/Listen.hpp b/src/libs/scrobbling/include/scrobbling/Listen.hpp index 48c10456..b67a2ae9 100644 --- a/src/libs/scrobbling/include/scrobbling/Listen.hpp +++ b/src/libs/scrobbling/include/scrobbling/Listen.hpp @@ -19,6 +19,8 @@ #pragma once +#include + #include "database/Types.hpp" namespace Scrobbling @@ -28,5 +30,10 @@ namespace Scrobbling Database::IdType userId {}; Database::IdType trackId {}; }; + + struct TimedListen : public Listen + { + Wt::WDateTime listenedAt; + }; } // ns Scrobbling diff --git a/src/libs/subsonic/impl/SubsonicResource.cpp b/src/libs/subsonic/impl/SubsonicResource.cpp index a9caad89..cccd9a2d 100644 --- a/src/libs/subsonic/impl/SubsonicResource.cpp +++ b/src/libs/subsonic/impl/SubsonicResource.cpp @@ -1679,7 +1679,7 @@ handleScrobble(RequestContext& context) { 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))); + Service::get()->addTimedListen({context.userId, trackId, Wt::WDateTime::fromTime_t(static_cast(time / 1000))}); } } } diff --git a/src/libs/utils/CMakeLists.txt b/src/libs/utils/CMakeLists.txt index 39d6ee8d..f6ef50b7 100644 --- a/src/libs/utils/CMakeLists.txt +++ b/src/libs/utils/CMakeLists.txt @@ -4,6 +4,7 @@ add_library(lmsutils SHARED impl/ChildProcessManager.cpp impl/Config.cpp impl/FileResourceHandler.cpp + impl/IOContextRunner.cpp impl/Logger.cpp impl/NetAddress.cpp impl/Path.cpp diff --git a/src/libs/utils/impl/ChildProcessManager.cpp b/src/libs/utils/impl/ChildProcessManager.cpp index 128fcd9f..ab563329 100644 --- a/src/libs/utils/impl/ChildProcessManager.cpp +++ b/src/libs/utils/impl/ChildProcessManager.cpp @@ -25,42 +25,14 @@ std::unique_ptr -createChildProcessManager() +createChildProcessManager(boost::asio::io_context& ioContext) { - return std::make_unique(); + return std::make_unique(ioContext); } -ChildProcessManager::ChildProcessManager() -: _work {boost::asio::make_work_guard(_ioContext)} +ChildProcessManager::ChildProcessManager(boost::asio::io_context& ioContext) +: _ioContext {ioContext} { - start(); -} - -ChildProcessManager::~ChildProcessManager() -{ - stop(); -} - -void -ChildProcessManager::start() -{ - LMS_LOG(CHILDPROCESS, INFO) << "Starting child process manager..."; - - _thread = std::make_unique([&]() - { - _ioContext.run(); - }); - - LMS_LOG(CHILDPROCESS, INFO) << "Child process manager started!"; -} - -void -ChildProcessManager::stop() -{ - LMS_LOG(CHILDPROCESS, INFO) << "Stopping child process manager"; - _work.reset(); - _thread->join(); - LMS_LOG(CHILDPROCESS, INFO) << "Stopped child process manager"; } std::unique_ptr diff --git a/src/libs/utils/impl/ChildProcessManager.hpp b/src/libs/utils/impl/ChildProcessManager.hpp index eb8f0353..79cf3943 100644 --- a/src/libs/utils/impl/ChildProcessManager.hpp +++ b/src/libs/utils/impl/ChildProcessManager.hpp @@ -23,15 +23,14 @@ #include #include -#include #include "utils/IChildProcessManager.hpp" class ChildProcessManager : public IChildProcessManager { public: - ChildProcessManager(); - ~ChildProcessManager(); + ChildProcessManager(boost::asio::io_context& ioContext); + ~ChildProcessManager() = default; ChildProcessManager(const ChildProcessManager&) = delete; ChildProcessManager(ChildProcessManager&&) = delete; @@ -41,12 +40,7 @@ class ChildProcessManager : public IChildProcessManager private: std::unique_ptr spawnChildProcess(const std::filesystem::path& path, const IChildProcess::Args& args) override; - void start(); - void stop(); - - boost::asio::io_context _ioContext; - std::unique_ptr _thread; - boost::asio::executor_work_guard _work; + boost::asio::io_context& _ioContext; }; diff --git a/src/libs/utils/impl/IOContextRunner.cpp b/src/libs/utils/impl/IOContextRunner.cpp new file mode 100644 index 00000000..038f7ff5 --- /dev/null +++ b/src/libs/utils/impl/IOContextRunner.cpp @@ -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 . + */ + +#include "utils/Logger.hpp" +#include "utils/IOContextRunner.hpp" + +IOContextRunner::IOContextRunner(boost::asio::io_service& ioService, std::size_t threadCount) +: _ioService {ioService} +, _work {ioService} +{ + LMS_LOG(UTILS, INFO) << "Starting IO Context with " << threadCount << " threads..."; + for (std::size_t i {}; i < threadCount; ++i) + _threads.emplace_back([&] { _ioService.run(); }); +} + +void +IOContextRunner::stop() +{ + LMS_LOG(UTILS, INFO) << "Stopping IO Context"; + _work.reset(); + _ioService.stop(); + LMS_LOG(UTILS, INFO) << "Stopped IO Context"; +} + +IOContextRunner::~IOContextRunner() +{ + + stop(); + + for (std::thread& t : _threads) + t.join(); + +} diff --git a/src/libs/utils/include/utils/IChildProcessManager.hpp b/src/libs/utils/include/utils/IChildProcessManager.hpp index fd9f8e08..ab4b34d3 100644 --- a/src/libs/utils/include/utils/IChildProcessManager.hpp +++ b/src/libs/utils/include/utils/IChildProcessManager.hpp @@ -20,10 +20,7 @@ #include #include -#pragma once - -#include -#include +#include #include "IChildProcess.hpp" @@ -35,6 +32,6 @@ class IChildProcessManager virtual std::unique_ptr spawnChildProcess(const std::filesystem::path& path, const IChildProcess::Args& args) = 0; }; -std::unique_ptr createChildProcessManager(); +std::unique_ptr createChildProcessManager(boost::asio::io_service& ioService); diff --git a/src/libs/utils/include/utils/IOContextRunner.hpp b/src/libs/utils/include/utils/IOContextRunner.hpp new file mode 100644 index 00000000..804a297c --- /dev/null +++ b/src/libs/utils/include/utils/IOContextRunner.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 +#include +#include + +class IOContextRunner +{ + public: + IOContextRunner(boost::asio::io_service& ioService, std::size_t threadCount); + ~IOContextRunner(); + + IOContextRunner(const IOContextRunner&) = delete; + IOContextRunner(IOContextRunner&&) = delete; + IOContextRunner& operator=(const IOContextRunner&) = delete; + IOContextRunner& operator=(IOContextRunner&&) = delete; + + void stop(); + + private: + boost::asio::io_service& _ioService; + std::optional _work; + std::vector _threads; +}; diff --git a/src/lms/main.cpp b/src/lms/main.cpp index 15f04cdc..65d626d7 100644 --- a/src/lms/main.cpp +++ b/src/lms/main.cpp @@ -19,6 +19,7 @@ #include +#include #include #include @@ -38,6 +39,7 @@ #include "ui/LmsApplicationManager.hpp" #include "utils/IChildProcessManager.hpp" #include "utils/IConfig.hpp" +#include "utils/IOContextRunner.hpp" #include "utils/Service.hpp" #include "utils/String.hpp" #include "utils/WtLogger.hpp" @@ -212,9 +214,12 @@ int main(int argc, char* argv[]) wtArgv[i] = wtServerArgs[i].c_str(); } + boost::asio::io_context ioContext; // ioContext used to dispatch all the services that are out of the Wt event loop Wt::WServer server {argv[0]}; server.setServerConfiguration(wtServerArgs.size(), const_cast(&wtArgv[0])); + IOContextRunner ioContextRunner {ioContext, std::max(2, std::thread::hardware_concurrency())}; + // Initializing a connection pool to the database that will be shared along services Database::Db database {config->getPath("working-dir") / "lms.db"}; { @@ -226,7 +231,7 @@ int main(int argc, char* argv[]) UserInterface::LmsApplicationManager appManager; // Service initialization order is important (reverse-order for deinit) - Service childProcessManagerService {createChildProcessManager()}; + Service childProcessManagerService {createChildProcessManager(ioContext)}; Service authTokenService; Service authPasswordService; @@ -251,7 +256,7 @@ int main(int argc, char* argv[]) config->getULong("cover-max-file-size", 10) * 1000 * 1000, config->getULong("cover-jpeg-quality", 75))}; Service recommendationEngineService {Recommendation::createEngine(database)}; - Service scannerService {Scanner::createScanner(database, *recommendationEngineService)}; + Service scannerService {Scanner::createScanner(/*ioContext,*/ database, *recommendationEngineService)}; scannerService->getEvents().scanComplete.connect([&] { @@ -260,7 +265,7 @@ int main(int argc, char* argv[]) coverArtService->flushCache(); }); - Service scrobblingService {Scrobbling::createScrobbling(database)}; + Service scrobblingService {Scrobbling::createScrobbling(ioContext, database)}; API::Subsonic::SubsonicResource subsonicResource {database}; diff --git a/src/test/database/DatabaseTest.cpp b/src/test/database/DatabaseTest.cpp index bd612339..edcfb4f5 100644 --- a/src/test/database/DatabaseTest.cpp +++ b/src/test/database/DatabaseTest.cpp @@ -481,6 +481,8 @@ testSingleTrackSingleRelease(Session& session) auto transaction {session.createUniqueTransaction()}; track.get().modify()->setRelease(release.get()); + track.get().modify()->setName("MyTrackName"); + release.get().modify()->setName("MyReleaseName"); } { @@ -498,6 +500,23 @@ testSingleTrackSingleRelease(Session& session) CHECK(track->getRelease()); CHECK(track->getRelease().id() == release.getId()); } + + { + auto transaction {session.createUniqueTransaction()}; + auto tracks {Track::getByNameAndReleaseName(session, "MyTrackName", "MyReleaseName")}; + CHECK(tracks.size() == 1); + CHECK(tracks.front().id() == track.getId()); + } + { + auto transaction {session.createUniqueTransaction()}; + auto tracks {Track::getByNameAndReleaseName(session, "MyTrackName", "MyReleaseFoo")}; + CHECK(tracks.size() == 0); + } + { + auto transaction {session.createUniqueTransaction()}; + auto tracks {Track::getByNameAndReleaseName(session, "MyTrackFoo", "MyReleaseName")}; + CHECK(tracks.size() == 0); + } } { From 444d4af3c6150c633dcfacd32bbba253e30b732b Mon Sep 17 00:00:00 2001 From: emeric Date: Sun, 2 May 2021 15:10:32 +0200 Subject: [PATCH 02/29] Escape special characters for like sql searches --- src/libs/av/impl/AudioFile.cpp | 2 +- src/libs/database/CMakeLists.txt | 1 + src/libs/database/impl/Artist.cpp | 18 ++--- src/libs/database/impl/Release.cpp | 9 +-- src/libs/database/impl/Track.cpp | 9 +-- src/libs/database/impl/Utils.cpp | 33 +++++++++ src/libs/database/impl/Utils.hpp | 33 +++++++++ src/libs/database/include/database/Artist.hpp | 3 +- .../database/include/database/Release.hpp | 2 +- src/libs/database/include/database/Track.hpp | 2 +- src/libs/metadata/impl/AvFormatParser.cpp | 22 +++--- src/libs/metadata/impl/TagLibParser.cpp | 9 ++- .../impl/listenbrainz/SendQueue.cpp | 2 +- src/libs/subsonic/impl/ParameterParsing.cpp | 4 +- src/libs/subsonic/impl/ParameterParsing.hpp | 4 +- src/libs/subsonic/impl/SubsonicId.cpp | 4 +- src/libs/subsonic/impl/SubsonicId.hpp | 2 +- src/libs/subsonic/impl/SubsonicResource.cpp | 4 +- src/libs/utils/impl/String.cpp | 61 +++++++++++++--- src/libs/utils/impl/UUID.cpp | 2 +- src/libs/utils/include/utils/String.hpp | 20 ++++-- src/libs/utils/include/utils/UUID.hpp | 2 +- src/lms/ui/admin/DatabaseSettingsView.cpp | 2 +- src/lms/ui/explore/SearchView.cpp | 4 +- src/lms/ui/explore/SearchView.hpp | 4 +- .../ui/resource/AudioTranscodeResource.cpp | 2 +- src/test/utils/UtilsTest.cpp | 69 +++++++++++++++++++ 27 files changed, 261 insertions(+), 68 deletions(-) create mode 100644 src/libs/database/impl/Utils.cpp create mode 100644 src/libs/database/impl/Utils.hpp diff --git a/src/libs/av/impl/AudioFile.cpp b/src/libs/av/impl/AudioFile.cpp index b9b51524..7002ab2d 100644 --- a/src/libs/av/impl/AudioFile.cpp +++ b/src/libs/av/impl/AudioFile.cpp @@ -251,7 +251,7 @@ guessMediaFileFormat(const std::filesystem::path& file) if (formats.size() > 1) LMS_LOG(AV, INFO) << "File '" << file.string() << "' reported several formats: '" << format->name << "'"; - std::vector mimeTypes; + std::vector mimeTypes; if (format->mime_type) mimeTypes = StringUtils::splitString(format->mime_type, ","); diff --git a/src/libs/database/CMakeLists.txt b/src/libs/database/CMakeLists.txt index 00a2ef23..cdf1288f 100644 --- a/src/libs/database/CMakeLists.txt +++ b/src/libs/database/CMakeLists.txt @@ -13,6 +13,7 @@ add_library(lmsdatabase SHARED impl/Track.cpp impl/TrackBookmark.cpp impl/User.cpp + impl/Utils.cpp ) target_include_directories(lmsdatabase INTERFACE diff --git a/src/libs/database/impl/Artist.cpp b/src/libs/database/impl/Artist.cpp index 936d6779..b3ebcc6b 100644 --- a/src/libs/database/impl/Artist.cpp +++ b/src/libs/database/impl/Artist.cpp @@ -27,7 +27,7 @@ #include "database/User.hpp" #include "utils/Logger.hpp" #include "SqlQuery.hpp" - +#include "Utils.hpp" namespace Database { @@ -83,7 +83,7 @@ Wt::Dbo::Query createQuery(Session& session, const std::string& queryStr, const std::set& clusterIds, - const std::vector& keywords, + const std::vector& keywords, std::optional linkType) { session.checkSharedLocked(); @@ -100,16 +100,16 @@ createQuery(Session& session, std::vector clauses; std::vector sortClauses; - for (const std::string& keyword : keywords) + for (std::string_view keyword : keywords) { - clauses.push_back("a.name LIKE ?"); - query.bind("%%" + keyword + "%%"); + clauses.push_back("a.name LIKE ? ESCAPE '" ESCAPE_CHAR_STR "'"); + query.bind("%%" + escapeLikeKeyword(keyword) + "%%"); } - for (const std::string& keyword : keywords) + for (std::string_view keyword : keywords) { - sortClauses.push_back("a.sort_name LIKE ?"); - query.bind("%%" + keyword + "%%"); + sortClauses.push_back("a.sort_name LIKE ? ESCAPE '" ESCAPE_CHAR_STR "'"); + query.bind("%%" + escapeLikeKeyword(keyword) + "%%"); } query.where("(" + StringUtils::joinStrings(clauses, " AND ") + ") OR (" + StringUtils::joinStrings(sortClauses, " AND ") + ")"); @@ -267,7 +267,7 @@ Artist::getByClusters(Session& session, const std::set& clusters, SortMe std::vector Artist::getByFilter(Session& session, const std::set& clusters, - const std::vector& keywords, + const std::vector& keywords, std::optional linkType, SortMethod sortMethod, std::optional range, diff --git a/src/libs/database/impl/Release.cpp b/src/libs/database/impl/Release.cpp index 473e7e08..4086099f 100644 --- a/src/libs/database/impl/Release.cpp +++ b/src/libs/database/impl/Release.cpp @@ -28,6 +28,7 @@ #include "database/User.hpp" #include "utils/Logger.hpp" #include "SqlQuery.hpp" +#include "Utils.hpp" namespace Database { @@ -38,14 +39,14 @@ Wt::Dbo::Query createQuery(Session& session, const std::string& queryStr, const std::set& clusterIds, - const std::vector& keywords) + const std::vector& keywords) { auto query {session.getDboSession().query(queryStr)}; query.join("track t ON t.release_id = r.id"); - for (const std::string& keyword : keywords) - query.where("r.name LIKE ?").bind("%%" + keyword + "%%"); + for (std::string_view keyword : keywords) + query.where("r.name LIKE ? ESCAPE '" ESCAPE_CHAR_STR "'").bind("%%" + escapeLikeKeyword(keyword) + "%%"); if (!clusterIds.empty()) { @@ -300,7 +301,7 @@ Release::getByClusters(Session& session, const std::set& clusters) std::vector Release::getByFilter(Session& session, const std::set& clusterIds, - const std::vector& keywords, + const std::vector& keywords, std::optional range, bool& moreResults) { diff --git a/src/libs/database/impl/Track.cpp b/src/libs/database/impl/Track.cpp index 860e4ccb..15dc8056 100644 --- a/src/libs/database/impl/Track.cpp +++ b/src/libs/database/impl/Track.cpp @@ -31,6 +31,7 @@ #include "SqlQuery.hpp" #include "StringViewTraits.hpp" +#include "Utils.hpp" namespace Database { @@ -40,14 +41,14 @@ Wt::Dbo::Query createQuery(Session& session, const std::string& queryStr, const std::set& clusterIds, - const std::vector& keywords) + const std::vector& keywords) { session.checkSharedLocked(); auto query {session.getDboSession().query(queryStr)}; - for (const std::string& keyword : keywords) - query.where("t.name LIKE ?").bind("%%" + keyword + "%%"); + for (std::string_view keyword : keywords) + query.where("t.name LIKE ? ESCAPE '" ESCAPE_CHAR_STR "'").bind("%%" + escapeLikeKeyword(keyword) + "%%"); if (!clusterIds.empty()) { @@ -333,7 +334,7 @@ Track::hasTrackFeatures() const std::vector Track::getByFilter(Session& session, const std::set& clusterIds, - const std::vector& keywords, + const std::vector& keywords, std::optional range, bool& moreResults) { diff --git a/src/libs/database/impl/Utils.cpp b/src/libs/database/impl/Utils.cpp new file mode 100644 index 00000000..d1b1df82 --- /dev/null +++ b/src/libs/database/impl/Utils.cpp @@ -0,0 +1,33 @@ +/* + * 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 "Utils.hpp" + +#include "utils/String.hpp" + +namespace Database +{ + std::string + escapeLikeKeyword(std::string_view keyword) + { + return StringUtils::escapeString(keyword, "*_", escapeChar); + } + +} // namespace Database + diff --git a/src/libs/database/impl/Utils.hpp b/src/libs/database/impl/Utils.hpp new file mode 100644 index 00000000..547c7995 --- /dev/null +++ b/src/libs/database/impl/Utils.hpp @@ -0,0 +1,33 @@ +/* + * 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 + +namespace Database +{ +#define ESCAPE_CHAR_STR "\\" + static constexpr char escapeChar {'\\'}; + std::string escapeLikeKeyword(std::string_view keywords); + +} // namespace Database + diff --git a/src/libs/database/include/database/Artist.hpp b/src/libs/database/include/database/Artist.hpp index 2afe5a6c..0b252d59 100644 --- a/src/libs/database/include/database/Artist.hpp +++ b/src/libs/database/include/database/Artist.hpp @@ -21,6 +21,7 @@ #include #include +#include #include #include @@ -69,7 +70,7 @@ class Artist : public Wt::Dbo::Dbo ); static std::vector getByFilter(Session& session, const std::set& clusters, // if non empty, at least one artist that belongs to these clusters - const std::vector& keywords, // if non empty, name must match all of these keywords (name + sort name fields) + const std::vector& keywords, // if non empty, name must match all of these keywords (name + sort name fields) std::optional linkType, // if set, only artists that have produced at least one track with this link type SortMethod sortMethod, std::optional range, diff --git a/src/libs/database/include/database/Release.hpp b/src/libs/database/include/database/Release.hpp index 8149fff1..f3be2da3 100644 --- a/src/libs/database/include/database/Release.hpp +++ b/src/libs/database/include/database/Release.hpp @@ -66,7 +66,7 @@ class Release : public Wt::Dbo::Dbo static std::vector getByClusters(Session& session, const std::set& clusters); static std::vector getByFilter(Session& session, const std::set& clusters, // if non empty, at least one release that belongs to these clusters - const std::vector& keywords, // if non empty, name must match all of these keywords + const std::vector& keywords, // if non empty, name must match all of these keywords std::optional range, bool& moreExpected); static std::vector getAllIdsWithClusters(Session& session, std::optional limit = {}); diff --git a/src/libs/database/include/database/Track.hpp b/src/libs/database/include/database/Track.hpp index fc8055ae..356f5671 100644 --- a/src/libs/database/include/database/Track.hpp +++ b/src/libs/database/include/database/Track.hpp @@ -71,7 +71,7 @@ class Track : public Wt::Dbo::Dbo const std::set& clusters); // tracks that belong to these clusters static std::vector getByFilter(Session& session, const std::set& clusters, // if non empty, tracks that belong to these clusters - const std::vector& keywords, // if non empty, name must match all of these keywords + const std::vector& keywords, // if non empty, name must match all of these keywords std::optional range, bool& moreExpected); static std::vector getByNameAndReleaseName(Session& session, std::string_view trackName, std::string_view releaseName); diff --git a/src/libs/metadata/impl/AvFormatParser.cpp b/src/libs/metadata/impl/AvFormatParser.cpp index 164f4c3f..e35bdd99 100644 --- a/src/libs/metadata/impl/AvFormatParser.cpp +++ b/src/libs/metadata/impl/AvFormatParser.cpp @@ -49,10 +49,10 @@ findFirstValueOfAs(const Av::IAudioFile::MetadataMap& metadataMap, std::initiali if (!str) return std::nullopt; - std::vector strUuids = StringUtils::splitString(*str, "/"); + const std::vector strUuids {StringUtils::splitString(*str, "/")}; std::vector res; - for (const std::string& strUuid : strUuids) + for (std::string_view strUuid : strUuids) { std::optional uuid {UUID::fromString(strUuid)}; if (!uuid) @@ -101,7 +101,7 @@ getArtists(const Av::IAudioFile::MetadataMap& metadataMap) { std::vector artists; - std::vector artistNames; + std::vector artistNames; if (metadataMap.find("ARTISTS") != metadataMap.end()) { artistNames = StringUtils::splitString(metadataMap.find("ARTISTS")->second, "/;"); @@ -161,8 +161,7 @@ AvFormatParser::parse(const std::filesystem::path& p, bool debug) else if (tag == "TRACK") { // Expecting 'Number/Total' - std::vector strings {StringUtils::splitString(value, "/") }; - + const std::vector strings {StringUtils::splitString(value, "/") }; if (strings.size() > 0) { track.trackNumber = StringUtils::readAs(strings[0]); @@ -174,8 +173,7 @@ AvFormatParser::parse(const std::filesystem::path& p, bool debug) else if (tag == "DISC") { // Expecting 'Number/Total' - std::vector strings {StringUtils::splitString(value, "/")}; - + const std::vector strings {StringUtils::splitString(value, "/")}; if (strings.size() > 0) { track.discNumber = StringUtils::readAs(strings[0]); @@ -217,10 +215,16 @@ AvFormatParser::parse(const std::filesystem::path& p, bool debug) } else if (_clusterTypeNames.find(tag) != _clusterTypeNames.end()) { - std::vector clusterNames {StringUtils::splitString(value, "/,;")}; + const std::vector clusterNames {StringUtils::splitString(value, "/,;")}; if (!clusterNames.empty()) - track.clusters[tag] = std::set{clusterNames.begin(), clusterNames.end()}; + { + std::set values; + std::transform(std::cbegin(clusterNames), std::cend(clusterNames), + std::inserter(values, std::begin(values)), + [](std::string_view clusterName) { return std::string {clusterName}; }); + track.clusters[tag] = std::move(values); + } } } diff --git a/src/libs/metadata/impl/TagLibParser.cpp b/src/libs/metadata/impl/TagLibParser.cpp index 227fc4ed..f0d22d71 100644 --- a/src/libs/metadata/impl/TagLibParser.cpp +++ b/src/libs/metadata/impl/TagLibParser.cpp @@ -78,12 +78,12 @@ getPropertyValuesAs(const TagLib::PropertyMap& properties, const std::string& ke static std::vector -splitAndTrimString(const std::string& str, const std::string& delimiters) +splitAndTrimString(const std::string& str, std::string_view delimiters) { std::vector res; - std::vector strings {StringUtils::splitString(str, delimiters)}; - for (const std::string& s : strings) + std::vector strings {StringUtils::splitString(str, delimiters)}; + for (std::string_view s : strings) res.emplace_back(StringUtils::stringTrim(s)); return res; @@ -203,8 +203,7 @@ TagLibParser::processTag(Track& track, const std::string& tag, const TagLib::Str else if (tag == "DISCNUMBER") { // Expecting 'Number/Total' - std::vector strings {StringUtils::splitString(value, "/")}; - + std::vector strings {StringUtils::splitString(value, "/")}; if (!strings.empty()) { track.discNumber = StringUtils::readAs(strings[0]); diff --git a/src/libs/scrobbling/impl/listenbrainz/SendQueue.cpp b/src/libs/scrobbling/impl/listenbrainz/SendQueue.cpp index dbede5e0..0123b9eb 100644 --- a/src/libs/scrobbling/impl/listenbrainz/SendQueue.cpp +++ b/src/libs/scrobbling/impl/listenbrainz/SendQueue.cpp @@ -30,7 +30,7 @@ namespace StringUtils { template<> std::optional - readAs(const std::string& str) + readAs(std::string_view str) { std::optional res; diff --git a/src/libs/subsonic/impl/ParameterParsing.cpp b/src/libs/subsonic/impl/ParameterParsing.cpp index 48dc09e4..bd8c8464 100644 --- a/src/libs/subsonic/impl/ParameterParsing.cpp +++ b/src/libs/subsonic/impl/ParameterParsing.cpp @@ -23,14 +23,14 @@ namespace StringUtils { template<> std::optional - readAs(const std::string& str) + readAs(std::string_view str) { return API::Subsonic::IdFromString(str); } template<> std::optional - readAs(const std::string& str) + readAs(std::string_view str) { if (str == "true") return true; diff --git a/src/libs/subsonic/impl/ParameterParsing.hpp b/src/libs/subsonic/impl/ParameterParsing.hpp index caf7f3c5..0efae6c3 100644 --- a/src/libs/subsonic/impl/ParameterParsing.hpp +++ b/src/libs/subsonic/impl/ParameterParsing.hpp @@ -90,10 +90,10 @@ namespace StringUtils { template<> std::optional - readAs(const std::string& str); + readAs(std::string_view str); template<> std::optional - readAs(const std::string& str); + readAs(std::string_view str); } diff --git a/src/libs/subsonic/impl/SubsonicId.cpp b/src/libs/subsonic/impl/SubsonicId.cpp index 6f9e7c19..368b7d94 100644 --- a/src/libs/subsonic/impl/SubsonicId.cpp +++ b/src/libs/subsonic/impl/SubsonicId.cpp @@ -28,12 +28,12 @@ namespace API::Subsonic { std::optional -IdFromString(const std::string& id) +IdFromString(const std::string_view id) { if (id == "root") return Id {Id::Type::Root}; - std::vector values {StringUtils::splitString(id, "-")}; + std::vector values {StringUtils::splitString(id, "-")}; if (values.size() != 2) return std::nullopt; diff --git a/src/libs/subsonic/impl/SubsonicId.hpp b/src/libs/subsonic/impl/SubsonicId.hpp index 91cc61bc..52760798 100644 --- a/src/libs/subsonic/impl/SubsonicId.hpp +++ b/src/libs/subsonic/impl/SubsonicId.hpp @@ -41,7 +41,7 @@ struct Id Database::IdType value {}; }; -std::optional IdFromString(const std::string& id); +std::optional IdFromString(std::string_view id); std::string IdToString(const Id& id); } // namespace API::Subsonic diff --git a/src/libs/subsonic/impl/SubsonicResource.cpp b/src/libs/subsonic/impl/SubsonicResource.cpp index cccd9a2d..ef5ee51a 100644 --- a/src/libs/subsonic/impl/SubsonicResource.cpp +++ b/src/libs/subsonic/impl/SubsonicResource.cpp @@ -71,7 +71,7 @@ namespace StringUtils { template<> std::optional - readAs(const std::string& str) + readAs(std::string_view str) { // Expects "X.Y.Z" const auto numbers {StringUtils::splitString(str, ".")}; @@ -1454,7 +1454,7 @@ handleSearchRequestCommon(RequestContext& context, bool id3) // Mandatory params std::string query {getMandatoryParameterAs(context.parameters, "query")}; - std::vector keywords {StringUtils::splitString(query, " ")}; + std::vector keywords {StringUtils::splitString(query, " ")}; // Optional params std::size_t artistCount {getParameterAs(context.parameters, "artistCount").value_or(20)}; diff --git a/src/libs/utils/impl/String.cpp b/src/libs/utils/impl/String.cpp index a2065c75..c26d9c6b 100644 --- a/src/libs/utils/impl/String.cpp +++ b/src/libs/utils/impl/String.cpp @@ -19,6 +19,7 @@ #include "utils/String.hpp" +#include #include #include @@ -57,13 +58,13 @@ readList(const std::string& str, const std::string& separators, std::list std::optional -readAs(const std::string& str) +readAs(std::string_view str) { - return str; + return std::string {str}; } std::vector -splitString(const std::string& string, const std::string& separators) +splitStringCopy(std::string_view string, std::string_view separators) { std::string str {stringTrim(string, separators)}; @@ -73,6 +74,29 @@ splitString(const std::string& string, const std::string& separators) return res; } +std::vector +splitString(std::string_view str, std::string_view separators) +{ + std::vector res; + + std::string_view::size_type strBegin {}; + + while ((strBegin = str.find_first_not_of(separators, strBegin)) != std::string_view::npos) + { + auto strEnd {str.find_first_of(separators, strBegin + 1)}; + if (strEnd == std::string_view::npos) + { + res.push_back(str.substr(strBegin, str.size() - strBegin)); + break; + } + + res.push_back(str.substr(strBegin, strEnd - strBegin)); + strBegin = strEnd + 1; + } + + return res; +} + std::string joinStrings(const std::vector& strings, const std::string& delimiter) { @@ -80,22 +104,22 @@ joinStrings(const std::vector& strings, const std::string& delimite } std::string -stringTrim(const std::string& str, const std::string& whitespace) +stringTrim(std::string_view str, std::string_view whitespaces) { - const auto strBegin = str.find_first_not_of(whitespace); - if (strBegin == std::string::npos) + const auto strBegin = str.find_first_not_of(whitespaces); + if (strBegin == std::string_view::npos) return ""; // no content - const auto strEnd = str.find_last_not_of(whitespace); + const auto strEnd = str.find_last_not_of(whitespaces); const auto strRange = strEnd - strBegin + 1; - return str.substr(strBegin, strRange); + return std::string {str.substr(strBegin, strRange)}; } std::string -stringTrimEnd(const std::string& str, const std::string& whitespace) +stringTrimEnd(std::string_view str, std::string_view whitespaces) { - return str.substr(0, str.find_last_not_of(whitespace)+1); + return std::string {str.substr(0, str.find_last_not_of(whitespaces) + 1)}; } std::string @@ -185,6 +209,23 @@ jsEscape(const std::string& str) return escaped; } +std::string +escapeString(std::string_view str, std::string_view charsToEscape, char escapeChar) +{ + std::string res; + res.reserve(str.size()); + + for (const char c : str) + { + if (std::any_of(std::cbegin(charsToEscape), std::cend(charsToEscape), [c](char charToEscape) { return c == charToEscape; })) + res += escapeChar; + + res += c; + } + + return res; +} + bool stringEndsWith(const std::string& str, const std::string& ending) { diff --git a/src/libs/utils/impl/UUID.cpp b/src/libs/utils/impl/UUID.cpp index 6b9b402a..93f30d34 100644 --- a/src/libs/utils/impl/UUID.cpp +++ b/src/libs/utils/impl/UUID.cpp @@ -27,7 +27,7 @@ namespace StringUtils { template <> std::optional - readAs(const std::string& str) + readAs(std::string_view str) { return UUID::fromString(str); } diff --git a/src/libs/utils/include/utils/String.hpp b/src/libs/utils/include/utils/String.hpp index 82f05f29..9f1d5462 100644 --- a/src/libs/utils/include/utils/String.hpp +++ b/src/libs/utils/include/utils/String.hpp @@ -19,6 +19,7 @@ #pragma once +#include #include #include #include @@ -31,16 +32,19 @@ namespace StringUtils { std::vector -splitString(const std::string& string, const std::string& separators); +splitStringCopy(std::string_view string, std::string_view separators); + +std::vector +splitString(std::string_view string, std::string_view separators); std::string joinStrings(const std::vector& strings, const std::string& delimiter); std::string -stringTrim(const std::string& str, const std::string& whitespaces = " \t"); +stringTrim(std::string_view str, std::string_view whitespaces = " \t"); std::string -stringTrimEnd(const std::string& str, const std::string& whitespaces = " \t"); +stringTrimEnd(std::string_view str, std::string_view whitespaces = " \t"); std::string stringToLower(std::string_view str); @@ -55,11 +59,11 @@ std::string bufferToString(const std::vector& data); template -std::optional readAs(const std::string& str) +std::optional readAs(std::string_view str) { T res; - std::istringstream iss ( str ); + std::istringstream iss {std::string {str}}; iss >> res; if (iss.fail()) return std::nullopt; @@ -69,15 +73,17 @@ std::optional readAs(const std::string& str) template<> std::optional -readAs(const std::string& str); +readAs(std::string_view str); -[[nodiscard]] std::string replaceInString(const std::string& str, const std::string& from, const std::string& to); std::string jsEscape(const std::string& str); +std::string +escapeString(std::string_view str, std::string_view charsToEscape, char escapeChar); + bool stringEndsWith(const std::string& str, const std::string& ending); diff --git a/src/libs/utils/include/utils/UUID.hpp b/src/libs/utils/include/utils/UUID.hpp index 38814d1f..82396cc7 100644 --- a/src/libs/utils/include/utils/UUID.hpp +++ b/src/libs/utils/include/utils/UUID.hpp @@ -42,6 +42,6 @@ namespace StringUtils { template <> std::optional - readAs(const std::string& str); + readAs(std::string_view str); } diff --git a/src/lms/ui/admin/DatabaseSettingsView.cpp b/src/lms/ui/admin/DatabaseSettingsView.cpp index dae20a93..d410b4cd 100644 --- a/src/lms/ui/admin/DatabaseSettingsView.cpp +++ b/src/lms/ui/admin/DatabaseSettingsView.cpp @@ -139,7 +139,7 @@ class DatabaseSettingsModel : public Wt::WFormModel if (recommendationEngineTypeRow) scanSettings.modify()->setRecommendationEngineType(_recommendationEngineTypeModel->getValue(*recommendationEngineTypeRow)); - auto clusterTypes {StringUtils::splitString(valueText(TagsField).toUTF8(), " ")}; + auto clusterTypes {StringUtils::splitStringCopy(valueText(TagsField).toUTF8(), " ")}; scanSettings.modify()->setClusterTypes(LmsApp->getDbSession(), std::set(clusterTypes.begin(), clusterTypes.end())); } diff --git a/src/lms/ui/explore/SearchView.cpp b/src/lms/ui/explore/SearchView.cpp index 61897b63..082277eb 100644 --- a/src/lms/ui/explore/SearchView.cpp +++ b/src/lms/ui/explore/SearchView.cpp @@ -53,7 +53,8 @@ namespace UserInterface void SearchView::refreshView(const Wt::WString& searchText) { - _keywords = StringUtils::splitString(searchText.toUTF8(), " "); + _searchValue = searchText.toUTF8(); + _keywords = StringUtils::splitString(_searchValue, " "); refreshView(); } @@ -73,6 +74,7 @@ namespace UserInterface SearchView::searchArtists() { bool more; + const auto artists {Database::Artist::getByFilter(LmsApp->getDbSession(), _filters->getClusterIds(), _keywords, diff --git a/src/lms/ui/explore/SearchView.hpp b/src/lms/ui/explore/SearchView.hpp index 8711547d..5e784be9 100644 --- a/src/lms/ui/explore/SearchView.hpp +++ b/src/lms/ui/explore/SearchView.hpp @@ -20,6 +20,7 @@ #pragma once #include +#include #include #include @@ -46,7 +47,8 @@ class SearchView : public Wt::WTemplate void searchTracks(); Filters* _filters {}; - std::vector _keywords; + std::string _searchValue; + std::vector _keywords; }; } // namespace UserInterface diff --git a/src/lms/ui/resource/AudioTranscodeResource.cpp b/src/lms/ui/resource/AudioTranscodeResource.cpp index f20a159c..ea374c0c 100644 --- a/src/lms/ui/resource/AudioTranscodeResource.cpp +++ b/src/lms/ui/resource/AudioTranscodeResource.cpp @@ -39,7 +39,7 @@ namespace StringUtils { template <> std::optional - readAs(const std::string& str) + readAs(std::string_view str) { auto encodedFormat {readAs(str)}; diff --git a/src/test/utils/UtilsTest.cpp b/src/test/utils/UtilsTest.cpp index aad60881..0b7f2ea8 100644 --- a/src/test/utils/UtilsTest.cpp +++ b/src/test/utils/UtilsTest.cpp @@ -26,6 +26,74 @@ #include #include "utils/RecursiveSharedMutex.hpp" +#include "utils/String.hpp" + + +void +testStrings() +{ + { + const std::string test{"a"}; + + const std::vector strings {StringUtils::splitString(test, "")}; + assert(strings.size() == 1); + assert(strings.front() == "a"); + } + + { + const std::string test{"a b"}; + + const std::vector strings {StringUtils::splitString(test, "|")}; + assert(strings.size() == 1); + assert(strings.front() == "a b"); + } + + { + const std::string test{" a"}; + + const std::vector strings {StringUtils::splitString(test, " ")}; + assert(strings.size() == 1); + assert(strings.front() == "a"); + } + + { + const std::string test{"a "}; + + const std::vector strings {StringUtils::splitString(test, " ")}; + assert(strings.size() == 1); + assert(strings.front() == "a"); + } + + { + const std::string test{"a b"}; + + const std::vector strings {StringUtils::splitString(test, " ")}; + assert(strings.size() == 2); + assert(strings.front() == "a"); + assert(strings.back() == "b"); + } + + { + const std::string test{"a b,c|defgh "}; + + const std::vector strings {StringUtils::splitString(test, " ,|")}; + assert(strings.size() == 4); + assert(strings[0] == "a"); + assert(strings[1] == "b"); + assert(strings[2] == "c"); + assert(strings[3] == "defgh"); + } + + { + assert(StringUtils::escapeString("", "*", ' ') == ""); + assert(StringUtils::escapeString("", "", ' ') == ""); + assert(StringUtils::escapeString("a", "", ' ') == "a"); + assert(StringUtils::escapeString("*", "*", '_') == "_*"); + assert(StringUtils::escapeString("*a*", "*", '_') == "_*a_*"); + assert(StringUtils::escapeString("*a|", "*|", '_') == "_*a_|"); + assert(StringUtils::escapeString("**||", "*|", '_') == "_*_*_|_|"); + } +} void testSharedMutex() @@ -109,6 +177,7 @@ int main() { try { + testStrings(); testSharedMutex(); } catch (std::exception& e) From 67ce7151ff4916c638abcaa1cf1542b51faf523e Mon Sep 17 00:00:00 2001 From: emeric Date: Mon, 3 May 2021 13:20:43 +0200 Subject: [PATCH 03/29] Switched to gtest for unit testing --- .travis.yml | 1 + CMakeLists.txt | 1 + INSTALL.md | 2 +- docker/Dockerfile | 3 +- src/libs/database/impl/Artist.cpp | 4 +- src/libs/database/impl/Release.cpp | 2 +- src/libs/database/impl/Track.cpp | 2 +- src/libs/database/impl/Utils.cpp | 2 +- src/test/database/Artist.cpp | 314 +++ src/test/database/CMakeLists.txt | 6 +- src/test/database/Cluster.cpp | 1096 +++++++++ src/test/database/Common.hpp | 182 ++ src/test/database/DatabaseTest.cpp | 1955 ++--------------- src/test/database/Track.cpp | 90 + src/test/som/CMakeLists.txt | 4 +- src/test/som/SomTest.cpp | 152 +- src/test/utils/CMakeLists.txt | 8 +- ...UtilsTest.cpp => RecursiveSharedMutex.cpp} | 96 +- src/test/utils/String.cpp | 89 + src/test/utils/Utils.cpp | 27 + 20 files changed, 2131 insertions(+), 1905 deletions(-) create mode 100644 src/test/database/Artist.cpp create mode 100644 src/test/database/Cluster.cpp create mode 100644 src/test/database/Common.hpp create mode 100644 src/test/database/Track.cpp rename src/test/utils/{UtilsTest.cpp => RecursiveSharedMutex.cpp} (50%) create mode 100644 src/test/utils/String.cpp create mode 100644 src/test/utils/Utils.cpp diff --git a/.travis.yml b/.travis.yml index 19b0faa3..11c942e5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -19,6 +19,7 @@ addons: - libtag1-dev - libpam0g-dev - libgraphicsmagick++1-dev + - libgtest-dev compiler: - clang - gcc diff --git a/CMakeLists.txt b/CMakeLists.txt index 23dbb83e..b5ecf689 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -19,6 +19,7 @@ find_package(PkgConfig REQUIRED) pkg_check_modules(Taglib REQUIRED IMPORTED_TARGET taglib) pkg_check_modules(Config++ REQUIRED IMPORTED_TARGET libconfig++) pkg_check_modules(GraphicsMagick++ IMPORTED_TARGET GraphicsMagick++) +find_package(GTest REQUIRED) # WT if (NOT Wt_FOUND) diff --git a/INSTALL.md b/INSTALL.md index d9c08fdc..3a15efb6 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-program-options-dev 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 libgtest-dev ``` __Notes__: * libpam0g-dev is optional (only for using PAM authentication) diff --git a/docker/Dockerfile b/docker/Dockerfile index 2d02c4c7..06411e51 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -36,7 +36,8 @@ ARG BUILD_PACKAGES=" \ openssl-dev \ boost-dev \ libconfig-dev \ - taglib-dev" + taglib-dev \ + gtest-dev" RUN apk add --no-cache --update ${BUILD_PACKAGES} diff --git a/src/libs/database/impl/Artist.cpp b/src/libs/database/impl/Artist.cpp index b3ebcc6b..368de2ed 100644 --- a/src/libs/database/impl/Artist.cpp +++ b/src/libs/database/impl/Artist.cpp @@ -103,13 +103,13 @@ createQuery(Session& session, for (std::string_view keyword : keywords) { clauses.push_back("a.name LIKE ? ESCAPE '" ESCAPE_CHAR_STR "'"); - query.bind("%%" + escapeLikeKeyword(keyword) + "%%"); + query.bind("%" + escapeLikeKeyword(keyword) + "%"); } for (std::string_view keyword : keywords) { sortClauses.push_back("a.sort_name LIKE ? ESCAPE '" ESCAPE_CHAR_STR "'"); - query.bind("%%" + escapeLikeKeyword(keyword) + "%%"); + query.bind("%" + escapeLikeKeyword(keyword) + "%"); } query.where("(" + StringUtils::joinStrings(clauses, " AND ") + ") OR (" + StringUtils::joinStrings(sortClauses, " AND ") + ")"); diff --git a/src/libs/database/impl/Release.cpp b/src/libs/database/impl/Release.cpp index 4086099f..5b0a2d1c 100644 --- a/src/libs/database/impl/Release.cpp +++ b/src/libs/database/impl/Release.cpp @@ -46,7 +46,7 @@ createQuery(Session& session, query.join("track t ON t.release_id = r.id"); for (std::string_view keyword : keywords) - query.where("r.name LIKE ? ESCAPE '" ESCAPE_CHAR_STR "'").bind("%%" + escapeLikeKeyword(keyword) + "%%"); + query.where("r.name LIKE ? ESCAPE '" ESCAPE_CHAR_STR "'").bind("%" + escapeLikeKeyword(keyword) + "%"); if (!clusterIds.empty()) { diff --git a/src/libs/database/impl/Track.cpp b/src/libs/database/impl/Track.cpp index 15dc8056..64dcd4f2 100644 --- a/src/libs/database/impl/Track.cpp +++ b/src/libs/database/impl/Track.cpp @@ -48,7 +48,7 @@ createQuery(Session& session, auto query {session.getDboSession().query(queryStr)}; for (std::string_view keyword : keywords) - query.where("t.name LIKE ? ESCAPE '" ESCAPE_CHAR_STR "'").bind("%%" + escapeLikeKeyword(keyword) + "%%"); + query.where("t.name LIKE ? ESCAPE '" ESCAPE_CHAR_STR "'").bind("%" + escapeLikeKeyword(keyword) + "%"); if (!clusterIds.empty()) { diff --git a/src/libs/database/impl/Utils.cpp b/src/libs/database/impl/Utils.cpp index d1b1df82..39849db1 100644 --- a/src/libs/database/impl/Utils.cpp +++ b/src/libs/database/impl/Utils.cpp @@ -26,7 +26,7 @@ namespace Database std::string escapeLikeKeyword(std::string_view keyword) { - return StringUtils::escapeString(keyword, "*_", escapeChar); + return StringUtils::escapeString(keyword, "%_", escapeChar); } } // namespace Database diff --git a/src/test/database/Artist.cpp b/src/test/database/Artist.cpp new file mode 100644 index 00000000..4c16f5fc --- /dev/null +++ b/src/test/database/Artist.cpp @@ -0,0 +1,314 @@ +/* + * 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 "Common.hpp" + +using namespace Database; + +TEST_F(DatabaseFixture, SingleArtist) +{ + ScopedArtist artist {session, "MyArtist"}; + + { + auto transaction {session.createSharedTransaction()}; + + auto artists {Artist::getAll(session, Artist::SortMethod::ByName)}; + ASSERT_EQ(artists.size(), 1); + EXPECT_EQ(artists.front().id(), artist.getId()); + + artists = Artist::getAllOrphans(session); + ASSERT_EQ(artists.size(), 1); + EXPECT_EQ(artists.front().id(), artist.getId()); + } +} + +TEST_F(DatabaseFixture, SingleTrackSingleArtist) +{ + ScopedTrack track {session, "MyTrack"}; + ScopedArtist artist {session, "MyArtist"}; + + { + auto transaction {session.createUniqueTransaction()}; + + TrackArtistLink::create(session, track.get(), artist.get(), TrackArtistLinkType::Artist); + } + + { + auto transaction {session.createSharedTransaction()}; + EXPECT_TRUE(Artist::getAllOrphans(session).empty()); + } + + { + auto transaction {session.createSharedTransaction()}; + + auto artists {track->getArtists({TrackArtistLinkType::Artist})}; + ASSERT_EQ(artists.size(), 1); + EXPECT_EQ(artists.front().id(), artist.getId()); + + EXPECT_EQ(artist->getReleaseCount(), 0); + + ASSERT_EQ(track->getArtistLinks().size(), 1); + auto artistLink {track->getArtistLinks().front()}; + EXPECT_EQ(artistLink->getTrack().id(), track.getId()); + EXPECT_EQ(artistLink->getArtist().id(), artist.getId()); + + ASSERT_EQ(track->getArtists({TrackArtistLinkType::Artist}).size(), 1); + EXPECT_TRUE(track->getArtists({TrackArtistLinkType::ReleaseArtist}).empty()); + EXPECT_EQ(track->getArtists({}).size(), 1); + } + + { + auto transaction {session.createUniqueTransaction()}; + + auto tracks {artist->getTracks()}; + ASSERT_EQ(tracks.size(), 1); + EXPECT_EQ(tracks.front().id(), track.getId()); + + EXPECT_TRUE(artist->getTracks(TrackArtistLinkType::ReleaseArtist).empty()); + EXPECT_EQ(artist->getTracks(TrackArtistLinkType::Artist).size(), 1); + } +} + +TEST_F(DatabaseFixture, SingleTrackSingleArtistMultiRoles) +{ + ScopedTrack track {session, "MyTrack"}; + ScopedArtist artist {session, "MyArtist"}; + { + auto transaction {session.createUniqueTransaction()}; + + TrackArtistLink::create(session, track.get(), artist.get(), TrackArtistLinkType::Artist); + TrackArtistLink::create(session, track.get(), artist.get(), TrackArtistLinkType::ReleaseArtist); + TrackArtistLink::create(session, track.get(), artist.get(), TrackArtistLinkType::Writer); + } + + { + auto transaction {session.createSharedTransaction()}; + EXPECT_TRUE(Artist::getAllOrphans(session).empty()); + } + + { + auto transaction {session.createSharedTransaction()}; + bool hasMore{}; + EXPECT_EQ(Artist::getByFilter(session, {}, {}, std::nullopt, Artist::SortMethod::ByName, std::nullopt, hasMore).size(), 1); + EXPECT_EQ(Artist::getByFilter(session, {}, {}, TrackArtistLinkType::Artist, Artist::SortMethod::ByName, std::nullopt, hasMore).size(), 1); + EXPECT_EQ(Artist::getByFilter(session, {}, {}, TrackArtistLinkType::ReleaseArtist, Artist::SortMethod::ByName, std::nullopt, hasMore).size(), 1); + EXPECT_EQ(Artist::getByFilter(session, {}, {}, TrackArtistLinkType::Writer, Artist::SortMethod::ByName, std::nullopt, hasMore).size(), 1); + EXPECT_TRUE(Artist::getByFilter(session, {}, {}, TrackArtistLinkType::Composer, Artist::SortMethod::ByName, std::nullopt, hasMore).empty()); + } + + { + auto transaction {session.createSharedTransaction()}; + + auto artists {track->getArtists({TrackArtistLinkType::Artist})}; + ASSERT_EQ(artists.size(), 1); + EXPECT_EQ(artists.front().id(), artist.getId()); + + artists = track->getArtists({TrackArtistLinkType::ReleaseArtist}); + ASSERT_EQ(artists.size(), 1); + EXPECT_EQ(artists.front().id(), artist.getId()); + + EXPECT_EQ(track->getArtistLinks().size(), 3); + + EXPECT_EQ(artist->getTracks().size(), 1); + EXPECT_EQ(artist->getTracks({TrackArtistLinkType::ReleaseArtist}).size(), 1); + EXPECT_EQ(artist->getTracks({TrackArtistLinkType::Artist}).size(), 1); + EXPECT_EQ(artist->getTracks({TrackArtistLinkType::Writer}).size(), 1); + } +} + +TEST_F(DatabaseFixture,SingleTrackMultiArtists) +{ + ScopedTrack track {session, "track"}; + ScopedArtist artist1 {session, "artist1"}; + ScopedArtist artist2 {session, "artist2"}; + ASSERT_NE(artist1.getId(), artist2.getId()); + + { + auto transaction {session.createUniqueTransaction()}; + + TrackArtistLink::create(session, track.get(), artist1.get(), TrackArtistLinkType::Artist); + TrackArtistLink::create(session, track.get(), artist2.get(), TrackArtistLinkType::Artist); + } + + { + auto transaction {session.createSharedTransaction()}; + EXPECT_TRUE(Artist::getAllOrphans(session).empty()); + } + + { + auto transaction {session.createSharedTransaction()}; + + auto artists {track->getArtists({TrackArtistLinkType::Artist})}; + ASSERT_EQ(artists.size(), 2); + EXPECT_TRUE((artists[0].id() == artist1.getId() && artists[1].id() == artist2.getId()) + || (artists[0].id() == artist2.getId() && artists[1].id() == artist1.getId())); + + EXPECT_EQ(track->getArtists({}).size(), 2); + EXPECT_EQ(track->getArtists({TrackArtistLinkType::Artist}).size(), 2); + EXPECT_TRUE(track->getArtists({TrackArtistLinkType::ReleaseArtist}).empty()); + EXPECT_EQ(Artist::getAll(session, Artist::SortMethod::ByName).size(), 2); + EXPECT_EQ(Artist::getAllIds(session).size(), 2); + } + + { + auto transaction {session.createUniqueTransaction()}; + + EXPECT_EQ(artist1->getTracks().front(), track.get()); + EXPECT_EQ(artist2->getTracks().front(), track.get()); + + EXPECT_TRUE(artist1->getTracks(TrackArtistLinkType::ReleaseArtist).empty()); + EXPECT_EQ(artist1->getTracks(TrackArtistLinkType::Artist).size(), 1); + EXPECT_TRUE(artist2->getTracks(TrackArtistLinkType::ReleaseArtist).empty()); + EXPECT_EQ(artist2->getTracks(TrackArtistLinkType::Artist).size(), 1); + } +} + +TEST_F(DatabaseFixture, SingleArtistSearchByName) +{ + ScopedArtist artist {session, "AAA"}; + ScopedTrack track {session, "MyTrack"}; // filters does not work on orphans + + { + auto transaction {session.createUniqueTransaction()}; + artist.get().modify()->setSortName("ZZZ"); + TrackArtistLink::create(session, track.get(), artist.get(), TrackArtistLinkType::Artist); + } + + { + auto transaction {session.createSharedTransaction()}; + + bool more {}; + EXPECT_TRUE(Artist::getByFilter(session, {}, {"N"}, std::nullopt, Artist::SortMethod::ByName, std::nullopt, more).empty()); + + const auto artistsByAAA {Artist::Artist::getByFilter(session, {}, {"A"}, std::nullopt, Artist::SortMethod::ByName, std::nullopt, more)}; + ASSERT_EQ(artistsByAAA.size(), 1); + EXPECT_EQ(artistsByAAA.front().id(), artist.getId()); + + const auto artistsByZZZ {Artist::Artist::getByFilter(session, {}, {"Z"}, std::nullopt, Artist::SortMethod::ByName, std::nullopt, more)}; + ASSERT_EQ(artistsByZZZ.size(), 1); + EXPECT_EQ(artistsByZZZ.front().id(), artist.getId()); + + EXPECT_TRUE(Artist::getByName(session, "NNN").empty()); + } +} + +TEST_F(DatabaseFixture, MultipleArtistsSearchByNameEscaped) +{ + ScopedArtist artist1 {session, "MyArtist%"}; + ScopedArtist artist2 {session, "%MyArtist"}; + ScopedArtist artist3 {session, "%_MyArtist"}; + + ScopedArtist artist4 {session, "MyArtist%foo"}; + ScopedArtist artist5 {session, "foo%MyArtist"}; + ScopedArtist artist6 {session, "%AMyArtist"}; + + { + auto transaction {session.createSharedTransaction()}; + { + const auto artists {Artist::getByName(session, "MyArtist%")}; + ASSERT_TRUE(artists.size() == 1); + EXPECT_EQ(artists.front().id(), artist1.getId()); + EXPECT_TRUE(Artist::getByName(session, "MyArtistFoo").empty()); + } + { + const auto artists {Artist::getByName(session, "%MyArtist")}; + ASSERT_TRUE(artists.size() == 1); + EXPECT_EQ(artists.front().id(), artist2.getId()); + EXPECT_TRUE(Artist::getByName(session, "FooMyArtist").empty()); + } + { + const auto artists {Artist::getByName(session, "%_MyArtist")}; + ASSERT_TRUE(artists.size() == 1); + ASSERT_EQ(artists.front().id(), artist3.getId()); + EXPECT_TRUE(Artist::getByName(session, "%CMyArtist").empty()); + } + } + + // get by filter only works with tracks links... + ScopedTrack track {session, "MyTrack"}; // filters does not work on orphans + + { + auto transaction {session.createUniqueTransaction()}; + + TrackArtistLink::create(session, track.get(), artist1.get(), TrackArtistLinkType::Artist); + TrackArtistLink::create(session, track.get(), artist2.get(), TrackArtistLinkType::Artist); + TrackArtistLink::create(session, track.get(), artist3.get(), TrackArtistLinkType::Artist); + TrackArtistLink::create(session, track.get(), artist4.get(), TrackArtistLinkType::Artist); + TrackArtistLink::create(session, track.get(), artist5.get(), TrackArtistLinkType::Artist); + TrackArtistLink::create(session, track.get(), artist6.get(), TrackArtistLinkType::Artist); + } + + { + auto transaction {session.createSharedTransaction()}; + bool more; + { + const auto artists {Artist::getByFilter(session, {}, {"MyArtist"}, std::nullopt, Artist::SortMethod::ByName, std::nullopt, more)}; + EXPECT_EQ(artists.size(), 6); + } + + { + const auto artists {Artist::getByFilter(session, {}, {"MyArtist%"}, std::nullopt, Artist::SortMethod::ByName, std::nullopt, more)}; + ASSERT_EQ(artists.size(), 2); + EXPECT_EQ(artists[0].id(), artist1.getId()); + EXPECT_EQ(artists[1].id(), artist4.getId()); + } + + { + const auto artists {Artist::getByFilter(session, {}, {"%MyArtist"}, std::nullopt, Artist::SortMethod::ByName, std::nullopt, more)}; + ASSERT_EQ(artists.size(), 2); + EXPECT_EQ(artists[0].id(), artist2.getId()); + EXPECT_EQ(artists[1].id(), artist5.getId()); + } + + { + const auto artists {Artist::getByFilter(session, {}, {"_MyArtist"}, std::nullopt, Artist::SortMethod::ByName, std::nullopt, more)}; + ASSERT_EQ(artists.size(), 1); + EXPECT_EQ(artists[0].id(), artist3.getId()); + } + } +} + +TEST_F(DatabaseFixture, MultiArtistsSortMethod) +{ + ScopedArtist artistA {session, "artistA"}; + ScopedArtist artistB {session, "artistB"}; + + { + auto transaction {session.createUniqueTransaction()}; + + artistA.get().modify()->setSortName("sortNameB"); + artistB.get().modify()->setSortName("sortNameA"); + } + + { + auto transaction {session.createSharedTransaction()}; + + auto allArtistsByName {Artist::getAll(session, Artist::SortMethod::ByName)}; + auto allArtistsBySortName {Artist::getAll(session, Artist::SortMethod::BySortName)}; + + ASSERT_EQ(allArtistsByName.size(), 2); + EXPECT_EQ(allArtistsByName.front().id(), artistA.getId()); + EXPECT_EQ(allArtistsByName.back().id(), artistB.getId()); + + ASSERT_EQ(allArtistsBySortName.size(), 2); + EXPECT_EQ(allArtistsBySortName.front().id(), artistB.getId()); + EXPECT_EQ(allArtistsBySortName.back().id(), artistA.getId()); + } +} + diff --git a/src/test/database/CMakeLists.txt b/src/test/database/CMakeLists.txt index ac42aa6b..30c59057 100644 --- a/src/test/database/CMakeLists.txt +++ b/src/test/database/CMakeLists.txt @@ -1,11 +1,15 @@ add_executable(test-database + Artist.cpp + Cluster.cpp DatabaseTest.cpp + Track.cpp ) target_link_libraries(test-database PRIVATE lmsdatabase + GTest::GTest ) -add_test(NAME database COMMAND test-database) +gtest_discover_tests(test-database) diff --git a/src/test/database/Cluster.cpp b/src/test/database/Cluster.cpp new file mode 100644 index 00000000..9135b48c --- /dev/null +++ b/src/test/database/Cluster.cpp @@ -0,0 +1,1096 @@ +/* + * 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 "Common.hpp" + +#include +#include + +using namespace Database; + +TEST_F(DatabaseFixture, SingleCluster) +{ + ScopedClusterType clusterType {session, "MyType"}; + + { + ScopedCluster cluster {session, clusterType.lockAndGet(), "MyCluster"}; + + { + auto transaction {session.createUniqueTransaction()}; + + auto clusters {Cluster::getAll(session)}; + ASSERT_EQ(clusters.size(), 1); + EXPECT_EQ(clusters.front().id(), cluster.getId()); + EXPECT_EQ(clusters.front()->getType().id(), clusterType.getId()); + + clusters = Cluster::getAllOrphans(session); + ASSERT_EQ(clusters.size(), 1); + EXPECT_EQ(clusters.front().id(), cluster.getId()); + + auto clusterTypes {ClusterType::getAll(session)}; + ASSERT_EQ(clusterTypes.size(), 1); + EXPECT_EQ(clusterTypes.front().id(), clusterType.getId()); + + clusterTypes = ClusterType::getAllUsed(session); + ASSERT_EQ(clusterTypes.size(), 1); + EXPECT_EQ(clusterTypes.front().id(), clusterType.getId()); + + clusterTypes = ClusterType::getAllOrphans(session); + EXPECT_TRUE(clusterTypes.empty()); + } + } + + { + auto transaction {session.createUniqueTransaction()}; + + auto clusterTypes {ClusterType::getAllOrphans(session)}; + ASSERT_EQ(clusterTypes.size(), 1); + EXPECT_EQ(clusterTypes.front().id(), clusterType.getId()); + + ASSERT_TRUE(ClusterType::getAllUsed(session).empty()); + } +} + +TEST_F(DatabaseFixture, SingleTrackSingleCluster) +{ + ScopedTrack track {session, "MyTrack"}; + ScopedClusterType clusterType {session, "MyClusterType"}; + + { + auto transaction {session.createSharedTransaction()}; + EXPECT_TRUE(Cluster::getAllOrphans(session).empty()); + auto clusterTypes {ClusterType::getAllOrphans(session)}; + ASSERT_EQ(clusterTypes.size(), 1); + EXPECT_EQ(clusterTypes.front().id(), clusterType.getId()); + } + + ScopedCluster cluster1 {session, clusterType.lockAndGet(), "MyCluster1"}; + ScopedCluster cluster2 {session, clusterType.lockAndGet(), "MyCluster2"}; + + { + auto transaction {session.createSharedTransaction()}; + auto clusters {Cluster::getAllOrphans(session)}; + EXPECT_EQ(clusters.size(), 2); + EXPECT_TRUE(track->getClusters().empty()); + EXPECT_TRUE(track->getClusterIds().empty()); + } + + { + auto transaction {session.createSharedTransaction()}; + EXPECT_TRUE(Track::getAllIdsWithClusters(session).empty()); + } + + { + auto transaction {session.createUniqueTransaction()}; + + cluster1.get().modify()->addTrack(track.get()); + } + + { + auto transaction {session.createSharedTransaction()}; + auto tracks {Track::getAllIdsWithClusters(session)}; + ASSERT_EQ(tracks.size(), 1); + EXPECT_EQ(tracks.front(), track.getId()); + } + + { + auto transaction {session.createSharedTransaction()}; + auto clusters {Cluster::getAllOrphans(session)}; + ASSERT_EQ(clusters.size(), 1); + EXPECT_EQ(clusters.front().id(), cluster2.getId()); + + EXPECT_TRUE(ClusterType::getAllOrphans(session).empty()); + } + + { + auto transaction {session.createSharedTransaction()}; + + auto tracks {Track::getByClusters(session, {cluster1.getId()})}; + ASSERT_EQ(tracks.size(), 1); + EXPECT_EQ(tracks.front().id(), track.getId()); + + tracks = Track::getByClusters(session, {cluster2.getId()}); + EXPECT_TRUE(tracks.empty()); + } + + { + auto transaction {session.createSharedTransaction()}; + + auto clusters {track->getClusters()}; + ASSERT_EQ(clusters.size(), 1); + EXPECT_EQ(clusters.front().id(), cluster1.getId()); + + auto clusterIds {track->getClusterIds()}; + ASSERT_EQ(clusterIds.size(), 1); + EXPECT_EQ(clusterIds.front(), cluster1.getId()); + } +} + +TEST_F(DatabaseFixture, MultipleTracksSingleCluster) +{ + std::list tracks; + ScopedClusterType clusterType {session, "MyClusterType"}; + ScopedCluster cluster {session, clusterType.lockAndGet(), "MyCluster"}; + + for (std::size_t i {}; i < 10; ++i) + { + tracks.emplace_back(session, "MyTrack" + std::to_string(i)); + + { + auto transaction {session.createUniqueTransaction()}; + cluster.get().modify()->addTrack(tracks.back().get()); + } + } + + { + auto transaction {session.createSharedTransaction()}; + EXPECT_TRUE(Cluster::getAllOrphans(session).empty()); + + EXPECT_EQ(cluster->getTracksCount(), tracks.size()); + + for (auto trackCluster : cluster->getTracks()) + { + auto it {std::find_if(std::cbegin(tracks), std::cend(tracks), [&](const ScopedTrack& track) { return trackCluster.id() == track.getId(); })}; + EXPECT_TRUE(it != std::cend(tracks)); + } + } +} + +TEST_F(DatabaseFixture, MultipleTracksMultipleClustersTopRelease) +{ + ScopedClusterType clusterType {session, "ClusterType"}; + ScopedCluster cluster1 {session, clusterType.lockAndGet(), "Cluster1"}; + ScopedCluster cluster2 {session, clusterType.lockAndGet(), "Cluster2"}; + ScopedCluster cluster3 {session, clusterType.lockAndGet(), "Cluster3"}; + ScopedTrack trackA {session, "TrackA"}; + ScopedTrack trackB {session, "TrackB"}; + ScopedTrack trackC {session, "TrackC"}; + ScopedRelease releaseA {session, "ReleaseA"}; + ScopedRelease releaseB {session, "ReleaseB"}; + ScopedRelease releaseC {session, "ReleaseC"}; + + ScopedUser user {session, "MyUser"}; + ScopedTrackList trackList {session, "TrackList", TrackList::Type::Playlist, false, user.lockAndGet()}; + + { + auto transaction {session.createSharedTransaction()}; + + EXPECT_EQ(trackList->getDuration(), std::chrono::seconds {0}); + } + + { + auto transaction {session.createUniqueTransaction()}; + + cluster1.get().modify()->addTrack(trackA.get()); + cluster2.get().modify()->addTrack(trackB.get()); + cluster2.get().modify()->addTrack(trackC.get()); + cluster3.get().modify()->addTrack(trackC.get()); + + trackA.get().modify()->setRelease(releaseA.get()); + trackB.get().modify()->setRelease(releaseB.get()); + trackC.get().modify()->setRelease(releaseC.get()); + } + + { + auto transaction {session.createUniqueTransaction()}; + + TrackListEntry::create(session, trackA.get(), trackList.get()); + TrackListEntry::create(session, trackB.get(), trackList.get()); + TrackListEntry::create(session, trackB.get(), trackList.get()); + } + + { + auto transaction {session.createSharedTransaction()}; + + bool hasMore; + const auto releases{trackList->getTopReleases({}, std::nullopt, hasMore)}; + ASSERT_EQ(releases.size(), 2); + EXPECT_EQ(releases[0].id(), releaseB.getId()); + EXPECT_EQ(releases[1].id(), releaseA.getId()); + } + + { + auto transaction {session.createSharedTransaction()}; + + bool hasMore; + auto releases{trackList->getTopReleases({cluster1.getId()}, std::nullopt, hasMore)}; + ASSERT_EQ(releases.size(), 1); + EXPECT_EQ(releases[0].id(), releaseA.getId()); + + releases = trackList->getTopReleases({cluster2.getId()}, std::nullopt, hasMore); + ASSERT_EQ(releases.size(), 1); + EXPECT_EQ(releases[0].id(), releaseB.getId()); + + releases = trackList->getTopReleases({cluster2.getId(), cluster1.getId()}, std::nullopt, hasMore); + EXPECT_TRUE(releases.empty()); + + releases = trackList->getTopReleases({cluster2.getId(), cluster3.getId()}, std::nullopt, hasMore); + EXPECT_TRUE(releases.empty()); + } + + + { + auto transaction {session.createUniqueTransaction()}; + + TrackListEntry::create(session, trackC.get(), trackList.get()); + TrackListEntry::create(session, trackC.get(), trackList.get()); + TrackListEntry::create(session, trackC.get(), trackList.get()); + } + + { + auto transaction {session.createSharedTransaction()}; + + bool hasMore; + auto releases {trackList->getTopReleases({cluster2.getId(), cluster3.getId()}, std::nullopt, hasMore)}; + ASSERT_EQ(releases.size(), 1); + EXPECT_EQ(releases[0].id(), releaseC.getId()); + + releases = trackList->getTopReleases({cluster2.getId()}, std::nullopt, hasMore); + ASSERT_EQ(releases.size(), 2); + EXPECT_EQ(releases[0].id(), releaseC.getId()); + EXPECT_EQ(releases[1].id(), releaseB.getId()); + } +} + +TEST_F(DatabaseFixture,SingleTrackSingleReleaseSingleCluster) +{ + ScopedTrack track {session, "MyTrackFile"}; + ScopedRelease release {session, "MyRelease"}; + ScopedClusterType clusterType {session, "MyClusterType"}; + ScopedCluster cluster {session, clusterType .lockAndGet(), "MyCluster"}; + + { + auto transaction {session.createSharedTransaction()}; + EXPECT_TRUE(Release::getAllIdsWithClusters(session).empty()); + } + + { + auto transaction {session.createUniqueTransaction()}; + + track.get().modify()->setRelease(release.get()); + cluster.get().modify()->addTrack(track.get()); + } + + { + auto transaction {session.createSharedTransaction()}; + auto releases {Release::getAllIdsWithClusters(session)}; + ASSERT_EQ(releases.size(), 1); + EXPECT_EQ(releases.front(), release.getId()); + } + + { + auto transaction {session.createSharedTransaction()}; + + EXPECT_TRUE(Cluster::getAllOrphans(session).empty()); + EXPECT_TRUE(Release::getAllOrphans(session).empty()); + } + + { + auto transaction {session.createSharedTransaction()}; + + auto releases {Release::getByClusters(session, {cluster.getId()})}; + ASSERT_EQ(releases.size(), 1); + EXPECT_EQ(releases.front().id(), release.getId()); + } + + { + auto transaction {session.createSharedTransaction()}; + + EXPECT_EQ(cluster->getReleasesCount(), 1); + EXPECT_EQ(cluster->getTracksCount(), 1); + } +} + +TEST_F(DatabaseFixture, SingleTrackSingleArtistMultiClusters) +{ + ScopedTrack track {session, "MyTrackFile"}; + ScopedArtist artist {session, "MyArtist"}; + ScopedClusterType clusterType {session, "MyType"}; + ScopedCluster cluster1 {session, clusterType.lockAndGet(), "Cluster1"}; + ScopedCluster cluster2 {session, clusterType.lockAndGet(), "Cluster2"}; + ScopedCluster cluster3 {session, clusterType.lockAndGet(), "Cluster3"}; + { + auto transaction {session.createUniqueTransaction()}; + + auto trackArtistLink {TrackArtistLink::create(session, track.get(), artist.get(), TrackArtistLinkType::Artist)}; + cluster1.get().modify()->addTrack(track.get()); + } + + { + auto transaction {session.createSharedTransaction()}; + EXPECT_TRUE(ClusterType::getAllOrphans(session).empty()); + EXPECT_EQ(Cluster::getAllOrphans(session).size(), 2); + EXPECT_TRUE(Release::getAllOrphans(session).empty()); + EXPECT_TRUE(Artist::getAllOrphans(session).empty()); + } + + { + auto transaction {session.createSharedTransaction()}; + EXPECT_EQ(track->getClusters().size(), 1); + EXPECT_EQ(track->getClusterIds().size(), 1); + } + + { + auto transaction {session.createSharedTransaction()}; + + auto artists {Artist::getByClusters(session, {cluster1.getId()}, Artist::SortMethod::ByName)}; + ASSERT_EQ(artists.size(), 1); + EXPECT_EQ(artists.front().id(), artist.getId()); + + EXPECT_TRUE(Artist::getByClusters(session, {cluster2.getId()}, Artist::SortMethod::ByName).empty()); + EXPECT_TRUE(Artist::getByClusters(session, {cluster3.getId()}, Artist::SortMethod::ByName).empty()); + + cluster2.get().modify()->addTrack(track.get()); + } + + { + auto transaction {session.createSharedTransaction()}; + + auto artists {Artist::getByClusters(session, {cluster1.getId()}, Artist::SortMethod::ByName)}; + ASSERT_EQ(artists.size(), 1); + EXPECT_EQ(artists.front().id(), artist.getId()); + + artists = Artist::getByClusters(session, {cluster2.getId()}, Artist::SortMethod::ByName); + ASSERT_EQ(artists.size(), 1); + EXPECT_EQ(artists.front().id(), artist.getId()); + + artists = Artist::getByClusters(session, {cluster1.getId(), cluster2.getId()}, Artist::SortMethod::ByName); + ASSERT_EQ(artists.size(), 1); + EXPECT_EQ(artists.front().id(), artist.getId()); + + EXPECT_TRUE(Artist::getByClusters(session, {cluster3.getId()}, Artist::SortMethod::ByName).empty()); + } +} + +TEST_F(DatabaseFixture, SingleTrackSingleArtistMultiRolesMultiClusters) +{ + ScopedTrack track {session, "MyTrackFile"}; + ScopedArtist artist {session, "MyArtist"}; + ScopedClusterType clusterType {session, "MyType"}; + ScopedCluster cluster {session, clusterType.lockAndGet(), "MyCluster"}; + + { + auto transaction {session.createUniqueTransaction()}; + + TrackArtistLink::create(session, track.get(), artist.get(), TrackArtistLinkType::Artist); + TrackArtistLink::create(session, track.get(), artist.get(), TrackArtistLinkType::ReleaseArtist); + cluster.get().modify()->addTrack(track.get()); + } + + { + auto transaction {session.createSharedTransaction()}; + EXPECT_TRUE(Cluster::getAllOrphans(session).empty()); + EXPECT_TRUE(Release::getAllOrphans(session).empty()); + EXPECT_TRUE(Artist::getAllOrphans(session).empty()); + } + + { + auto transaction {session.createSharedTransaction()}; + + auto artists {Artist::getByClusters(session, {cluster.getId()}, Artist::SortMethod::ByName)}; + ASSERT_EQ(artists.size(), 1); + EXPECT_EQ(artists.front().id(), artist.getId()); + } +} + +TEST_F(DatabaseFixture, MultiTracksSingleArtistMultiClusters) +{ + constexpr std::size_t nbTracks {10}; + constexpr std::size_t nbClusters {5}; + + std::list tracks; + std::list clusters; + ScopedArtist artist {session, "MyArtist"}; + ScopedClusterType clusterType {session, "MyType"}; + + for (std::size_t i {}; i < nbClusters; ++i) + clusters.emplace_back(session, clusterType.lockAndGet(), "MyCluster" + std::to_string(i)); + + for (std::size_t i {}; i < nbTracks ; ++i) + { + tracks.emplace_back(session, "MyTrackFile" + std::to_string(i)); + + auto transaction {session.createUniqueTransaction()}; + TrackArtistLink::create(session, tracks.back().get(), artist.get(), TrackArtistLinkType::Artist); + + for (auto& cluster : clusters) + cluster.get().modify()->addTrack(tracks.back().get()); + } + + { + auto transaction {session.createSharedTransaction()}; + EXPECT_TRUE(Cluster::getAllOrphans(session).empty()); + EXPECT_TRUE(Artist::getAllOrphans(session).empty()); + } + + { + auto transaction {session.createSharedTransaction()}; + + std::set clusterIds; + std::transform(std::cbegin(clusters), std::cend(clusters), std::inserter(clusterIds, std::begin(clusterIds)), [](const ScopedCluster& cluster) { return cluster.getId(); }); + + auto artists {Artist::getByClusters(session, clusterIds, Artist::SortMethod::ByName)}; + ASSERT_EQ(artists.size(), 1); + EXPECT_EQ(artists.front().id(), artist.getId()); + } +} + + +TEST_F(DatabaseFixture, MultipleTracksSingleClusterSimilarity) +{ + std::list tracks; + ScopedClusterType clusterType {session, "MyClusterType"}; + ScopedCluster cluster {session, clusterType.lockAndGet(), "MyClusterType"}; + + for (std::size_t i {}; i < 10; ++i) + { + tracks.emplace_back(session, "MyTrack" + std::to_string(i)); + + { + auto transaction {session.createUniqueTransaction()}; + cluster.get().modify()->addTrack(tracks.back().get()); + } + } + + { + auto transaction {session.createSharedTransaction()}; + + const auto similarTracks {Track::getSimilarTracks(session, {tracks.front().getId()})}; + EXPECT_EQ(similarTracks.size(), tracks.size() - 1); + for (auto similarTrack : similarTracks) + { + EXPECT_TRUE(std::find_if(std::next(std::cbegin(tracks), 1), std::cend(tracks), + [&](const ScopedTrack& track) + { + return similarTrack.id() == track.getId(); + }) != std::cend(tracks)); + } + } +} + +TEST_F(DatabaseFixture, MultipleTracksMultipleClustersSimilarity) +{ + std::list tracks; + ScopedClusterType clusterType {session, "MyClusterType"}; + ScopedCluster cluster1 {session, clusterType.lockAndGet(), "MyCluster1"}; + ScopedCluster cluster2 {session, clusterType.lockAndGet(), "MyCluster2"}; + + for (std::size_t i {}; i < 5; ++i) + { + tracks.emplace_back(session, "MyTrack" + std::to_string(i)); + + { + auto transaction {session.createUniqueTransaction()}; + cluster1.get().modify()->addTrack(tracks.back().get()); + } + } + + for (std::size_t i {5}; i < 10; ++i) + { + tracks.emplace_back(session, "MyTrack" + std::to_string(i)); + + { + auto transaction {session.createUniqueTransaction()}; + cluster1.get().modify()->addTrack(tracks.back().get()); + cluster2.get().modify()->addTrack(tracks.back().get()); + } + } + + { + auto transaction {session.createSharedTransaction()}; + + { + auto similarTracks {Track::getSimilarTracks(session, {tracks.back().getId()}, 0, 4)}; + EXPECT_EQ(similarTracks.size(), 4); + for (auto similarTrack : similarTracks) + EXPECT_TRUE(std::find_if(std::next(std::cbegin(tracks), 5), std::next(std::cend(tracks), -1), [&](const ScopedTrack& track) { return similarTrack.id() == track.getId(); }) != std::cend(tracks)); + } + + { + auto similarTracks {Track::getSimilarTracks(session, {tracks.front().getId()})}; + EXPECT_EQ(similarTracks.size(), tracks.size() - 1); + for (auto similarTrack : similarTracks) + EXPECT_TRUE(std::find_if(std::next(std::cbegin(tracks), 1), std::cend(tracks), [&](const ScopedTrack& track) { return similarTrack.id() == track.getId(); }) != std::cend(tracks)); + } + } +} + +TEST_F(DatabaseFixture, SingleTrackSingleReleaseSingleArtistSingleCluster) +{ + ScopedTrack track {session, "MyTrack"}; + ScopedRelease release {session, "MyRelease"}; + ScopedArtist artist {session, "MyArtist"}; + ScopedClusterType clusterType {session, "MyType"}; + ScopedCluster cluster {session, clusterType.lockAndGet(), "MyCluster"}; + + { + auto transaction {session.createSharedTransaction()}; + EXPECT_TRUE(Artist::getAllIdsWithClusters(session).empty()); + } + + { + auto transaction {session.createUniqueTransaction()}; + + TrackArtistLink::create(session, track.get(), artist.get(), TrackArtistLinkType::Artist); + track.get().modify()->setRelease(release.get()); + cluster.get().modify()->addTrack(track.get()); + } + + { + auto transaction {session.createSharedTransaction()}; + + EXPECT_TRUE(Cluster::getAllOrphans(session).empty()); + EXPECT_TRUE(ClusterType::getAllOrphans(session).empty()); + EXPECT_TRUE(Artist::getAllOrphans(session).empty()); + EXPECT_TRUE(Release::getAllOrphans(session).empty()); + } + + { + auto transaction {session.createSharedTransaction()}; + auto artists {Artist::getAllIdsWithClusters(session)}; + ASSERT_EQ(artists.size(), 1); + EXPECT_EQ(artists.front(), artist.getId()); + } + + { + auto transaction {session.createSharedTransaction()}; + + auto artists {Artist::getByClusters(session, {cluster.getId()}, Artist::SortMethod::ByName)}; + ASSERT_EQ(artists.size(), 1); + EXPECT_EQ(artists.front().id(), artist.getId()); + + auto releases {artist->getReleases()}; + ASSERT_EQ(releases.size(), 1); + EXPECT_EQ(releases.front().id(), release.getId()); + + releases = artist->getReleases({cluster.getId()}); + ASSERT_EQ(releases.size(), 1); + EXPECT_EQ(releases.front().id(), release.getId()); + } +} + +TEST_F(DatabaseFixture, SingleTrackSingleReleaseSingleArtistMultiClusters) +{ + ScopedTrack track {session, "MyTrack"}; + ScopedRelease release {session, "MyRelease"}; + ScopedArtist artist {session, "MyArtist"}; + ScopedClusterType clusterType {session, "MyClusterType"}; + ScopedCluster cluster1 {session, clusterType.lockAndGet(), "MyCluster1"}; + ScopedCluster cluster2 {session, clusterType.lockAndGet(), "MyCluster2"}; + + { + auto transaction {session.createUniqueTransaction()}; + + auto trackArtistLink {TrackArtistLink::create(session, track.get(), artist.get(), TrackArtistLinkType::Artist)}; + track.get().modify()->setRelease(release.get()); + cluster1.get().modify()->addTrack(track.get()); + cluster2.get().modify()->addTrack(track.get()); + } + + { + auto transaction {session.createSharedTransaction()}; + + auto releases {artist->getReleases()}; + ASSERT_EQ(releases.size(), 1); + EXPECT_EQ(releases.front().id(), release.getId()); + + releases = artist->getReleases({cluster1.getId(), cluster2.getId()}); + ASSERT_EQ(releases.size(), 1); + EXPECT_EQ(releases.front().id(), release.getId()); + } +} + +TEST_F(DatabaseFixture, SingleTrackListMultipleTrackSingleCluster) +{ + ScopedUser user {session, "MyUser"}; + ScopedTrackList trackList {session, "MyTrackList", TrackList::Type::Playlist, false, user.lockAndGet()}; + ScopedClusterType clusterType {session, "MyClusterType"}; + ScopedCluster cluster {session, clusterType.lockAndGet(), "MyCluster"}; + std::list tracks; + + for (std::size_t i {}; i < 20; ++i) + { + tracks.emplace_back(session, "MyTrack" + std::to_string(i)); + + auto transaction {session.createUniqueTransaction()}; + + if (i < 5) + TrackListEntry::create(session, tracks.back().get(), trackList.get()); + + if (i < 10) + cluster.get().modify()->addTrack(tracks.back().get()); + } + + { + auto transaction {session.createSharedTransaction()}; + + const auto similarTracks {trackList->getSimilarTracks()}; + EXPECT_EQ(similarTracks.size(), 5); + + for (auto similarTrack : similarTracks) + EXPECT_TRUE(std::any_of(std::next(std::cbegin(tracks), 5), std::cend(tracks), [similarTrack](const ScopedTrack& track) { return track.getId() == similarTrack.id(); })); + } +} + +TEST_F(DatabaseFixture, SingleTrackListMultipleTrackMultiClusters) +{ + ScopedUser user {session, "MyUser"}; + ScopedTrackList trackList {session, "MyTrackList", TrackList::Type::Playlist, false, user.lockAndGet()}; + ScopedClusterType clusterType {session, "MyClusterType"}; + ScopedCluster cluster1 {session, clusterType.lockAndGet(), "MyCluster1"}; + ScopedCluster cluster2 {session, clusterType.lockAndGet(), "MyCluster2"}; + std::list tracks; + + for (std::size_t i {}; i < 20; ++i) + { + tracks.emplace_back(session, "MyTrack" + std::to_string(i)); + + auto transaction {session.createUniqueTransaction()}; + + if (i < 5) + TrackListEntry::create(session, tracks.back().get(), trackList.get()); + + if (i < 10) + { + cluster1.get().modify()->addTrack(tracks.back().get()); + cluster2.get().modify()->addTrack(tracks.back().get()); + } + else if (i < 15) + { + cluster1.get().modify()->addTrack(tracks.back().get()); + } + } + + { + auto transaction {session.createSharedTransaction()}; + + { + const auto similarTracks {trackList->getSimilarTracks(0, 5)}; + ASSERT_EQ(similarTracks.size(), 5); + + for (auto similarTrack : similarTracks) + EXPECT_TRUE(std::any_of(std::next(std::cbegin(tracks), 5), std::next(std::cbegin(tracks), 10), [similarTrack](const ScopedTrack& track) { return track.getId() == similarTrack.id(); })); + } + + { + const auto similarTracks {trackList->getSimilarTracks(5, 10)}; + ASSERT_EQ(similarTracks.size(), 5); + + for (auto similarTrack : similarTracks) + EXPECT_TRUE(std::any_of(std::next(std::cbegin(tracks), 10), std::next(std::cbegin(tracks), 15), [similarTrack](const ScopedTrack& track) { return track.getId() == similarTrack.id(); })); + } + + EXPECT_TRUE(trackList->getSimilarTracks(10, 10).empty()); + } +} + +TEST_F(DatabaseFixture, SingleTrackListMultipleTrackMultiClustersRecentlyPlayed) +{ + ScopedUser user {session, "MyUser"}; + ScopedTrackList trackList {session, "MyTrackList", TrackList::Type::Playlist, false, user.lockAndGet()}; + ScopedClusterType clusterType {session, "MyClusterType"}; + ScopedCluster cluster1 {session, clusterType.lockAndGet(), "MyCluster1"}; + ScopedCluster cluster2 {session, clusterType.lockAndGet(), "MyCluster2"}; + ScopedCluster cluster3 {session, clusterType.lockAndGet(), "MyCluster3"}; + 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); + + cluster1.get().modify()->addTrack(track1.get()); + cluster2.get().modify()->addTrack(track2.get()); + cluster3.get().modify()->addTrack(track1.get()); + cluster3.get().modify()->addTrack(track2.get()); + } + + { + auto transaction {session.createSharedTransaction()}; + + bool moreResults {}; + EXPECT_TRUE(trackList->getArtistsReverse({}, std::nullopt, std::nullopt, moreResults).empty()); + EXPECT_TRUE(trackList->getReleasesReverse({}, std::nullopt, moreResults).empty()); + EXPECT_TRUE(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)}; + ASSERT_EQ(artists.size(), 1); + EXPECT_EQ(artists.front().id(), artist1.getId()); + + const auto releases {trackList->getReleasesReverse({}, std::nullopt, moreResults)}; + ASSERT_EQ(releases.size() , 1); + EXPECT_EQ(releases.front().id(), release1.getId()); + + const auto tracks {trackList->getTracksReverse({}, std::nullopt, moreResults)}; + ASSERT_EQ(tracks.size(), 1); + EXPECT_EQ(tracks.front().id(), track1.getId()); + } + + { + auto transaction {session.createSharedTransaction()}; + + bool moreResults {}; + const auto artists {trackList->getArtistsReverse({cluster1.getId()}, std::nullopt, std::nullopt, moreResults)}; + ASSERT_EQ(artists.size(), 1); + EXPECT_EQ(artists.front().id(), artist1.getId()); + + const auto releases {trackList->getReleasesReverse({cluster1.getId()}, std::nullopt, moreResults)}; + ASSERT_EQ(releases.size(), 1); + EXPECT_EQ(releases.front().id(), release1.getId()); + + const auto tracks {trackList->getTracksReverse({cluster1.getId()}, std::nullopt, moreResults)}; + ASSERT_EQ(tracks.size(), 1); + EXPECT_EQ(tracks.front().id(), track1.getId()); + } + + { + auto transaction {session.createSharedTransaction()}; + + bool moreResults {}; + const auto artists {trackList->getArtistsReverse({cluster3.getId()}, std::nullopt, std::nullopt, moreResults)}; + ASSERT_EQ(artists.size(), 1); + EXPECT_EQ(artists.front().id(), artist1.getId()); + + const auto releases {trackList->getReleasesReverse({cluster3.getId()}, std::nullopt, moreResults)}; + ASSERT_EQ(releases.size(), 1); + EXPECT_EQ(releases.front().id(), release1.getId()); + + const auto tracks {trackList->getTracksReverse({cluster3.getId()}, std::nullopt, moreResults)}; + ASSERT_EQ(tracks.size(), 1); + EXPECT_EQ(tracks.front().id(), track1.getId()); + } + + { + auto transaction {session.createSharedTransaction()}; + + bool moreResults {}; + const auto artists {trackList->getArtistsReverse({cluster1.getId()}, TrackArtistLinkType::Artist, std::nullopt, moreResults)}; + ASSERT_EQ(artists.size(), 1); + EXPECT_EQ(artists.front().id(), artist1.getId()); + } + + { + auto transaction {session.createSharedTransaction()}; + + bool moreResults {}; + const auto artists {trackList->getArtistsReverse({}, TrackArtistLinkType::Artist, std::nullopt, moreResults)}; + ASSERT_EQ(artists.size(), 1); + EXPECT_EQ(artists.front().id(), artist1.getId()); + } + + { + auto transaction {session.createSharedTransaction()}; + + bool moreResults {}; + EXPECT_TRUE(trackList->getArtistsReverse({cluster2.getId()}, std::nullopt, std::nullopt, moreResults).empty()); + EXPECT_TRUE(trackList->getReleasesReverse({cluster2.getId()}, std::nullopt, moreResults).empty()); + EXPECT_TRUE(trackList->getTracksReverse({cluster2.getId()}, std::nullopt, moreResults).empty()); + + EXPECT_TRUE(trackList->getArtistsReverse({}, TrackArtistLinkType::ReleaseArtist, std::nullopt, moreResults).empty()); + } + + { + 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)}; + ASSERT_EQ(artists.size(), 2); + EXPECT_EQ(artists[0].id(), artist2.getId()); + EXPECT_EQ(artists[1].id(), artist1.getId()); + + const auto releases {trackList->getReleasesReverse({}, std::nullopt, moreResults)}; + ASSERT_EQ(releases.size(), 2); + EXPECT_EQ(releases[0].id(), release2.getId()); + EXPECT_EQ(releases[1].id(), release1.getId()); + + const auto tracks {trackList->getTracksReverse({}, std::nullopt, moreResults)}; + ASSERT_EQ(tracks.size(), 2); + EXPECT_EQ(tracks[0].id(), track2.getId()); + EXPECT_EQ(tracks[1].id(),track1.getId()); + } + + { + auto transaction {session.createSharedTransaction()}; + + bool moreResults {}; + const auto artists {trackList->getArtistsReverse({cluster3.getId()}, std::nullopt, std::nullopt, moreResults)}; + ASSERT_EQ(artists.size(), 2); + EXPECT_EQ(artists[0].id(), artist2.getId()); + EXPECT_EQ(artists[1].id(), artist1.getId()); + + const auto releases {trackList->getReleasesReverse({cluster3.getId()}, std::nullopt, moreResults)}; + ASSERT_EQ(releases.size(), 2); + EXPECT_EQ(releases[0].id(), release2.getId()); + EXPECT_EQ(releases[1].id(), release1.getId()); + + const auto tracks {trackList->getTracksReverse({cluster3.getId()}, std::nullopt, moreResults)}; + ASSERT_EQ(tracks.size(), 2); + EXPECT_EQ(tracks[0].id(), track2.getId()); + EXPECT_EQ(tracks[1].id(), track1.getId()); + } + + { + auto transaction {session.createSharedTransaction()}; + + bool moreResults {}; + const auto artists {trackList->getArtistsReverse({cluster1.getId()}, std::nullopt, std::nullopt, moreResults)}; + ASSERT_EQ(artists.size(), 1); + EXPECT_EQ(artists.front().id(), artist1.getId()); + + const auto releases {trackList->getReleasesReverse({cluster1.getId()}, std::nullopt, moreResults)}; + ASSERT_EQ(releases.size(), 1); + EXPECT_EQ(releases.front().id(), release1.getId()); + + const auto tracks {trackList->getTracksReverse({cluster1.getId()}, std::nullopt, moreResults)}; + ASSERT_EQ(tracks.size(), 1); + EXPECT_EQ(tracks.front().id(), track1.getId()); + } + + { + auto transaction {session.createSharedTransaction()}; + + bool moreResults {}; + const auto artists {trackList->getArtistsReverse({cluster2.getId()}, std::nullopt, std::nullopt, moreResults)}; + ASSERT_EQ(artists.size(), 1); + EXPECT_EQ(artists.front().id(), artist2.getId()); + + const auto releases {trackList->getReleasesReverse({cluster2.getId()}, std::nullopt, moreResults)}; + ASSERT_EQ(releases.size(), 1); + EXPECT_EQ(releases.front().id(), release2.getId()); + + const auto tracks {trackList->getTracksReverse({cluster2.getId()}, std::nullopt, moreResults)}; + ASSERT_EQ(tracks.size(), 1); + EXPECT_EQ(tracks.front().id(), track2.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)}; + ASSERT_EQ(artists.size(), 2); + EXPECT_EQ(artists[0].id(), artist1.getId()); + EXPECT_EQ(artists[1].id(), artist2.getId()); + + const auto releases {trackList->getReleasesReverse({}, std::nullopt, moreResults)}; + ASSERT_EQ(releases.size(), 2); + EXPECT_EQ(releases[0].id(), release1.getId()); + EXPECT_EQ(releases[1].id(), release2.getId()); + + const auto tracks {trackList->getTracksReverse({}, std::nullopt, moreResults)}; + ASSERT_EQ(tracks.size(), 2); + EXPECT_EQ(tracks[0].id(), track1.getId()); + EXPECT_EQ(tracks[1].id(), track2.getId()); + } + + { + auto transaction {session.createSharedTransaction()}; + + bool moreResults {}; + const auto artists {trackList->getArtistsReverse({cluster3.getId()}, std::nullopt, std::nullopt, moreResults)}; + ASSERT_EQ(artists.size(), 2); + EXPECT_EQ(artists[0].id(), artist1.getId()); + EXPECT_EQ(artists[1].id(), artist2.getId()); + + const auto releases {trackList->getReleasesReverse({cluster3.getId()}, std::nullopt, moreResults)}; + ASSERT_EQ(releases.size(), 2); + EXPECT_EQ(releases[0].id(), release1.getId()); + EXPECT_EQ(releases[1].id(), release2.getId()); + + const auto tracks {trackList->getTracksReverse({cluster3.getId()}, std::nullopt, moreResults)}; + ASSERT_EQ(tracks.size(), 2); + EXPECT_EQ(tracks[0].id(), track1.getId()); + EXPECT_EQ(tracks[1].id(), track2.getId()); + } +} + +TEST_F(DatabaseFixture, MultipleTracksMultipleArtistsMultiClusters) +{ + ScopedArtist artist1 {session, "MyArtist1"}; + ScopedArtist artist2 {session, "MyArtist2"}; + ScopedArtist artist3 {session, "MyArtist3"}; + ScopedClusterType clusterType {session, "MyClusterType"}; + ScopedCluster cluster1 {session, clusterType.lockAndGet(), "MyCluster1"}; + ScopedCluster cluster2 {session, clusterType.lockAndGet(), "MyCluster2"}; + + { + auto transaction {session.createSharedTransaction()}; + EXPECT_TRUE(artist1->getSimilarArtists().empty()); + EXPECT_TRUE(artist2->getSimilarArtists().empty()); + EXPECT_TRUE(artist3->getSimilarArtists().empty()); + } + + std::list tracks; + for (std::size_t i {}; i < 10; ++i) + { + tracks.emplace_back(session, "MyTrack" + std::to_string(i)); + + auto transaction {session.createUniqueTransaction()}; + + if (i < 5) + TrackArtistLink::create(session, tracks.back().get(), artist1.get(), TrackArtistLinkType::Artist); + else + { + TrackArtistLink::create(session, tracks.back().get(), artist2.get(), TrackArtistLinkType::Artist); + cluster2.get().modify()->addTrack(tracks.back().get()); + } + + cluster1.get().modify()->addTrack(tracks.back().get()); + } + + tracks.emplace_back(session, "MyTrack" + std::to_string(tracks.size())); + { + auto transaction {session.createUniqueTransaction()}; + TrackArtistLink::create(session, tracks.back().get(), artist3.get(), TrackArtistLinkType::Artist); + cluster2.get().modify()->addTrack(tracks.back().get()); + } + + { + auto transaction {session.createSharedTransaction()}; + + { + auto artists {artist1->getSimilarArtists()}; + ASSERT_EQ(artists.size(), 1); + EXPECT_EQ(artists.front().id(), artist2.getId()); + } + + { + auto artists {artist1->getSimilarArtists({TrackArtistLinkType::Artist})}; + ASSERT_EQ(artists.size(), 1); + EXPECT_EQ(artists.front().id(), artist2.getId()); + } + + { + auto artists {artist1->getSimilarArtists({TrackArtistLinkType::ReleaseArtist})}; + EXPECT_EQ(artists.empty(), 1); + } + + { + auto artists {artist1->getSimilarArtists({TrackArtistLinkType::Artist, TrackArtistLinkType::ReleaseArtist})}; + ASSERT_EQ(artists.size(), 1); + EXPECT_EQ(artists.front().id(), artist2.getId()); + } + + { + auto artists {artist1->getSimilarArtists({TrackArtistLinkType::Composer})}; + EXPECT_TRUE(artists.empty()); + } + + { + auto artists {artist2->getSimilarArtists()}; + ASSERT_EQ(artists.size(), 2); + EXPECT_EQ(artists[0].id(), artist1.getId()); + EXPECT_EQ(artists[1].id(), artist3.getId()); + } + } +} + +TEST_F(DatabaseFixture, MultipleTracksMultipleReleasesMultiClusters) +{ + ScopedRelease release1 {session, "MyRelease1"}; + ScopedRelease release2 {session, "MyRelease2"}; + ScopedRelease release3 {session, "MyRelease3"}; + ScopedClusterType clusterType {session, "MyClusterType"}; + ScopedCluster cluster1 {session, clusterType.lockAndGet(), "MyCluster1"}; + ScopedCluster cluster2 {session, clusterType.lockAndGet(), "MyCluster2"}; + + { + auto transaction {session.createSharedTransaction()}; + EXPECT_TRUE(release1->getSimilarReleases().empty()); + EXPECT_TRUE(release2->getSimilarReleases().empty()); + EXPECT_TRUE(release3->getSimilarReleases().empty()); + } + + std::list tracks; + for (std::size_t i {}; i < 10; ++i) + { + tracks.emplace_back(session, "MyTrack" + std::to_string(i)); + + auto transaction {session.createUniqueTransaction()}; + + if (i < 5) + tracks.back().get().modify()->setRelease(release1.get()); + else + { + tracks.back().get().modify()->setRelease(release2.get()); + cluster2.get().modify()->addTrack(tracks.back().get()); + } + + cluster1.get().modify()->addTrack(tracks.back().get()); + } + + tracks.emplace_back(session, "MyTrack" + std::to_string(tracks.size())); + { + auto transaction {session.createUniqueTransaction()}; + tracks.back().get().modify()->setRelease(release3.get()); + cluster2.get().modify()->addTrack(tracks.back().get()); + } + + { + auto transaction {session.createSharedTransaction()}; + + { + auto releases {release1->getSimilarReleases()}; + ASSERT_EQ(releases.size(), 1); + EXPECT_EQ(releases.front().id(), release2.getId()); + } + + { + auto releases {release2->getSimilarReleases()}; + ASSERT_EQ(releases.size(), 2); + EXPECT_EQ(releases[0].id(), release1.getId()); + EXPECT_EQ(releases[1].id(), release3.getId()); + } + } +} + + diff --git a/src/test/database/Common.hpp b/src/test/database/Common.hpp new file mode 100644 index 00000000..82f9115c --- /dev/null +++ b/src/test/database/Common.hpp @@ -0,0 +1,182 @@ +/* + * 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 "database/Artist.hpp" +#include "database/Cluster.hpp" +#include "database/Db.hpp" +#include "database/Release.hpp" +#include "database/Session.hpp" +#include "database/Track.hpp" +#include "database/TrackArtistLink.hpp" +#include "database/TrackBookmark.hpp" +#include "database/TrackList.hpp" +#include "database/Types.hpp" +#include "database/User.hpp" + +template +class ScopedEntity +{ + public: + template + ScopedEntity(Database::Session& session, Args&& ...args) + : _session {session} + { + auto transaction {_session.createUniqueTransaction()}; + + auto entity {T::create(_session, std::forward(args)...)}; + EXPECT_TRUE(entity); + _id = entity.id(); + } + + ~ScopedEntity() + { + auto transaction {_session.createUniqueTransaction()}; + + auto entity {T::getById(_session, _id)}; + entity.remove(); + } + + ScopedEntity(const ScopedEntity&) = delete; + ScopedEntity(ScopedEntity&&) = delete; + ScopedEntity& operator=(const ScopedEntity&) = delete; + ScopedEntity& operator=(ScopedEntity&&) = delete; + + typename T::pointer lockAndGet() + { + auto transaction {_session.createSharedTransaction()}; + return get(); + } + + typename T::pointer get() + { + _session.checkSharedLocked(); + + auto entity {T::getById(_session, _id)}; + EXPECT_TRUE(entity); + return entity; + } + + typename T::pointer operator->() + { + return get(); + } + + Database::IdType getId() const { return _id; } + + private: + Database::Session& _session; + Database::IdType _id {}; +}; + +using ScopedArtist = ScopedEntity; +using ScopedCluster = ScopedEntity; +using ScopedClusterType = ScopedEntity; +using ScopedRelease = ScopedEntity; +using ScopedTrack = ScopedEntity; +using ScopedTrackBookmark = ScopedEntity; +using ScopedTrackList = ScopedEntity; +using ScopedUser = ScopedEntity; + +class ScopedFileDeleter final +{ + public: + ScopedFileDeleter(const std::filesystem::path& path) : _path {path} {} + ~ScopedFileDeleter() { std::filesystem::remove(_path); } + + ScopedFileDeleter(const ScopedFileDeleter&) = delete; + ScopedFileDeleter(ScopedFileDeleter&&) = delete; + ScopedFileDeleter operator=(const ScopedFileDeleter&) = delete; + ScopedFileDeleter operator=(ScopedFileDeleter&&) = delete; + + private: + const std::filesystem::path _path; +}; + +class TmpDatabase final +{ + public: + Database::Db& getDb() { return _db; } + + private: + const std::filesystem::path _tmpFile {std::tmpnam(nullptr)}; + ScopedFileDeleter fileDeleter {_tmpFile}; + Database::Db _db {_tmpFile}; + +}; + +class DatabaseFixture : public ::testing::Test +{ +public: + ~DatabaseFixture() + { + testDatabaseEmpty(); + } + +public: + static void SetUpTestCase() + { + _tmpDb = std::make_unique(); + { + Database::Session s {_tmpDb->getDb()}; + s.prepareTables(); + s.optimize(); + + // remove default created entries + { + auto transaction {s.createUniqueTransaction()}; + auto clusterTypes {Database::ClusterType::getAll(s)}; + for (auto& clusterType : clusterTypes) + clusterType.remove(); + } + } + } + + static void TearDownTestCase() + { + _tmpDb.reset(); + } + +private: + void testDatabaseEmpty() + { + auto uniqueTransaction {session.createUniqueTransaction()}; + + EXPECT_TRUE(Database::Artist::getAll(session, Database::Artist::SortMethod::ByName).empty()); + EXPECT_TRUE(Database::Cluster::getAll(session).empty()); + EXPECT_TRUE(Database::ClusterType::getAll(session).empty()); + EXPECT_TRUE(Database::Release::getAll(session).empty()); + EXPECT_TRUE(Database::Track::getAll(session).empty()); + EXPECT_TRUE(Database::TrackBookmark::getAll(session).empty()); + EXPECT_TRUE(Database::TrackList::getAll(session).empty()); + EXPECT_TRUE(Database::User::getAll(session).empty()); + } + + static inline std::unique_ptr _tmpDb {}; + +public: + Database::Session session {_tmpDb->getDb()}; +}; + diff --git a/src/test/database/DatabaseTest.cpp b/src/test/database/DatabaseTest.cpp index edcfb4f5..c5d2ad4b 100644 --- a/src/test/database/DatabaseTest.cpp +++ b/src/test/database/DatabaseTest.cpp @@ -17,187 +17,13 @@ * along with LMS. If not, see . */ -#include - -#include #include -#include "database/Artist.hpp" -#include "database/Cluster.hpp" -#include "database/Db.hpp" -#include "database/Release.hpp" -#include "database/Session.hpp" -#include "database/Track.hpp" -#include "database/TrackArtistLink.hpp" -#include "database/TrackBookmark.hpp" -#include "database/TrackList.hpp" -#include "database/User.hpp" - -#include "utils/StreamLogger.hpp" +#include "Common.hpp" using namespace Database; -#define CHECK(PRED) \ - do \ - { \ - try \ - { \ - if (!(PRED)) \ - { \ - std::string error {"Predicate FAILED '" + std::string {#PRED} + "' at " + __FUNCTION__ + "@l." + std::to_string(__LINE__)}; \ - std::cerr << error << std::endl; \ - throw std::runtime_error {error}; \ - } \ - } \ - catch (Wt::Dbo::Exception& e) \ - { \ - std::cerr << "Caught DBO exception: " < -class ScopedEntity -{ - public: - - template - ScopedEntity(Session& session, Args&& ...args) - : _session {session} - { - auto transaction {_session.createUniqueTransaction()}; - - auto entity {T::create(_session, std::forward(args)...)}; - CHECK(entity); - _id = entity.id(); - } - - ~ScopedEntity() - { - auto transaction {_session.createUniqueTransaction()}; - - auto entity {T::getById(_session, _id)}; - entity.remove(); - } - - ScopedEntity(const ScopedEntity&) = delete; - ScopedEntity(ScopedEntity&&) = delete; - ScopedEntity& operator=(const ScopedEntity&) = delete; - ScopedEntity& operator=(ScopedEntity&&) = delete; - - typename T::pointer lockAndGet() - { - auto transaction {_session.createSharedTransaction()}; - return get(); - } - - typename T::pointer get() - { - _session.checkSharedLocked(); - - auto entity {T::getById(_session, _id)}; - CHECK(entity); - return entity; - } - - typename T::pointer operator->() - { - return get(); - } - - IdType getId() const { return _id; } - - private: - Session& _session; - IdType _id {}; -}; - -using ScopedArtist = ScopedEntity; -using ScopedCluster = ScopedEntity; -using ScopedClusterType = ScopedEntity; -using ScopedRelease = ScopedEntity; -using ScopedTrack = ScopedEntity; -using ScopedTrackBookmark = ScopedEntity; -using ScopedTrackList = ScopedEntity; -using ScopedUser = ScopedEntity; - - -static -void -testRemoveDefaultEntries(Session& session) -{ - { - auto transaction {session.createUniqueTransaction()}; - - auto clusterTypes {ClusterType::getAll(session)}; - for (auto& clusterType : clusterTypes) - clusterType.remove(); - } -} - -static -void -testSingleTrack(Session& session) -{ - { - auto transaction {session.createSharedTransaction()}; - CHECK(Track::getCount(session) == 0); - } - - ScopedTrack track {session, "MyTrackFile"}; - - { - auto transaction {session.createSharedTransaction()}; - - CHECK(Track::getAll(session).size() == 1); - CHECK(Track::getCount(session) == 1); - - } -} - -static -void -testSingleArtist(Session& session) -{ - ScopedArtist artist {session, "MyArtist"}; - - { - auto transaction {session.createSharedTransaction()}; - - auto artists {Artist::getAll(session, Artist::SortMethod::ByName)}; - CHECK(artists.size() == 1); - CHECK(artists.front().id() == artist.getId()); - - artists = Artist::getAllOrphans(session); - CHECK(artists.size() == 1); - CHECK(artists.front().id() == artist.getId()); - } -} - -static -void -testSingleRelease(Session& session) +TEST_F(DatabaseFixture, SingleRelease) { ScopedRelease release {session, "MyRelease"}; @@ -205,273 +31,17 @@ testSingleRelease(Session& session) auto transaction {session.createSharedTransaction()}; auto releases {Release::getAllOrphans(session)}; - CHECK(releases.size() == 1); - CHECK(releases.front().id() == release.getId()); + ASSERT_EQ(releases.size(), 1); + EXPECT_EQ(releases.front().id(), release.getId()); releases = Release::getAll(session); - CHECK(releases.size() == 1); - CHECK(releases.front().id() == release.getId()); - - CHECK(release->getDuration() == std::chrono::seconds {0}); + ASSERT_EQ(releases.size(), 1); + EXPECT_EQ(releases.front().id(), release.getId()); + EXPECT_EQ(release->getDuration(), std::chrono::seconds {0}); } } -static -void -testSingleCluster(Session& session) -{ - ScopedClusterType clusterType {session, "MyType"}; - - { - ScopedCluster cluster {session, clusterType.lockAndGet(), "MyCluster"}; - - { - auto transaction {session.createUniqueTransaction()}; - - auto clusters {Cluster::getAll(session)}; - CHECK(clusters.size() == 1); - CHECK(clusters.front().id() == cluster.getId()); - CHECK(clusters.front()->getType().id() == clusterType.getId()); - - clusters = Cluster::getAllOrphans(session); - CHECK(clusters.size() == 1); - CHECK(clusters.front().id() == cluster.getId()); - - auto clusterTypes {ClusterType::getAll(session)}; - CHECK(clusterTypes.size() == 1); - CHECK(clusterTypes.front().id() == clusterType.getId()); - - clusterTypes = ClusterType::getAllUsed(session); - CHECK(clusterTypes.size() == 1); - CHECK(clusterTypes.front().id() == clusterType.getId()); - - clusterTypes = ClusterType::getAllOrphans(session); - CHECK(clusterTypes.empty()); - } - } - - { - auto transaction {session.createUniqueTransaction()}; - - auto clusterTypes {ClusterType::getAllOrphans(session)}; - CHECK(clusterTypes.size() == 1); - CHECK(clusterTypes.front().id() == clusterType.getId()); - - CHECK(ClusterType::getAllUsed(session).empty()); - } -} - -static -void -testSingleTrackSingleArtist(Session& session) -{ - ScopedTrack track {session, "MyTrack"}; - ScopedArtist artist {session, "MyArtist"}; - - { - auto transaction {session.createUniqueTransaction()}; - - TrackArtistLink::create(session, track.get(), artist.get(), TrackArtistLinkType::Artist); - } - - { - auto transaction {session.createSharedTransaction()}; - CHECK(Artist::getAllOrphans(session).empty()); - } - - { - auto transaction {session.createSharedTransaction()}; - - auto artists {track->getArtists({TrackArtistLinkType::Artist})}; - CHECK(artists.size() == 1); - CHECK(artists.front().id() == artist.getId()); - - CHECK(artist->getReleaseCount() == 0); - - CHECK(track->getArtistLinks().size() == 1); - auto artistLink {track->getArtistLinks().front()}; - CHECK(artistLink->getTrack().id() == track.getId()); - CHECK(artistLink->getArtist().id() == artist.getId()); - - CHECK(track->getArtists({TrackArtistLinkType::Artist}).size() == 1); - CHECK(track->getArtists({TrackArtistLinkType::ReleaseArtist}).empty()); - CHECK(track->getArtists({}).size() == 1); - } - - { - auto transaction {session.createUniqueTransaction()}; - - auto tracks {artist->getTracks()}; - CHECK(tracks.size() == 1); - CHECK(tracks.front().id() == track.getId()); - - CHECK(artist->getTracks(TrackArtistLinkType::ReleaseArtist).empty()); - CHECK(artist->getTracks(TrackArtistLinkType::Artist).size() == 1); - } -} - -static -void -testSingleTrackSingleArtistMultiRoles(Session& session) -{ - ScopedTrack track {session, "MyTrack"}; - ScopedArtist artist {session, "MyArtist"}; - { - auto transaction {session.createUniqueTransaction()}; - - TrackArtistLink::create(session, track.get(), artist.get(), TrackArtistLinkType::Artist); - TrackArtistLink::create(session, track.get(), artist.get(), TrackArtistLinkType::ReleaseArtist); - TrackArtistLink::create(session, track.get(), artist.get(), TrackArtistLinkType::Writer); - } - - { - auto transaction {session.createSharedTransaction()}; - CHECK(Artist::getAllOrphans(session).empty()); - } - - { - auto transaction {session.createSharedTransaction()}; - bool hasMore{}; - CHECK(Artist::getByFilter(session, {}, {}, std::nullopt, Artist::SortMethod::ByName, std::nullopt, hasMore).size() == 1); - CHECK(Artist::getByFilter(session, {}, {}, TrackArtistLinkType::Artist, Artist::SortMethod::ByName, std::nullopt, hasMore).size() == 1); - CHECK(Artist::getByFilter(session, {}, {}, TrackArtistLinkType::ReleaseArtist, Artist::SortMethod::ByName, std::nullopt, hasMore).size() == 1); - CHECK(Artist::getByFilter(session, {}, {}, TrackArtistLinkType::Writer, Artist::SortMethod::ByName, std::nullopt, hasMore).size() == 1); - CHECK(Artist::getByFilter(session, {}, {}, TrackArtistLinkType::Composer, Artist::SortMethod::ByName, std::nullopt, hasMore).empty()); - } - - { - auto transaction {session.createSharedTransaction()}; - - auto artists {track->getArtists({TrackArtistLinkType::Artist})}; - CHECK(artists.size() == 1); - CHECK(artists.front().id() == artist.getId()); - - artists = track->getArtists({TrackArtistLinkType::ReleaseArtist}); - CHECK(artists.size() == 1); - CHECK(artists.front().id() == artist.getId()); - - CHECK(track->getArtistLinks().size() == 3); - - CHECK(artist->getTracks().size() == 1); - CHECK(artist->getTracks({TrackArtistLinkType::ReleaseArtist}).size() == 1); - CHECK(artist->getTracks({TrackArtistLinkType::Artist}).size() == 1); - CHECK(artist->getTracks({TrackArtistLinkType::Writer}).size() == 1); - } -} - -static -void -testSingleTrackMultiArtists(Session& session) -{ - ScopedTrack track {session, "track"}; - ScopedArtist artist1 {session, "artist1"}; - ScopedArtist artist2 {session, "artist2"}; - CHECK(artist1.getId() != artist2.getId()); - - { - auto transaction {session.createUniqueTransaction()}; - - TrackArtistLink::create(session, track.get(), artist1.get(), TrackArtistLinkType::Artist); - TrackArtistLink::create(session, track.get(), artist2.get(), TrackArtistLinkType::Artist); - } - - { - auto transaction {session.createSharedTransaction()}; - CHECK(Artist::getAllOrphans(session).empty()); - } - - { - auto transaction {session.createSharedTransaction()}; - - auto artists {track->getArtists({TrackArtistLinkType::Artist})}; - CHECK(artists.size() == 2); - CHECK((artists[0].id() == artist1.getId() && artists[1].id() == artist2.getId()) - || (artists[0].id() == artist2.getId() && artists[1].id() == artist1.getId())); - - CHECK(track->getArtists({}).size() == 2); - CHECK(track->getArtists({TrackArtistLinkType::Artist}).size() == 2); - CHECK(track->getArtists({TrackArtistLinkType::ReleaseArtist}).empty()); - CHECK(Artist::getAll(session, Artist::SortMethod::ByName).size() == 2); - CHECK(Artist::getAllIds(session).size() == 2); - } - - { - auto transaction {session.createUniqueTransaction()}; - - CHECK(artist1->getTracks().front() == track.get()); - CHECK(artist2->getTracks().front() == track.get()); - - CHECK(artist1->getTracks(TrackArtistLinkType::ReleaseArtist).empty()); - CHECK(artist1->getTracks(TrackArtistLinkType::Artist).size() == 1); - CHECK(artist2->getTracks(TrackArtistLinkType::ReleaseArtist).empty()); - CHECK(artist2->getTracks(TrackArtistLinkType::Artist).size() == 1); - } -} - -static -void -testSingleArtistSearchByName(Session& session) -{ - ScopedArtist artist {session, "AAA"}; - ScopedTrack track {session, "MyTrack"}; // filters does not work on orphans - - { - auto transaction {session.createUniqueTransaction()}; - artist.get().modify()->setSortName("ZZZ"); - TrackArtistLink::create(session, track.get(), artist.get(), TrackArtistLinkType::Artist); - } - - { - auto transaction {session.createSharedTransaction()}; - - bool more {}; - CHECK(Artist::getByFilter(session, {}, {"N"}, std::nullopt, Artist::SortMethod::ByName, std::nullopt, more).empty()); - - const auto artistsByAAA {Artist::Artist::getByFilter(session, {}, {"A"}, std::nullopt, Artist::SortMethod::ByName, std::nullopt, more)}; - CHECK(artistsByAAA.size() == 1); - CHECK(artistsByAAA.front().id() == artist.getId()); - - const auto artistsByZZZ {Artist::Artist::getByFilter(session, {}, {"Z"}, std::nullopt, Artist::SortMethod::ByName, std::nullopt, more)}; - CHECK(artistsByZZZ.size() == 1); - CHECK(artistsByZZZ.front().id() == artist.getId()); - - CHECK(Artist::getByName(session, "NNN").empty()); - } -} - -static -void -testMultiArtistsSortMethod(Session& session) -{ - ScopedArtist artistA {session, "artistA"}; - ScopedArtist artistB {session, "artistB"}; - - { - auto transaction {session.createUniqueTransaction()}; - - artistA.get().modify()->setSortName("sortNameB"); - artistB.get().modify()->setSortName("sortNameA"); - } - - { - auto transaction {session.createSharedTransaction()}; - - auto allArtistsByName {Artist::getAll(session, Artist::SortMethod::ByName)}; - auto allArtistsBySortName {Artist::getAll(session, Artist::SortMethod::BySortName)}; - - CHECK(allArtistsByName.size() == 2); - CHECK(allArtistsByName.front().id() == artistA.getId()); - CHECK(allArtistsByName.back().id() == artistB.getId()); - - CHECK(allArtistsBySortName.size() == 2); - CHECK(allArtistsBySortName.front().id() == artistB.getId()); - CHECK(allArtistsBySortName.back().id() == artistA.getId()); - } -} - -static -void -testSingleTrackSingleRelease(Session& session) +TEST_F(DatabaseFixture, SingleTrackSingleRelease) { ScopedRelease release {session, "MyRelease"}; @@ -487,60 +57,125 @@ testSingleTrackSingleRelease(Session& session) { auto transaction {session.createSharedTransaction()}; - CHECK(Release::getAllOrphans(session).empty()); + EXPECT_TRUE(Release::getAllOrphans(session).empty()); - CHECK(release->getTracks().size() == 1); - CHECK(release->getTracksCount() == 1); - CHECK(release->getTracks().front().id() == track.getId()); + EXPECT_EQ(release->getTracksCount(), 1); + ASSERT_EQ(release->getTracks().size(), 1); + EXPECT_EQ(release->getTracks().front().id(), track.getId()); } { auto transaction {session.createUniqueTransaction()}; - CHECK(track->getRelease()); - CHECK(track->getRelease().id() == release.getId()); + ASSERT_TRUE(track->getRelease()); + EXPECT_EQ(track->getRelease().id(), release.getId()); } { auto transaction {session.createUniqueTransaction()}; auto tracks {Track::getByNameAndReleaseName(session, "MyTrackName", "MyReleaseName")}; - CHECK(tracks.size() == 1); - CHECK(tracks.front().id() == track.getId()); + ASSERT_EQ(tracks.size(), 1); + EXPECT_EQ(tracks.front().id(), track.getId()); } { auto transaction {session.createUniqueTransaction()}; auto tracks {Track::getByNameAndReleaseName(session, "MyTrackName", "MyReleaseFoo")}; - CHECK(tracks.size() == 0); + EXPECT_EQ(tracks.size(), 0); } { auto transaction {session.createUniqueTransaction()}; auto tracks {Track::getByNameAndReleaseName(session, "MyTrackFoo", "MyReleaseName")}; - CHECK(tracks.size() == 0); + EXPECT_EQ(tracks.size(), 0); } } { auto transaction {session.createUniqueTransaction()}; - CHECK(release->getTracks().empty()); + EXPECT_TRUE(release->getTracks().empty()); auto releases {Release::getAllOrphans(session)}; - CHECK(releases.size() == 1); - CHECK(releases.front().id() == release.getId()); + ASSERT_EQ(releases.size(), 1); + EXPECT_EQ(releases.front().id(), release.getId()); } } -static -void -testMultiTracksSingleReleaseTotalDiscTrack(Session& session) +TEST_F(DatabaseFixture, MulitpleReleaseSearchByName) +{ + ScopedRelease release1 {session, "MyRelease"}; + ScopedRelease release2 {session, "MyRelease%"}; + ScopedRelease release3 {session, "%MyRelease"}; + ScopedRelease release4 {session, "MyRelease%Foo"}; + ScopedRelease release5 {session, "Foo%MyRelease"}; + ScopedRelease release6 {session, "_yRelease"}; + + // filters does not work on orphans + ScopedTrack track1 {session, "MyTrack"}; + ScopedTrack track2 {session, "MyTrack"}; + ScopedTrack track3 {session, "MyTrack"}; + ScopedTrack track4 {session, "MyTrack"}; + ScopedTrack track5 {session, "MyTrack"}; + ScopedTrack track6 {session, "MyTrack"}; + + { + auto transaction {session.createUniqueTransaction()}; + + track1.get().modify()->setRelease(release1.get()); + track2.get().modify()->setRelease(release2.get()); + track3.get().modify()->setRelease(release3.get()); + track4.get().modify()->setRelease(release4.get()); + track5.get().modify()->setRelease(release5.get()); + track6.get().modify()->setRelease(release6.get()); + } + + { + auto transaction {session.createSharedTransaction()}; + + bool more; + { + const auto releases {Release::getByFilter(session, {}, {"Release"}, std::nullopt, more)}; + EXPECT_EQ(releases.size(), 6); + } + + { + const auto releases {Release::getByFilter(session, {}, {"MyRelease"}, std::nullopt, more)}; + EXPECT_EQ(releases.size(), 5); + EXPECT_TRUE(std::none_of(std::cbegin(releases), std::cend(releases), [&](const Release::pointer& release) { return release.id() == release6.getId(); })); + } + { + const auto releases {Release::getByFilter(session, {}, {"MyRelease%"}, std::nullopt, more)}; + EXPECT_EQ(releases.size(), 2); + EXPECT_EQ(releases[0].id(), release2.getId()); + EXPECT_EQ(releases[1].id(), release4.getId()); + } + { + const auto releases {Release::getByFilter(session, {}, {"%MyRelease"}, std::nullopt, more)}; + EXPECT_EQ(releases.size(), 2); + EXPECT_EQ(releases[0].id(), release3.getId()); + EXPECT_EQ(releases[1].id(), release5.getId()); + } + { + const auto releases {Release::getByFilter(session, {}, {"Foo%MyRelease"}, std::nullopt, more)}; + EXPECT_EQ(releases.size(), 1); + EXPECT_EQ(releases[0].id(), release5.getId()); + } + { + const auto releases {Release::getByFilter(session, {}, {"MyRelease%Foo"}, std::nullopt, more)}; + EXPECT_EQ(releases.size(), 1); + EXPECT_EQ(releases[0].id(), release4.getId()); + } + } +} + +TEST_F(DatabaseFixture, MultiTracksSingleReleaseTotalDiscTrack) { ScopedRelease release1 {session, "MyRelease"}; { auto transaction {session.createSharedTransaction()}; - CHECK(!release1->getTotalTrack()); - CHECK(!release1->getTotalDisc()); + EXPECT_FALSE(release1->getTotalTrack()); + EXPECT_FALSE(release1->getTotalDisc()); } ScopedTrack track1 {session, "MyTrack"}; @@ -553,8 +188,8 @@ testMultiTracksSingleReleaseTotalDiscTrack(Session& session) { auto transaction {session.createSharedTransaction()}; - CHECK(!release1->getTotalTrack()); - CHECK(!release1->getTotalDisc()); + EXPECT_FALSE(release1->getTotalTrack()); + EXPECT_FALSE(release1->getTotalDisc()); } { @@ -567,8 +202,10 @@ testMultiTracksSingleReleaseTotalDiscTrack(Session& session) { auto transaction {session.createSharedTransaction()}; - CHECK(release1->getTotalTrack() && *release1->getTotalTrack() == 36); - CHECK(release1->getTotalDisc() && *release1->getTotalDisc() == 6); + ASSERT_TRUE(release1->getTotalTrack()); + EXPECT_EQ(*release1->getTotalTrack(), 36); + ASSERT_TRUE(release1->getTotalDisc()); + EXPECT_EQ(*release1->getTotalDisc(), 6); } ScopedTrack track2 {session, "MyTrack2"}; @@ -583,16 +220,18 @@ testMultiTracksSingleReleaseTotalDiscTrack(Session& session) { auto transaction {session.createSharedTransaction()}; - CHECK(release1->getTotalTrack() && *release1->getTotalTrack() == 37); - CHECK(release1->getTotalDisc() && *release1->getTotalDisc() == 67); + ASSERT_TRUE(release1->getTotalTrack()); + EXPECT_EQ(*release1->getTotalTrack(), 37); + ASSERT_TRUE(release1->getTotalDisc()); + EXPECT_EQ(*release1->getTotalDisc(), 67); } ScopedRelease release2 {session, "MyRelease2"}; { auto transaction {session.createSharedTransaction()}; - CHECK(!release2->getTotalTrack()); - CHECK(!release2->getTotalDisc()); + EXPECT_FALSE(release2->getTotalTrack()); + EXPECT_FALSE(release2->getTotalDisc()); } ScopedTrack track3 {session, "MyTrack3"}; @@ -606,16 +245,18 @@ testMultiTracksSingleReleaseTotalDiscTrack(Session& session) { auto transaction {session.createSharedTransaction()}; - CHECK(release1->getTotalTrack() && *release1->getTotalTrack() == 37); - CHECK(release1->getTotalDisc() && *release1->getTotalDisc() == 67); - CHECK(release2->getTotalTrack() && *release2->getTotalTrack() == 7); - CHECK(release2->getTotalDisc() && *release2->getTotalDisc() == 5); + ASSERT_TRUE(release1->getTotalTrack()); + EXPECT_EQ(*release1->getTotalTrack(), 37); + ASSERT_TRUE(release1->getTotalDisc()); + EXPECT_EQ(*release1->getTotalDisc(), 67); + ASSERT_TRUE(release2->getTotalTrack()); + EXPECT_EQ(*release2->getTotalTrack(), 7); + ASSERT_TRUE(release2->getTotalDisc()); + EXPECT_EQ(*release2->getTotalDisc(), 5); } } -static -void -testMultiTracksSingleReleaseFirstTrack(Session& session) +TEST_F(DatabaseFixture, MultiTracksSingleReleaseFirstTrack) { ScopedRelease release1 {session, "MyRelease1"}; ScopedRelease release2 {session, "MyRelease2"}; @@ -628,8 +269,8 @@ testMultiTracksSingleReleaseFirstTrack(Session& session) { auto transaction {session.createSharedTransaction()}; - CHECK(!release1->getFirstTrack()); - CHECK(!release2->getFirstTrack()); + EXPECT_FALSE(release1->getFirstTrack()); + EXPECT_FALSE(release2->getFirstTrack()); } { @@ -652,500 +293,15 @@ testMultiTracksSingleReleaseFirstTrack(Session& session) { auto transaction {session.createSharedTransaction()}; - CHECK(release1->getFirstTrack()); - CHECK(release2->getFirstTrack()); + EXPECT_TRUE(release1->getFirstTrack()); + EXPECT_TRUE(release2->getFirstTrack()); - CHECK(release1->getFirstTrack().id() == track1A.getId()); - CHECK(release2->getFirstTrack().id() == track2B.getId()); + EXPECT_EQ(release1->getFirstTrack().id(), track1A.getId()); + EXPECT_EQ(release2->getFirstTrack().id(), track2B.getId()); } } -static -void -testSingleTrackSingleCluster(Session& session) -{ - ScopedTrack track {session, "MyTrack"}; - ScopedClusterType clusterType {session, "MyClusterType"}; - - { - auto transaction {session.createSharedTransaction()}; - CHECK(Cluster::getAllOrphans(session).empty()); - auto clusterTypes {ClusterType::getAllOrphans(session)}; - CHECK(clusterTypes.size() ==1); - CHECK(clusterTypes.front().id() == clusterType.getId()); - } - - ScopedCluster cluster1 {session, clusterType.lockAndGet(), "MyCluster1"}; - ScopedCluster cluster2 {session, clusterType.lockAndGet(), "MyCluster2"}; - - { - auto transaction {session.createSharedTransaction()}; - auto clusters {Cluster::getAllOrphans(session)}; - CHECK(clusters.size() == 2); - CHECK(track->getClusters().empty()); - CHECK(track->getClusterIds().empty()); - } - - { - auto transaction {session.createSharedTransaction()}; - CHECK(Track::getAllIdsWithClusters(session).empty()); - } - - { - auto transaction {session.createUniqueTransaction()}; - - cluster1.get().modify()->addTrack(track.get()); - } - - { - auto transaction {session.createSharedTransaction()}; - auto tracks {Track::getAllIdsWithClusters(session)}; - CHECK(tracks.size() == 1); - CHECK(tracks.front() == track.getId()); - } - - { - auto transaction {session.createSharedTransaction()}; - auto clusters {Cluster::getAllOrphans(session)}; - CHECK(clusters.size() == 1); - CHECK(clusters.front().id() == cluster2.getId()); - - CHECK(ClusterType::getAllOrphans(session).empty()); - } - - { - auto transaction {session.createSharedTransaction()}; - - auto tracks {Track::getByClusters(session, {cluster1.getId()})}; - CHECK(tracks.size() == 1); - CHECK(tracks.front().id() == track.getId()); - - tracks = Track::getByClusters(session, {cluster2.getId()}); - CHECK(tracks.empty()); - } - - { - auto transaction {session.createSharedTransaction()}; - - auto clusters {track->getClusters()}; - CHECK(clusters.size() == 1); - CHECK(clusters.front().id() == cluster1.getId()); - - auto clusterIds {track->getClusterIds()}; - CHECK(clusterIds.size() == 1); - CHECK(clusterIds.front() == cluster1.getId()); - } -} - -static -void -testMultipleTracksSingleCluster(Session& session) -{ - std::list tracks; - ScopedClusterType clusterType {session, "MyClusterType"}; - ScopedCluster cluster {session, clusterType.lockAndGet(), "MyCluster"}; - - for (std::size_t i {}; i < 10; ++i) - { - tracks.emplace_back(session, "MyTrack" + std::to_string(i)); - - { - auto transaction {session.createUniqueTransaction()}; - cluster.get().modify()->addTrack(tracks.back().get()); - } - } - - { - auto transaction {session.createSharedTransaction()}; - CHECK(Cluster::getAllOrphans(session).empty()); - - CHECK(cluster->getTracksCount() == tracks.size()); - - for (auto trackCluster : cluster->getTracks()) - { - auto it {std::find_if(std::cbegin(tracks), std::cend(tracks), [&](const ScopedTrack& track) { return trackCluster.id() == track.getId(); })}; - CHECK(it != std::cend(tracks)); - } - } -} - -static -void -testMultipleTracksMultipleClustersTopRelease(Session& session) -{ - ScopedClusterType clusterType {session, "ClusterType"}; - ScopedCluster cluster1 {session, clusterType.lockAndGet(), "Cluster1"}; - ScopedCluster cluster2 {session, clusterType.lockAndGet(), "Cluster2"}; - ScopedCluster cluster3 {session, clusterType.lockAndGet(), "Cluster3"}; - ScopedTrack trackA {session, "TrackA"}; - ScopedTrack trackB {session, "TrackB"}; - ScopedTrack trackC {session, "TrackC"}; - ScopedRelease releaseA {session, "ReleaseA"}; - ScopedRelease releaseB {session, "ReleaseB"}; - ScopedRelease releaseC {session, "ReleaseC"}; - - ScopedUser user {session, "MyUser"}; - ScopedTrackList trackList {session, "TrackList", TrackList::Type::Playlist, false, user.lockAndGet()}; - - { - auto transaction {session.createSharedTransaction()}; - - CHECK(trackList->getDuration() == std::chrono::seconds {0}); - } - - { - auto transaction {session.createUniqueTransaction()}; - - cluster1.get().modify()->addTrack(trackA.get()); - cluster2.get().modify()->addTrack(trackB.get()); - cluster2.get().modify()->addTrack(trackC.get()); - cluster3.get().modify()->addTrack(trackC.get()); - - trackA.get().modify()->setRelease(releaseA.get()); - trackB.get().modify()->setRelease(releaseB.get()); - trackC.get().modify()->setRelease(releaseC.get()); - } - - { - auto transaction {session.createUniqueTransaction()}; - - TrackListEntry::create(session, trackA.get(), trackList.get()); - TrackListEntry::create(session, trackB.get(), trackList.get()); - TrackListEntry::create(session, trackB.get(), trackList.get()); - } - - { - auto transaction {session.createSharedTransaction()}; - - bool hasMore; - const auto releases{trackList->getTopReleases({}, std::nullopt, hasMore)}; - CHECK(releases.size() == 2); - CHECK(releases[0].id() == releaseB.getId()); - CHECK(releases[1].id() == releaseA.getId()); - } - - { - auto transaction {session.createSharedTransaction()}; - - bool hasMore; - auto releases{trackList->getTopReleases({cluster1.getId()}, std::nullopt, hasMore)}; - CHECK(releases.size() == 1); - CHECK(releases[0].id() == releaseA.getId()); - - releases = trackList->getTopReleases({cluster2.getId()}, std::nullopt, hasMore); - CHECK(releases.size() == 1); - CHECK(releases[0].id() == releaseB.getId()); - - releases = trackList->getTopReleases({cluster2.getId(), cluster1.getId()}, std::nullopt, hasMore); - CHECK(releases.empty()); - - releases = trackList->getTopReleases({cluster2.getId(), cluster3.getId()}, std::nullopt, hasMore); - CHECK(releases.empty()); - } - - - { - auto transaction {session.createUniqueTransaction()}; - - TrackListEntry::create(session, trackC.get(), trackList.get()); - TrackListEntry::create(session, trackC.get(), trackList.get()); - TrackListEntry::create(session, trackC.get(), trackList.get()); - } - - { - auto transaction {session.createSharedTransaction()}; - - bool hasMore; - auto releases {trackList->getTopReleases({cluster2.getId(), cluster3.getId()}, std::nullopt, hasMore)}; - CHECK(releases.size() == 1); - CHECK(releases[0].id() == releaseC.getId()); - - releases = trackList->getTopReleases({cluster2.getId()}, std::nullopt, hasMore); - CHECK(releases.size() == 2); - CHECK(releases[0].id() == releaseC.getId()); - CHECK(releases[1].id() == releaseB.getId()); - } - - -} - -static -void -testMultipleTracksSingleClusterSimilarity(Session& session) -{ - std::list tracks; - ScopedClusterType clusterType {session, "MyClusterType"}; - ScopedCluster cluster {session, clusterType.lockAndGet(), "MyClusterType"}; - - for (std::size_t i {}; i < 10; ++i) - { - tracks.emplace_back(session, "MyTrack" + std::to_string(i)); - - { - auto transaction {session.createUniqueTransaction()}; - cluster.get().modify()->addTrack(tracks.back().get()); - } - } - - { - auto transaction {session.createSharedTransaction()}; - - const auto similarTracks {Track::getSimilarTracks(session, {tracks.front().getId()})}; - CHECK(similarTracks.size() == tracks.size() - 1); - for (auto similarTrack : similarTracks) - { - CHECK(std::find_if(std::next(std::cbegin(tracks), 1), std::cend(tracks), - [&](const ScopedTrack& track) - { - return similarTrack.id() == track.getId(); - }) != std::cend(tracks)); - } - } -} - -static -void -testMultipleTracksMultipleClustersSimilarity(Session& session) -{ - std::list tracks; - ScopedClusterType clusterType {session, "MyClusterType"}; - ScopedCluster cluster1 {session, clusterType.lockAndGet(), "MyCluster1"}; - ScopedCluster cluster2 {session, clusterType.lockAndGet(), "MyCluster2"}; - - for (std::size_t i {}; i < 5; ++i) - { - tracks.emplace_back(session, "MyTrack" + std::to_string(i)); - - { - auto transaction {session.createUniqueTransaction()}; - cluster1.get().modify()->addTrack(tracks.back().get()); - } - } - - for (std::size_t i {5}; i < 10; ++i) - { - tracks.emplace_back(session, "MyTrack" + std::to_string(i)); - - { - auto transaction {session.createUniqueTransaction()}; - cluster1.get().modify()->addTrack(tracks.back().get()); - cluster2.get().modify()->addTrack(tracks.back().get()); - } - } - - { - auto transaction {session.createSharedTransaction()}; - - { - auto similarTracks {Track::getSimilarTracks(session, {tracks.back().getId()}, 0, 4)}; - CHECK(similarTracks.size() == 4); - for (auto similarTrack : similarTracks) - CHECK(std::find_if(std::next(std::cbegin(tracks), 5), std::next(std::cend(tracks), -1), [&](const ScopedTrack& track) { return similarTrack.id() == track.getId(); }) != std::cend(tracks)); - } - - { - auto similarTracks {Track::getSimilarTracks(session, {tracks.front().getId()})}; - CHECK(similarTracks.size() == tracks.size() - 1); - for (auto similarTrack : similarTracks) - CHECK(std::find_if(std::next(std::cbegin(tracks), 1), std::cend(tracks), [&](const ScopedTrack& track) { return similarTrack.id() == track.getId(); }) != std::cend(tracks)); - } - } -} - -static -void -testSingleTrackSingleReleaseSingleCluster(Session& session) -{ - ScopedTrack track {session, "MyTrackFile"}; - ScopedRelease release {session, "MyRelease"}; - ScopedClusterType clusterType {session, "MyClusterType"}; - ScopedCluster cluster {session, clusterType .lockAndGet(), "MyCluster"}; - - { - auto transaction {session.createSharedTransaction()}; - CHECK(Release::getAllIdsWithClusters(session).empty()); - } - - { - auto transaction {session.createUniqueTransaction()}; - - track.get().modify()->setRelease(release.get()); - cluster.get().modify()->addTrack(track.get()); - } - - { - auto transaction {session.createSharedTransaction()}; - auto releases {Release::getAllIdsWithClusters(session)}; - CHECK(releases.size() == 1); - CHECK(releases.front() == release.getId()); - } - - { - auto transaction {session.createSharedTransaction()}; - - CHECK(Cluster::getAllOrphans(session).empty()); - CHECK(Release::getAllOrphans(session).empty()); - } - - { - auto transaction {session.createSharedTransaction()}; - - auto releases {Release::getByClusters(session, {cluster.getId()})}; - CHECK(releases.size() == 1); - CHECK(releases.front().id() == release.getId()); - } - - { - auto transaction {session.createSharedTransaction()}; - - CHECK(cluster->getReleasesCount() == 1); - CHECK(cluster->getTracksCount() == 1); - } -} - -static -void -testSingleTrackSingleArtistMultiClusters(Session& session) -{ - ScopedTrack track {session, "MyTrackFile"}; - ScopedArtist artist {session, "MyArtist"}; - ScopedClusterType clusterType {session, "MyType"}; - ScopedCluster cluster1 {session, clusterType.lockAndGet(), "Cluster1"}; - ScopedCluster cluster2 {session, clusterType.lockAndGet(), "Cluster2"}; - ScopedCluster cluster3 {session, clusterType.lockAndGet(), "Cluster3"}; - { - auto transaction {session.createUniqueTransaction()}; - - auto trackArtistLink {TrackArtistLink::create(session, track.get(), artist.get(), TrackArtistLinkType::Artist)}; - cluster1.get().modify()->addTrack(track.get()); - } - - { - auto transaction {session.createSharedTransaction()}; - CHECK(ClusterType::getAllOrphans(session).empty()); - CHECK(Cluster::getAllOrphans(session).size() == 2); - CHECK(Release::getAllOrphans(session).empty()); - CHECK(Artist::getAllOrphans(session).empty()); - } - - { - auto transaction {session.createSharedTransaction()}; - CHECK(track->getClusters().size() == 1); - CHECK(track->getClusterIds().size() == 1); - } - - { - auto transaction {session.createSharedTransaction()}; - - auto artists {Artist::getByClusters(session, {cluster1.getId()}, Artist::SortMethod::ByName)}; - CHECK(artists.size() == 1); - CHECK(artists.front().id() == artist.getId()); - - CHECK(Artist::getByClusters(session, {cluster2.getId()}, Artist::SortMethod::ByName).empty()); - CHECK(Artist::getByClusters(session, {cluster3.getId()}, Artist::SortMethod::ByName).empty()); - - cluster2.get().modify()->addTrack(track.get()); - } - - { - auto transaction {session.createSharedTransaction()}; - - auto artists {Artist::getByClusters(session, {cluster1.getId()}, Artist::SortMethod::ByName)}; - CHECK(artists.size() == 1); - CHECK(artists.front().id() == artist.getId()); - - artists = Artist::getByClusters(session, {cluster2.getId()}, Artist::SortMethod::ByName); - CHECK(artists.size() == 1); - CHECK(artists.front().id() == artist.getId()); - - artists = Artist::getByClusters(session, {cluster1.getId(), cluster2.getId()}, Artist::SortMethod::ByName); - CHECK(artists.size() == 1); - CHECK(artists.front().id() == artist.getId()); - - CHECK(Artist::getByClusters(session, {cluster3.getId()}, Artist::SortMethod::ByName).empty()); - } -} - -static -void -testSingleTrackSingleArtistMultiRolesMultiClusters(Session& session) -{ - ScopedTrack track {session, "MyTrackFile"}; - ScopedArtist artist {session, "MyArtist"}; - ScopedClusterType clusterType {session, "MyType"}; - ScopedCluster cluster {session, clusterType.lockAndGet(), "MyCluster"}; - - { - auto transaction {session.createUniqueTransaction()}; - - TrackArtistLink::create(session, track.get(), artist.get(), TrackArtistLinkType::Artist); - TrackArtistLink::create(session, track.get(), artist.get(), TrackArtistLinkType::ReleaseArtist); - cluster.get().modify()->addTrack(track.get()); - } - - { - auto transaction {session.createSharedTransaction()}; - CHECK(Cluster::getAllOrphans(session).empty()); - CHECK(Release::getAllOrphans(session).empty()); - CHECK(Artist::getAllOrphans(session).empty()); - } - - { - auto transaction {session.createSharedTransaction()}; - - auto artists {Artist::getByClusters(session, {cluster.getId()}, Artist::SortMethod::ByName)}; - CHECK(artists.size() == 1); - CHECK(artists.front().id() == artist.getId()); - } -} - -static -void -testMultiTracksSingleArtistMultiClusters(Session& session) -{ - constexpr std::size_t nbTracks {10}; - constexpr std::size_t nbClusters {5}; - - std::list tracks; - std::list clusters; - ScopedArtist artist {session, "MyArtist"}; - ScopedClusterType clusterType {session, "MyType"}; - - for (std::size_t i {}; i < nbClusters; ++i) - clusters.emplace_back(session, clusterType.lockAndGet(), "MyCluster" + std::to_string(i)); - - for (std::size_t i {}; i < nbTracks ; ++i) - { - tracks.emplace_back(session, "MyTrackFile" + std::to_string(i)); - - auto transaction {session.createUniqueTransaction()}; - TrackArtistLink::create(session, tracks.back().get(), artist.get(), TrackArtistLinkType::Artist); - - for (auto& cluster : clusters) - cluster.get().modify()->addTrack(tracks.back().get()); - } - - { - auto transaction {session.createSharedTransaction()}; - CHECK(Cluster::getAllOrphans(session).empty()); - CHECK(Artist::getAllOrphans(session).empty()); - } - - { - auto transaction {session.createSharedTransaction()}; - - std::set clusterIds; - std::transform(std::cbegin(clusters), std::cend(clusters), std::inserter(clusterIds, std::begin(clusterIds)), [](const ScopedCluster& cluster) { return cluster.getId(); }); - - auto artists {Artist::getByClusters(session, clusterIds, Artist::SortMethod::ByName)}; - CHECK(artists.size() == 1); - CHECK(artists.front().id() == artist.getId()); - } -} - -static -void -testMultiTracksSingleArtistSingleRelease(Session& session) +TEST_F(DatabaseFixture, MultiTracksSingleArtistSingleRelease) { constexpr std::size_t nbTracks {10}; std::list tracks; @@ -1164,25 +320,22 @@ testMultiTracksSingleArtistSingleRelease(Session& session) { auto transaction {session.createSharedTransaction()}; - CHECK(Release::getAllOrphans(session).empty()); - CHECK(Artist::getAllOrphans(session).empty()); + EXPECT_TRUE(Release::getAllOrphans(session).empty()); + EXPECT_TRUE(Artist::getAllOrphans(session).empty()); } { auto transaction {session.createSharedTransaction()}; - CHECK(artist->getReleaseCount() == 1); - CHECK(artist->getReleases().size() == 1); - CHECK(artist->getReleases().front().id() == release.getId()); + EXPECT_EQ(artist->getReleaseCount(), 1); + ASSERT_EQ(artist->getReleases().size(), 1); + EXPECT_EQ(artist->getReleases().front().id(), release.getId()); - CHECK(release->getTracks().size() == nbTracks); + EXPECT_EQ(release->getTracks().size(), nbTracks); } - } -static -void -testSingleTrackSingleReleaseSingleArtist(Session& session) +TEST_F(DatabaseFixture, SingleTrackSingleReleaseSingleArtist) { ScopedTrack track {session, "MyTrack"}; ScopedRelease release {session, "MyRelease"}; @@ -1199,127 +352,48 @@ testSingleTrackSingleReleaseSingleArtist(Session& session) auto transaction {session.createUniqueTransaction()}; auto releases {artist->getReleases()}; - CHECK(releases.size() == 1); - CHECK(releases.front().id() == release.getId()); + ASSERT_EQ(releases.size(), 1); + EXPECT_EQ(releases.front().id(), release.getId()); - CHECK(artist->getReleaseCount() == 1); + EXPECT_EQ(artist->getReleaseCount(), 1); auto artists {release->getArtists()}; - CHECK(artists.size() == 1); - CHECK(artists.front().id() == artist.getId()); + ASSERT_EQ(artists.size(), 1); + ASSERT_EQ(artists.front().id(), artist.getId()); } } -static -void -testSingleTrackSingleReleaseSingleArtistSingleCluster(Session& session) +TEST_F(DatabaseFixture, SingleUser) { - ScopedTrack track {session, "MyTrack"}; - ScopedRelease release {session, "MyRelease"}; - ScopedArtist artist {session, "MyArtist"}; - ScopedClusterType clusterType {session, "MyType"}; - ScopedCluster cluster {session, clusterType.lockAndGet(), "MyCluster"}; - { auto transaction {session.createSharedTransaction()}; - CHECK(Artist::getAllIdsWithClusters(session).empty()); + EXPECT_TRUE(User::getAll(session).empty()); + EXPECT_TRUE(User::getAllIds(session).empty()); } - { - auto transaction {session.createUniqueTransaction()}; - - TrackArtistLink::create(session, track.get(), artist.get(), TrackArtistLinkType::Artist); - track.get().modify()->setRelease(release.get()); - cluster.get().modify()->addTrack(track.get()); - } - - { - auto transaction {session.createSharedTransaction()}; - - CHECK(Cluster::getAllOrphans(session).empty()); - CHECK(ClusterType::getAllOrphans(session).empty()); - CHECK(Artist::getAllOrphans(session).empty()); - CHECK(Release::getAllOrphans(session).empty()); - } - - { - auto transaction {session.createSharedTransaction()}; - auto artists {Artist::getAllIdsWithClusters(session)}; - CHECK(artists.size() == 1); - CHECK(artists.front() == artist.getId()); - } - - { - auto transaction {session.createSharedTransaction()}; - - auto artists {Artist::getByClusters(session, {cluster.getId()}, Artist::SortMethod::ByName)}; - CHECK(artists.size() == 1); - CHECK(artists.front().id() == artist.getId()); - - auto releases {artist->getReleases()}; - CHECK(releases.size() == 1); - CHECK(releases.front().id() == release.getId()); - - releases = artist->getReleases({cluster.getId()}); - CHECK(releases.size() == 1); - CHECK(releases.front().id() == release.getId()); - } -} - -static -void -testSingleTrackSingleReleaseSingleArtistMultiClusters(Session& session) -{ - ScopedTrack track {session, "MyTrack"}; - ScopedRelease release {session, "MyRelease"}; - ScopedArtist artist {session, "MyArtist"}; - ScopedClusterType clusterType {session, "MyClusterType"}; - ScopedCluster cluster1 {session, clusterType.lockAndGet(), "MyCluster1"}; - ScopedCluster cluster2 {session, clusterType.lockAndGet(), "MyCluster2"}; - - { - auto transaction {session.createUniqueTransaction()}; - - auto trackArtistLink {TrackArtistLink::create(session, track.get(), artist.get(), TrackArtistLinkType::Artist)}; - track.get().modify()->setRelease(release.get()); - cluster1.get().modify()->addTrack(track.get()); - cluster2.get().modify()->addTrack(track.get()); - } - - { - auto transaction {session.createSharedTransaction()}; - - auto releases {artist->getReleases()}; - CHECK(releases.size() == 1); - CHECK(releases.front().id() == release.getId()); - - releases = artist->getReleases({cluster1.getId(), cluster2.getId()}); - CHECK(releases.size() == 1); - CHECK(releases.front().id() == release.getId()); - } -} - -static -void -testSingleUser(Session& session) -{ ScopedUser user {session, "MyUser"}; { auto transaction {session.createSharedTransaction()}; - CHECK(user->getQueuedTrackList(session)->getCount() == 0); + EXPECT_EQ(user->getQueuedTrackList(session)->getCount(), 0); + EXPECT_EQ(User::getAll(session).size(), 1); + EXPECT_EQ(User::getAllIds(session).size(), 1); } } -static -void -testSingleStarredArtist(Session& session) +TEST_F(DatabaseFixture, SingleStarredArtist) { ScopedArtist artist {session, "MyArtist"}; ScopedTrack track {session, "MyTrack"}; ScopedUser user {session, "MyUser"}; + { + auto transaction {session.createUniqueTransaction()}; + + EXPECT_FALSE(user->hasStarredArtist(artist.get())); + } + { auto transaction {session.createUniqueTransaction()}; @@ -1330,24 +404,28 @@ testSingleStarredArtist(Session& session) { auto transaction {session.createSharedTransaction()}; - CHECK(user->hasStarredArtist(artist.get())); + EXPECT_TRUE(user->hasStarredArtist(artist.get())); bool hasMore {}; auto artists {Artist::getStarred(session, user.get(), {}, std::nullopt, Artist::SortMethod::BySortName, std::nullopt, hasMore)}; - CHECK(artists.size() == 1); - CHECK(artists.front().id() == artist.getId()); - CHECK(hasMore == false); + ASSERT_EQ(artists.size(), 1); + EXPECT_EQ(artists.front().id(), artist.getId()); + EXPECT_FALSE(hasMore); } } -static -void -testSingleStarredRelease(Session& session) +TEST_F(DatabaseFixture, SingleStarredRelease) { ScopedRelease release {session, "MyRelease"}; ScopedTrack track {session, "MyTrack"}; ScopedUser user {session, "MyUser"}; + { + auto transaction {session.createSharedTransaction()}; + + EXPECT_FALSE(user->hasStarredRelease(release.get())); + } + { auto transaction {session.createUniqueTransaction()}; @@ -1358,23 +436,27 @@ testSingleStarredRelease(Session& session) { auto transaction {session.createSharedTransaction()}; - CHECK(user->hasStarredRelease(release.get())); + EXPECT_TRUE(user->hasStarredRelease(release.get())); bool hasMore {}; auto releases {Release::getStarred(session, user.get(), {}, std::nullopt, hasMore)}; - CHECK(releases.size() == 1); - CHECK(releases.front().id() == release.getId()); - CHECK(hasMore == false); + ASSERT_EQ(releases.size(), 1); + EXPECT_EQ(releases.front().id(), release.getId()); + EXPECT_FALSE(hasMore); } } -static -void -testSingleStarredTrack(Session& session) +TEST_F(DatabaseFixture, SingleStarredTrack) { ScopedTrack track {session, "MyTrack"}; ScopedUser user {session, "MyUser"}; + { + auto transaction {session.createUniqueTransaction()}; + + EXPECT_FALSE(user->hasStarredTrack(track.get())); + } + { auto transaction {session.createUniqueTransaction()}; @@ -1384,19 +466,17 @@ testSingleStarredTrack(Session& session) { auto transaction {session.createUniqueTransaction()}; - CHECK(user->hasStarredTrack(track.get())); + EXPECT_TRUE(user->hasStarredTrack(track.get())); bool hasMore {}; auto tracks {Track::getStarred(session, user.get(), {}, std::nullopt, hasMore)}; - CHECK(tracks.size() == 1); - CHECK(tracks.front().id() == track.getId()); - CHECK(hasMore == false); + ASSERT_EQ(tracks.size(), 1); + EXPECT_EQ(tracks.front().id(), track.getId()); + EXPECT_FALSE(hasMore); } } -static -void -testSingleTrackList(Session& session) +TEST_F(DatabaseFixture, SingleTrackList) { ScopedUser user {session, "MyUser"}; ScopedTrackList trackList {session, "MytrackList", TrackList::Type::Playlist, false, user.lockAndGet()}; @@ -1405,15 +485,12 @@ testSingleTrackList(Session& session) auto transaction {session.createSharedTransaction()}; auto trackLists {TrackList::getAll(session, user.get(), TrackList::Type::Playlist)}; - CHECK(trackLists.size() == 1); - CHECK(trackLists.front().id() == trackList.getId()); + ASSERT_EQ(trackLists.size(), 1); + EXPECT_EQ(trackLists.front().id(), trackList.getId()); } - } -static -void -testSingleTrackListMultipleTrack(Session& session) +TEST_F(DatabaseFixture, SingleTrackListMultipleTrack) { ScopedUser user {session, "MyUser"}; ScopedTrackList trackList {session, "MytrackList", TrackList::Type::Playlist, false, user.lockAndGet()}; @@ -1430,15 +507,14 @@ testSingleTrackListMultipleTrack(Session& session) { auto transaction {session.createSharedTransaction()}; - CHECK(trackList->getCount() == tracks.size()); + ASSERT_EQ(trackList->getCount(), tracks.size()); const auto trackIds {trackList->getTrackIds()}; for (auto trackId : trackIds) - CHECK(std::any_of(std::cbegin(tracks), std::cend(tracks), [trackId](const ScopedTrack& track) { return track.getId() == trackId; })); + EXPECT_TRUE(std::any_of(std::cbegin(tracks), std::cend(tracks), [trackId](const ScopedTrack& track) { return track.getId() == trackId; })); } } -void -testSingleTrackListMultipleTrackDateTime(Session& session) +TEST_F(DatabaseFixture, SingleTrackListMultipleTrackDateTime) { ScopedUser user {session, "MyUser"}; ScopedTrackList trackList {session, "MytrackList", TrackList::Type::Playlist, false, user.lockAndGet()}; @@ -1459,108 +535,16 @@ testSingleTrackListMultipleTrackDateTime(Session& session) 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()); + ASSERT_EQ(tracks.size(), 3); + EXPECT_EQ(tracks.front().id(), track3.getId()); + EXPECT_EQ(tracks.back().id(), track2.getId()); } } -static -void -testSingleTrackListMultipleTrackSingleCluster(Session& session) +TEST_F(DatabaseFixture, SingleTrackListMultipleTrackRecentlyPlayed) { ScopedUser user {session, "MyUser"}; ScopedTrackList trackList {session, "MyTrackList", TrackList::Type::Playlist, false, user.lockAndGet()}; - ScopedClusterType clusterType {session, "MyClusterType"}; - ScopedCluster cluster {session, clusterType.lockAndGet(), "MyCluster"}; - std::list tracks; - - for (std::size_t i {}; i < 20; ++i) - { - tracks.emplace_back(session, "MyTrack" + std::to_string(i)); - - auto transaction {session.createUniqueTransaction()}; - - if (i < 5) - TrackListEntry::create(session, tracks.back().get(), trackList.get()); - - if (i < 10) - cluster.get().modify()->addTrack(tracks.back().get()); - } - - { - auto transaction {session.createSharedTransaction()}; - - const auto similarTracks {trackList->getSimilarTracks()}; - CHECK(similarTracks.size() == 5); - - for (auto similarTrack : similarTracks) - CHECK(std::any_of(std::next(std::cbegin(tracks), 5), std::cend(tracks), [similarTrack](const ScopedTrack& track) { return track.getId() == similarTrack.id(); })); - } -} - -static -void -testSingleTrackListMultipleTrackMultiClusters(Session& session) -{ - ScopedUser user {session, "MyUser"}; - ScopedTrackList trackList {session, "MyTrackList", TrackList::Type::Playlist, false, user.lockAndGet()}; - ScopedClusterType clusterType {session, "MyClusterType"}; - ScopedCluster cluster1 {session, clusterType.lockAndGet(), "MyCluster1"}; - ScopedCluster cluster2 {session, clusterType.lockAndGet(), "MyCluster2"}; - std::list tracks; - - for (std::size_t i {}; i < 20; ++i) - { - tracks.emplace_back(session, "MyTrack" + std::to_string(i)); - - auto transaction {session.createUniqueTransaction()}; - - if (i < 5) - TrackListEntry::create(session, tracks.back().get(), trackList.get()); - - if (i < 10) - { - cluster1.get().modify()->addTrack(tracks.back().get()); - cluster2.get().modify()->addTrack(tracks.back().get()); - } - else if (i < 15) - { - cluster1.get().modify()->addTrack(tracks.back().get()); - } - } - - { - auto transaction {session.createSharedTransaction()}; - - { - const auto similarTracks {trackList->getSimilarTracks(0, 5)}; - CHECK(similarTracks.size() == 5); - - for (auto similarTrack : similarTracks) - CHECK(std::any_of(std::next(std::cbegin(tracks), 5), std::next(std::cbegin(tracks), 10), [similarTrack](const ScopedTrack& track) { return track.getId() == similarTrack.id(); })); - } - - { - const auto similarTracks {trackList->getSimilarTracks(5, 10)}; - CHECK(similarTracks.size() == 5); - - for (auto similarTrack : similarTracks) - CHECK(std::any_of(std::next(std::cbegin(tracks), 10), std::next(std::cbegin(tracks), 15), [similarTrack](const ScopedTrack& track) { return track.getId() == similarTrack.id(); })); - } - - CHECK(trackList->getSimilarTracks(10, 10).empty()); - - } -} - -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"}; @@ -1583,9 +567,9 @@ testSingleTrackListMultipleTrackRecentlyPlayed(Session& session) 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()); + EXPECT_TRUE(trackList->getArtistsReverse({}, std::nullopt, std::nullopt, moreResults).empty()); + EXPECT_TRUE(trackList->getReleasesReverse({}, std::nullopt, moreResults).empty()); + EXPECT_TRUE(trackList->getTracksReverse({}, std::nullopt, moreResults).empty()); } { @@ -1599,15 +583,15 @@ testSingleTrackListMultipleTrackRecentlyPlayed(Session& session) bool moreResults {}; const auto artists {trackList->getArtistsReverse({}, std::nullopt, std::nullopt, moreResults)}; - CHECK(artists.size() == 1); - CHECK(artists.front().id() == artist1.getId()); + ASSERT_EQ(artists.size(), 1); + EXPECT_EQ(artists.front().id(), artist1.getId()); const auto releases {trackList->getReleasesReverse({}, std::nullopt, moreResults)}; - CHECK(releases.size() == 1); - CHECK(releases.front().id() == release1.getId()); + ASSERT_EQ(releases.size(), 1); + EXPECT_EQ(releases.front().id(), release1.getId()); const auto tracks {trackList->getTracksReverse({}, std::nullopt, moreResults)}; - CHECK(tracks.size() == 1); + EXPECT_EQ(tracks.size(), 1); } { @@ -1621,19 +605,19 @@ testSingleTrackListMultipleTrackRecentlyPlayed(Session& session) 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()); + ASSERT_EQ(artists.size(), 2); + EXPECT_EQ(artists[0].id(), artist2.getId()); + EXPECT_EQ(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()); + ASSERT_EQ(releases.size(), 2); + EXPECT_EQ(releases[0].id(), release2.getId()); + EXPECT_EQ(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()); + ASSERT_EQ(tracks.size(), 2); + EXPECT_EQ(tracks[0].id(), track2.getId()); + EXPECT_EQ(tracks[1].id(), track1.getId()); } { @@ -1647,425 +631,23 @@ testSingleTrackListMultipleTrackRecentlyPlayed(Session& session) 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()); + ASSERT_EQ(artists.size(), 2); + EXPECT_EQ(artists[0].id(), artist1.getId()); + EXPECT_EQ(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()); + ASSERT_EQ(releases.size(), 2); + EXPECT_EQ(releases[0].id(), release1.getId()); + EXPECT_EQ(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()); + ASSERT_EQ(tracks.size(), 2); + EXPECT_EQ(tracks[0].id(), track1.getId()); + EXPECT_EQ(tracks[1].id(), track2.getId()); } } -static -void -testSingleTrackListMultipleTrackMultiClustersRecentlyPlayed(Session& session) -{ - ScopedUser user {session, "MyUser"}; - ScopedTrackList trackList {session, "MyTrackList", TrackList::Type::Playlist, false, user.lockAndGet()}; - ScopedClusterType clusterType {session, "MyClusterType"}; - ScopedCluster cluster1 {session, clusterType.lockAndGet(), "MyCluster1"}; - ScopedCluster cluster2 {session, clusterType.lockAndGet(), "MyCluster2"}; - ScopedCluster cluster3 {session, clusterType.lockAndGet(), "MyCluster3"}; - 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); - - cluster1.get().modify()->addTrack(track1.get()); - cluster2.get().modify()->addTrack(track2.get()); - cluster3.get().modify()->addTrack(track1.get()); - cluster3.get().modify()->addTrack(track2.get()); - } - - { - 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); - CHECK(tracks.front().id() == track1.getId()); - } - - { - auto transaction {session.createSharedTransaction()}; - - bool moreResults {}; - const auto artists {trackList->getArtistsReverse({cluster1.getId()}, std::nullopt, std::nullopt, moreResults)}; - CHECK(artists.size() == 1); - CHECK(artists.front().id() == artist1.getId()); - - const auto releases {trackList->getReleasesReverse({cluster1.getId()}, std::nullopt, moreResults)}; - CHECK(releases.size() == 1); - CHECK(releases.front().id() == release1.getId()); - - const auto tracks {trackList->getTracksReverse({cluster1.getId()}, std::nullopt, moreResults)}; - CHECK(tracks.size() == 1); - CHECK(tracks.front().id() == track1.getId()); - } - - { - auto transaction {session.createSharedTransaction()}; - - bool moreResults {}; - const auto artists {trackList->getArtistsReverse({cluster3.getId()}, std::nullopt, std::nullopt, moreResults)}; - CHECK(artists.size() == 1); - CHECK(artists.front().id() == artist1.getId()); - - const auto releases {trackList->getReleasesReverse({cluster3.getId()}, std::nullopt, moreResults)}; - CHECK(releases.size() == 1); - CHECK(releases.front().id() == release1.getId()); - - const auto tracks {trackList->getTracksReverse({cluster3.getId()}, std::nullopt, moreResults)}; - CHECK(tracks.size() == 1); - CHECK(tracks.front().id() == track1.getId()); - } - - { - auto transaction {session.createSharedTransaction()}; - - bool moreResults {}; - const auto artists {trackList->getArtistsReverse({cluster1.getId()}, TrackArtistLinkType::Artist, std::nullopt, moreResults)}; - CHECK(artists.size() == 1); - CHECK(artists.front().id() == artist1.getId()); - } - - { - auto transaction {session.createSharedTransaction()}; - - bool moreResults {}; - const auto artists {trackList->getArtistsReverse({}, TrackArtistLinkType::Artist, std::nullopt, moreResults)}; - CHECK(artists.size() == 1); - CHECK(artists.front().id() == artist1.getId()); - } - - { - auto transaction {session.createSharedTransaction()}; - - bool moreResults {}; - CHECK(trackList->getArtistsReverse({cluster2.getId()}, std::nullopt, std::nullopt, moreResults).empty()); - CHECK(trackList->getReleasesReverse({cluster2.getId()}, std::nullopt, moreResults).empty()); - CHECK(trackList->getTracksReverse({cluster2.getId()}, std::nullopt, moreResults).empty()); - - CHECK(trackList->getArtistsReverse({}, TrackArtistLinkType::ReleaseArtist, std::nullopt, moreResults).empty()); - } - - { - 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.createSharedTransaction()}; - - bool moreResults {}; - const auto artists {trackList->getArtistsReverse({cluster3.getId()}, 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({cluster3.getId()}, std::nullopt, moreResults)}; - CHECK(releases.size() == 2); - CHECK(releases[0].id() == release2.getId()); - CHECK(releases[1].id() == release1.getId()); - - const auto tracks {trackList->getTracksReverse({cluster3.getId()}, std::nullopt, moreResults)}; - CHECK(tracks.size() == 2); - CHECK(tracks[0].id() == track2.getId()); - CHECK(tracks[1].id() == track1.getId()); - } - - { - auto transaction {session.createSharedTransaction()}; - - bool moreResults {}; - const auto artists {trackList->getArtistsReverse({cluster1.getId()}, std::nullopt, std::nullopt, moreResults)}; - CHECK(artists.size() == 1); - CHECK(artists.front().id() == artist1.getId()); - - const auto releases {trackList->getReleasesReverse({cluster1.getId()}, std::nullopt, moreResults)}; - CHECK(releases.size() == 1); - CHECK(releases.front().id() == release1.getId()); - - const auto tracks {trackList->getTracksReverse({cluster1.getId()}, std::nullopt, moreResults)}; - CHECK(tracks.size() == 1); - CHECK(tracks.front().id() == track1.getId()); - } - - { - auto transaction {session.createSharedTransaction()}; - - bool moreResults {}; - const auto artists {trackList->getArtistsReverse({cluster2.getId()}, std::nullopt, std::nullopt, moreResults)}; - CHECK(artists.size() == 1); - CHECK(artists.front().id() == artist2.getId()); - - const auto releases {trackList->getReleasesReverse({cluster2.getId()}, std::nullopt, moreResults)}; - CHECK(releases.size() == 1); - CHECK(releases.front().id() == release2.getId()); - - const auto tracks {trackList->getTracksReverse({cluster2.getId()}, std::nullopt, moreResults)}; - CHECK(tracks.size() == 1); - CHECK(tracks.front().id() == track2.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()); - } - - { - auto transaction {session.createSharedTransaction()}; - - bool moreResults {}; - const auto artists {trackList->getArtistsReverse({cluster3.getId()}, 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({cluster3.getId()}, std::nullopt, moreResults)}; - CHECK(releases.size() == 2); - CHECK(releases[0].id() == release1.getId()); - CHECK(releases[1].id() == release2.getId()); - - const auto tracks {trackList->getTracksReverse({cluster3.getId()}, std::nullopt, moreResults)}; - CHECK(tracks.size() == 2); - CHECK(tracks[0].id() == track1.getId()); - CHECK(tracks[1].id() == track2.getId()); - } -} - - -static -void -testMultipleTracksMultipleArtistsMultiClusters(Session& session) -{ - ScopedArtist artist1 {session, "MyArtist1"}; - ScopedArtist artist2 {session, "MyArtist2"}; - ScopedArtist artist3 {session, "MyArtist3"}; - ScopedClusterType clusterType {session, "MyClusterType"}; - ScopedCluster cluster1 {session, clusterType.lockAndGet(), "MyCluster1"}; - ScopedCluster cluster2 {session, clusterType.lockAndGet(), "MyCluster2"}; - - { - auto transaction {session.createSharedTransaction()}; - CHECK(artist1->getSimilarArtists().empty()); - CHECK(artist2->getSimilarArtists().empty()); - CHECK(artist3->getSimilarArtists().empty()); - } - - std::list tracks; - for (std::size_t i {}; i < 10; ++i) - { - tracks.emplace_back(session, "MyTrack" + std::to_string(i)); - - auto transaction {session.createUniqueTransaction()}; - - if (i < 5) - TrackArtistLink::create(session, tracks.back().get(), artist1.get(), TrackArtistLinkType::Artist); - else - { - TrackArtistLink::create(session, tracks.back().get(), artist2.get(), TrackArtistLinkType::Artist); - cluster2.get().modify()->addTrack(tracks.back().get()); - } - - cluster1.get().modify()->addTrack(tracks.back().get()); - } - - tracks.emplace_back(session, "MyTrack" + std::to_string(tracks.size())); - { - auto transaction {session.createUniqueTransaction()}; - TrackArtistLink::create(session, tracks.back().get(), artist3.get(), TrackArtistLinkType::Artist); - cluster2.get().modify()->addTrack(tracks.back().get()); - } - - { - auto transaction {session.createSharedTransaction()}; - - { - auto artists {artist1->getSimilarArtists()}; - CHECK(artists.size() == 1); - CHECK(artists.front().id() == artist2.getId()); - } - - { - auto artists {artist1->getSimilarArtists({TrackArtistLinkType::Artist})}; - CHECK(artists.size() == 1); - CHECK(artists.front().id() == artist2.getId()); - } - - { - auto artists {artist1->getSimilarArtists({TrackArtistLinkType::ReleaseArtist})}; - CHECK(artists.empty() == 1); - } - - { - auto artists {artist1->getSimilarArtists({TrackArtistLinkType::Artist, TrackArtistLinkType::ReleaseArtist})}; - CHECK(artists.size() == 1); - CHECK(artists.front().id() == artist2.getId()); - } - - { - auto artists {artist1->getSimilarArtists({TrackArtistLinkType::Composer})}; - CHECK(artists.empty()); - } - - { - auto artists {artist2->getSimilarArtists()}; - CHECK(artists.size() == 2); - CHECK(artists[0].id() == artist1.getId()); - CHECK(artists[1].id() == artist3.getId()); - } - } -} - -static -void -testMultipleTracksMultipleReleasesMultiClusters(Session& session) -{ - ScopedRelease release1 {session, "MyRelease1"}; - ScopedRelease release2 {session, "MyRelease2"}; - ScopedRelease release3 {session, "MyRelease3"}; - ScopedClusterType clusterType {session, "MyClusterType"}; - ScopedCluster cluster1 {session, clusterType.lockAndGet(), "MyCluster1"}; - ScopedCluster cluster2 {session, clusterType.lockAndGet(), "MyCluster2"}; - - { - auto transaction {session.createSharedTransaction()}; - CHECK(release1->getSimilarReleases().empty()); - CHECK(release2->getSimilarReleases().empty()); - CHECK(release3->getSimilarReleases().empty()); - } - - std::list tracks; - for (std::size_t i {}; i < 10; ++i) - { - tracks.emplace_back(session, "MyTrack" + std::to_string(i)); - - auto transaction {session.createUniqueTransaction()}; - - if (i < 5) - tracks.back().get().modify()->setRelease(release1.get()); - else - { - tracks.back().get().modify()->setRelease(release2.get()); - cluster2.get().modify()->addTrack(tracks.back().get()); - } - - cluster1.get().modify()->addTrack(tracks.back().get()); - } - - tracks.emplace_back(session, "MyTrack" + std::to_string(tracks.size())); - { - auto transaction {session.createUniqueTransaction()}; - tracks.back().get().modify()->setRelease(release3.get()); - cluster2.get().modify()->addTrack(tracks.back().get()); - } - - { - auto transaction {session.createSharedTransaction()}; - - { - auto releases {release1->getSimilarReleases()}; - CHECK(releases.size() == 1); - CHECK(releases.front().id() == release2.getId()); - } - - { - auto releases {release2->getSimilarReleases()}; - CHECK(releases.size() == 2); - CHECK(releases[0].id() == release1.getId()); - CHECK(releases[1].id() == release3.getId()); - } - } -} - -static -void -testSingleTrackSingleUserSingleBookmark(Session& session) +TEST_F(DatabaseFixture, SingleTrackSingleUserSingleBookmark) { ScopedTrack track {session, "MyTrack"}; ScopedUser user {session, "MyUser"}; @@ -2081,127 +663,26 @@ testSingleTrackSingleUserSingleBookmark(Session& session) { auto transaction {session.createSharedTransaction()}; - CHECK(TrackBookmark::getAll(session).size() == 1); + EXPECT_EQ(TrackBookmark::getAll(session).size(), 1); const auto bookmarks {TrackBookmark::getByUser(session, user.get())}; - CHECK(bookmarks.size() == 1); - CHECK(bookmarks.back() == bookmark.get()); + ASSERT_EQ(bookmarks.size(), 1); + EXPECT_EQ(bookmarks.back(), bookmark.get()); } { auto transaction {session.createSharedTransaction()}; auto userBookmark {TrackBookmark::getByUser(session, user.get(), track.get())}; - CHECK(userBookmark); - CHECK(userBookmark == bookmark.get()); + ASSERT_TRUE(userBookmark); + EXPECT_EQ(userBookmark, bookmark.get()); - CHECK(userBookmark->getOffset() == std::chrono::milliseconds {5}); - CHECK(userBookmark->getComment() == "MyComment"); + EXPECT_EQ(userBookmark->getOffset(), std::chrono::milliseconds {5}); + EXPECT_EQ(userBookmark->getComment(), "MyComment"); } } -static -void -testDatabaseEmpty(Session& session) +int main(int argc, char **argv) { - auto uniqueTransaction {session.createUniqueTransaction()}; - - CHECK(Artist::getAll(session, Artist::SortMethod::ByName).empty()); - CHECK(Cluster::getAll(session).empty()); - CHECK(ClusterType::getAll(session).empty()); - CHECK(Release::getAll(session).empty()); - CHECK(Track::getAll(session).empty()); - CHECK(TrackBookmark::getAll(session).empty()); - CHECK(TrackList::getAll(session).empty()); - CHECK(User::getAll(session).empty()); -} - -int main() -{ - try - { - // log to stdout - Service logger {std::make_unique(std::cout)}; - - const std::filesystem::path tmpFile {std::tmpnam(nullptr)}; - ScopedFileDeleter tmpFileDeleter {tmpFile}; - - std::cout << "Database test file: '" << tmpFile.string() << "'" << std::endl; - - Database::Db db {tmpFile}; - Database::Session session {db}; - session.prepareTables(); - - auto runTest = [&session](const std::string& name, std::function testFunc) - { - std::cout << "Running test '" << name << "'..." << std::endl; - testFunc(session); - testDatabaseEmpty(session); - std::cout << "Running test '" << name << "': SUCCESS" << std::endl; - }; - -#define RUN_TEST(test) runTest(#test, test) - - // Special test to remove any default created entries - RUN_TEST(testRemoveDefaultEntries); - - RUN_TEST(testSingleTrack); - RUN_TEST(testSingleArtist); - RUN_TEST(testSingleRelease); - RUN_TEST(testSingleCluster); - - RUN_TEST(testSingleTrackSingleArtist); - RUN_TEST(testSingleTrackSingleArtistMultiRoles); - RUN_TEST(testSingleTrackMultiArtists); - - RUN_TEST(testSingleArtistSearchByName); - RUN_TEST(testMultiArtistsSortMethod); - - RUN_TEST(testSingleTrackSingleRelease); - RUN_TEST(testMultiTracksSingleReleaseTotalDiscTrack); - RUN_TEST(testMultiTracksSingleReleaseFirstTrack); - - RUN_TEST(testSingleTrackSingleCluster); - RUN_TEST(testMultipleTracksSingleCluster); - - RUN_TEST(testMultipleTracksMultipleClustersTopRelease); - - RUN_TEST(testMultipleTracksSingleClusterSimilarity); - RUN_TEST(testMultipleTracksMultipleClustersSimilarity); - - RUN_TEST(testSingleTrackSingleReleaseSingleCluster); - RUN_TEST(testSingleTrackSingleArtistMultiClusters); - RUN_TEST(testSingleTrackSingleArtistMultiRolesMultiClusters); - RUN_TEST(testMultiTracksSingleArtistMultiClusters); - RUN_TEST(testMultiTracksSingleArtistSingleRelease); - - RUN_TEST(testSingleTrackSingleReleaseSingleArtist); - - RUN_TEST(testSingleTrackSingleReleaseSingleArtistSingleCluster); - RUN_TEST(testSingleTrackSingleReleaseSingleArtistMultiClusters); - - RUN_TEST(testSingleUser); - - RUN_TEST(testSingleStarredArtist); - RUN_TEST(testSingleStarredRelease); - RUN_TEST(testSingleStarredTrack); - - 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); - - RUN_TEST(testSingleTrackSingleUserSingleBookmark); - } - catch (std::exception& e) - { - std::cerr << "Caught exception: " << e.what() << std::endl; - return EXIT_FAILURE; - } - - return EXIT_SUCCESS; + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); } diff --git a/src/test/database/Track.cpp b/src/test/database/Track.cpp new file mode 100644 index 00000000..1223fc9c --- /dev/null +++ b/src/test/database/Track.cpp @@ -0,0 +1,90 @@ +/* + * 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 "Common.hpp" + +#include + +using namespace Database; + +TEST_F(DatabaseFixture, SingleTrack) +{ + { + auto transaction {session.createSharedTransaction()}; + EXPECT_EQ(Track::getCount(session), 0); + } + + ScopedTrack track {session, "MyTrackFile"}; + + { + auto transaction {session.createSharedTransaction()}; + + EXPECT_EQ(Track::getAll(session).size(), 1); + EXPECT_EQ(Track::getCount(session), 1); + } +} + +TEST_F(DatabaseFixture, MultipleTracksSearchByFilter) +{ + + ScopedTrack track1 {session, ""}; + ScopedTrack track2 {session, ""}; + ScopedTrack track3 {session, ""}; + ScopedTrack track4 {session, ""}; + ScopedTrack track5 {session, ""}; + ScopedTrack track6 {session, ""}; + + { + auto transaction {session.createUniqueTransaction()}; + track1.get().modify()->setName("MyTrack"); + track2.get().modify()->setName("MyTrack%"); + track3.get().modify()->setName("MyTrack%Foo"); + track4.get().modify()->setName("%MyTrack"); + track5.get().modify()->setName("Foo%MyTrack"); + track6.get().modify()->setName("M_Track"); + } + + { + auto transaction {session.createSharedTransaction()}; + + bool more; + { + const auto tracks {Track::getByFilter(session, {}, {"Track"}, std::nullopt, more)}; + EXPECT_EQ(tracks.size(), 6); + } + { + const auto tracks {Track::getByFilter(session, {}, {"MyTrack"}, std::nullopt, more)}; + EXPECT_EQ(tracks.size(), 5); + EXPECT_TRUE(std::none_of(std::cbegin(tracks), std::cend(tracks), [&](const Track::pointer& track) { return track.id() == track6.getId(); })); + } + { + const auto tracks {Track::getByFilter(session, {}, {"MyTrack%"}, std::nullopt, more)}; + ASSERT_EQ(tracks.size(), 2); + EXPECT_EQ(tracks[0].id(), track2.getId()); + EXPECT_EQ(tracks[1].id(), track3.getId()); + } + { + const auto tracks {Track::getByFilter(session, {}, {"%MyTrack"}, std::nullopt, more)}; + ASSERT_EQ(tracks.size(), 2); + EXPECT_EQ(tracks[0].id(), track4.getId()); + EXPECT_EQ(tracks[1].id(), track5.getId()); + } + } +} + diff --git a/src/test/som/CMakeLists.txt b/src/test/som/CMakeLists.txt index aaaf43d4..4c409913 100644 --- a/src/test/som/CMakeLists.txt +++ b/src/test/som/CMakeLists.txt @@ -1,3 +1,4 @@ +include(GoogleTest) add_executable(test-som SomTest.cpp @@ -5,7 +6,8 @@ add_executable(test-som target_link_libraries(test-som PRIVATE lmssom + GTest::GTest ) -add_test(NAME som COMMAND test-som) +gtest_discover_tests(test-som) diff --git a/src/test/som/SomTest.cpp b/src/test/som/SomTest.cpp index bfbf05a5..c2ef65db 100644 --- a/src/test/som/SomTest.cpp +++ b/src/test/som/SomTest.cpp @@ -17,25 +17,40 @@ * along with LMS. If not, see . */ -#include -#include -#include +#include #include "som/DataNormalizer.hpp" #include "som/Network.hpp" using namespace SOM; -int main() +static constexpr InputVector::value_type EPSILON = 0.01; + +TEST(som, Matrix) { - static const InputVector::value_type EPSILON = 0.01; { Matrix testMatrix {2, 2, 123}; - assert((testMatrix[{0,0}] == 123)); - assert((testMatrix[{0,1}] == 123)); - assert((testMatrix[{1,0}] == 123)); - assert((testMatrix[{1,1}] == 123)); + { + const Position pos {0, 0}; + EXPECT_EQ(testMatrix[pos], 123); + } + { + const Position pos {0, 1}; + EXPECT_EQ(testMatrix[pos], 123); + } + { + const Position pos {1, 0}; + EXPECT_EQ(testMatrix[pos], 123); + } + { + const Position pos {1, 1}; + EXPECT_EQ(testMatrix[pos], 123); + } } +} + +TEST(som, InputVector) +{ { InputVector test1 {2}; @@ -48,69 +63,72 @@ int main() InputVector test3 {test1}; test3 += test2; - assert(std::abs(test3[0] - 1) < EPSILON); - assert(std::abs(test3[1] - 1) < EPSILON); + EXPECT_LT(std::abs(test3[0] - 1), EPSILON); + EXPECT_LT(std::abs(test3[1] - 1), EPSILON); + } +} + +TEST(som, Network) +{ + Network network {2, 2, 1}; + + const InputVector weights {1, 1}; + std::vector trainData + { + { 1, 50 }, + { 1, 100 }, + { 1, 150 }, + { 1, 200 }, + }; + + DataNormalizer normalizer {1}; + normalizer.computeNormalizationFactors(trainData); + for (auto& data: trainData) + normalizer.normalizeData(data); + + network.dump(std::cout); + network.train(trainData, 20); + network.dump(std::cout); + + auto distFunc {network.getDistanceFunc()}; + + EXPECT_LT((std::abs(distFunc({1, 0}, {1, 1}, weights) - 1)), EPSILON); + EXPECT_LT((std::abs(distFunc({1, 0}, {1, 2}, weights) - 4)), EPSILON); + EXPECT_LT(std::abs(distFunc({1, 0}, {1, 0.33}, weights) - distFunc({1, 0.66}, {1, 1.}, weights)), EPSILON); + + { + std::unordered_set positions; + for (const InputVector& data : trainData) + positions.insert(network.getClosestRefVectorPosition(data)); + + EXPECT_EQ(positions.size(), 4); } { - Network network {2, 2, 1}; - - const InputVector weights {1, 1}; - std::vector trainData { - { 1, 50 }, - { 1, 100 }, - { 1, 150 }, - { 1, 200 }, - }; - - DataNormalizer normalizer {1}; - normalizer.computeNormalizationFactors(trainData); - for (auto& data: trainData) - normalizer.normalizeData(data); - - network.dump(std::cout); - network.train(trainData, 20); - network.dump(std::cout); - - std::cout << "MEAN dist = " << network.computeRefVectorsDistanceMean() << std::endl; - std::cout << "MEDIAN dist = " << network.computeRefVectorsDistanceMedian() << std::endl; - - auto distFunc {network.getDistanceFunc()}; - - assert((std::abs(distFunc({1, 0}, {1, 1}, weights) - 1) < EPSILON)); - assert((std::abs(distFunc({1, 0}, {1, 2}, weights) - 4) < EPSILON)); - assert((std::abs(distFunc({1, 0}, {1, 0.33}, weights) - distFunc({1, 0.66}, {1, 1.}, weights)) < EPSILON)); - + Position pos {network.getClosestRefVectorPosition(InputVector{1, 0.66})}; + for (std::size_t i {}; i < 40; ++i) { - std::unordered_set positions; - for (const InputVector& data : trainData) - positions.insert(network.getClosestRefVectorPosition(data)); - assert(positions.size() == 4); + InputVector input {1, 130 + static_cast(i) }; + normalizer.normalizeData(input); + + EXPECT_EQ(network.getClosestRefVectorPosition(input), pos); } - - { - Position pos {network.getClosestRefVectorPosition(InputVector{1, 0.66})}; - for (std::size_t i {}; i < 40; ++i) - { - InputVector input {1, 130 + static_cast(i) }; - normalizer.normalizeData(input); - - assert( network.getClosestRefVectorPosition(input) == pos); - } - } - - { - Position pos {network.getClosestRefVectorPosition(InputVector{1, 1})}; - for (std::size_t i {}; i < 40; ++i) - { - InputVector input {1, 180 + static_cast(i) }; - normalizer.normalizeData(input); - - assert( network.getClosestRefVectorPosition(input) == pos); - } - } - } - return 0; + { + Position pos {network.getClosestRefVectorPosition(InputVector{1, 1})}; + for (std::size_t i {}; i < 40; ++i) + { + InputVector input {1, 180 + static_cast(i) }; + normalizer.normalizeData(input); + + EXPECT_EQ(network.getClosestRefVectorPosition(input), pos); + } + } +} + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); } diff --git a/src/test/utils/CMakeLists.txt b/src/test/utils/CMakeLists.txt index 806f856f..1b355f9b 100644 --- a/src/test/utils/CMakeLists.txt +++ b/src/test/utils/CMakeLists.txt @@ -1,12 +1,16 @@ +include(GoogleTest) add_executable(test-utils - UtilsTest.cpp + String.cpp + RecursiveSharedMutex.cpp + Utils.cpp ) target_link_libraries(test-utils PRIVATE lmsutils Threads::Threads + GTest::GTest ) -add_test(NAME utils COMMAND test-utils) +gtest_discover_tests(test-utils) diff --git a/src/test/utils/UtilsTest.cpp b/src/test/utils/RecursiveSharedMutex.cpp similarity index 50% rename from src/test/utils/UtilsTest.cpp rename to src/test/utils/RecursiveSharedMutex.cpp index 0b7f2ea8..0728b91f 100644 --- a/src/test/utils/UtilsTest.cpp +++ b/src/test/utils/RecursiveSharedMutex.cpp @@ -18,85 +18,15 @@ */ #include -#include #include -#include #include #include -#include + +#include #include "utils/RecursiveSharedMutex.hpp" -#include "utils/String.hpp" - -void -testStrings() -{ - { - const std::string test{"a"}; - - const std::vector strings {StringUtils::splitString(test, "")}; - assert(strings.size() == 1); - assert(strings.front() == "a"); - } - - { - const std::string test{"a b"}; - - const std::vector strings {StringUtils::splitString(test, "|")}; - assert(strings.size() == 1); - assert(strings.front() == "a b"); - } - - { - const std::string test{" a"}; - - const std::vector strings {StringUtils::splitString(test, " ")}; - assert(strings.size() == 1); - assert(strings.front() == "a"); - } - - { - const std::string test{"a "}; - - const std::vector strings {StringUtils::splitString(test, " ")}; - assert(strings.size() == 1); - assert(strings.front() == "a"); - } - - { - const std::string test{"a b"}; - - const std::vector strings {StringUtils::splitString(test, " ")}; - assert(strings.size() == 2); - assert(strings.front() == "a"); - assert(strings.back() == "b"); - } - - { - const std::string test{"a b,c|defgh "}; - - const std::vector strings {StringUtils::splitString(test, " ,|")}; - assert(strings.size() == 4); - assert(strings[0] == "a"); - assert(strings[1] == "b"); - assert(strings[2] == "c"); - assert(strings[3] == "defgh"); - } - - { - assert(StringUtils::escapeString("", "*", ' ') == ""); - assert(StringUtils::escapeString("", "", ' ') == ""); - assert(StringUtils::escapeString("a", "", ' ') == "a"); - assert(StringUtils::escapeString("*", "*", '_') == "_*"); - assert(StringUtils::escapeString("*a*", "*", '_') == "_*a_*"); - assert(StringUtils::escapeString("*a|", "*|", '_') == "_*a_|"); - assert(StringUtils::escapeString("**||", "*|", '_') == "_*_*_|_|"); - } -} - -void -testSharedMutex() +TEST(RecursiveSharedMutex, SingleThreaded) { { RecursiveSharedMutex mutex; @@ -124,7 +54,10 @@ testSharedMutex() std::shared_lock lock2 {mutex}; } } +} +TEST(RecursiveSharedMutex, MultiThreaded) +{ { constexpr std::size_t nbThreads {10}; std::vector threads; @@ -171,20 +104,3 @@ testSharedMutex() t.join(); } } - - -int main() -{ - try - { - testStrings(); - testSharedMutex(); - } - catch (std::exception& e) - { - std::cerr << "Caught exception: " << e.what(); - return EXIT_FAILURE; - } - - return EXIT_SUCCESS; -} diff --git a/src/test/utils/String.cpp b/src/test/utils/String.cpp new file mode 100644 index 00000000..9ece8f13 --- /dev/null +++ b/src/test/utils/String.cpp @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2019 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include + +#include "utils/String.hpp" + +TEST(StringUtils, splitString) +{ + { + const std::string test{"a"}; + + const std::vector strings {StringUtils::splitString(test, "")}; + ASSERT_EQ(strings.size(), 1); + EXPECT_EQ(strings.front() , "a"); + } + + { + const std::string test{"a b"}; + + const std::vector strings {StringUtils::splitString(test, "|")}; + ASSERT_EQ(strings.size(), 1); + EXPECT_EQ(strings.front(), "a b"); + } + + { + const std::string test{" a"}; + + const std::vector strings {StringUtils::splitString(test, " ")}; + ASSERT_EQ(strings.size(), 1); + EXPECT_EQ(strings.front(), "a"); + } + + { + const std::string test{"a "}; + + const std::vector strings {StringUtils::splitString(test, " ")}; + ASSERT_EQ(strings.size(), 1); + EXPECT_EQ(strings.front(), "a"); + } + + { + const std::string test{"a b"}; + + const std::vector strings {StringUtils::splitString(test, " ")}; + ASSERT_EQ(strings.size(), 2); + EXPECT_EQ(strings.front(), "a"); + EXPECT_EQ(strings.back(), "b"); + } + + { + const std::string test{"a b,c|defgh "}; + + const std::vector strings {StringUtils::splitString(test, " ,|")}; + ASSERT_EQ(strings.size(), 4); + EXPECT_EQ(strings[0], "a"); + EXPECT_EQ(strings[1], "b"); + EXPECT_EQ(strings[2], "c"); + EXPECT_EQ(strings[3], "defgh"); + } +} + +TEST(StringUtils, escapeString) +{ + EXPECT_EQ(StringUtils::escapeString("", "*", ' '), ""); + EXPECT_EQ(StringUtils::escapeString("", "", ' '), ""); + EXPECT_EQ(StringUtils::escapeString("a", "", ' '), "a"); + EXPECT_EQ(StringUtils::escapeString("*", "*", '_'), "_*"); + EXPECT_EQ(StringUtils::escapeString("*a*", "*", '_'), "_*a_*"); + EXPECT_EQ(StringUtils::escapeString("*a|", "*|", '_'), "_*a_|"); + EXPECT_EQ(StringUtils::escapeString("**||", "*|", '_'), "_*_*_|_|"); +} + diff --git a/src/test/utils/Utils.cpp b/src/test/utils/Utils.cpp new file mode 100644 index 00000000..88325e8b --- /dev/null +++ b/src/test/utils/Utils.cpp @@ -0,0 +1,27 @@ +/* + * 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 + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} + From fd0a291b62bbd144a7250fecdff256dba5325148 Mon Sep 17 00:00:00 2001 From: emeric Date: Mon, 3 May 2021 19:36:21 +0200 Subject: [PATCH 04/29] Made the docker file work again (regression with gtest) + bumped alpine to 3.13 + removed useless openssl tool --- docker/Dockerfile | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 06411e51..91952d95 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,4 +1,4 @@ -FROM alpine:3.10 AS build +FROM alpine:3.13 AS build WORKDIR /tmp/workdir @@ -6,7 +6,7 @@ ARG MAKEFLAGS="-j2" ARG FFMPEG_VERSION=4.1.4 ARG WT_VERSION=4.5.0 ARG STB_VERSION=b42009b3b9d4ca35bc703f5310eedc74f584be58 -ARG LMS_VERSION=v3.6.3 +ARG LMS_VERSION=v3.25.2 ARG PREFIX="/tmp/install" @@ -22,7 +22,6 @@ ARG BUILD_PACKAGES=" \ g++ \ make \ openjpeg-dev \ - jasper-dev \ libpng-dev \ nasm \ yasm \ @@ -111,7 +110,7 @@ RUN \ RUN \ DIR=/tmp/lms && mkdir -p ${DIR} && cd ${DIR} && \ PKG_CONFIG_PATH=/tmp/install/lib/pkgconfig CXXFLAGS="-I${PREFIX}/include" LDFLAGS="-L${PREFIX}/lib -Wl,--rpath-link=${PREFIX}/lib" cmake . -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=${PREFIX} -DCMAKE_PREFIX_PATH=${PREFIX} && \ - make && \ + LD_LIBRARY_PATH=${PREFIX}/lib VERBOSE=1 make && \ LD_LIBRARY_PATH=${PREFIX}/lib make test && \ make install && \ mkdir -p ${PREFIX}/etc/ && \ @@ -135,14 +134,14 @@ RUN \ rm -rf /tmp/fakeroot/share/man ## Release Stage -FROM alpine:3.10 AS release +FROM alpine:3.13 AS release LABEL maintainer="Emeric Poupon " LABEL version=${LMS_VERSION} ARG RUNTIME_PACKAGES=" \ - openssl \ + libssl1.1 \ + libcrypto1.1 \ openjpeg \ - jasper-libs \ libpng \ libogg \ opus \ From c5078ccaf0a7ac243271c8e995166b47a05e6a35 Mon Sep 17 00:00:00 2001 From: emeric Date: Tue, 4 May 2021 12:36:56 +0200 Subject: [PATCH 05/29] added the 'created' field for tracks --- src/libs/subsonic/impl/SubsonicResource.cpp | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/libs/subsonic/impl/SubsonicResource.cpp b/src/libs/subsonic/impl/SubsonicResource.cpp index ef5ee51a..19727338 100644 --- a/src/libs/subsonic/impl/SubsonicResource.cpp +++ b/src/libs/subsonic/impl/SubsonicResource.cpp @@ -312,6 +312,17 @@ formatToSuffix(AudioFormat format) return ""; } +static +std::string +dateTimeToCreatedString(const Wt::WDateTime& dateTime) +{ + const std::time_t t {dateTime.toTime_t()}; + std::tm gmTime; + std::ostringstream oss; + oss << std::put_time(::gmtime_r(&t, &gmTime), "%FT%T"); + return oss.str(); +} + static Response::Node trackToResponseNode(const Track::pointer& track, Session& dbSession, const User::pointer& user) @@ -365,6 +376,7 @@ trackToResponseNode(const Track::pointer& track, Session& dbSession, const User: trackResponse.setAttribute("duration", std::chrono::duration_cast(track->getDuration()).count()); trackResponse.setAttribute("type", "music"); + trackResponse.setAttribute("created", dateTimeToCreatedString(track->getLastWritten())); if (user->hasStarredTrack(track)) trackResponse.setAttribute("starred", reportedStarredDate); @@ -415,13 +427,7 @@ releaseToResponseNode(const Release::pointer& release, Session& dbSession, const albumNode.setAttribute("isDir", true); } - { - std::time_t t {release->getLastWritten().toTime_t()}; - std::tm gmTime; - std::ostringstream oss; oss << std::put_time(::gmtime_r(&t, &gmTime), "%FT%T"); - albumNode.setAttribute("created", oss.str()); - } - + albumNode.setAttribute("created", dateTimeToCreatedString(release->getLastWritten())); albumNode.setAttribute("id", IdToString({Id::Type::Release, release.id()})); albumNode.setAttribute("coverArt", IdToString({Id::Type::Release, release.id()})); auto releaseYear {release->getReleaseYear()}; From c600d95af5b9bceeee9a475945507bb993f9f343 Mon Sep 17 00:00:00 2001 From: emeric Date: Tue, 4 May 2021 12:41:41 +0200 Subject: [PATCH 06/29] Fixed cast --- src/libs/scrobbling/impl/listenbrainz/ListensSynchronizer.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/scrobbling/impl/listenbrainz/ListensSynchronizer.cpp b/src/libs/scrobbling/impl/listenbrainz/ListensSynchronizer.cpp index 03395ae0..6d788e08 100644 --- a/src/libs/scrobbling/impl/listenbrainz/ListensSynchronizer.cpp +++ b/src/libs/scrobbling/impl/listenbrainz/ListensSynchronizer.cpp @@ -98,7 +98,7 @@ namespace Wt::Json::Object root; Wt::Json::parse(std::string {msgBody}, root); - const Wt::Json::Object& payload {static_cast(root.get("payload"))}; + const Wt::Json::Object& payload {static_cast(root.get("payload"))}; return static_cast(payload.get("count")); } catch (const Wt::WException& e) From 347f95c44ca5ffe34b349616b4067af5c6a186f1 Mon Sep 17 00:00:00 2001 From: emeric Date: Wed, 5 May 2021 12:31:20 +0200 Subject: [PATCH 07/29] added the 'created' field for tracks --- src/libs/database/include/database/Track.hpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libs/database/include/database/Track.hpp b/src/libs/database/include/database/Track.hpp index 356f5671..7165c7a7 100644 --- a/src/libs/database/include/database/Track.hpp +++ b/src/libs/database/include/database/Track.hpp @@ -129,6 +129,7 @@ class Track : public Wt::Dbo::Dbo std::string getName() const { return _name; } std::filesystem::path getPath() const { return _filePath; } std::chrono::milliseconds getDuration() const { return _duration; } + const Wt::WDateTime& getLastWritten() const { return _fileLastWrite; } std::optional getYear() const; std::optional getOriginalYear() const; Wt::WDateTime getLastWriteTime() const { return _fileLastWrite; } From d9df72d9cf9bec047a8b510cd493afd7488ccc62 Mon Sep 17 00:00:00 2001 From: emeric Date: Wed, 5 May 2021 12:31:56 +0200 Subject: [PATCH 08/29] Restored AcousticBrainz --- conf/lms.conf | 2 +- src/libs/scanner/impl/AcousticBrainzUtils.cpp | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/conf/lms.conf b/conf/lms.conf index f62d8d83..550db81c 100644 --- a/conf/lms.conf +++ b/conf/lms.conf @@ -42,7 +42,7 @@ listenbrainz-max-sync-listen-count = 1000; listenbrainz-sync-listens-period-hours = 1; # Acousticbrainz root API -acousticbrainz-api-base-url = "https://acousticbrainz.org/api"; +acousticbrainz-api-base-url = "https://acousticbrainz.org"; # Authentication # Available backends: "internal", "PAM", "http-headers" diff --git a/src/libs/scanner/impl/AcousticBrainzUtils.cpp b/src/libs/scanner/impl/AcousticBrainzUtils.cpp index 64980664..ba1a3a66 100644 --- a/src/libs/scanner/impl/AcousticBrainzUtils.cpp +++ b/src/libs/scanner/impl/AcousticBrainzUtils.cpp @@ -38,9 +38,9 @@ static std::string getJsonData(const UUID& mbid) { - static constexpr std::string_view defaultAPIURL {"https://acousticbrainz.org/api"}; + static constexpr std::string_view defaultAPIURL {"https://acousticbrainz.org"}; - const std::string url {std::string {Service::get()->getString("acousticbrainz-api-base-url", defaultAPIURL)} + std::string {mbid.getAsString()} + "/low-level"}; + const std::string url {std::string {Service::get()->getString("acousticbrainz-api-base-url", defaultAPIURL)} + "/api/v1/" + std::string {mbid.getAsString()} + "/low-level"}; boost::asio::io_service ioService; From 6e86fe2f6299383f038993767a192b35ae400fea Mon Sep 17 00:00:00 2001 From: emeric Date: Mon, 10 May 2021 14:22:44 +0200 Subject: [PATCH 09/29] Added support for various non standard tags incluging albumartists and albumartistssort, ref #154 --- src/libs/metadata/impl/TagLibParser.cpp | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/libs/metadata/impl/TagLibParser.cpp b/src/libs/metadata/impl/TagLibParser.cpp index f0d22d71..e383da71 100644 --- a/src/libs/metadata/impl/TagLibParser.cpp +++ b/src/libs/metadata/impl/TagLibParser.cpp @@ -396,13 +396,13 @@ TagLibParser::parse(const std::filesystem::path& p, bool debug) track.album = getAlbum(properties); track.artists = getArtists(properties, {"ARTISTS", "ARTIST"}, {"ARTISTSORT"}, {"MUSICBRAINZ_ARTISTID", "MUSICBRAINZ ARTIST ID"}); - track.albumArtists = getArtists(properties, {"ALBUMARTIST"}, {"ALBUMARTISTSORT"}, {"MUSICBRAINZ_ALBUMARTISTID", "MUSICBRAINZ ALBUM ARTIST ID"}); - track.conductorArtists = getArtists(properties, {"CONDUCTOR"}, {""}, {}); - track.composerArtists = getArtists(properties, {"COMPOSER"}, {"COMPOSERSORT"}, {}); - track.lyricistArtists = getArtists(properties, {"LYRICIST"}, {"LYRICISTSORT"}, {}); - track.mixerArtists = getArtists(properties, {"MIXER"}, {""}, {}); - track.producerArtists = getArtists(properties, {"PRODUCER"}, {""}, {}); - track.remixerArtists = getArtists(properties, {"REMIXER", "ModifiedBy"}, {""}, {}); + track.albumArtists = getArtists(properties, {"ALBUMARTISTS", "ALBUMARTIST"}, {"ALBUMARTISTSSORT", "ALBUMARTISTSORT"}, {"MUSICBRAINZ_ALBUMARTISTID", "MUSICBRAINZ ALBUM ARTIST ID"}); + track.conductorArtists = getArtists(properties, {"CONDUCTORS", "CONDUCTOR"}, {"CONDUCTORSSORT", "CONDUCTORSORT"}, {}); + track.composerArtists = getArtists(properties, {"COMPOSERS", "COMPOSER"}, {"COMPOSERSSORT", "COMPOSERSORT"}, {}); + track.lyricistArtists = getArtists(properties, {"LYRICISTS", "LYRICIST"}, {"LYRICISTSSORT", "LYRICISTSORT"}, {}); + track.mixerArtists = getArtists(properties, {"MIXERS", "MIXER"}, {"MIXERSSORT", "MIXERSORT"}, {}); + track.producerArtists = getArtists(properties, {"PRODUCERS", "PRODUCER"}, {"PRODUCERSSORT", "PRODUCERSORT"}, {}); + track.remixerArtists = getArtists(properties, {"REMIXERS", "REMIXER", "ModifiedBy"}, {"REMIXERSSORT", "REMIXERSORT"}, {}); return track; } From a110b6737d4a5b0d71af5e1e5371db8d604ea350 Mon Sep 17 00:00:00 2001 From: emeric Date: Mon, 10 May 2021 14:57:31 +0200 Subject: [PATCH 10/29] Displaying all release artists instead of just 'Various Artists' in the release view, ref #154 --- approot/release.xml | 8 ++++++-- src/lms/ui/explore/ReleaseView.cpp | 20 ++++++++++---------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/approot/release.xml b/approot/release.xml index ba7e36b3..ce72ff94 100644 --- a/approot/release.xml +++ b/approot/release.xml @@ -14,7 +14,7 @@

${name}

- ${}

${artist class="Lms-btn Lms-artistname text-muted"}

${
} + ${}${artists}${}
${}${year}${}${} (${orig-year})${}
${clusters} ${play-btn class="Lms-explore-btn Lms-btn"}${more-btn class="Lms-explore-btn Lms-btn"} @@ -52,7 +52,11 @@
- + +

${artist class="Lms-btn Lms-artistname text-muted"}

+
+ +

${disc-title}

${tracks}
diff --git a/src/lms/ui/explore/ReleaseView.cpp b/src/lms/ui/explore/ReleaseView.cpp index 6c393eac..6ea64c21 100644 --- a/src/lms/ui/explore/ReleaseView.cpp +++ b/src/lms/ui/explore/ReleaseView.cpp @@ -114,15 +114,16 @@ Release::refreshView() if (artists.empty()) artists = release->getArtists(); - if (artists.size() > 1) + if (!artists.empty()) { - setCondition("if-has-artist", true); - bindNew("artist", Wt::WString::tr("Lms.Explore.various-artists")); - } - else if (artists.size() == 1) - { - setCondition("if-has-artist", true); - bindWidget("artist", LmsApplication::createArtistAnchor(artists.front())); + setCondition("if-has-release-artists", true); + + Wt::WContainerWidget* artistsContainer {bindNew("artists")}; + for (const auto& artist : artists) + { + Wt::WTemplate* artistTemplate {artistsContainer->addNew(Wt::WString::tr("Lms.Explore.Release.template.entry-release-artist"))}; + artistTemplate->bindWidget("artist", LmsApplication::createArtistAnchor(artist)); + } } } @@ -183,8 +184,7 @@ Release::refreshView() return it->second; } - Wt::WTemplate* disc {rootContainer->addNew(Wt::WString::tr("Lms.Explore.Release.template.disc-entry"))}; - disc->addFunction("tr", &Wt::WTemplate::Functions::tr); + Wt::WTemplate* disc {rootContainer->addNew(Wt::WString::tr("Lms.Explore.Release.template.entry-disc"))}; if (discSubtitle.empty()) disc->bindNew("disc-title", Wt::WString::tr("Lms.Explore.Release.disc").arg(discNumber)); From 89295075867591bee2780e09fd9ced9b427b7957 Mon Sep 17 00:00:00 2001 From: emeric Date: Wed, 19 May 2021 18:39:56 +0200 Subject: [PATCH 11/29] Fixed bad access since some tracks may be duplicated in the database --- src/libs/database/impl/Track.cpp | 6 ++++-- src/libs/database/include/database/Track.hpp | 2 +- .../scrobbling/impl/listenbrainz/ListensSynchronizer.cpp | 7 ++++++- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/libs/database/impl/Track.cpp b/src/libs/database/impl/Track.cpp index 64dcd4f2..9e1ef2a0 100644 --- a/src/libs/database/impl/Track.cpp +++ b/src/libs/database/impl/Track.cpp @@ -152,13 +152,15 @@ Track::getById(Session& session, IdType id) .where("id = ?").bind(id); } -Track::pointer +std::vector Track::getByRecordingMBID(Session& session, const UUID& mbid) { session.checkSharedLocked(); - return session.getDboSession().find() + Wt::Dbo::collection res = session.getDboSession().find() .where("recording_mbid = ?").bind(std::string {mbid.getAsString()}); + + return std::vector(res.begin(), res.end()); } Track::pointer diff --git a/src/libs/database/include/database/Track.hpp b/src/libs/database/include/database/Track.hpp index 7165c7a7..7fee6355 100644 --- a/src/libs/database/include/database/Track.hpp +++ b/src/libs/database/include/database/Track.hpp @@ -62,7 +62,7 @@ class Track : public Wt::Dbo::Dbo static std::size_t getCount(Session& session); static pointer getByPath(Session& session, const std::filesystem::path& p); static pointer getById(Session& session, IdType id); - static pointer getByRecordingMBID(Session& session, const UUID& MBID); + static std::vector getByRecordingMBID(Session& session, const UUID& MBID); static std::vector getSimilarTracks(Session& session, const std::unordered_set& trackIds, std::optional offset = {}, diff --git a/src/libs/scrobbling/impl/listenbrainz/ListensSynchronizer.cpp b/src/libs/scrobbling/impl/listenbrainz/ListensSynchronizer.cpp index 6d788e08..a6db9b37 100644 --- a/src/libs/scrobbling/impl/listenbrainz/ListensSynchronizer.cpp +++ b/src/libs/scrobbling/impl/listenbrainz/ListensSynchronizer.cpp @@ -130,7 +130,12 @@ namespace { const Wt::Json::Object& additionalInfo = metadata.get("additional_info"); if (std::optional recordingMBID {UUID::fromString(additionalInfo.get("recording_mbid").orIfNull(""))}) - track = Database::Track::getByRecordingMBID(session, *recordingMBID); + { + const auto tracks {Database::Track::getByRecordingMBID(session, *recordingMBID)}; + // if duplicated files, do not record it (let the user correct its database) + if (tracks.size() == 1) + track = tracks.front(); + } } if (track) From d0fc045f634b1bbff4409500d1f12ef7df4ca11b Mon Sep 17 00:00:00 2001 From: emeric Date: Wed, 19 May 2021 19:01:13 +0200 Subject: [PATCH 12/29] Refactored the search view. fixes #146 --- approot/artists.xml | 1 - approot/releases.xml | 13 +- approot/search.xml | 31 +-- approot/templates.xml | 7 +- approot/tracks.xml | 22 +- src/lms/CMakeLists.txt | 5 + .../ui/common/InfiniteScrollingContainer.cpp | 83 +++++++ .../ui/common/InfiniteScrollingContainer.hpp | 50 +++++ src/lms/ui/explore/ArtistCollector.cpp | 130 +++++++++++ src/lms/ui/explore/ArtistCollector.hpp | 51 +++++ src/lms/ui/explore/ArtistsView.cpp | 206 +++++------------- src/lms/ui/explore/ArtistsView.hpp | 74 ++----- src/lms/ui/explore/DatabaseCollectorBase.cpp | 61 ++++++ src/lms/ui/explore/DatabaseCollectorBase.hpp | 73 +++++++ src/lms/ui/explore/Explore.cpp | 6 +- src/lms/ui/explore/ReleaseCollector.cpp | 120 ++++++++++ src/lms/ui/explore/ReleaseCollector.hpp | 47 ++++ src/lms/ui/explore/ReleasesView.cpp | 173 +++------------ src/lms/ui/explore/ReleasesView.hpp | 71 ++---- src/lms/ui/explore/SearchView.cpp | 154 +++++++++---- src/lms/ui/explore/SearchView.hpp | 77 +++++-- src/lms/ui/explore/TrackCollector.cpp | 120 ++++++++++ src/lms/ui/explore/TrackCollector.hpp | 47 ++++ src/lms/ui/explore/TracksView.cpp | 182 ++++------------ src/lms/ui/explore/TracksView.hpp | 67 ++---- 25 files changed, 1168 insertions(+), 703 deletions(-) create mode 100644 src/lms/ui/common/InfiniteScrollingContainer.cpp create mode 100644 src/lms/ui/common/InfiniteScrollingContainer.hpp create mode 100644 src/lms/ui/explore/ArtistCollector.cpp create mode 100644 src/lms/ui/explore/ArtistCollector.hpp create mode 100644 src/lms/ui/explore/DatabaseCollectorBase.cpp create mode 100644 src/lms/ui/explore/DatabaseCollectorBase.hpp create mode 100644 src/lms/ui/explore/ReleaseCollector.cpp create mode 100644 src/lms/ui/explore/ReleaseCollector.hpp create mode 100644 src/lms/ui/explore/TrackCollector.cpp create mode 100644 src/lms/ui/explore/TrackCollector.hpp diff --git a/approot/artists.xml b/approot/artists.xml index 2f29fbb6..cf00bdf7 100644 --- a/approot/artists.xml +++ b/approot/artists.xml @@ -12,7 +12,6 @@
${artists} - ${loading-indicator class="Lms-horizontal-center Lms-loading-indicator"} diff --git a/approot/releases.xml b/approot/releases.xml index 65e3cb8a..e4600c8b 100644 --- a/approot/releases.xml +++ b/approot/releases.xml @@ -7,10 +7,7 @@ ${mode class="nav nav-pills Lms-explore-mode-container"} ${play-btn class="Lms-explore-btn Lms-btn"}${more-btn class="Lms-explore-btn Lms-btn"} -
- ${releases} -
- ${loading-indicator class="Lms-horizontal-center Lms-loading-indicator"} + ${releases}
@@ -27,4 +24,12 @@ + +
+ ${elements} +
+ ${loading-indicator class="Lms-horizontal-center Lms-loading-indicator"} +
+ + diff --git a/approot/search.xml b/approot/search.xml index a6f8c536..afa104fd 100644 --- a/approot/search.xml +++ b/approot/search.xml @@ -4,36 +4,9 @@
-

${tr:Lms.Explore.Search.search}

-
-
- ${} -
-
-

${tr:Lms.Explore.artists}

- ${artists} -
-
- ${
} - ${} -
-
-

${tr:Lms.Explore.releases}

-
- ${releases} -
-
-
- ${
} - ${} -
-
-

${tr:Lms.Explore.tracks}

- ${tracks} -
-
- ${
} + ${mode class="nav nav-pills Lms-explore-mode-container"}
+ ${stack}
diff --git a/approot/templates.xml b/approot/templates.xml index 5be1e07d..f2aa8a7b 100644 --- a/approot/templates.xml +++ b/approot/templates.xml @@ -18,8 +18,8 @@ diff --git a/src/lms/ui/explore/ReleaseListHelpers.cpp b/src/lms/ui/explore/ReleaseListHelpers.cpp index fc29d3c1..eb1984cc 100644 --- a/src/lms/ui/explore/ReleaseListHelpers.cpp +++ b/src/lms/ui/explore/ReleaseListHelpers.cpp @@ -41,6 +41,7 @@ namespace UserInterface::ReleaseListHelpers auto entry {std::make_unique(Wt::WString::tr(templateKey))}; entry->bindWidget("release-name", LmsApplication::createReleaseAnchor(release)); + entry->addFunction("tr", &Wt::WTemplate::Functions::tr); Wt::WAnchor* anchor = entry->bindWidget("cover", LmsApplication::createReleaseAnchor(release, false)); auto cover = std::make_unique(); @@ -57,8 +58,7 @@ namespace UserInterface::ReleaseListHelpers if (artists.size() > 1) { - entry->setCondition("if-has-artist", true); - entry->bindNew("artist-name", Wt::WString::tr("Lms.Explore.various-artists")); + entry->setCondition("if-has-various-artists", true); } else if (artists.size() == 1 && !isSameArtist) { From dc829b3ef8ed6786c697c08da08cb985d30f22bd Mon Sep 17 00:00:00 2001 From: emeric Date: Thu, 20 May 2021 00:00:13 +0200 Subject: [PATCH 16/29] Do not initially search for empty things. ref #146 --- src/lms/ui/explore/SearchView.cpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/lms/ui/explore/SearchView.cpp b/src/lms/ui/explore/SearchView.cpp index e7493056..83651022 100644 --- a/src/lms/ui/explore/SearchView.cpp +++ b/src/lms/ui/explore/SearchView.cpp @@ -76,8 +76,6 @@ namespace UserInterface { refreshView(); }); - - refreshView(); } std::size_t From 4126bb6d0f9094d60b1fe089f6800e6ec509bea9 Mon Sep 17 00:00:00 2001 From: emeric Date: Thu, 20 May 2021 00:06:35 +0200 Subject: [PATCH 17/29] Bumped some display limits --- src/lms/ui/explore/ArtistsView.hpp | 2 +- src/lms/ui/explore/ReleasesView.hpp | 2 +- src/lms/ui/explore/SearchView.hpp | 4 ++-- src/lms/ui/explore/TracksView.hpp | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/lms/ui/explore/ArtistsView.hpp b/src/lms/ui/explore/ArtistsView.hpp index 2ad4a340..f7e61e22 100644 --- a/src/lms/ui/explore/ArtistsView.hpp +++ b/src/lms/ui/explore/ArtistsView.hpp @@ -46,7 +46,7 @@ namespace UserInterface void addSome(); static constexpr std::size_t _batchSize {30}; - static constexpr std::size_t _maxCount {128}; + static constexpr std::size_t _maxCount {256}; InfiniteScrollingContainer* _container {}; ArtistCollector _artistCollector; diff --git a/src/lms/ui/explore/ReleasesView.hpp b/src/lms/ui/explore/ReleasesView.hpp index abcff442..31f1d17e 100644 --- a/src/lms/ui/explore/ReleasesView.hpp +++ b/src/lms/ui/explore/ReleasesView.hpp @@ -50,7 +50,7 @@ namespace UserInterface static constexpr std::size_t _maxItemsPerLine {6}; static constexpr std::size_t _batchSize {_maxItemsPerLine}; - static constexpr std::size_t _maxCount {_maxItemsPerLine * 24}; + static constexpr std::size_t _maxCount {_maxItemsPerLine * 32}; InfiniteScrollingContainer* _container {}; ReleaseCollector _releaseCollector; diff --git a/src/lms/ui/explore/SearchView.hpp b/src/lms/ui/explore/SearchView.hpp index 0f4b85ab..590d941f 100644 --- a/src/lms/ui/explore/SearchView.hpp +++ b/src/lms/ui/explore/SearchView.hpp @@ -71,9 +71,9 @@ namespace UserInterface }; static inline std::unordered_map _maxCounts { - {Mode::Artist, 50}, + {Mode::Artist, 64}, {Mode::Release, 60}, - {Mode::Track, 50}, + {Mode::Track, 64}, }; std::size_t getBatchSize(Mode mode) const; std::size_t getMaxCount(Mode mode) const; diff --git a/src/lms/ui/explore/TracksView.hpp b/src/lms/ui/explore/TracksView.hpp index 62b9bdfa..f81355ad 100644 --- a/src/lms/ui/explore/TracksView.hpp +++ b/src/lms/ui/explore/TracksView.hpp @@ -50,7 +50,7 @@ namespace UserInterface static constexpr TrackCollector::Mode _defaultMode {TrackCollector::Mode::Random}; static constexpr std::size_t _batchSize {6}; - static constexpr std::size_t _maxCount {128}; + static constexpr std::size_t _maxCount {160}; InfiniteScrollingContainer* _container {}; TrackCollector _trackCollector; From b59596699a9b0cc87a9a8e11fe00f77f23e630ce Mon Sep 17 00:00:00 2001 From: emeric Date: Thu, 20 May 2021 13:19:20 +0200 Subject: [PATCH 18/29] Added some explanation on tags. ref #154 --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index ffebd306..1d8d524f 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,22 @@ The Subsonic API is enabled by default. __Note__: since _LMS_ may store hashed and salted passwords or may forward authentication requests to external services, it cannot handle the __token authentication__ method defined from version 1.13.0. +## About tags +_LMS_ relies exclusively on tags to organize your music collection. + +### Filtering +You can specify the tags you want to be used to filter your collection. By default, `GENRE`, `ALBUMGROUPING`, `MOOD` and `ALBUMMOOD` tags are used. +In the administration panel, you can set whatever tags you want, even custom tags. + +### Multiple album artists +_LMS_ requires the `ALBUMARTISTS` and `ALBUMARTISTSSORT` tags to properly handle multiple album artists on the same album. As they are a custom tags, you may need to setup your favorite tagger to add them. + +__Note__: if you use [Picard](https://picard.musicbrainz.org/), add the following script to include these tags: +``` +$setmulti(albumartists,%_albumartists%) +$setmulti(albumartistssort,%_albumartists_sort%) +``` + ## Keyboard shortcuts * Play/pause: Space * Previous track: Ctrl + Left From 1477fa125ee88c095ba6e2ccdca71fa91b7ae017 Mon Sep 17 00:00:00 2001 From: emeric Date: Thu, 20 May 2021 16:50:46 +0200 Subject: [PATCH 19/29] CodeFactor review --- src/libs/scrobbling/impl/listenbrainz/ListensSynchronizer.hpp | 1 - src/libs/utils/impl/IOContextRunner.cpp | 2 -- src/lms/ui/explore/SearchView.hpp | 1 - src/test/database/Common.hpp | 1 - src/test/database/Track.cpp | 1 - 5 files changed, 6 deletions(-) diff --git a/src/libs/scrobbling/impl/listenbrainz/ListensSynchronizer.hpp b/src/libs/scrobbling/impl/listenbrainz/ListensSynchronizer.hpp index 1a49ed98..511e07e8 100644 --- a/src/libs/scrobbling/impl/listenbrainz/ListensSynchronizer.hpp +++ b/src/libs/scrobbling/impl/listenbrainz/ListensSynchronizer.hpp @@ -67,7 +67,6 @@ namespace Scrobbling::ListenBrainz std::size_t fetchedListenCount{}; std::size_t matchedListenCount{}; std::size_t importedListenCount{}; - }; UserContext& getUserContext(Database::IdType userId); diff --git a/src/libs/utils/impl/IOContextRunner.cpp b/src/libs/utils/impl/IOContextRunner.cpp index 038f7ff5..888dd786 100644 --- a/src/libs/utils/impl/IOContextRunner.cpp +++ b/src/libs/utils/impl/IOContextRunner.cpp @@ -40,10 +40,8 @@ IOContextRunner::stop() IOContextRunner::~IOContextRunner() { - stop(); for (std::thread& t : _threads) t.join(); - } diff --git a/src/lms/ui/explore/SearchView.hpp b/src/lms/ui/explore/SearchView.hpp index 590d941f..e7a802b4 100644 --- a/src/lms/ui/explore/SearchView.hpp +++ b/src/lms/ui/explore/SearchView.hpp @@ -49,7 +49,6 @@ namespace UserInterface void refreshView(const Wt::WString& searchText); private: - // same order as in the menu enum class Mode { diff --git a/src/test/database/Common.hpp b/src/test/database/Common.hpp index 82f9115c..056ca415 100644 --- a/src/test/database/Common.hpp +++ b/src/test/database/Common.hpp @@ -124,7 +124,6 @@ class TmpDatabase final const std::filesystem::path _tmpFile {std::tmpnam(nullptr)}; ScopedFileDeleter fileDeleter {_tmpFile}; Database::Db _db {_tmpFile}; - }; class DatabaseFixture : public ::testing::Test diff --git a/src/test/database/Track.cpp b/src/test/database/Track.cpp index 1223fc9c..b5de2e56 100644 --- a/src/test/database/Track.cpp +++ b/src/test/database/Track.cpp @@ -42,7 +42,6 @@ TEST_F(DatabaseFixture, SingleTrack) TEST_F(DatabaseFixture, MultipleTracksSearchByFilter) { - ScopedTrack track1 {session, ""}; ScopedTrack track2 {session, ""}; ScopedTrack track3 {session, ""}; From 7cc8d22d5d1818b46b48b5fb17ca14414710474c Mon Sep 17 00:00:00 2001 From: emeric Date: Thu, 20 May 2021 17:10:51 +0200 Subject: [PATCH 20/29] CodeFactor review --- .../impl/listenbrainz/SendQueue.cpp | 44 ++++++++++++------- .../impl/listenbrainz/SendQueue.hpp | 2 + 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/src/libs/scrobbling/impl/listenbrainz/SendQueue.cpp b/src/libs/scrobbling/impl/listenbrainz/SendQueue.cpp index 0123b9eb..21255328 100644 --- a/src/libs/scrobbling/impl/listenbrainz/SendQueue.cpp +++ b/src/libs/scrobbling/impl/listenbrainz/SendQueue.cpp @@ -150,28 +150,40 @@ namespace Scrobbling::ListenBrainz LOG(DEBUG) << "Client done. status = " << msg.status(); if (ec) + onClientDoneError(std::move(request), ec); + else + onClientDoneSuccess(std::move(request), msg); + } + + void + SendQueue::onClientDoneError(Request request, Wt::AsioWrapper::error_code ec) + { + LOG(ERROR) << "Retry " << request._retryCount << ", client error: '" << ec.message() << "'"; + + // may be a network error, try again later + throttle(_defaultRetryWaitDuration); + + if (request._retryCount++ < _maxRetryCount) { - LOG(ERROR) << "Retry " << request._retryCount << ", client error: '" << ec.message() << "'"; - - // may be a network error, try again later - throttle(_defaultRetryWaitDuration); - - if (request._retryCount++ < _maxRetryCount) - { - _sendQueue[request._priority].emplace_front(std::move(request)); - } - else - { - LOG(ERROR) << "Too many retries, giving up operation and throttle"; - if (request._onFailureFunc) - request._onFailureFunc(); - } - return; + _sendQueue[request._priority].emplace_front(std::move(request)); } + else + { + LOG(ERROR) << "Too many retries, giving up operation and throttle"; + if (request._onFailureFunc) + request._onFailureFunc(); + } + } + void + SendQueue::onClientDoneSuccess(Request request, const Wt::Http::Message& msg) + { bool mustThrottle{}; if (msg.status() == 429) + { _sendQueue[request._priority].emplace_front(std::move(request)); + mustThrottle = true; + } const auto remainingCount {headerReadAs(msg, "X-RateLimit-Remaining")}; LOG(DEBUG) << "Remaining messages = " << (remainingCount ? *remainingCount : 0); diff --git a/src/libs/scrobbling/impl/listenbrainz/SendQueue.hpp b/src/libs/scrobbling/impl/listenbrainz/SendQueue.hpp index 7345212e..f682e80f 100644 --- a/src/libs/scrobbling/impl/listenbrainz/SendQueue.hpp +++ b/src/libs/scrobbling/impl/listenbrainz/SendQueue.hpp @@ -91,6 +91,8 @@ namespace Scrobbling::ListenBrainz void sendNextQueuedRequest(); bool sendRequest(const RequestData& request); void onClientDone(Wt::AsioWrapper::error_code ec, const Wt::Http::Message& msg); + void onClientDoneError(Request request, Wt::AsioWrapper::error_code ec); + void onClientDoneSuccess(Request request, const Wt::Http::Message& msg); void throttle(std::chrono::seconds duration); const std::size_t _maxRetryCount {2}; From 0af6342b342692f7838e3bc76dd89a555639a9a5 Mon Sep 17 00:00:00 2001 From: emeric Date: Thu, 20 May 2021 17:39:06 +0200 Subject: [PATCH 21/29] Factorized some more code --- approot/playqueue.xml | 9 ++- src/lms/ui/PlayQueue.cpp | 57 ++++++------------- src/lms/ui/PlayQueue.hpp | 13 +++-- .../ui/common/InfiniteScrollingContainer.cpp | 20 ++++++- .../ui/common/InfiniteScrollingContainer.hpp | 14 +++++ 5 files changed, 62 insertions(+), 51 deletions(-) diff --git a/approot/playqueue.xml b/approot/playqueue.xml index b1c4424e..0633bcbf 100644 --- a/approot/playqueue.xml +++ b/approot/playqueue.xml @@ -8,14 +8,13 @@ +
+ ${clear-btn class="Lms-playqueue-btn Lms-btn"}${shuffle-btn class="Lms-playqueue-btn Lms-btn"}${repeat-btn class="Lms-playqueue-btn Lms-btn"}${radio-btn class="Lms-playqueue-btn Lms-btn"} +

${nb-tracks}

+
-
- ${clear-btn class="Lms-playqueue-btn Lms-btn"}${shuffle-btn class="Lms-playqueue-btn Lms-btn"}${repeat-btn class="Lms-playqueue-btn Lms-btn"}${radio-btn class="Lms-playqueue-btn Lms-btn"} -

${nb-tracks}

-
${entries} - ${loading-indicator class="Lms-horizontal-center Lms-loading-indicator"}
diff --git a/src/lms/ui/PlayQueue.cpp b/src/lms/ui/PlayQueue.cpp index d4a790cd..e9d17ea9 100644 --- a/src/lms/ui/PlayQueue.cpp +++ b/src/lms/ui/PlayQueue.cpp @@ -33,7 +33,7 @@ #include "utils/Service.hpp" #include "utils/String.hpp" -#include "common/LoadingIndicator.hpp" +#include "common/InfiniteScrollingContainer.hpp" #include "resource/CoverResource.hpp" #include "resource/DownloadResource.hpp" #include "LmsApplication.hpp" @@ -67,8 +67,12 @@ PlayQueue::PlayQueue() clearTracks(); }); - _entriesContainer = bindNew("entries"); - hideLoadingIndicator(); + _entriesContainer = bindNew("entries"); + _entriesContainer->onRequestElements.connect([this] + { + addSome(); + updateCurrentTrack(true); + }); Wt::WText* shuffleBtn = bindNew("shuffle-btn", Wt::WString::tr("Lms.PlayQueue.template.shuffle-btn"), Wt::TextFormat::XHTML); setToolTip(*shuffleBtn, Wt::WString::tr("Lms.PlayQueue.shuffle")); @@ -184,27 +188,6 @@ PlayQueue::updateRadioBtn() _radioBtn->toggleStyleClass("text-muted", !_radioMode); } -void -PlayQueue::displayLoadingIndicator() -{ - _loadingIndicator = bindWidget("loading-indicator", createLoadingIndicator()); - _loadingIndicator->scrollVisibilityChanged().connect([this](bool visible) - { - if (!visible) - return; - - addSome(); - updateCurrentTrack(true); - }); -} - -void -PlayQueue::hideLoadingIndicator() -{ - _loadingIndicator = nullptr; - bindEmpty("loading-indicator"); -} - Database::TrackList::pointer PlayQueue::getTrackList() const { @@ -226,7 +209,6 @@ PlayQueue::clearTracks() getTrackList().modify()->clear(); } - hideLoadingIndicator(); _entriesContainer->clear(); updateInfo(); } @@ -322,10 +304,10 @@ PlayQueue::updateInfo() void PlayQueue::updateCurrentTrack(bool selected) { - if (!_trackPos || *_trackPos >= static_cast(_entriesContainer->count())) + if (!_trackPos || *_trackPos >= static_cast(_entriesContainer->getCount())) return; - Wt::WTemplate* entry {static_cast(_entriesContainer->widget(*_trackPos))}; + Wt::WTemplate* entry {static_cast(_entriesContainer->getWidget(*_trackPos))}; if (entry) entry->bindString("is-selected", selected ? "Lms-playqueue-selected" : ""); } @@ -410,14 +392,11 @@ PlayQueue::addSome() auto tracklist = getTrackList(); - auto tracklistEntries = tracklist->getEntries(_entriesContainer->count(), 50); + auto tracklistEntries = tracklist->getEntries(_entriesContainer->getCount(), 50); for (const Database::TrackListEntry::pointer& tracklistEntry : tracklistEntries) addEntry(tracklistEntry); - if (static_cast(_entriesContainer->count()) < tracklist->getCount()) - displayLoadingIndicator(); - else - hideLoadingIndicator(); + _entriesContainer->setHasMore(_entriesContainer->getCount() < tracklist->getCount()); } void @@ -474,9 +453,9 @@ PlayQueue::addEntry(const Database::TrackListEntry::pointer& tracklistEntry) Wt::WText* playBtn {entry->bindNew("play-btn", Wt::WString::tr("Lms.PlayQueue.template.play-btn"), Wt::TextFormat::XHTML)}; playBtn->clicked().connect([=] { - auto pos = _entriesContainer->indexOf(entry); - if (pos >= 0) - loadTrack(pos, true); + const std::optional pos {_entriesContainer->getIndexOf(*entry)}; + if (pos) + loadTrack(*pos, true); }); Wt::WText* delBtn {entry->bindNew("del-btn", Wt::WString::tr("Lms.PlayQueue.template.delete-btn"), Wt::TextFormat::XHTML)}; @@ -492,12 +471,12 @@ PlayQueue::addEntry(const Database::TrackListEntry::pointer& tracklistEntry) if (_trackPos) { - auto pos {_entriesContainer->indexOf(entry)}; - if (pos > 0 && *_trackPos >= static_cast(pos)) - (*_trackPos)--; + const std::optional pos {_entriesContainer->getIndexOf(*entry)}; + if (pos && *_trackPos >= *pos) + (*_trackPos)--; } - _entriesContainer->removeWidget(entry); + _entriesContainer->remove(*entry); updateInfo(); }); diff --git a/src/lms/ui/PlayQueue.hpp b/src/lms/ui/PlayQueue.hpp index 9a65c177..1ab611c5 100644 --- a/src/lms/ui/PlayQueue.hpp +++ b/src/lms/ui/PlayQueue.hpp @@ -28,11 +28,13 @@ #include "database/Types.hpp" #include "PlayQueueAction.hpp" -namespace Similarity { +namespace Similarity +{ class Finder; } -namespace Database { +namespace Database +{ class Track; class TrackList; class TrackListEntry; @@ -40,6 +42,8 @@ namespace Database { namespace UserInterface { +class InfiniteScrollingContainer; + class PlayQueue : public Wt::WTemplate { public: @@ -72,8 +76,6 @@ class PlayQueue : public Wt::WTemplate void updateCurrentTrack(bool selected); void updateRepeatBtn(); void updateRadioBtn(); - void displayLoadingIndicator(); - void hideLoadingIndicator(); void loadTrack(std::size_t pos, bool play); void stop(); @@ -88,8 +90,7 @@ class PlayQueue : public Wt::WTemplate bool _radioMode {}; bool _mediaPlayerSettingsLoaded {}; Database::IdType _tracklistId {}; - Wt::WContainerWidget* _entriesContainer {}; - Wt::WTemplate* _loadingIndicator {}; + InfiniteScrollingContainer* _entriesContainer {}; Wt::WText* _nbTracks {}; Wt::WText* _repeatBtn {}; Wt::WText* _radioBtn {}; diff --git a/src/lms/ui/common/InfiniteScrollingContainer.cpp b/src/lms/ui/common/InfiniteScrollingContainer.cpp index 954c3a28..aca5c057 100644 --- a/src/lms/ui/common/InfiniteScrollingContainer.cpp +++ b/src/lms/ui/common/InfiniteScrollingContainer.cpp @@ -59,6 +59,25 @@ namespace UserInterface hideLoadingIndicator(); } + void + InfiniteScrollingContainer::remove(Wt::WWidget& widget) + { + _elements->removeWidget(&widget); + } + + Wt::WWidget* + InfiniteScrollingContainer::getWidget(std::size_t pos) const + { + return _elements->widget(pos); + } + + std::optional + InfiniteScrollingContainer::getIndexOf(Wt::WWidget& widget) const + { + return _elements->indexOf(&widget); + } + + void InfiniteScrollingContainer::displayLoadingIndicator() { @@ -79,5 +98,4 @@ namespace UserInterface bindEmpty("loading-indicator"); } - } diff --git a/src/lms/ui/common/InfiniteScrollingContainer.hpp b/src/lms/ui/common/InfiniteScrollingContainer.hpp index 48836b58..eae4fa7f 100644 --- a/src/lms/ui/common/InfiniteScrollingContainer.hpp +++ b/src/lms/ui/common/InfiniteScrollingContainer.hpp @@ -19,6 +19,9 @@ #pragma once +#include +#include + #include #include #include @@ -36,6 +39,17 @@ namespace UserInterface std::size_t getCount(); void add(std::unique_ptr result); + template + T* addNew(Args&&... args) + { + return _elements->addNew(std::forward(args)...); + } + + void remove(Wt::WWidget& widget); + + Wt::WWidget* getWidget(std::size_t pos) const; + std::optional getIndexOf(Wt::WWidget& widget) const; + void setHasMore(bool hasMore); Wt::Signal<> onRequestElements; From e6f0e650b3acbe47466ac36872c69bd6d4d8414e Mon Sep 17 00:00:00 2001 From: emeric Date: Thu, 20 May 2021 17:44:22 +0200 Subject: [PATCH 22/29] Bumped wt version + skip useless stuff --- .lgtm.yml | 4 ++-- .travis.yml | 4 ++-- docker/Dockerfile | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.lgtm.yml b/.lgtm.yml index 569efc34..87100036 100644 --- a/.lgtm.yml +++ b/.lgtm.yml @@ -17,13 +17,13 @@ extraction: - libtag1-dev - libpam0g-dev after_prepare: - - export WT_VERSION=4.4.0 + - export WT_VERSION=4.5.0 - export WT_INSTALL_PREFIX=${LGTM_WORKSPACE}/wt-${WT_VERSION} - pushd ${LGTM_WORKSPACE} - git clone https://github.com/emweb/wt.git ${LGTM_WORKSPACE}/wt - pushd ${LGTM_WORKSPACE}/wt - git checkout ${WT_VERSION} - - cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=${WT_INSTALL_PREFIX} -DENABLE_LIBWTTEST=OFF + - cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=${WT_INSTALL_PREFIX} -DBUILD_EXAMPLES=OFF -DENABLE_LIBWTTEST=OFF -DCONNECTOR_FCGI=OFF - make install - popd configure: diff --git a/.travis.yml b/.travis.yml index 11c942e5..db3f1713 100644 --- a/.travis.yml +++ b/.travis.yml @@ -29,7 +29,7 @@ before_install: git clone https://github.com/emweb/wt.git wt cd wt git checkout ${WT_VERSION} - cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=${WT_INSTALL_PREFIX} -DENABLE_LIBWTTEST=OFF + cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=${WT_INSTALL_PREFIX} -DBUILD_EXAMPLES=OFF -DENABLE_LIBWTTEST=OFF -DCONNECTOR_FCGI=OFF make install cd .. fi @@ -40,7 +40,7 @@ cache: env: global: - MAKEFLAGS="-j 2" - - WT_VERSION=4.4.0 + - WT_VERSION=4.5.0 - WT_INSTALL_PREFIX=${HOME}/wt-${WT_VERSION} jobs: - BUILD_TYPE=Release USE_PAM=ON IMAGE_LIBRARY=STB diff --git a/docker/Dockerfile b/docker/Dockerfile index 91952d95..7d72e119 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -97,7 +97,7 @@ RUN \ RUN \ DIR=/tmp/wt && mkdir -p ${DIR} && cd ${DIR} && \ - cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=${PREFIX} -DBUILD_EXAMPLES=NO -DENABLE_LIBWTTEST=OFF && \ + cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=${PREFIX} -DBUILD_EXAMPLES=OFF -DENABLE_LIBWTTEST=OFF && -DCONNECTOR_FCGI=OFF \ make && \ make install From 42af0d8e3e05dbe8848a16097e8d2a5dd62a069d Mon Sep 17 00:00:00 2001 From: emeric Date: Thu, 20 May 2021 18:05:27 +0200 Subject: [PATCH 23/29] Fixed lgtm --- .lgtm.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.lgtm.yml b/.lgtm.yml index 87100036..295120c5 100644 --- a/.lgtm.yml +++ b/.lgtm.yml @@ -28,7 +28,7 @@ extraction: - popd configure: command: - - export WT_VERSION=4.4.0 + - export WT_VERSION=4.5.0 - export WT_INSTALL_PREFIX=${LGTM_WORKSPACE}/wt-${WT_VERSION} - cmake -DCMAKE_PREFIX_PATH=${WT_INSTALL_PREFIX} -DCMAKE_BUILD_TYPE=Release From fd9bbb09d7bb4fd7913cd99b010f7a50805fbcff Mon Sep 17 00:00:00 2001 From: emeric Date: Thu, 20 May 2021 18:05:56 +0200 Subject: [PATCH 24/29] Lowered the number of tracks loaded at once in the playqueue --- src/lms/ui/PlayQueue.cpp | 2 +- src/lms/ui/PlayQueue.hpp | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lms/ui/PlayQueue.cpp b/src/lms/ui/PlayQueue.cpp index e9d17ea9..be8c0c82 100644 --- a/src/lms/ui/PlayQueue.cpp +++ b/src/lms/ui/PlayQueue.cpp @@ -392,7 +392,7 @@ PlayQueue::addSome() auto tracklist = getTrackList(); - auto tracklistEntries = tracklist->getEntries(_entriesContainer->getCount(), 50); + auto tracklistEntries = tracklist->getEntries(_entriesContainer->getCount(), _batchSize); for (const Database::TrackListEntry::pointer& tracklistEntry : tracklistEntries) addEntry(tracklistEntry); diff --git a/src/lms/ui/PlayQueue.hpp b/src/lms/ui/PlayQueue.hpp index 1ab611c5..9ed4eada 100644 --- a/src/lms/ui/PlayQueue.hpp +++ b/src/lms/ui/PlayQueue.hpp @@ -85,6 +85,7 @@ class PlayQueue : public Wt::WTemplate std::optional getReplayGain(std::size_t pos, const Wt::Dbo::ptr& track) const; static inline constexpr std::size_t _nbMaxEntries {1000}; + static inline constexpr std::size_t _batchSize {6}; bool _repeatAll {}; bool _radioMode {}; From 26238fbfeb83f5ff2dd71728f17be04d3fa5f32a Mon Sep 17 00:00:00 2001 From: emeric Date: Fri, 21 May 2021 13:32:37 +0200 Subject: [PATCH 25/29] Optimized build when changing args --- docker/Dockerfile | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 7d72e119..6528beac 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -3,10 +3,6 @@ FROM alpine:3.13 AS build WORKDIR /tmp/workdir ARG MAKEFLAGS="-j2" -ARG FFMPEG_VERSION=4.1.4 -ARG WT_VERSION=4.5.0 -ARG STB_VERSION=b42009b3b9d4ca35bc703f5310eedc74f584be58 -ARG LMS_VERSION=v3.25.2 ARG PREFIX="/tmp/install" @@ -40,15 +36,8 @@ ARG BUILD_PACKAGES=" \ RUN apk add --no-cache --update ${BUILD_PACKAGES} -# STB -RUN \ - DIR=/tmp/stb && mkdir -p ${DIR} && cd ${DIR} && \ - curl -sLO https://github.com/nothings/stb/archive/${STB_VERSION}.tar.gz && \ - tar -x --strip-components=1 -f ${STB_VERSION}.tar.gz && \ - mkdir -p ${PREFIX}/include/stb && \ - cp ./*.h ${PREFIX}/include/stb - # ffmpeg +ARG FFMPEG_VERSION=4.1.4 RUN \ DIR=/tmp/ffmpeg && mkdir -p ${DIR} && cd ${DIR} && \ curl -sLO https://ffmpeg.org/releases/ffmpeg-${FFMPEG_VERSION}.tar.bz2 && \ @@ -90,6 +79,7 @@ RUN \ make distclean # WT +ARG WT_VERSION=4.5.0 RUN \ DIR=/tmp/wt && mkdir -p ${DIR} && cd ${DIR} && \ curl -sLO https://github.com/emweb/wt/archive/${WT_VERSION}.tar.gz && \ @@ -97,11 +87,21 @@ RUN \ RUN \ DIR=/tmp/wt && mkdir -p ${DIR} && cd ${DIR} && \ - cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=${PREFIX} -DBUILD_EXAMPLES=OFF -DENABLE_LIBWTTEST=OFF && -DCONNECTOR_FCGI=OFF \ + cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=${PREFIX} -DBUILD_EXAMPLES=OFF -DENABLE_LIBWTTEST=OFF -DCONNECTOR_FCGI=OFF && \ make && \ make install +# STB +ARG STB_VERSION=b42009b3b9d4ca35bc703f5310eedc74f584be58 +RUN \ + DIR=/tmp/stb && mkdir -p ${DIR} && cd ${DIR} && \ + curl -sLO https://github.com/nothings/stb/archive/${STB_VERSION}.tar.gz && \ + tar -x --strip-components=1 -f ${STB_VERSION}.tar.gz && \ + mkdir -p ${PREFIX}/include/stb && \ + cp ./*.h ${PREFIX}/include/stb + # LMS +ARG LMS_VERSION=v3.25.2 RUN \ DIR=/tmp/lms && mkdir -p ${DIR} && cd ${DIR} && \ curl -sL https://github.com/epoupon/lms/archive/${LMS_VERSION}.tar.gz -o ${LMS_VERSION}.tar.gz && \ From 7d44505d3f4cc76160c51b288aeb0d35373ed90d Mon Sep 17 00:00:00 2001 From: emeric Date: Fri, 21 May 2021 13:32:55 +0200 Subject: [PATCH 26/29] Rerasied a bit the number of tracks loaded at once in the playqueue --- src/lms/ui/PlayQueue.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lms/ui/PlayQueue.hpp b/src/lms/ui/PlayQueue.hpp index 9ed4eada..77b10afa 100644 --- a/src/lms/ui/PlayQueue.hpp +++ b/src/lms/ui/PlayQueue.hpp @@ -85,7 +85,7 @@ class PlayQueue : public Wt::WTemplate std::optional getReplayGain(std::size_t pos, const Wt::Dbo::ptr& track) const; static inline constexpr std::size_t _nbMaxEntries {1000}; - static inline constexpr std::size_t _batchSize {6}; + static inline constexpr std::size_t _batchSize {12}; bool _repeatAll {}; bool _radioMode {}; From 43fa93bb39c9a75297026e4a0705817525fad636 Mon Sep 17 00:00:00 2001 From: emeric Date: Fri, 21 May 2021 13:34:04 +0200 Subject: [PATCH 27/29] CodeFactor review --- src/libs/scrobbling/impl/listenbrainz/SendQueue.hpp | 1 - 1 file changed, 1 deletion(-) diff --git a/src/libs/scrobbling/impl/listenbrainz/SendQueue.hpp b/src/libs/scrobbling/impl/listenbrainz/SendQueue.hpp index f682e80f..9878d5d8 100644 --- a/src/libs/scrobbling/impl/listenbrainz/SendQueue.hpp +++ b/src/libs/scrobbling/impl/listenbrainz/SendQueue.hpp @@ -58,7 +58,6 @@ namespace Scrobbling::ListenBrainz class Request { public: - enum class Priority { High, From 52447c0b563f9b8eed08d459bc1c0e18864ab214 Mon Sep 17 00:00:00 2001 From: emeric Date: Fri, 21 May 2021 23:28:50 +0200 Subject: [PATCH 28/29] Use MBID for internal path when possible --- src/lms/ui/LmsApplication.cpp | 10 ++++++++-- src/lms/ui/LmsApplicationException.hpp | 6 +++--- src/lms/ui/admin/UserView.cpp | 6 +++--- src/lms/ui/explore/ArtistView.cpp | 26 ++++++++++++++++++++++--- src/lms/ui/explore/ReleaseView.cpp | 27 +++++++++++++++++++++++--- 5 files changed, 61 insertions(+), 14 deletions(-) diff --git a/src/lms/ui/LmsApplication.cpp b/src/lms/ui/LmsApplication.cpp index 6662d59a..aba96fab 100644 --- a/src/lms/ui/LmsApplication.cpp +++ b/src/lms/ui/LmsApplication.cpp @@ -277,7 +277,10 @@ LmsApplication::finalize() Wt::WLink LmsApplication::createArtistLink(Database::Artist::pointer artist) { - return Wt::WLink {Wt::LinkType::InternalPath, "/artist/" + std::to_string(artist.id())}; + if (const auto mbid {artist->getMBID()}) + return Wt::WLink {Wt::LinkType::InternalPath, "/artist/mbid/" + std::string {mbid->getAsString()}}; + else + return Wt::WLink {Wt::LinkType::InternalPath, "/artist/" + std::to_string(artist.id())}; } std::unique_ptr @@ -298,7 +301,10 @@ LmsApplication::createArtistAnchor(Database::Artist::pointer artist, bool addTex Wt::WLink LmsApplication::createReleaseLink(Database::Release::pointer release) { - return Wt::WLink {Wt::LinkType::InternalPath, "/release/" + std::to_string(release.id())}; + if (const auto mbid {release->getMBID()}) + return Wt::WLink {Wt::LinkType::InternalPath, "/release/mbid/" + std::string {mbid->getAsString()}}; + else + return Wt::WLink {Wt::LinkType::InternalPath, "/release/" + std::to_string(release.id())}; } std::unique_ptr diff --git a/src/lms/ui/LmsApplicationException.hpp b/src/lms/ui/LmsApplicationException.hpp index 61ef2dbd..0671a6c4 100644 --- a/src/lms/ui/LmsApplicationException.hpp +++ b/src/lms/ui/LmsApplicationException.hpp @@ -34,19 +34,19 @@ class LmsApplicationException : public LmsException class ArtistNotFoundException : public LmsApplicationException { public: - ArtistNotFoundException(Database::IdType artistId) : LmsApplicationException {Wt::WString::tr("Lms.Error.artist-not-found").arg(artistId)} {} + ArtistNotFoundException() : LmsApplicationException {Wt::WString::tr("Lms.Error.artist-not-found")} {} }; class ReleaseNotFoundException : public LmsApplicationException { public: - ReleaseNotFoundException(Database::IdType releaseId) : LmsApplicationException {Wt::WString::tr("Lms.Error.release-not-found").arg(releaseId)} {} + ReleaseNotFoundException() : LmsApplicationException {Wt::WString::tr("Lms.Error.release-not-found")} {} }; class UserNotFoundException : public LmsApplicationException { public: - UserNotFoundException(Database::IdType userId) : LmsApplicationException {Wt::WString::tr("Lms.Error.user-not-found").arg(userId)} {} + UserNotFoundException() : LmsApplicationException {Wt::WString::tr("Lms.Error.user-not-found")} {} }; class UserNotAllowedException : public LmsApplicationException diff --git a/src/lms/ui/admin/UserView.cpp b/src/lms/ui/admin/UserView.cpp index 4d76dfd4..2d4c7ab6 100644 --- a/src/lms/ui/admin/UserView.cpp +++ b/src/lms/ui/admin/UserView.cpp @@ -84,7 +84,7 @@ class UserModel : public Wt::WFormModel // Update user Database::User::pointer user {Database::User::getById(LmsApp->getDbSession(), *_userId)}; if (!user) - throw UserNotFoundException {*_userId}; + throw UserNotFoundException {}; if (_authPasswordService && !valueText(PasswordField).empty()) _authPasswordService->setPassword(LmsApp->getDbSession(), user.id(), valueText(PasswordField).toUTF8()); @@ -117,7 +117,7 @@ class UserModel : public Wt::WFormModel const Database::User::pointer user {Database::User::getById(LmsApp->getDbSession(), *_userId)}; if (!user) - throw UserNotFoundException {*_userId}; + throw UserNotFoundException {}; else if (user == LmsApp->getUser()) throw UserNotAllowedException {}; } @@ -215,7 +215,7 @@ UserView::refreshView() const Database::User::pointer user {Database::User::getById(LmsApp->getDbSession(), *userId)}; if (!user) - throw UserNotFoundException {*userId}; + throw UserNotFoundException {}; t->bindString("title", Wt::WString::tr("Lms.Admin.User.user-edit").arg(user->getLoginName()), Wt::TextFormat::Plain); t->setCondition("if-has-last-login", true); diff --git a/src/lms/ui/explore/ArtistView.cpp b/src/lms/ui/explore/ArtistView.cpp index 79dbfc9f..68b428d6 100644 --- a/src/lms/ui/explore/ArtistView.cpp +++ b/src/lms/ui/explore/ArtistView.cpp @@ -64,6 +64,26 @@ Artist::Artist(Filters* filters) refreshView(); } +static +std::optional +extractArtistIdFromInternalPath() +{ + if (wApp->internalPathMatches("/artist/mbid/")) + { + const auto mbid {UUID::fromString(wApp->internalPathNextPart("/artist/mbid/"))}; + if (mbid) + { + auto transaction {LmsApp->getDbSession().createSharedTransaction()}; + if (const Database::Artist::pointer artist {Database::Artist::getByMBID(LmsApp->getDbSession(), *mbid)}) + return artist.id(); + } + + return std::nullopt; + } + + return StringUtils::readAs(wApp->internalPathNextPart("/artist/")); +} + void Artist::refreshView() { @@ -72,9 +92,9 @@ Artist::refreshView() clear(); - const auto artistId {StringUtils::readAs(wApp->internalPathNextPart("/artist/"))}; + const auto artistId {extractArtistIdFromInternalPath()}; if (!artistId) - throw ArtistNotFoundException {*artistId}; + throw ArtistNotFoundException {}; const auto similarArtistIds {Service::get()->getSimilarArtists(LmsApp->getDbSession(), *artistId, @@ -85,7 +105,7 @@ Artist::refreshView() const Database::Artist::pointer artist {Database::Artist::getById(LmsApp->getDbSession(), *artistId)}; if (!artist) - throw ArtistNotFoundException {*artistId}; + throw ArtistNotFoundException {}; refreshLinks(artist); refreshSimilarArtists(similarArtistIds); diff --git a/src/lms/ui/explore/ReleaseView.cpp b/src/lms/ui/explore/ReleaseView.cpp index 35ee0e4d..cc6d0f23 100644 --- a/src/lms/ui/explore/ReleaseView.cpp +++ b/src/lms/ui/explore/ReleaseView.cpp @@ -67,6 +67,27 @@ Release::Release(Filters* filters) refreshView(); } +static +std::optional +extractReleaseIdFromInternalPath() +{ + if (wApp->internalPathMatches("/release/mbid/")) + { + const auto mbid {UUID::fromString(wApp->internalPathNextPart("/release/mbid/"))}; + if (mbid) + { + auto transaction {LmsApp->getDbSession().createSharedTransaction()}; + if (const Database::Release::pointer release {Database::Release::getByMBID(LmsApp->getDbSession(), *mbid)}) + return release.id(); + } + + return std::nullopt; + } + + return StringUtils::readAs(wApp->internalPathNextPart("/release/")); +} + + void Release::refreshView() { @@ -75,9 +96,9 @@ Release::refreshView() clear(); - const auto releaseId {StringUtils::readAs(wApp->internalPathNextPart("/release/"))}; + const auto releaseId {extractReleaseIdFromInternalPath()}; if (!releaseId) - throw ReleaseNotFoundException {*releaseId}; + throw ReleaseNotFoundException {}; auto similarReleasesIds {Service::get()->getSimilarReleases(LmsApp->getDbSession(), *releaseId, 6)}; @@ -85,7 +106,7 @@ Release::refreshView() const Database::Release::pointer release {Database::Release::getById(LmsApp->getDbSession(), *releaseId)}; if (!release) - throw ReleaseNotFoundException {*releaseId}; + throw ReleaseNotFoundException {}; refreshCopyright(release); refreshLinks(release); From 8e9f02edada1ad0e6326d6ccc8db51bc9194f737 Mon Sep 17 00:00:00 2001 From: emeric Date: Sat, 22 May 2021 18:11:51 +0200 Subject: [PATCH 29/29] Better fatal error reporting --- .../impl/listenbrainz/ListensSynchronizer.cpp | 5 +++ .../impl/listenbrainz/SendQueue.cpp | 8 +++-- .../include/scrobbling/Exception.hpp | 32 +++++++++++++++++++ src/libs/utils/impl/IOContextRunner.cpp | 20 ++++++++++-- src/lms/main.cpp | 6 ++-- 5 files changed, 64 insertions(+), 7 deletions(-) create mode 100644 src/libs/scrobbling/include/scrobbling/Exception.hpp diff --git a/src/libs/scrobbling/impl/listenbrainz/ListensSynchronizer.cpp b/src/libs/scrobbling/impl/listenbrainz/ListensSynchronizer.cpp index a6db9b37..1a59abcf 100644 --- a/src/libs/scrobbling/impl/listenbrainz/ListensSynchronizer.cpp +++ b/src/libs/scrobbling/impl/listenbrainz/ListensSynchronizer.cpp @@ -32,6 +32,7 @@ #include "database/Track.hpp" #include "database/TrackList.hpp" #include "database/User.hpp" +#include "scrobbling/Exception.hpp" #include "utils/IConfig.hpp" #include "utils/Logger.hpp" #include "utils/Service.hpp" @@ -321,6 +322,10 @@ namespace Scrobbling::ListenBrainz LOG(DEBUG) << "getListens aborted"; return; } + else if (ec) + { + throw Exception {"GetListens timer failure: " + std::string {ec.message()} }; + } startGetListens(); })); diff --git a/src/libs/scrobbling/impl/listenbrainz/SendQueue.cpp b/src/libs/scrobbling/impl/listenbrainz/SendQueue.cpp index 21255328..aa1cfde7 100644 --- a/src/libs/scrobbling/impl/listenbrainz/SendQueue.cpp +++ b/src/libs/scrobbling/impl/listenbrainz/SendQueue.cpp @@ -21,6 +21,7 @@ #include +#include "scrobbling/Exception.hpp" #include "utils/Logger.hpp" #include "utils/String.hpp" @@ -228,9 +229,10 @@ namespace Scrobbling::ListenBrainz LOG(DEBUG) << "SendQueue: throttle aborted"; return; } - - if (ec) - LOG(ERROR) << "async_wait failed:" << ec.message(); + else if (ec) + { + throw Exception {"Throttle timer failure: " + std::string {ec.message()} }; + } _state = State::Idle; sendNextQueuedRequest(); diff --git a/src/libs/scrobbling/include/scrobbling/Exception.hpp b/src/libs/scrobbling/include/scrobbling/Exception.hpp new file mode 100644 index 00000000..b60f7189 --- /dev/null +++ b/src/libs/scrobbling/include/scrobbling/Exception.hpp @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2019 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 "utils/Exception.hpp" + +namespace Scrobbling +{ + + class Exception : public LmsException + { + public: + using LmsException::LmsException; + }; +} diff --git a/src/libs/utils/impl/IOContextRunner.cpp b/src/libs/utils/impl/IOContextRunner.cpp index 888dd786..934b3b64 100644 --- a/src/libs/utils/impl/IOContextRunner.cpp +++ b/src/libs/utils/impl/IOContextRunner.cpp @@ -17,16 +17,32 @@ * along with LMS. If not, see . */ -#include "utils/Logger.hpp" #include "utils/IOContextRunner.hpp" +#include + +#include "utils/Logger.hpp" + IOContextRunner::IOContextRunner(boost::asio::io_service& ioService, std::size_t threadCount) : _ioService {ioService} , _work {ioService} { LMS_LOG(UTILS, INFO) << "Starting IO Context with " << threadCount << " threads..."; for (std::size_t i {}; i < threadCount; ++i) - _threads.emplace_back([&] { _ioService.run(); }); + { + _threads.emplace_back([&] + { + try + { + _ioService.run(); + } + catch (const std::exception& e) + { + LMS_LOG(UTILS, FATAL) << "Exception caught in IO context: " << e.what(); + std::abort(); + } + }); + } } void diff --git a/src/lms/main.cpp b/src/lms/main.cpp index 65d626d7..ae7b34e1 100644 --- a/src/lms/main.cpp +++ b/src/lms/main.cpp @@ -294,13 +294,15 @@ int main(int argc, char* argv[]) LMS_LOG(MAIN, INFO) << "Quitting..."; res = EXIT_SUCCESS; } - catch(Wt::WServer::Exception& e) + catch (const Wt::WServer::Exception& e) { + LMS_LOG(MAIN, FATAL) << "Caught WServer::Exception: " << e.what(); std::cerr << "Caught a WServer::Exception: " << e.what() << std::endl; res = EXIT_FAILURE; } - catch(std::exception& e) + catch (const std::exception& e) { + LMS_LOG(MAIN, FATAL) << "Caught std::exception: " << e.what(); std::cerr << "Caught std::exception: " << e.what() << std::endl; res = EXIT_FAILURE; }