From acf81e3f1a28c8f060651c7bb482a67c7ab005be Mon Sep 17 00:00:00 2001 From: emeric Date: Tue, 6 Apr 2021 19:59:12 +0200 Subject: [PATCH] Implemented a Scrobbling service + ListenBrainz scrobbler. fixes #118 --- README.md | 1 + approot/messages.xml | 5 + approot/messages_fr.xml | 5 + approot/settings.xml | 21 + conf/lms.conf | 5 +- docroot/js/mediaplayer.js | 42 +- src/libs/CMakeLists.txt | 1 + src/libs/database/impl/Session.cpp | 11 +- src/libs/database/impl/Track.cpp | 4 +- src/libs/database/impl/TrackList.cpp | 50 +-- src/libs/database/impl/User.cpp | 11 - src/libs/database/include/database/Track.hpp | 14 +- .../database/include/database/TrackList.hpp | 15 +- src/libs/database/include/database/Types.hpp | 19 + src/libs/database/include/database/User.hpp | 28 +- src/libs/metadata/impl/AvFormatParser.cpp | 9 +- src/libs/metadata/impl/TagLibParser.cpp | 5 +- .../metadata/include/metadata/IParser.hpp | 4 +- src/libs/scanner/impl/AcousticBrainzUtils.cpp | 4 +- src/libs/scanner/impl/AcousticBrainzUtils.hpp | 2 +- src/libs/scanner/impl/Scanner.cpp | 23 +- src/libs/scrobbling/CMakeLists.txt | 23 + src/libs/scrobbling/impl/IScrobbler.hpp | 56 +++ src/libs/scrobbling/impl/Scrobbling.cpp | 186 ++++++++ src/libs/scrobbling/impl/Scrobbling.hpp | 89 ++++ .../impl/internal/InternalScrobbler.cpp | 78 ++++ .../impl/internal/InternalScrobbler.hpp | 43 ++ .../listenbrainz/ListenBrainzScrobbler.cpp | 400 ++++++++++++++++++ .../listenbrainz/ListenBrainzScrobbler.hpp | 91 ++++ .../include/scrobbling/IScrobbling.hpp | 101 +++++ .../scrobbling/include/scrobbling/Listen.hpp | 32 ++ src/libs/subsonic/CMakeLists.txt | 1 + src/libs/subsonic/impl/SubsonicResource.cpp | 53 ++- src/libs/utils/impl/Logger.cpp | 1 + src/libs/utils/include/utils/Logger.hpp | 1 + src/lms/CMakeLists.txt | 2 + src/lms/main.cpp | 3 + src/lms/ui/LmsApplication.cpp | 16 + src/lms/ui/MediaPlayer.cpp | 14 +- src/lms/ui/MediaPlayer.hpp | 26 +- src/lms/ui/SettingsView.cpp | 95 ++++- src/lms/ui/admin/ScannerController.cpp | 4 +- src/lms/ui/common/UUIDValidator.cpp | 31 ++ src/lms/ui/common/UUIDValidator.hpp | 28 ++ src/lms/ui/explore/ArtistsView.cpp | 9 +- src/lms/ui/explore/ReleasesView.cpp | 5 +- src/lms/ui/explore/TracksView.cpp | 7 +- src/test/database/DatabaseTest.cpp | 157 ++++++- src/tools/metadata/LmsMetadata.cpp | 8 +- 49 files changed, 1662 insertions(+), 177 deletions(-) create mode 100644 src/libs/scrobbling/CMakeLists.txt create mode 100644 src/libs/scrobbling/impl/IScrobbler.hpp create mode 100644 src/libs/scrobbling/impl/Scrobbling.cpp create mode 100644 src/libs/scrobbling/impl/Scrobbling.hpp create mode 100644 src/libs/scrobbling/impl/internal/InternalScrobbler.cpp create mode 100644 src/libs/scrobbling/impl/internal/InternalScrobbler.hpp create mode 100644 src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.cpp create mode 100644 src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.hpp create mode 100644 src/libs/scrobbling/include/scrobbling/IScrobbling.hpp create mode 100644 src/libs/scrobbling/include/scrobbling/Listen.hpp create mode 100644 src/lms/ui/common/UUIDValidator.cpp create mode 100644 src/lms/ui/common/UUIDValidator.hpp diff --git a/README.md b/README.md index 516f3e5f..5b764349 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ A [demo instance](http://lms.demo.poupon.io) is available. Note the administrati * Multi-value tags: artists, genres, composers, lyricists, moods, ... * Compilation support * [MusicBrainz Identifier](https://musicbrainz.org/doc/MusicBrainz_Identifier) support to handle duplicated artist and release names +* Scrobbling to [ListenBrainz](https://listenbrainz.org) * Disc subtitles support * ReplayGain support * Persistent play queue across sessions diff --git a/approot/messages.xml b/approot/messages.xml index 06e52b7d..bd252296 100644 --- a/approot/messages.xml +++ b/approot/messages.xml @@ -194,6 +194,11 @@ Album ReplayGain preamp ReplayGain preamp (if no info) +Scrobbling +Scrobbler +Internal +ListenBrainz +ListenBrainz token Artist list mode All artists Album artists diff --git a/approot/messages_fr.xml b/approot/messages_fr.xml index c8dbaeb6..52f5f10c 100644 --- a/approot/messages_fr.xml +++ b/approot/messages_fr.xml @@ -194,6 +194,11 @@ Album Pre-amplification ReplayGain Pre-amplification ReplayGain (si pas d'info) +Scrobbling +Scrobbler +Interne +ListenBrainz +Jeton ListenBrainz Mode de listage des artistes Tous les artistes Tous les artistes d'album diff --git a/approot/settings.xml b/approot/settings.xml index c5df89f0..4a3aa7c2 100644 --- a/approot/settings.xml +++ b/approot/settings.xml @@ -131,6 +131,27 @@ ${} + ${tr:Lms.Settings.scrobbling} +
+
+ +
+ ${scrobbler} + ${scrobbler-info class="help-block"} +
+
+
+ +
+ ${listenbrainz-token} + ${listenbrainz-token-info class="help-block"} +
+
+
${} ${tr:Lms.Settings.change-password}
diff --git a/conf/lms.conf b/conf/lms.conf index e4c00109..5ec9768d 100644 --- a/conf/lms.conf +++ b/conf/lms.conf @@ -34,7 +34,10 @@ deploy-path = "/"; # Number of threads to be used to dispatch http requests (0 means auto detect) http-server-thread-count = 0; -# Acoustic brainz's root API +# ListenBrainz root API +listenbrainz-api-url = "https://api.listenbrainz.org/1/"; + +# Acousticbrainz root API acousticbrainz-api-url = "https://acousticbrainz.org/api/v1/"; # Authentication diff --git a/docroot/js/mediaplayer.js b/docroot/js/mediaplayer.js index cd4f7da9..6023a9ca 100644 --- a/docroot/js/mediaplayer.js +++ b/docroot/js/mediaplayer.js @@ -20,12 +20,15 @@ LMS.mediaplayer = function () { var _root = {}; var _elems = {}; var _offset = 0; + var _trackId = null; var _duration = 0; var _audioNativeSrc; var _audioTranscodeSrc; var _settings = {}; var _audioCtx = new (window.AudioContext || window.webkitAudioContext)(); var _gainNode = _audioCtx.createGain(); + var _playedDuration = 0; + var _lastStartPlaying = null; var _updateControls = function() { if (_elems.audio.paused) { @@ -38,16 +41,35 @@ LMS.mediaplayer = function () { } } + var _startTimer = function() { + if (_lastStartPlaying == null) + Wt.emit(_root, "scrobbleListenNow", _trackId); + _lastStartPlaying = Date.now(); + } + + var _stopTimer = function() { + if (_lastStartPlaying != null) { + _playedDuration += Date.now() - _lastStartPlaying; + } + } + + var _resetTimer = function() { + if (_lastStartPlaying != null) + Wt.emit(_root, "scrobbleListenFinished", _trackId, _playedDuration); + _playedDuration = 0; + _lastStartPlaying = null; + } + var _durationToString = function (duration) { - var minutes = parseInt(duration / 60, 10); - var seconds = parseInt(duration, 10) % 60; + var minutes = parseInt(duration / 60, 10); + var seconds = parseInt(duration, 10) % 60; - var res = ""; + var res = ""; - res += minutes + ":"; - res += (seconds < 10 ? "0" + seconds : seconds); + res += minutes + ":"; + res += (seconds < 10 ? "0" + seconds : seconds); - return res; + return res; } var _playTrack = function() { @@ -201,6 +223,10 @@ LMS.mediaplayer = function () { _elems.audio.addEventListener("playing", _updateControls); _elems.audio.addEventListener("pause", _updateControls); + _elems.audio.addEventListener("pause", _stopTimer); + _elems.audio.addEventListener("playing", _startTimer); + _elems.audio.addEventListener("waiting", _stopTimer); + _elems.audio.addEventListener("timeupdate", function() { _elems.progress.style.width = "" + ((_offset + _elems.audio.currentTime) / _duration) * 100 + "%"; _elems.curtime.innerHTML = _durationToString(_offset + _elems.audio.currentTime); @@ -293,6 +319,10 @@ LMS.mediaplayer = function () { } var loadTrack = function(params, autoplay) { + _stopTimer(); + _resetTimer(); + + _trackId = params.trackId; _offset = 0; _duration = params.duration; _audioNativeSrc = params.nativeResource; diff --git a/src/libs/CMakeLists.txt b/src/libs/CMakeLists.txt index 7302b579..e8c1413c 100644 --- a/src/libs/CMakeLists.txt +++ b/src/libs/CMakeLists.txt @@ -6,6 +6,7 @@ add_subdirectory(database) add_subdirectory(metadata) add_subdirectory(recommendation) add_subdirectory(scanner) +add_subdirectory(scrobbling) add_subdirectory(som) add_subdirectory(subsonic) add_subdirectory(utils) diff --git a/src/libs/database/impl/Session.cpp b/src/libs/database/impl/Session.cpp index f5ca6a76..5141f6d6 100644 --- a/src/libs/database/impl/Session.cpp +++ b/src/libs/database/impl/Session.cpp @@ -318,8 +318,16 @@ CREATE TABLE "user_backup" ( } else if (version == 29) { - // new field data_time in tracklist_entry (used by async scrobble or to make stats) _session.execute("ALTER TABLE tracklist_entry ADD date_time TEXT"); + _session.execute("ALTER TABLE user ADD listenbrainz_token TEXT"); + _session.execute("ALTER TABLE user ADD scrobbler INTEGER NOT NULL DEFAULT(" + std::to_string(static_cast(User::defaultScrobbler)) + ")"); + _session.execute("ALTER TABLE track ADD recording_mbid TEXT"); + + _session.execute("DELETE from tracklist WHERE name = ?").bind("__played_tracks__"); + + // MBID changes + // Just increment the scan version of the settings to make the next scheduled scan rescan everything + ScanSettings::get(*this).modify()->incScanVersion(); } else { @@ -432,6 +440,7 @@ Session::prepareTables() _session.execute("CREATE INDEX IF NOT EXISTS track_name_idx ON track(name)"); _session.execute("CREATE INDEX IF NOT EXISTS track_name_nocase_idx ON track(name COLLATE NOCASE)"); _session.execute("CREATE INDEX IF NOT EXISTS track_mbid_idx ON track(mbid)"); + _session.execute("CREATE INDEX IF NOT EXISTS track_recording_mbid_idx ON track(recording_mbid)"); _session.execute("CREATE INDEX IF NOT EXISTS track_release_idx ON track(release_id)"); _session.execute("CREATE INDEX IF NOT EXISTS track_year_idx ON track(year)"); _session.execute("CREATE INDEX IF NOT EXISTS track_original_year_idx ON track(original_year)"); diff --git a/src/libs/database/impl/Track.cpp b/src/libs/database/impl/Track.cpp index 13ec9cf6..604e7062 100644 --- a/src/libs/database/impl/Track.cpp +++ b/src/libs/database/impl/Track.cpp @@ -229,13 +229,13 @@ Track::getLastWritten(Session& session, std::optional after, cons } std::vector -Track::getAllWithMBIDAndMissingFeatures(Session& session) +Track::getAllWithRecordingMBIDAndMissingFeatures(Session& session) { session.checkSharedLocked(); Wt::Dbo::collection res = session.getDboSession().query ("SELECT t FROM track t") - .where("LENGTH(t.mbid) > 0") + .where("LENGTH(t.recording_mbid) > 0") .where("NOT EXISTS (SELECT * FROM track_features t_f WHERE t_f.track_id = t.id)"); return std::vector(res.begin(), res.end()); } diff --git a/src/libs/database/impl/TrackList.cpp b/src/libs/database/impl/TrackList.cpp index 39cff1a8..54b685e3 100644 --- a/src/libs/database/impl/TrackList.cpp +++ b/src/libs/database/impl/TrackList.cpp @@ -29,10 +29,11 @@ #include "database/User.hpp" #include "database/Track.hpp" #include "SqlQuery.hpp" +#include "StringViewTraits.hpp" namespace Database { -TrackList::TrackList(const std::string& name, Type type, bool isPublic, Wt::Dbo::ptr user) +TrackList::TrackList(std::string_view name, Type type, bool isPublic, Wt::Dbo::ptr user) : _name {name}, _type {type}, _isPublic {isPublic}, @@ -42,7 +43,7 @@ TrackList::TrackList(const std::string& name, Type type, bool isPublic, Wt::Dbo: } TrackList::pointer -TrackList::create(Session& session, const std::string& name, Type type, bool isPublic, Wt::Dbo::ptr user) +TrackList::create(Session& session, std::string_view name, Type type, bool isPublic, Wt::Dbo::ptr user) { session.checkUniqueLocked(); assert(user); @@ -54,7 +55,7 @@ TrackList::create(Session& session, const std::string& name, Type type, bool isP } TrackList::pointer -TrackList::get(Session& session, const std::string& name, Type type, Wt::Dbo::ptr user) +TrackList::get(Session& session, std::string_view name, Type type, Wt::Dbo::ptr user) { session.checkSharedLocked(); assert(user); @@ -147,22 +148,6 @@ TrackList::getEntries(std::optional offset, std::optional>(entries.begin(), entries.end()); } -std::vector> -TrackList::getEntriesReverse(std::optional offset, std::optional size) const -{ - assert(session()); - assert(IdIsValid(self()->id())); - - Wt::Dbo::collection> entries = - session()->find() - .where("tracklist_id = ?").bind(self().id()) - .orderBy("id DESC") - .limit(size ? static_cast(*size) : -1) - .offset(offset ? static_cast(*offset) : -1); - - return std::vector>(entries.begin(), entries.end()); -} - static Wt::Dbo::Query createArtistsQuery(Wt::Dbo::Session& session, const std::string& queryStr, IdType tracklistId, const std::set& clusterIds, std::optional linkType) @@ -275,8 +260,9 @@ TrackList::getArtistsReverse(const std::set& clusterIds, std::optionalid())); - Wt::Dbo::collection collection = createArtistsQuery(*session(), "SELECT DISTINCT a from artist a", self()->id(), clusterIds, linkType) - .orderBy("p_e.id DESC") + Wt::Dbo::collection collection = createArtistsQuery(*session(), "SELECT a from artist a", self()->id(), clusterIds, linkType) + .groupBy("a.id").having("p_e.date_time = MAX(p_e.date_time)") + .orderBy("p_e.date_time DESC") .limit(range ? static_cast(range->limit) + 1 : -1) .offset(range ? static_cast(range->offset) : -1); @@ -298,8 +284,9 @@ TrackList::getReleasesReverse(const std::set& clusterIds, std::optional< assert(session()); assert(IdIsValid(self()->id())); - Wt::Dbo::collection collection = createReleasesQuery(*session(), "SELECT DISTINCT r from release r", self()->id(), clusterIds) - .orderBy("p_e.id DESC") + Wt::Dbo::collection collection = createReleasesQuery(*session(), "SELECT r from release r", self()->id(), clusterIds) + .groupBy("r.id").having("p_e.date_time = MAX(p_e.date_time)") + .orderBy("p_e.date_time DESC") .limit(range ? static_cast(range->limit) + 1 : -1) .offset(range ? static_cast(range->offset) : -1); @@ -322,8 +309,8 @@ TrackList::getTracksReverse(const std::set& clusterIds, std::optionalid())); Wt::Dbo::collection collection = createTracksQuery(*session(), self()->id(), clusterIds) - .orderBy("p_e.id DESC") - .groupBy("t.id") + .groupBy("t.id").having("p_e.date_time = MAX(p_e.date_time)") + .orderBy("p_e.date_time DESC") .limit(range ? static_cast(range->limit) + 1 : -1) .offset(range ? static_cast(range->offset) : -1); @@ -498,21 +485,22 @@ TrackList::getTopTracks(const std::set& clusterIds, std::optional return res; } -TrackListEntry::TrackListEntry(Wt::Dbo::ptr track, Wt::Dbo::ptr tracklist) -: _track(track), - _tracklist(tracklist) +TrackListEntry::TrackListEntry(Wt::Dbo::ptr track, Wt::Dbo::ptr tracklist, const Wt::WDateTime& dateTime) +: _dateTime {Wt::WDateTime::fromTime_t(dateTime.toTime_t())} // force second resolution +, _track {track} +, _tracklist {tracklist} { - + assert(_dateTime.isValid()); } TrackListEntry::pointer -TrackListEntry::create(Session& session, Wt::Dbo::ptr track, Wt::Dbo::ptr tracklist) +TrackListEntry::create(Session& session, Wt::Dbo::ptr track, Wt::Dbo::ptr tracklist, const Wt::WDateTime& dateTime) { session.checkUniqueLocked(); assert(track); assert(tracklist); - auto res = session.getDboSession().add( std::make_unique( track, tracklist) ); + auto res = session.getDboSession().add(std::make_unique( track, tracklist, dateTime)); session.getDboSession().flush(); return res; diff --git a/src/libs/database/impl/User.cpp b/src/libs/database/impl/User.cpp index 055fcac0..0e3b09bd 100644 --- a/src/libs/database/impl/User.cpp +++ b/src/libs/database/impl/User.cpp @@ -68,7 +68,6 @@ AuthToken::getByValue(Session& session, const std::string& value) .where("value = ?").bind(value); } -static const std::string playedListName {"__played_tracks__"}; static const std::string queuedListName {"__queued_tracks__"}; User::User(std::string_view loginName) @@ -109,7 +108,6 @@ User::create(Session& session, std::string_view loginName) User::pointer user {session.getDboSession().add(std::make_unique(loginName))}; - TrackList::create(session, playedListName, TrackList::Type::Internal, false, user); TrackList::create(session, queuedListName, TrackList::Type::Internal, false, user); session.getDboSession().flush(); @@ -143,15 +141,6 @@ User::clearAuthTokens() _authTokens.clear(); } -Wt::Dbo::ptr -User::getPlayedTrackList(Session& session) const -{ - assert(self()); - session.checkSharedLocked(); - - return TrackList::get(session, playedListName, TrackList::Type::Internal, self()); -} - Wt::Dbo::ptr User::getQueuedTrackList(Session& session) const { diff --git a/src/libs/database/include/database/Track.hpp b/src/libs/database/include/database/Track.hpp index 22e97643..119b7d75 100644 --- a/src/libs/database/include/database/Track.hpp +++ b/src/libs/database/include/database/Track.hpp @@ -81,7 +81,7 @@ class Track : public Wt::Dbo::Dbo static std::vector> getAllPaths(Session& session, std::optional offset = std::nullopt, std::optional size = std::nullopt); static std::vector getMBIDDuplicates(Session& session); static std::vector getLastWritten(Session& session, std::optional after, const std::set& clusters, std::optional range, bool& moreResults); - static std::vector getAllWithMBIDAndMissingFeatures(Session& session); + static std::vector getAllWithRecordingMBIDAndMissingFeatures(Session& session); static std::vector getAllIdsWithFeatures(Session& session, std::optional limit = {}); static std::vector getAllIdsWithClusters(Session& session, std::optional limit = {}); static std::vector getStarred(Session& session, @@ -106,7 +106,8 @@ class Track : public Wt::Dbo::Dbo void setYear(int year) { _year = year; } void setOriginalYear(int year) { _originalYear = year; } void setHasCover(bool hasCover) { _hasCover = hasCover; } - void setMBID(const std::optional& MBID) { _MBID = MBID ? MBID->getAsString() : ""; } + void setTrackMBID(const std::optional& MBID) { _trackMBID = MBID ? MBID->getAsString() : ""; } + void setRecordingMBID(const std::optional& MBID) { _recordingMBID = MBID ? MBID->getAsString() : ""; } void setCopyright(const std::string& copyright) { _copyright = std::string(copyright, 0, _maxCopyrightLength); } void setCopyrightURL(const std::string& copyrightURL) { _copyrightURL = std::string(copyrightURL, 0, _maxCopyrightURLLength); } void setTrackReplayGain(float replayGain) { _trackReplayGain = replayGain; } @@ -131,7 +132,8 @@ class Track : public Wt::Dbo::Dbo Wt::WDateTime getLastWriteTime() const { return _fileLastWrite; } Wt::WDateTime getAddedTime() const { return _fileAdded; } bool hasCover() const { return _hasCover; } - std::optional getMBID() const { return UUID::fromString(_MBID); } + std::optional getTrackMBID() const { return UUID::fromString(_trackMBID); } + std::optional getRecordingMBID() const { return UUID::fromString(_recordingMBID); } std::optional getCopyright() const; std::optional getCopyrightURL() const; std::optional getTrackReplayGain() const { return _trackReplayGain; } @@ -166,7 +168,8 @@ class Track : public Wt::Dbo::Dbo Wt::Dbo::field(a, _fileLastWrite, "file_last_write"); Wt::Dbo::field(a, _fileAdded, "file_added"); Wt::Dbo::field(a, _hasCover, "has_cover"); - Wt::Dbo::field(a, _MBID, "mbid"); + Wt::Dbo::field(a, _trackMBID, "mbid"); + Wt::Dbo::field(a, _recordingMBID, "recording_mbid"); Wt::Dbo::field(a, _copyright, "copyright"); Wt::Dbo::field(a, _copyrightURL, "copyright_url"); Wt::Dbo::field(a, _trackReplayGain, "track_replay_gain"); @@ -201,7 +204,8 @@ class Track : public Wt::Dbo::Dbo Wt::WDateTime _fileLastWrite; Wt::WDateTime _fileAdded; bool _hasCover {}; - std::string _MBID; // Musicbrainz Identifier + std::string _trackMBID; + std::string _recordingMBID; std::string _copyright; std::string _copyrightURL; std::optional _trackReplayGain; diff --git a/src/libs/database/include/database/TrackList.hpp b/src/libs/database/include/database/TrackList.hpp index a7ba1a8c..7e7099da 100644 --- a/src/libs/database/include/database/TrackList.hpp +++ b/src/libs/database/include/database/TrackList.hpp @@ -47,11 +47,11 @@ class TrackList : public Wt::Dbo::Dbo enum class Type { Playlist, // user controlled playlists - Internal, // current playqueue, history + Internal, // internal usage (current playqueue, history, ...) }; TrackList() = default; - TrackList(const std::string& name, Type type, bool isPublic, Wt::Dbo::ptr user); + TrackList(std::string_view name, Type type, bool isPublic, Wt::Dbo::ptr user); // Stats utility std::vector> getTopArtists(const std::set& clusterIds, std::optional linkType, std::optional range, bool& moreResults) const; @@ -59,14 +59,14 @@ class TrackList : public Wt::Dbo::Dbo std::vector> getTopTracks(const std::set& clusterIds, std::optional range, bool& moreResults) const; // Search utility - static pointer get(Session& session, const std::string& name, Type type, Wt::Dbo::ptr user); + static pointer get(Session& session, std::string_view name, Type type, Wt::Dbo::ptr user); static pointer getById(Session& session, IdType tracklistId); static std::vector getAll(Session& session); static std::vector getAll(Session& session, Wt::Dbo::ptr user); static std::vector getAll(Session& session, Wt::Dbo::ptr user, Type type); // Create utility - static pointer create(Session& session, const std::string& name, Type type, bool isPublic, Wt::Dbo::ptr user); + static pointer create(Session& session, std::string_view name, Type type, bool isPublic, Wt::Dbo::ptr user); // Accessors std::string getName() const { return _name; } @@ -84,7 +84,6 @@ class TrackList : public Wt::Dbo::Dbo std::size_t getCount() const; Wt::Dbo::ptr getEntry(std::size_t pos) const; std::vector> getEntries(std::optional offset = {}, std::optional size = {}) const; - std::vector> getEntriesReverse(std::optional offset = {}, std::optional size = {}) const; std::vector> getArtistsReverse(const std::set& clusterIds, std::optional linkType, std::optional range, bool& moreResults) const; std::vector> getReleasesReverse(const std::set& clusterIds, std::optional range, bool& moreResults) const; @@ -131,15 +130,17 @@ class TrackListEntry : public Wt::Dbo::Dbo using pointer = Wt::Dbo::ptr; TrackListEntry() = default; - TrackListEntry(Wt::Dbo::ptr track, Wt::Dbo::ptr tracklist); + TrackListEntry(Wt::Dbo::ptr track, Wt::Dbo::ptr tracklist, const Wt::WDateTime& dateTime); + // find utility static pointer getById(Session& session, IdType id); // Create utility - static pointer create(Session& session, Wt::Dbo::ptr track, Wt::Dbo::ptr tracklist); + static pointer create(Session& session, Wt::Dbo::ptr track, Wt::Dbo::ptr tracklist, const Wt::WDateTime& dateTime = Wt::WDateTime::currentDateTime()); // Accessors Wt::Dbo::ptr getTrack() const { return _track; } + const Wt::WDateTime& getDateTime() const { return _dateTime; } template void persist(Action& a) diff --git a/src/libs/database/include/database/Types.hpp b/src/libs/database/include/database/Types.hpp index 15fdb0b3..080075f1 100644 --- a/src/libs/database/include/database/Types.hpp +++ b/src/libs/database/include/database/Types.hpp @@ -19,6 +19,7 @@ #pragma once +#include #include namespace Database @@ -51,5 +52,23 @@ namespace Database Writer, }; + // User selectable audio file formats + // Do not change values + enum class AudioFormat + { + MP3 = 1, + OGG_OPUS = 2, + OGG_VORBIS = 3, + WEBM_VORBIS = 4, + MATROSKA_OPUS = 5, + }; + using Bitrate = std::uint32_t; + + // Do not change enum values! + enum class Scrobbler + { + Internal = 0, + ListenBrainz = 1, + }; } diff --git a/src/libs/database/include/database/User.hpp b/src/libs/database/include/database/User.hpp index 52932a88..0f5b5758 100644 --- a/src/libs/database/include/database/User.hpp +++ b/src/libs/database/include/database/User.hpp @@ -26,6 +26,7 @@ #include #include +#include "utils/UUID.hpp" #include "Types.hpp" namespace Database { @@ -37,19 +38,6 @@ class Session; class TrackList; class Track; -// User selectable audio file formats -// Do not change values -enum class AudioFormat -{ - MP3 = 1, - OGG_OPUS = 2, - OGG_VORBIS = 3, - WEBM_VORBIS = 4, - MATROSKA_OPUS = 5, -}; - -using Bitrate = std::size_t; - class User; class AuthToken { @@ -139,6 +127,7 @@ class User : public Wt::Dbo::Dbo static inline const Bitrate defaultSubsonicTranscodeBitrate {128000}; static inline const UITheme defaultUITheme {UITheme::Dark}; static inline const SubsonicArtistListMode defaultSubsonicArtistListMode {SubsonicArtistListMode::AllArtists}; + static inline const Scrobbler defaultScrobbler {Scrobbler::Internal}; User() = default; @@ -172,6 +161,8 @@ class User : public Wt::Dbo::Dbo void setUITheme(UITheme uiTheme) { _uiTheme = uiTheme; } void clearAuthTokens(); void setSubsonicArtistListMode(SubsonicArtistListMode mode) { _subsonicArtistListMode = mode; } + void setScrobbler(Scrobbler scrobbler) { _scrobbler = scrobbler; } + void setListenBrainzToken(const std::optional& MBID) { _listenbrainzToken = MBID ? MBID->getAsString() : ""; } // read bool isAdmin() const { return _type == Type::ADMIN; } @@ -184,8 +175,9 @@ class User : public Wt::Dbo::Dbo bool isRadioSet() const { return _radio; } UITheme getUITheme() const { return _uiTheme; } SubsonicArtistListMode getSubsonicArtistListMode() const { return _subsonicArtistListMode; } + Scrobbler getScrobbler() const { return _scrobbler; } + std::optional getListenBrainzToken() const { return UUID::fromString(_listenbrainzToken); } - Wt::Dbo::ptr getPlayedTrackList(Session& session) const; Wt::Dbo::ptr getQueuedTrackList(Session& session) const; void starArtist(Wt::Dbo::ptr artist); @@ -214,10 +206,14 @@ class User : public Wt::Dbo::Dbo Wt::Dbo::field(a, _subsonicTranscodeBitrate, "subsonic_transcode_bitrate"); Wt::Dbo::field(a, _subsonicArtistListMode, "subsonic_artist_list_mode"); Wt::Dbo::field(a, _uiTheme, "ui_theme"); - // User's dynamic data + Wt::Dbo::field(a, _scrobbler, "scrobbler"); + Wt::Dbo::field(a, _listenbrainzToken, "listenbrainz_token"); + + // UI settings Wt::Dbo::field(a, _curPlayingTrackPos, "cur_playing_track_pos"); Wt::Dbo::field(a, _repeatAll, "repeat_all"); Wt::Dbo::field(a, _radio, "radio"); + Wt::Dbo::hasMany(a, _tracklists, Wt::Dbo::ManyToOne, "user"); Wt::Dbo::hasMany(a, _starredArtists, Wt::Dbo::ManyToMany, "user_artist_starred", "", Wt::Dbo::OnDeleteCascade); Wt::Dbo::hasMany(a, _starredReleases, Wt::Dbo::ManyToMany, "user_release_starred", "", Wt::Dbo::OnDeleteCascade); @@ -232,6 +228,8 @@ class User : public Wt::Dbo::Dbo std::string _passwordHash; Wt::WDateTime _lastLogin; UITheme _uiTheme {defaultUITheme}; + Scrobbler _scrobbler {defaultScrobbler}; + std::string _listenbrainzToken; // Musicbrainz Identifier // Admin defined settings Type _type {Type::REGULAR}; diff --git a/src/libs/metadata/impl/AvFormatParser.cpp b/src/libs/metadata/impl/AvFormatParser.cpp index 3bde19ab..164f4c3f 100644 --- a/src/libs/metadata/impl/AvFormatParser.cpp +++ b/src/libs/metadata/impl/AvFormatParser.cpp @@ -200,11 +200,14 @@ AvFormatParser::parse(const std::filesystem::path& p, bool debug) track.acoustID = UUID::fromString(value); } else if (tag == "MUSICBRAINZ RELEASE TRACK ID" - || tag == "MUSICBRAINZ_RELEASETRACKID" - || tag == "MUSICBRAINZ_TRACKID" + || tag == "MUSICBRAINZ_RELEASETRACKID") + { + track.trackMBID = UUID::fromString(value); + } + else if (tag == "MUSICBRAINZ_TRACKID" || tag == "MUSICBRAINZ/TRACK ID") { - track.musicBrainzTrackID = UUID::fromString(value); + track.recordingMBID = UUID::fromString(value); } else if (tag == "TSST" || tag == "DISCSUBTITLE" diff --git a/src/libs/metadata/impl/TagLibParser.cpp b/src/libs/metadata/impl/TagLibParser.cpp index 8b7d70c3..227fc4ed 100644 --- a/src/libs/metadata/impl/TagLibParser.cpp +++ b/src/libs/metadata/impl/TagLibParser.cpp @@ -149,7 +149,6 @@ void TagLibParser::processTag(Track& track, const std::string& tag, const TagLib::StringList& values, bool debug) { - // TODO validate MBID format if (debug) { std::vector strs; @@ -168,11 +167,11 @@ TagLibParser::processTag(Track& track, const std::string& tag, const TagLib::Str else if (tag == "MUSICBRAINZ_RELEASETRACKID" || tag == "MUSICBRAINZ RELEASE TRACK ID") { - track.musicBrainzTrackID = UUID::fromString(value); + track.trackMBID = UUID::fromString(value); } else if (tag == "MUSICBRAINZ_TRACKID" || tag == "MUSICBRAINZ TRACK ID") - track.musicBrainzRecordID = UUID::fromString(value); + track.recordingMBID = UUID::fromString(value); else if (tag == "ACOUSTID_ID") track.acoustID = UUID::fromString(value); else if (tag == "TRACKTOTAL") diff --git a/src/libs/metadata/include/metadata/IParser.hpp b/src/libs/metadata/include/metadata/IParser.hpp index 06a521bf..61244066 100644 --- a/src/libs/metadata/include/metadata/IParser.hpp +++ b/src/libs/metadata/include/metadata/IParser.hpp @@ -59,8 +59,8 @@ namespace MetaData std::vector artists; std::vector albumArtists; std::string title; - std::optional musicBrainzTrackID; - std::optional musicBrainzRecordID; + std::optional trackMBID; + std::optional recordingMBID; std::optional album; Clusters clusters; std::chrono::milliseconds duration; diff --git a/src/libs/scanner/impl/AcousticBrainzUtils.cpp b/src/libs/scanner/impl/AcousticBrainzUtils.cpp index 918b4d13..37f06166 100644 --- a/src/libs/scanner/impl/AcousticBrainzUtils.cpp +++ b/src/libs/scanner/impl/AcousticBrainzUtils.cpp @@ -79,9 +79,9 @@ getJsonData(const UUID& mbid) } std::string -extractLowLevelFeatures(const UUID& mbid) +extractLowLevelFeatures(const UUID& recordingMBID) { - return getJsonData(mbid); + return getJsonData(recordingMBID); } } // namespace Scanner::AcousticBrainz diff --git a/src/libs/scanner/impl/AcousticBrainzUtils.hpp b/src/libs/scanner/impl/AcousticBrainzUtils.hpp index cdd73d77..07787d59 100644 --- a/src/libs/scanner/impl/AcousticBrainzUtils.hpp +++ b/src/libs/scanner/impl/AcousticBrainzUtils.hpp @@ -25,6 +25,6 @@ class UUID; namespace AcousticBrainz { - std::string extractLowLevelFeatures(const UUID& MBID); + std::string extractLowLevelFeatures(const UUID& recordingMBID); } diff --git a/src/libs/scanner/impl/Scanner.cpp b/src/libs/scanner/impl/Scanner.cpp index 3a95b646..f1b1fe3f 100644 --- a/src/libs/scanner/impl/Scanner.cpp +++ b/src/libs/scanner/impl/Scanner.cpp @@ -543,15 +543,15 @@ Scanner::scan(bool forceScan) } bool -Scanner::fetchTrackFeatures(Database::IdType trackId, const UUID& MBID) +Scanner::fetchTrackFeatures(Database::IdType trackId, const UUID& recordingMBID) { std::map features; - LMS_LOG(DBUPDATER, INFO) << "Fetching low level features for track '" << MBID.getAsString() << "'"; - const std::string data {AcousticBrainz::extractLowLevelFeatures(MBID)}; + LMS_LOG(DBUPDATER, INFO) << "Fetching low level features for recording '" << recordingMBID.getAsString() << "'"; + const std::string data {AcousticBrainz::extractLowLevelFeatures(recordingMBID)}; if (data.empty()) { - LMS_LOG(DBUPDATER, ERROR) << "Track " << trackId << ", MBID = '" << MBID.getAsString() << "': cannot extract features using AcousticBrainz"; + LMS_LOG(DBUPDATER, ERROR) << "Track " << trackId << ", recording MBID = '" << recordingMBID.getAsString() << "': cannot extract features using AcousticBrainz"; return false; } @@ -581,7 +581,7 @@ Scanner::fetchTrackFeatures(ScanStats& stats) struct TrackInfo { Database::IdType id; - UUID mbid; + UUID recordingMBID; }; const auto tracksToFetch {[&]() @@ -590,9 +590,9 @@ Scanner::fetchTrackFeatures(ScanStats& stats) auto transaction {_dbSession.createSharedTransaction()}; - auto tracks {Database::Track::getAllWithMBIDAndMissingFeatures(_dbSession)}; + auto tracks {Database::Track::getAllWithRecordingMBIDAndMissingFeatures(_dbSession)}; for (const auto& track : tracks) - res.emplace_back(TrackInfo {track.id(), *track->getMBID()}); + res.emplace_back(TrackInfo {track.id(), *track->getRecordingMBID()}); return res; }()}; @@ -607,7 +607,7 @@ Scanner::fetchTrackFeatures(ScanStats& stats) if (_abortScan) return; - if (fetchTrackFeatures(trackToFetch.id, trackToFetch.mbid)) + if (fetchTrackFeatures(trackToFetch.id, trackToFetch.recordingMBID)) stats.featuresFetched++; stepStats.processedElems++; @@ -824,7 +824,8 @@ Scanner::scanAudioFile(const std::filesystem::path& file, bool forceScan, ScanSt if (!trackInfo->year && trackInfo->originalYear) track.modify()->setYear(*trackInfo->originalYear); - track.modify()->setMBID(trackInfo->musicBrainzRecordID); + track.modify()->setRecordingMBID(trackInfo->recordingMBID); + track.modify()->setTrackMBID(trackInfo->trackMBID); track.modify()->setFeatures({}); // TODO: only if MBID changed? track.modify()->setHasCover(trackInfo->hasCover); track.modify()->setCopyright(trackInfo->copyright); @@ -1022,9 +1023,9 @@ Scanner::checkDuplicatedAudioFiles(ScanStats& stats) const std::vector tracks = Database::Track::getMBIDDuplicates(_dbSession); for (const Track::pointer& track : tracks) { - if (track->getMBID()) + if (auto trackMBID {track->getTrackMBID()}) { - LMS_LOG(DBUPDATER, INFO) << "Found duplicated MBID [" << track->getMBID()->getAsString() << "], file: " << track->getPath().string() << " - " << track->getName(); + LMS_LOG(DBUPDATER, INFO) << "Found duplicated Track MBID [" << trackMBID->getAsString() << "], file: " << track->getPath().string() << " - " << track->getName(); stats.duplicates.emplace_back(ScanDuplicate {track.id(), DuplicateReason::SameMBID}); } } diff --git a/src/libs/scrobbling/CMakeLists.txt b/src/libs/scrobbling/CMakeLists.txt new file mode 100644 index 00000000..162f1c9c --- /dev/null +++ b/src/libs/scrobbling/CMakeLists.txt @@ -0,0 +1,23 @@ + +add_library(lmsscrobbling SHARED + impl/internal/InternalScrobbler.cpp + impl/listenbrainz/ListenBrainzScrobbler.cpp + impl/Scrobbling.cpp + ) + +target_include_directories(lmsscrobbling INTERFACE + include + ) + +target_include_directories(lmsscrobbling PRIVATE + include + impl + ) + +target_link_libraries(lmsscrobbling PUBLIC + lmsdatabase + lmsutils + ) + +install(TARGETS lmsscrobbling DESTINATION lib) + diff --git a/src/libs/scrobbling/impl/IScrobbler.hpp b/src/libs/scrobbling/impl/IScrobbler.hpp new file mode 100644 index 00000000..9359a071 --- /dev/null +++ b/src/libs/scrobbling/impl/IScrobbler.hpp @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2021 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include +#include + +#include + +#include "scrobbling/Listen.hpp" + +namespace Database +{ + class Db; + class Session; + class TrackList; + class User; +} + +namespace Scrobbling +{ + + class IScrobbler + { + public: + virtual ~IScrobbler() = default; + + virtual void listenStarted(const Listen& listen) = 0; + virtual void listenFinished(const Listen& listen, std::chrono::seconds duration) = 0; + + virtual void addListen(const Listen& listen, const Wt::WDateTime& timePoint) = 0; + + virtual Wt::Dbo::ptr getListensTrackList(Database::Session& session, Wt::Dbo::ptr user) = 0; + }; + + std::unique_ptr createScrobbler(std::string_view backendName); + +} // ns Scrobbling + diff --git a/src/libs/scrobbling/impl/Scrobbling.cpp b/src/libs/scrobbling/impl/Scrobbling.cpp new file mode 100644 index 00000000..6b755f8e --- /dev/null +++ b/src/libs/scrobbling/impl/Scrobbling.cpp @@ -0,0 +1,186 @@ +/* + * Copyright (C) 2021 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include "Scrobbling.hpp" + +#include "database/Db.hpp" +#include "database/Session.hpp" +#include "database/TrackList.hpp" +#include "database/User.hpp" + +#include "internal/InternalScrobbler.hpp" +#include "listenbrainz/ListenBrainzScrobbler.hpp" + +namespace Scrobbling +{ + std::unique_ptr + createScrobbling(Database::Db& db) + { + return std::make_unique(db); + } + + Scrobbling::Scrobbling(Database::Db& db) + : _db {db} + { + _scrobblers.emplace(Database::Scrobbler::Internal, std::make_unique(_db)); + _scrobblers.emplace(Database::Scrobbler::ListenBrainz, std::make_unique(_db)); + } + + void + Scrobbling::listenStarted(const Listen& listen) + { + if (auto scrobbler {getUserScrobbler(listen.userId)}) + _scrobblers[*scrobbler]->listenStarted(listen); + } + + void + Scrobbling::listenFinished(const Listen& listen, std::chrono::seconds duration) + { + if (auto scrobbler {getUserScrobbler(listen.userId)}) + _scrobblers[*scrobbler]->listenFinished(listen, duration); + } + + void + Scrobbling::addListen(const Listen& listen, Wt::WDateTime timePoint) + { + if (auto scrobbler {getUserScrobbler(listen.userId)}) + _scrobblers[*scrobbler]->addListen(listen, timePoint); + } + + std::optional + Scrobbling::getUserScrobbler(Database::IdType userId) + { + std::optional scrobbler; + + Database::Session& session {_db.getTLSSession()}; + auto transaction {session.createSharedTransaction()}; + if (const Database::User::pointer user {Database::User::getById(session, userId)}) + scrobbler = user->getScrobbler(); + + return scrobbler; + } + + std::vector> + Scrobbling::getRecentArtists(Database::Session& session, + Wt::Dbo::ptr user, + const std::set& clusterIds, + std::optional linkType, + std::optional range, + bool& moreResults) + { + const Wt::Dbo::ptr history {getListensTrackList(session, user)}; + + std::vector> res; + if (history) + res = history->getArtistsReverse(clusterIds, linkType, range, moreResults); + + return res; + } + + std::vector> + Scrobbling::getRecentReleases(Database::Session& session, + Wt::Dbo::ptr user, + const std::set& clusterIds, + std::optional range, + bool& moreResults) + { + const Wt::Dbo::ptr history {getListensTrackList(session, user)}; + + std::vector> res; + if (history) + res = history->getReleasesReverse(clusterIds, range, moreResults); + + return res; + } + + std::vector> + Scrobbling::getRecentTracks(Database::Session& session, + Wt::Dbo::ptr user, + const std::set& clusterIds, + std::optional range, + bool& moreResults) + { + const Wt::Dbo::ptr history {getListensTrackList(session, user)}; + + std::vector> res; + if (history) + res = history->getTracksReverse(clusterIds, range, moreResults); + + return res; + } + + + // Top + std::vector> + Scrobbling::getTopArtists(Database::Session& session, + Wt::Dbo::ptr user, + const std::set& clusterIds, + std::optional linkType, + std::optional range, + bool& moreResults) + { + const Wt::Dbo::ptr history {getListensTrackList(session, user)}; + + std::vector> res; + if (history) + res = history->getTopArtists(clusterIds, linkType, range, moreResults); + + return res; + } + + std::vector> + Scrobbling::getTopReleases(Database::Session& session, + Wt::Dbo::ptr user, + const std::set& clusterIds, + std::optional range, + bool& moreResults) + { + const Wt::Dbo::ptr history {getListensTrackList(session, user)}; + + std::vector> res; + if (history) + res = history->getTopReleases(clusterIds, range, moreResults); + + return res; + } + + std::vector> + Scrobbling::getTopTracks(Database::Session& session, + Wt::Dbo::ptr user, + const std::set& clusterIds, + std::optional range, + bool& moreResults) + { + const Wt::Dbo::ptr history {getListensTrackList(session, user)}; + + std::vector> res; + if (history) + res = history->getTopTracks(clusterIds, range, moreResults); + + return res; + } + + Wt::Dbo::ptr + Scrobbling::getListensTrackList(Database::Session& session, Wt::Dbo::ptr user) + { + return _scrobblers[user->getScrobbler()]->getListensTrackList(session, user); + } + +} // ns Scrobbling + diff --git a/src/libs/scrobbling/impl/Scrobbling.hpp b/src/libs/scrobbling/impl/Scrobbling.hpp new file mode 100644 index 00000000..e4ac4256 --- /dev/null +++ b/src/libs/scrobbling/impl/Scrobbling.hpp @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2021 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include +#include +#include + +#include "scrobbling/IScrobbling.hpp" +#include "IScrobbler.hpp" + +namespace Scrobbling +{ + class Scrobbling : public IScrobbling + { + public: + + Scrobbling(Database::Db& db); + + private: + void listenStarted(const Listen& listen) override; + void listenFinished(const Listen& listen, std::chrono::seconds duration) override; + void addListen(const Listen& listen, Wt::WDateTime timePoint) override; + + std::vector> getRecentArtists(Database::Session& session, + Wt::Dbo::ptr user, + const std::set& clusterIds, + std::optional linkType, + std::optional range, + bool& moreResults) override; + + std::vector> getRecentReleases(Database::Session& session, + Wt::Dbo::ptr user, + const std::set& clusterIds, + std::optional range, + bool& moreResults) override; + + std::vector> getRecentTracks(Database::Session& session, + Wt::Dbo::ptr user, + const std::set& clusterIds, + std::optional range, + bool& moreResults) override; + + std::vector> getTopArtists(Database::Session& session, + Wt::Dbo::ptr user, + const std::set& clusterIds, + std::optional linkType, + std::optional range, + bool& moreResults) override; + + std::vector> getTopReleases(Database::Session& session, + Wt::Dbo::ptr user, + const std::set& clusterIds, + std::optional range, + bool& moreResults) override; + + std::vector> getTopTracks(Database::Session& session, + Wt::Dbo::ptr user, + const std::set& clusterIds, + std::optional range, + bool& moreResults) override; + + Wt::Dbo::ptr getListensTrackList(Database::Session& session, Wt::Dbo::ptr user); + + std::optional getUserScrobbler(Database::IdType userId); + + Database::Db& _db; + std::unordered_map> _scrobblers; + }; + +} // ns Scrobbling + diff --git a/src/libs/scrobbling/impl/internal/InternalScrobbler.cpp b/src/libs/scrobbling/impl/internal/InternalScrobbler.cpp new file mode 100644 index 00000000..cd0ff9d2 --- /dev/null +++ b/src/libs/scrobbling/impl/internal/InternalScrobbler.cpp @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2021 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include "InternalScrobbler.hpp" + +#include "database/Db.hpp" +#include "database/Session.hpp" +#include "database/Track.hpp" +#include "database/TrackList.hpp" +#include "database/User.hpp" +#include "utils/Logger.hpp" + +namespace Scrobbling +{ + static const std::string historyTracklistName {"__scrobbler_internal_history__"}; + + InternalScrobbler::InternalScrobbler(Database::Db& db) + : _db {db} + {} + + void + InternalScrobbler::listenStarted(const Listen& listen) + { + addListen(listen, Wt::WDateTime::currentDateTime()); + } + + void + InternalScrobbler::listenFinished(const Listen& /*event*/, std::chrono::seconds /* duration */) + { + // nothing to do + } + + void + InternalScrobbler::addListen(const Listen& listen, const Wt::WDateTime& timePoint) + { + Database::Session& session {_db.getTLSSession()}; + + auto transaction {session.createUniqueTransaction()}; + + const Database::User::pointer user {Database::User::getById(session, listen.userId)}; + if (!user) + return; + + Wt::Dbo::ptr tracklist {getListensTrackList(session, user)}; + if (!tracklist) + tracklist = Database::TrackList::create(session, historyTracklistName, Database::TrackList::Type::Internal, false, user); + + const Database::Track::pointer track {Database::Track::getById(session, listen.trackId)}; + if (!track) + return; + + Database::TrackListEntry::create(session, track, getListensTrackList(session, user), timePoint); + } + + Wt::Dbo::ptr + InternalScrobbler::getListensTrackList(Database::Session& session, Wt::Dbo::ptr user) + { + return Database::TrackList::get(session, historyTracklistName, Database::TrackList::Type::Internal, user); + } + +} // Scrobbling + diff --git a/src/libs/scrobbling/impl/internal/InternalScrobbler.hpp b/src/libs/scrobbling/impl/internal/InternalScrobbler.hpp new file mode 100644 index 00000000..062c3f95 --- /dev/null +++ b/src/libs/scrobbling/impl/internal/InternalScrobbler.hpp @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2021 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include "IScrobbler.hpp" + +namespace Scrobbling +{ + class InternalScrobbler final : public IScrobbler + { + public: + InternalScrobbler(Database::Db& db); + + private: + + void listenStarted(const Listen& listen) override; + void listenFinished(const Listen& listen, std::chrono::seconds duration) override; + + void addListen(const Listen& listen, const Wt::WDateTime& timePoint) override; + + Wt::Dbo::ptr getListensTrackList(Database::Session& session, Wt::Dbo::ptr user) override; + + Database::Db& _db; + }; +} // Scrobbling + diff --git a/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.cpp b/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.cpp new file mode 100644 index 00000000..c1f86aeb --- /dev/null +++ b/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.cpp @@ -0,0 +1,400 @@ +/* + * Copyright (C) 2021 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include "ListenBrainzScrobbler.hpp" + +#include +#include +#include +#include + +#include "database/Artist.hpp" +#include "database/Db.hpp" +#include "database/Release.hpp" +#include "database/Session.hpp" +#include "database/Track.hpp" +#include "database/TrackList.hpp" +#include "database/User.hpp" +#include "utils/IConfig.hpp" +#include "utils/Logger.hpp" +#include "utils/Service.hpp" + +#define LOG(sev) LMS_LOG(SCROBBLING, sev) << "[listenbrainz] - " + +namespace StringUtils +{ + template<> + std::optional + readAs(const std::string& str) + { + std::optional res; + + if (const std::optional value {StringUtils::readAs(str)}) + res = std::chrono::seconds {*value}; + + return res; + } +} + +namespace +{ + std::optional + getListenBrainzToken(Database::Session& session, Database::IdType userId) + { + auto transaction {session.createSharedTransaction()}; + + const Database::User::pointer user {Database::User::getById(session, userId)}; + if (!user) + return std::nullopt; + + if (user->getScrobbler() != Database::Scrobbler::ListenBrainz) + return std::nullopt; + + return user->getListenBrainzToken(); + } + + bool + canBeScrobbled(Database::Session& session, Database::IdType trackId, std::chrono::seconds duration) + { + auto transaction {session.createSharedTransaction()}; + + const Database::Track::pointer track {Database::Track::getById(session, trackId)}; + if (!track) + return false; + + const bool res {duration >= std::chrono::minutes(4) || (duration >= track->getDuration() / 2)}; + if (!res) + LOG(DEBUG) << "Track cannot be scrobbled since played duration is too short: " << duration.count() << "s, total duration = " << std::chrono::duration_cast(track->getDuration()).count() << "s"; + + return res; + } + + std::optional + listenToJsonPayload(Database::Session& session, const Scrobbling::Listen& listen, const Wt::WDateTime& timePoint) + { + auto transaction {session.createSharedTransaction()}; + + const Database::Track::pointer track {Database::Track::getById(session, listen.trackId)}; + if (!track) + return std::nullopt; + + auto artists {track->getArtists({Database::TrackArtistLinkType::Artist})}; + if (artists.empty()) + artists = track->getArtists({Database::TrackArtistLinkType::ReleaseArtist}); + + Wt::Json::Object additionalInfo; + additionalInfo["listening_from"] = "LMS"; + if (track->getRelease()) + { + if (auto MBID {track->getRelease()->getMBID()}) + additionalInfo["release_mbid"] = Wt::Json::Value {std::string {MBID->getAsString()}}; + } + + if (!artists.empty()) + { + Wt::Json::Array artistMBIDs; + for (const Database::Artist::pointer& artist : artists) + { + if (auto MBID {artist->getMBID()}) + artistMBIDs.push_back(Wt::Json::Value {std::string {MBID->getAsString()}}); + } + + if (!artistMBIDs.empty()) + additionalInfo["artist_mbids"] = std::move(artistMBIDs); + } + + if (auto MBID {track->getTrackMBID()}) + additionalInfo["track_mbid"] = Wt::Json::Value {std::string {MBID->getAsString()}}; + + if (auto MBID {track->getRecordingMBID()}) + additionalInfo["recording_mbid"] = Wt::Json::Value {std::string {MBID->getAsString()}}; + + if (const std::optional trackNumber {track->getTrackNumber()}) + additionalInfo["tracknumber"] = Wt::Json::Value {static_cast(*trackNumber)}; + + Wt::Json::Object trackMetadata; + trackMetadata["additional_info"] = std::move(additionalInfo); + + if (!artists.empty()) + trackMetadata["artist_name"] = Wt::Json::Value {artists.front()->getName()}; + + trackMetadata["track_name"] = Wt::Json::Value {track->getName()}; + if (track->getRelease()) + trackMetadata["release_name"] = Wt::Json::Value {track->getRelease()->getName()}; + + Wt::Json::Object payload; + payload["track_metadata"] = std::move(trackMetadata); + if (timePoint.isValid()) + payload["listened_at"] = Wt::Json::Value {static_cast(timePoint.toTime_t())}; + + return payload; + } + + std::string + listenToJsonString(Database::Session& session, const Scrobbling::Listen& listen, const Wt::WDateTime& timePoint, std::string_view listenType) + { + std::string res; + + std::optional payload {listenToJsonPayload(session, listen, timePoint)}; + if (!payload) + return res; + + Wt::Json::Object root; + root["listen_type"] = Wt::Json::Value {std::string {listenType}}; + root["payload"] = Wt::Json::Array {std::move(*payload)}; + + res = Wt::Json::serialize(root); + return res; + } + + template + std::optional + headerReadAs(const Wt::Http::Message& msg, std::string_view headerName) + { + std::optional res; + + if (const std::string* headerValue {msg.getHeader(std::string {headerName})}) + res = StringUtils::readAs(*headerValue); + + return res; + } +} + +namespace Scrobbling +{ + static const std::string historyTracklistName {"__scrobbler_listenbrainz_history__"}; + + ListenBrainzScrobbler::ListenBrainzScrobbler(Database::Db& db) + : _apiEndpoint {Service::get()->getString("listenbrainz-api-url", "https://api.listenbrainz.org/1/")} + , _db {db} + { + LOG(INFO) << "Starting ListenBrainz scrobbler... API endpoint = '" << _apiEndpoint << "'"; + + _client.done().connect([this](Wt::AsioWrapper::error_code ec, Wt::Http::Message msg) + { + onClientDone(ec, msg); + }); + + _ioService.setThreadCount(1); + _ioService.start(); + } + + ListenBrainzScrobbler::~ListenBrainzScrobbler() + { + _ioService.stop(); + + LOG(INFO) << "Stopped ListenBrainz scrobbler"; + } + + void + ListenBrainzScrobbler::listenStarted(const Listen& listen) + { + _ioService.post([=] + { + enqueListen(listen, Wt::WDateTime {}); + }); + } + + void + ListenBrainzScrobbler::listenFinished(const Listen& listen, std::chrono::seconds duration) + { + if (!canBeScrobbled(_db.getTLSSession(), listen.trackId, duration)) + return; + + Listen timedListen {listen}; + const Wt::WDateTime now {Wt::WDateTime::currentDateTime().addSecs(-duration.count())}; + + _ioService.post([=] + { + enqueListen(timedListen, now); + }); + } + + void + ListenBrainzScrobbler::addListen(const Listen& listen, const Wt::WDateTime& timePoint) + { + assert(timePoint.isValid()); + + _ioService.post([=] + { + enqueListen(listen, timePoint); + }); + } + + Wt::Dbo::ptr + ListenBrainzScrobbler::getListensTrackList(Database::Session& session, Wt::Dbo::ptr user) + { + return Database::TrackList::get(session, historyTracklistName, Database::TrackList::Type::Internal, user); + } + + void + ListenBrainzScrobbler::enqueListen(const Listen& listen, const Wt::WDateTime& timePoint) + { + if (!timePoint.isValid()) + { + // If we are currently throttled, just replace the entry if it has no timePoint + // in order to only report the newest track listened to + // If not throttled, just search past the next current first message as it is being sent + + const std::size_t offset {_state == State::Throttled ? std::size_t {0} : std::size_t {1}}; + if (_sendQueue.size() > offset) + { + _sendQueue.erase(std::remove_if(std::next(std::begin(_sendQueue), offset), std::end(_sendQueue), + [&](const QueuedListen& queuedListen) { return queuedListen.listen.userId == listen.userId && !queuedListen.timePoint.isValid(); }), std::end(_sendQueue)); + } + } + + _sendQueue.emplace_back(QueuedListen {listen, timePoint}); + + if (_state == State::Idle) + sendNextQueuedListen(); + } + + void + ListenBrainzScrobbler::sendNextQueuedListen() + { + assert(_state == State::Idle); + if (_sendQueue.empty()) + return; + + sendListen(_sendQueue.front().listen, _sendQueue.front().timePoint); + _state = State::Sending; + } + + void + ListenBrainzScrobbler::sendListen(const Listen& listen, const Wt::WDateTime& timePoint) + { + Database::Session& session {_db.getTLSSession()}; + + const std::optional listenBrainzToken {getListenBrainzToken(session, listen.userId)}; + if (!listenBrainzToken) + return; + + std::string payload {listenToJsonString(session, listen, timePoint, timePoint.isValid() ? "single" : "playing_now")}; + if (payload.empty()) + { + LOG(DEBUG) << "Cannot convert listen to json: skipping"; + return; + } + + // now send this + Wt::Http::Message message; + message.addHeader("Authorization", "Token " + std::string {listenBrainzToken->getAsString()}); + message.addBodyText(payload); + + const std::string endPoint {_apiEndpoint + "submit-listens"}; + if (!_client.post(endPoint, message)) + LOG(ERROR) << "Cannot post to '" << endPoint << "': invalid scheme or URL?"; + + LOG(DEBUG) << "POST done to '" << endPoint << "'"; + } + + void + ListenBrainzScrobbler::onClientDone(Wt::AsioWrapper::error_code ec, const Wt::Http::Message& msg) + { + assert(!_sendQueue.empty()); + QueuedListen& queuedListen {_sendQueue.front()}; + + _state = State::Idle; + + LOG(DEBUG) << "POST done. status = " << msg.status() << ", msg = '" << msg.body() << "'"; + if (ec) + { + LOG(ERROR) << "Client error: " << ec.message(); + // may be a network error, try again later + if (++queuedListen.retryCount > _maxRetryCount) + _sendQueue.pop_front(); + + throttle(_defaultRetryWaitDuration); + return; + } + + bool mustThrottle{}; + + switch (msg.status()) + { + case 429: + mustThrottle = true; + break; + + case 200: + if (queuedListen.timePoint.isValid()) + cacheListen(queuedListen.listen, queuedListen.timePoint); + _sendQueue.pop_front(); + break; + + default: + LOG(ERROR) << "Submit error: '" << msg.body() << "'"; + _sendQueue.pop_front(); + break; + } + + const auto remainingCount {headerReadAs(msg, "X-RateLimit-Remaining")}; + LOG(DEBUG) << "Remaining messages = " << (remainingCount ? *remainingCount : 0); + if (mustThrottle || (remainingCount && *remainingCount == 0)) + { + const auto waitDuration {headerReadAs(msg, "X-RateLimit-Reset-In")}; + throttle(waitDuration.value_or(_defaultRetryWaitDuration)); + } + else + { + sendNextQueuedListen(); + } + } + + void + ListenBrainzScrobbler::throttle(std::chrono::seconds requestedDuration) + { + assert(_state == State::Idle); + + const std::chrono::seconds duration {requestedDuration.count() > 0 ? requestedDuration : std::chrono::seconds {1}}; + LOG(DEBUG) << "Throttling for " << duration.count() << " seconds"; + + _ioService.schedule(duration, [this] + { + _state = State::Idle; + sendNextQueuedListen(); + }); + _state = State::Throttled; + } + + void + ListenBrainzScrobbler::cacheListen(const Listen& listen, const Wt::WDateTime& timePoint) + { + Database::Session& session {_db.getTLSSession()}; + + auto transaction {session.createUniqueTransaction()}; + + const Database::User::pointer user {Database::User::getById(session, listen.userId)}; + if (!user) + return; + + const Database::Track::pointer track {Database::Track::getById(session, listen.trackId)}; + if (!track) + return; + + Database::TrackList::pointer tracklist {getListensTrackList(session, user)}; + if (!tracklist) + tracklist = Database::TrackList::create(session, historyTracklistName, Database::TrackList::Type::Internal, false, user); + + Database::TrackListEntry::create(session, track, getListensTrackList(session, user), timePoint); + } + +} // Scrobbling + diff --git a/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.hpp b/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.hpp new file mode 100644 index 00000000..98611bc3 --- /dev/null +++ b/src/libs/scrobbling/impl/listenbrainz/ListenBrainzScrobbler.hpp @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2021 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include +#include +#include + +#include +#include + +#include "IScrobbler.hpp" + +namespace Database +{ + class Db; + class Session; + class TrackList; +} + +namespace Scrobbling +{ + class ListenBrainzScrobbler final : public IScrobbler + { + public: + ListenBrainzScrobbler(Database::Db& db); + ~ListenBrainzScrobbler(); + + ListenBrainzScrobbler(const ListenBrainzScrobbler&) = delete; + ListenBrainzScrobbler(const ListenBrainzScrobbler&&) = delete; + ListenBrainzScrobbler& operator=(const ListenBrainzScrobbler&) = delete; + ListenBrainzScrobbler& operator=(const ListenBrainzScrobbler&&) = delete; + + private: + void listenStarted(const Listen& listen) override; + void listenFinished(const Listen& listen, std::chrono::seconds duration) override; + void addListen(const Listen& listen, const Wt::WDateTime& timePoint) override; + + Wt::Dbo::ptr getListensTrackList(Database::Session& session, Wt::Dbo::ptr user) override; + + void enqueListen(const Listen& listen, const Wt::WDateTime& timePoint); + void sendNextQueuedListen(); + void sendListen(const Listen& listen, const Wt::WDateTime& timePoint); + void onClientDone(Wt::AsioWrapper::error_code ec, const Wt::Http::Message& msg); + void throttle(std::chrono::seconds duration); + + void cacheListen(const Listen& listen, const Wt::WDateTime& timePoint); + + enum class State + { + Idle, + Throttled, + Sending, + }; + State _state {State::Idle}; + + const std::string _apiEndpoint; + const std::size_t _maxRetryCount {2}; + const std::chrono::seconds _defaultRetryWaitDuration {30}; + + Database::Db& _db; + Wt::WIOService _ioService; + Wt::Http::Client _client {_ioService}; + + struct QueuedListen + { + Listen listen; + Wt::WDateTime timePoint; + std::size_t retryCount {}; + }; + std::deque _sendQueue; + }; +} // Scrobbling + diff --git a/src/libs/scrobbling/include/scrobbling/IScrobbling.hpp b/src/libs/scrobbling/include/scrobbling/IScrobbling.hpp new file mode 100644 index 00000000..3a5a3e0f --- /dev/null +++ b/src/libs/scrobbling/include/scrobbling/IScrobbling.hpp @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2021 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include +#include +#include +#include +#include + +#include + +#include "scrobbling/Listen.hpp" + +namespace Database +{ + class Artist; + class Db; + class Release; + class Session; + class Track; + class User; +} + +namespace Scrobbling +{ + + class IScrobbling + { + public: + virtual ~IScrobbling() = default; + + // Scrobbling + virtual void listenStarted(const Listen& listen) = 0; + virtual void listenFinished(const Listen& listen, std::chrono::seconds duration) = 0; + + virtual void addListen(const Listen& listen, Wt::WDateTime timePoint) = 0; + + // Stats + // From most recent to oldest + virtual std::vector> getRecentArtists(Database::Session& session, + Wt::Dbo::ptr user, + const std::set& clusterIds, + std::optional linkType, + std::optional range, + bool& moreResults) = 0; + + virtual std::vector> getRecentReleases(Database::Session& session, + Wt::Dbo::ptr user, + const std::set& clusterIds, + std::optional range, + bool& moreResults) = 0; + + virtual std::vector> getRecentTracks(Database::Session& session, + Wt::Dbo::ptr user, + const std::set& clusterIds, + std::optional range, + bool& moreResults) = 0; + + // Top + virtual std::vector> getTopArtists(Database::Session& session, + Wt::Dbo::ptr user, + const std::set& clusterIds, + std::optional linkType, + std::optional range, + bool& moreResults) = 0; + + virtual std::vector> getTopReleases(Database::Session& session, + Wt::Dbo::ptr user, + const std::set& clusterIds, + std::optional range, + bool& moreResults) = 0; + + virtual std::vector> getTopTracks(Database::Session& session, + Wt::Dbo::ptr user, + const std::set& clusterIds, + std::optional range, + bool& moreResults) = 0; + }; + + std::unique_ptr createScrobbling(Database::Db& db); + +} // ns Scrobbling + diff --git a/src/libs/scrobbling/include/scrobbling/Listen.hpp b/src/libs/scrobbling/include/scrobbling/Listen.hpp new file mode 100644 index 00000000..48c10456 --- /dev/null +++ b/src/libs/scrobbling/include/scrobbling/Listen.hpp @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2021 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include "database/Types.hpp" + +namespace Scrobbling +{ + struct Listen + { + Database::IdType userId {}; + Database::IdType trackId {}; + }; +} // ns Scrobbling + diff --git a/src/libs/subsonic/CMakeLists.txt b/src/libs/subsonic/CMakeLists.txt index e11f5814..e4aeeb0f 100644 --- a/src/libs/subsonic/CMakeLists.txt +++ b/src/libs/subsonic/CMakeLists.txt @@ -23,6 +23,7 @@ target_link_libraries(lmssubsonic PRIVATE lmsdatabase lmsrecommendation lmsscanner + lmsscrobbling lmsutils std::filesystem ) diff --git a/src/libs/subsonic/impl/SubsonicResource.cpp b/src/libs/subsonic/impl/SubsonicResource.cpp index 70ba40ba..854e8cf8 100644 --- a/src/libs/subsonic/impl/SubsonicResource.cpp +++ b/src/libs/subsonic/impl/SubsonicResource.cpp @@ -38,6 +38,7 @@ #include "database/TrackList.hpp" #include "database/User.hpp" #include "recommendation/IEngine.hpp" +#include "scrobbling/IScrobbling.hpp" #include "utils/Logger.hpp" #include "utils/Random.hpp" #include "utils/Service.hpp" @@ -815,7 +816,7 @@ handleGetAlbumListRequestCommon(const RequestContext& context, bool id3) else if (type == "frequent") { bool moreResults {}; - releases = user->getPlayedTrackList(context.dbSession)->getTopReleases({}, range, moreResults); + releases = Service::get()->getTopReleases(context.dbSession, user, {}, range, moreResults); } else if (type == "newest") { @@ -830,7 +831,7 @@ handleGetAlbumListRequestCommon(const RequestContext& context, bool id3) else if (type == "recent") { bool moreResults {}; - releases = user->getPlayedTrackList(context.dbSession)->getReleasesReverse({}, range, moreResults); + releases = Service::get()->getRecentReleases(context.dbSession, user, {}, range, moreResults); } else if (type == "starred") { @@ -1643,26 +1644,52 @@ Response handleScrobble(RequestContext& context) { const std::vector ids {getMandatoryMultiParametersAs(context.parameters, "id")}; - // TODO handle time in some way (need underlying refacto) + const std::vector times {getMandatoryMultiParametersAs(context.parameters, "time")}; if (!std::all_of(std::cbegin(ids), std::cend(ids), [](const Id& id) { return id.type == Id::Type::Track; })) throw BadParameterGenericError {"id"}; - auto transaction {context.dbSession.createUniqueTransaction()}; + if (ids.size() != times.size()) + throw BadParameterGenericError {"time"}; - User::pointer user {User::getById(context.dbSession, context.userId)}; - if (!user) - throw RequestedDataNotFoundError {}; - - for (Id id : ids) + struct Scrobble { - Track::pointer track {Track::getById(context.dbSession, id.value)}; - if (!track) - continue; + Scrobbling::Listen listen; + Wt::WDateTime timePoint; + }; - TrackListEntry::create(context.dbSession, track, user->getPlayedTrackList(context.dbSession)); + std::vector scrobbles; + scrobbles.reserve(ids.size()); + + { + auto transaction {context.dbSession.createSharedTransaction()}; + + User::pointer user {User::getById(context.dbSession, context.userId)}; + if (!user) + throw RequestedDataNotFoundError {}; + + Scrobble scrobble; + scrobble.listen.userId = context.userId; + + for (std::size_t i {}; i < ids.size(); ++i) + { + const Id id {ids[i]}; + const unsigned long time {times[i]}; + + const Track::pointer track {Track::getById(context.dbSession, id.value)}; + if (!track) + continue; + + scrobble.listen.trackId = id.value; + scrobble.timePoint.setTime_t(static_cast(time / 1000)); + + scrobbles.emplace_back(scrobble); + } } + for (const Scrobble& scrobble : scrobbles) + Service::get()->addListen(scrobble.listen, scrobble.timePoint); + return Response::createOkResponse(context); } diff --git a/src/libs/utils/impl/Logger.cpp b/src/libs/utils/impl/Logger.cpp index 1f3dbca4..3c0d6668 100644 --- a/src/libs/utils/impl/Logger.cpp +++ b/src/libs/utils/impl/Logger.cpp @@ -34,6 +34,7 @@ const char* getModuleName(Module mod) case Module::MAIN: return "MAIN"; case Module::METADATA: return "METADATA"; case Module::REMOTE: return "REMOTE"; + case Module::SCROBBLING: return "SCROBBLING"; case Module::SERVICE: return "SERVICE"; case Module::RECOMMENDATION: return "RECOMMENDATION"; case Module::TRANSCODE: return "TRANSCODE"; diff --git a/src/libs/utils/include/utils/Logger.hpp b/src/libs/utils/include/utils/Logger.hpp index 8e9be839..4da7f4ba 100644 --- a/src/libs/utils/include/utils/Logger.hpp +++ b/src/libs/utils/include/utils/Logger.hpp @@ -46,6 +46,7 @@ enum class Module MAIN, METADATA, REMOTE, + SCROBBLING, SERVICE, RECOMMENDATION, TRANSCODE, diff --git a/src/lms/CMakeLists.txt b/src/lms/CMakeLists.txt index d81ae7b4..1dada5af 100644 --- a/src/lms/CMakeLists.txt +++ b/src/lms/CMakeLists.txt @@ -19,6 +19,7 @@ add_executable(lms ui/common/LoginNameValidator.cpp ui/common/MandatoryValidator.cpp ui/common/PasswordValidator.cpp + ui/common/UUIDValidator.cpp ui/explore/ArtistListHelpers.cpp ui/explore/ArtistView.cpp ui/explore/ArtistsView.cpp @@ -49,6 +50,7 @@ target_link_libraries(lms PRIVATE lmsdatabase lmsrecommendation lmsscanner + lmsscrobbling lmssubsonic lmsutils Wt::Wt diff --git a/src/lms/main.cpp b/src/lms/main.cpp index 38a450d8..15f04cdc 100644 --- a/src/lms/main.cpp +++ b/src/lms/main.cpp @@ -33,6 +33,7 @@ #include "scanner/IScanner.hpp" #include "recommendation/IEngine.hpp" #include "subsonic/SubsonicResource.hpp" +#include "scrobbling/IScrobbling.hpp" #include "ui/LmsApplication.hpp" #include "ui/LmsApplicationManager.hpp" #include "utils/IChildProcessManager.hpp" @@ -259,6 +260,8 @@ int main(int argc, char* argv[]) coverArtService->flushCache(); }); + Service scrobblingService {Scrobbling::createScrobbling(database)}; + API::Subsonic::SubsonicResource subsonicResource {database}; // bind API resources diff --git a/src/lms/ui/LmsApplication.cpp b/src/lms/ui/LmsApplication.cpp index a778c796..6662d59a 100644 --- a/src/lms/ui/LmsApplication.cpp +++ b/src/lms/ui/LmsApplication.cpp @@ -37,6 +37,7 @@ #include "database/Release.hpp" #include "database/Session.hpp" #include "database/User.hpp" +#include "scrobbling/IScrobbling.hpp" #include "utils/Logger.hpp" #include "utils/Service.hpp" #include "utils/String.hpp" @@ -544,6 +545,21 @@ LmsApplication::createHome() { _playQueue->playPrevious(); }); + + _mediaPlayer->scrobbleListenNow.connect([this](Database::IdType trackId) + { + LMS_LOG(UI, DEBUG) << "Received ScrobbleListenNow from player for trackId = " << trackId; + const Scrobbling::Listen listen {getUserId(), trackId}; + Service::get()->listenStarted(listen); + }); + _mediaPlayer->scrobbleListenFinished.connect([this](Database::IdType trackId, unsigned durationMs) + { + LMS_LOG(UI, DEBUG) << "Received ScrobbleListenFinished from player for trackId = " << trackId << ", duration = " << (durationMs / 1000) << "s"; + const std::chrono::milliseconds duration {durationMs}; + const Scrobbling::Listen listen {getUserId(), trackId}; + Service::get()->listenFinished(listen, std::chrono::duration_cast(duration)); + }); + _mediaPlayer->playbackEnded.connect([this] { _playQueue->playNext(); diff --git a/src/lms/ui/MediaPlayer.cpp b/src/lms/ui/MediaPlayer.cpp index cb771f2b..5095c5a7 100644 --- a/src/lms/ui/MediaPlayer.cpp +++ b/src/lms/ui/MediaPlayer.cpp @@ -193,9 +193,11 @@ static MediaPlayer::Settings settingsfromJSString(const std::string& strSettings MediaPlayer::MediaPlayer() : Wt::WTemplate {Wt::WString::tr("Lms.MediaPlayer.template")} -, playbackEnded {this, "playbackEnded"} , playPrevious {this, "playPrevious"} , playNext {this, "playNext"} +, scrobbleListenNow {this, "scrobbleListenNow"} +, scrobbleListenFinished {this, "scrobbleListenFinished"} +, playbackEnded {this, "playbackEnded"} , _settingsLoaded {this, "settingsLoaded"} { addFunction("tr", &Wt::WTemplate::Functions::tr); @@ -250,6 +252,7 @@ MediaPlayer::loadTrack(Database::IdType trackId, bool play, float replayGain) oss << "var params = {" + << " trackId :\"" << trackId << "\"," << " nativeResource: \"" << nativeResource << "\"," << " transcodeResource: \"" << transcodeResource << "\"," << " duration: " << std::chrono::duration_cast(track->getDuration()).count() << "," @@ -295,15 +298,6 @@ MediaPlayer::loadTrack(Database::IdType trackId, bool play, float replayGain) LMS_LOG(UI, DEBUG) << "Running js = '" << oss.str() << "'"; wApp->doJavaScript(oss.str()); - { - auto transaction {LmsApp->getDbSession().createUniqueTransaction()}; - - const Database::Track::pointer track {Database::Track::getById(LmsApp->getDbSession(), trackId)}; - if (track) - Database::TrackListEntry::create(LmsApp->getDbSession(), track, LmsApp->getUser()->getPlayedTrackList(LmsApp->getDbSession())); - - }; - _trackIdLoaded = trackId; trackLoaded.emit(*_trackIdLoaded); } diff --git a/src/lms/ui/MediaPlayer.hpp b/src/lms/ui/MediaPlayer.hpp index af2374b9..033b53be 100644 --- a/src/lms/ui/MediaPlayer.hpp +++ b/src/lms/ui/MediaPlayer.hpp @@ -19,13 +19,15 @@ #pragma once +#include +#include + #include #include #include #include #include "database/Types.hpp" -#include "database/User.hpp" namespace UserInterface { @@ -101,13 +103,26 @@ class MediaPlayer : public Wt::WTemplate void setSettings(const Settings& settings); // Signals - Wt::JSignal<> playbackEnded; Wt::JSignal<> playPrevious; Wt::JSignal<> playNext; Wt::Signal trackLoaded; Wt::Signal<> settingsLoaded; + Wt::JSignal scrobbleListenNow; + Wt::JSignal scrobbleListenFinished; + + Wt::JSignal<> playbackEnded; + private: + + enum class State + { + Playing, + Stopped, + }; + std::chrono::steady_clock::time_point _lastStateTimePoint; + State _state {State::Stopped}; + std::unique_ptr _audioFileResource; std::unique_ptr _audioTranscodeResource; @@ -115,9 +130,10 @@ class MediaPlayer : public Wt::WTemplate std::optional _settings; Wt::JSignal _settingsLoaded; - Wt::WText* _title; - Wt::WAnchor* _release; - Wt::WAnchor* _artist; + + Wt::WText* _title {}; + Wt::WAnchor* _release {}; + Wt::WAnchor* _artist {}; }; } // namespace UserInterface diff --git a/src/lms/ui/SettingsView.cpp b/src/lms/ui/SettingsView.cpp index 7662f385..05d56242 100644 --- a/src/lms/ui/SettingsView.cpp +++ b/src/lms/ui/SettingsView.cpp @@ -31,6 +31,7 @@ #include "common/PasswordValidator.hpp" #include "common/MandatoryValidator.hpp" +#include "common/UUIDValidator.hpp" #include "common/ValueStringModel.hpp" #include "auth/IPasswordService.hpp" @@ -62,12 +63,15 @@ class SettingsModel : public Wt::WFormModel static inline const Field SubsonicTranscodeEnableField {"subsonic-transcode-enable"}; static inline const Field SubsonicTranscodeFormatField {"subsonic-transcode-format"}; static inline const Field SubsonicTranscodeBitrateField {"subsonic-transcode-bitrate"}; + static inline const Field ScrobblerField {"scrobbler"}; + static inline const Field ListenBrainzTokenField {"listenbrainz-token"}; static inline const Field PasswordOldField {"password-old"}; static inline const Field PasswordField {"password"}; static inline const Field PasswordConfirmField {"password-confirm"}; using TranscodeModeModel = ValueStringModel; using ReplayGainModeModel = ValueStringModel; + using ScrobblerModel = ValueStringModel; SettingsModel(::Auth::IPasswordService* authPasswordService, bool withOldPassword) : _authPasswordService {authPasswordService} @@ -85,6 +89,9 @@ class SettingsModel : public Wt::WFormModel addField(SubsonicTranscodeEnableField); addField(SubsonicTranscodeBitrateField); addField(SubsonicTranscodeFormatField); + addField(ScrobblerField); + addField(ListenBrainzTokenField); + setValidator(ListenBrainzTokenField, createUUIDValidator()); if (_authPasswordService) { @@ -103,7 +110,6 @@ class SettingsModel : public Wt::WFormModel setValidator(TranscodeBitrateField, createMandatoryValidator()); setValidator(TranscodeFormatField, createMandatoryValidator()); setValidator(ReplayGainModeField, createMandatoryValidator()); - auto createPreAmpValidator = [] { auto preampGainValidator {std::make_unique()}; @@ -119,11 +125,12 @@ class SettingsModel : public Wt::WFormModel loadData(); } - std::shared_ptr getTranscodeModeModel() { return _transcodeModeModel; } - std::shared_ptr getTranscodeBitrateModel() { return _transcodeBitrateModel; } - std::shared_ptr getTranscodeFormatModel() { return _transcodeFormatModel; } - std::shared_ptr getReplayGainModeModel() { return _replayGainModeModel; } - std::shared_ptr getSubsonicArtistListModeModel() { return _subsonicArtistListModeModel; } + std::shared_ptr getTranscodeModeModel() { return _transcodeModeModel; } + std::shared_ptr getTranscodeBitrateModel() { return _transcodeBitrateModel; } + std::shared_ptr getTranscodeFormatModel() { return _transcodeFormatModel; } + std::shared_ptr getReplayGainModeModel() { return _replayGainModeModel; } + std::shared_ptr getSubsonicArtistListModeModel() { return _subsonicArtistListModeModel; } + std::shared_ptr getScrobblerModel() { return _scrobblerModel; } void saveData() { @@ -174,11 +181,19 @@ class SettingsModel : public Wt::WFormModel auto subsonicTranscodeFormatRow {_transcodeFormatModel->getRowFromString(valueText(SubsonicTranscodeFormatField))}; if (subsonicTranscodeFormatRow) user.modify()->setSubsonicTranscodeFormat(_transcodeFormatModel->getValue(*subsonicTranscodeFormatRow)); + + auto subsonicArtistListModeRow {_subsonicArtistListModeModel->getRowFromString(valueText(SubsonicArtistListModeField))}; + if (subsonicArtistListModeRow) + user.modify()->setSubsonicArtistListMode(_subsonicArtistListModeModel->getValue(*subsonicArtistListModeRow)); + } - auto subsonicArtistListModeRow {_subsonicArtistListModeModel->getRowFromString(valueText(SubsonicArtistListModeField))}; - if (subsonicArtistListModeRow) - user.modify()->setSubsonicArtistListMode(_subsonicArtistListModeModel->getValue(*subsonicArtistListModeRow)); + { + if (auto scrobblerRow {_scrobblerModel->getRowFromString(valueText(ScrobblerField))}) + user.modify()->setScrobbler(_scrobblerModel->getValue(*scrobblerRow)); + + user.modify()->setListenBrainzToken(UUID::fromString(Wt::asString(value(ListenBrainzTokenField)).toUTF8())); + } if (_authPasswordService && !valueText(PasswordField).empty()) { @@ -225,17 +240,30 @@ class SettingsModel : public Wt::WFormModel setReadOnly(SubsonicTranscodeBitrateField, true); } - auto subsonicTranscodeBitrateRow {_transcodeBitrateModel->getRowFromValue(user->getSubsonicTranscodeBitrate())}; - if (subsonicTranscodeBitrateRow) - setValue(SubsonicTranscodeBitrateField, _transcodeBitrateModel->getString(*subsonicTranscodeBitrateRow)); + { + auto subsonicTranscodeBitrateRow {_transcodeBitrateModel->getRowFromValue(user->getSubsonicTranscodeBitrate())}; + if (subsonicTranscodeBitrateRow) + setValue(SubsonicTranscodeBitrateField, _transcodeBitrateModel->getString(*subsonicTranscodeBitrateRow)); - auto subsonicTranscodeFormatRow {_transcodeFormatModel->getRowFromValue(user->getSubsonicTranscodeFormat())}; - if (subsonicTranscodeFormatRow) - setValue(SubsonicTranscodeFormatField, _transcodeFormatModel->getString(*subsonicTranscodeFormatRow)); + auto subsonicTranscodeFormatRow {_transcodeFormatModel->getRowFromValue(user->getSubsonicTranscodeFormat())}; + if (subsonicTranscodeFormatRow) + setValue(SubsonicTranscodeFormatField, _transcodeFormatModel->getString(*subsonicTranscodeFormatRow)); - auto subsonicArtistListModeRow {_subsonicArtistListModeModel->getRowFromValue(user->getSubsonicArtistListMode())}; - if (subsonicArtistListModeRow) - setValue(SubsonicArtistListModeField, _subsonicArtistListModeModel->getString(*subsonicArtistListModeRow)); + auto subsonicArtistListModeRow {_subsonicArtistListModeModel->getRowFromValue(user->getSubsonicArtistListMode())}; + if (subsonicArtistListModeRow) + setValue(SubsonicArtistListModeField, _subsonicArtistListModeModel->getString(*subsonicArtistListModeRow)); + } + + { + if (auto scrobblerRow {_scrobblerModel->getRowFromValue(user->getScrobbler())}) + setValue(ScrobblerField, _scrobblerModel->getString(*scrobblerRow)); + + if (auto listenBrainzToken {user->getListenBrainzToken()}) + { + LMS_LOG(UI, DEBUG) << "Read listenBrainzToken! value = " << listenBrainzToken->getAsString(); + setValue(ListenBrainzTokenField, Wt::WString::fromUTF8( std::string {listenBrainzToken->getAsString()})); + } + } } private: @@ -309,16 +337,21 @@ class SettingsModel : public Wt::WFormModel _subsonicArtistListModeModel->add(Wt::WString::tr("Lms.Settings.subsonic-artist-list-mode.all-artists"), User::SubsonicArtistListMode::AllArtists); _subsonicArtistListModeModel->add(Wt::WString::tr("Lms.Settings.subsonic-artist-list-mode.release-artists"), User::SubsonicArtistListMode::ReleaseArtists); _subsonicArtistListModeModel->add(Wt::WString::tr("Lms.Settings.subsonic-artist-list-mode.track-artists"), User::SubsonicArtistListMode::TrackArtists); + + _scrobblerModel = std::make_shared>(); + _scrobblerModel->add(Wt::WString::tr("Lms.Settings.scrobbling.scrobbler.internal"), Scrobbler::Internal); + _scrobblerModel->add(Wt::WString::tr("Lms.Settings.scrobbling.scrobbler.listenbrainz"), Scrobbler::ListenBrainz); } ::Auth::IPasswordService* _authPasswordService {}; bool _withOldPassword {}; - std::shared_ptr _transcodeModeModel; + std::shared_ptr _transcodeModeModel; std::shared_ptr> _transcodeBitrateModel; - std::shared_ptr> _transcodeFormatModel; + std::shared_ptr> _transcodeFormatModel; std::shared_ptr _replayGainModeModel; std::shared_ptr> _subsonicArtistListModeModel; + std::shared_ptr _scrobblerModel; }; SettingsView::SettingsView() @@ -473,11 +506,11 @@ SettingsView::refreshView() t->setFormWidget(SettingsModel::SubsonicTranscodeBitrateField, std::move(transcodeBitrate)); // Artist list mode - auto artistListMode = std::make_unique(); + auto artistListMode {std::make_unique()}; artistListMode->setModel(model->getSubsonicArtistListModeModel()); t->setFormWidget(SettingsModel::SubsonicArtistListModeField, std::move(artistListMode)); - transcodeRaw->changed().connect([=]() + transcodeRaw->changed().connect([=] { const bool enable {transcodeRaw->checkState() == Wt::CheckState::Checked}; model->setReadOnly(SettingsModel::SubsonicTranscodeFormatField, !enable); @@ -487,6 +520,24 @@ SettingsView::refreshView() }); } + // Scrobbling + { + auto scrobbler {std::make_unique()}; + scrobbler->setModel(model->getScrobblerModel()); + auto* scrobblerRaw {scrobbler.get()}; + t->setFormWidget(SettingsModel::ScrobblerField, std::move(scrobbler)); + + auto listenbrainzToken {std::make_unique()}; + t->setFormWidget(SettingsModel::ListenBrainzTokenField, std::move(listenbrainzToken)); + scrobblerRaw->activated().connect([=](int row) + { + const bool enable {model->getScrobblerModel()->getValue(row) == Scrobbler::ListenBrainz}; + model->setReadOnly(SettingsModel::ListenBrainzTokenField, !enable); + t->updateModel(model.get()); + t->updateView(model.get()); + }); + } + // Buttons Wt::WPushButton *saveBtn {t->bindWidget("apply-btn", std::make_unique(Wt::WString::tr("Lms.apply")))}; Wt::WPushButton *discardBtn {t->bindWidget("discard-btn", std::make_unique(Wt::WString::tr("Lms.discard")))}; diff --git a/src/lms/ui/admin/ScannerController.cpp b/src/lms/ui/admin/ScannerController.cpp index d19b4974..f01cfc71 100644 --- a/src/lms/ui/admin/ScannerController.cpp +++ b/src/lms/ui/admin/ScannerController.cpp @@ -95,8 +95,8 @@ class ReportResource : public Wt::WResource continue; response.out() << track->getPath().string(); - if (auto mbid {track->getMBID()}) - response.out() << " (MBID " << mbid->getAsString() << ")"; + if (auto mbid {track->getTrackMBID()}) + response.out() << " (Track MBID " << mbid->getAsString() << ")"; response.out() << " - " << duplicateReasonToWString(duplicate.reason).toUTF8() << '\n'; } diff --git a/src/lms/ui/common/UUIDValidator.cpp b/src/lms/ui/common/UUIDValidator.cpp new file mode 100644 index 00000000..a333c7be --- /dev/null +++ b/src/lms/ui/common/UUIDValidator.cpp @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2021 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include "UUIDValidator.hpp" + +#include + +namespace UserInterface +{ + std::shared_ptr + createUUIDValidator() + { + return std::make_unique("[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"); + } +} // namespace UserInterface diff --git a/src/lms/ui/common/UUIDValidator.hpp b/src/lms/ui/common/UUIDValidator.hpp new file mode 100644 index 00000000..e6120e5d --- /dev/null +++ b/src/lms/ui/common/UUIDValidator.hpp @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2021 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include + +namespace UserInterface +{ + std::shared_ptr createUUIDValidator(); +} // namespace UserInterface + diff --git a/src/lms/ui/explore/ArtistsView.cpp b/src/lms/ui/explore/ArtistsView.cpp index 25f34307..83e6316f 100644 --- a/src/lms/ui/explore/ArtistsView.cpp +++ b/src/lms/ui/explore/ArtistsView.cpp @@ -29,6 +29,7 @@ #include "database/User.hpp" #include "database/TrackArtistLink.hpp" #include "database/TrackList.hpp" +#include "scrobbling/IScrobbling.hpp" #include "utils/Logger.hpp" #include "common/LoadingIndicator.hpp" @@ -212,15 +213,15 @@ Artists::getArtists(std::optional range, bool& moreResults) break; case Mode::RecentlyPlayed: - artists = LmsApp->getUser()->getPlayedTrackList(LmsApp->getDbSession()) - ->getArtistsReverse(_filters->getClusterIds(), + artists = Service::get()->getRecentArtists(LmsApp->getDbSession(), LmsApp->getUser(), + _filters->getClusterIds(), linkType, range, moreResults); break; case Mode::MostPlayed: - artists = LmsApp->getUser()->getPlayedTrackList(LmsApp->getDbSession()) - ->getTopArtists(_filters->getClusterIds(), + artists = Service::get()->getTopArtists(LmsApp->getDbSession(), LmsApp->getUser(), + _filters->getClusterIds(), linkType, range, moreResults); break; diff --git a/src/lms/ui/explore/ReleasesView.cpp b/src/lms/ui/explore/ReleasesView.cpp index 84ac60d0..b56a5acf 100644 --- a/src/lms/ui/explore/ReleasesView.cpp +++ b/src/lms/ui/explore/ReleasesView.cpp @@ -31,6 +31,7 @@ #include "database/Session.hpp" #include "database/TrackList.hpp" #include "database/User.hpp" +#include "scrobbling/IScrobbling.hpp" #include "utils/Logger.hpp" #include "utils/String.hpp" @@ -205,11 +206,11 @@ Releases::getReleases(std::optional range, bool& moreResults) break; case Mode::RecentlyPlayed: - releases = LmsApp->getUser()->getPlayedTrackList(LmsApp->getDbSession())->getReleasesReverse(_filters->getClusterIds(), range, moreResults); + releases = Service::get()->getRecentReleases(LmsApp->getDbSession(), LmsApp->getUser(), _filters->getClusterIds(), range, moreResults); break; case Mode::MostPlayed: - releases = LmsApp->getUser()->getPlayedTrackList(LmsApp->getDbSession())->getTopReleases(_filters->getClusterIds(), range, moreResults); + releases = Service::get()->getTopReleases(LmsApp->getDbSession(), LmsApp->getUser(), _filters->getClusterIds(), range, moreResults); break; case Mode::RecentlyAdded: diff --git a/src/lms/ui/explore/TracksView.cpp b/src/lms/ui/explore/TracksView.cpp index 6093b45d..683dd563 100644 --- a/src/lms/ui/explore/TracksView.cpp +++ b/src/lms/ui/explore/TracksView.cpp @@ -30,7 +30,8 @@ #include "database/Session.hpp" #include "database/Track.hpp" #include "database/TrackList.hpp" - +#include "database/User.hpp" +#include "scrobbling/IScrobbling.hpp" #include "utils/Logger.hpp" #include "utils/String.hpp" @@ -192,11 +193,11 @@ Tracks::getTracks(std::optional range, bool& moreResults) break; case Mode::RecentlyPlayed: - tracks = LmsApp->getUser()->getPlayedTrackList(LmsApp->getDbSession())->getTracksReverse(_filters->getClusterIds(), range, moreResults); + tracks = Service::get()->getRecentTracks(LmsApp->getDbSession(), LmsApp->getUser(), _filters->getClusterIds(), range, moreResults); break; case Mode::MostPlayed: - tracks = LmsApp->getUser()->getPlayedTrackList(LmsApp->getDbSession())->getTopTracks(_filters->getClusterIds(), range, moreResults); + tracks = Service::get()->getTopTracks(LmsApp->getDbSession(), LmsApp->getUser(), _filters->getClusterIds(), range, moreResults); break; case Mode::RecentlyAdded: diff --git a/src/test/database/DatabaseTest.cpp b/src/test/database/DatabaseTest.cpp index 6569f1a4..56443d25 100644 --- a/src/test/database/DatabaseTest.cpp +++ b/src/test/database/DatabaseTest.cpp @@ -1289,11 +1289,6 @@ testSingleUser(Session& session) { auto transaction {session.createSharedTransaction()}; - bool hasMore {}; - CHECK(user->getPlayedTrackList(session)->getCount() == 0); - CHECK(user->getPlayedTrackList(session)->getTopTracks({}, Range {0, 1}, hasMore).empty()); - CHECK(user->getPlayedTrackList(session)->getTopArtists({}, std::nullopt, Range {0, 1}, hasMore).empty()); - CHECK(user->getPlayedTrackList(session)->getTopReleases({}, Range {0, 1}, hasMore).empty()); CHECK(user->getQueuedTrackList(session)->getCount() == 0); } } @@ -1423,6 +1418,34 @@ testSingleTrackListMultipleTrack(Session& session) } } +void +testSingleTrackListMultipleTrackDateTime(Session& session) +{ + ScopedUser user {session, "MyUser"}; + ScopedTrackList trackList {session, "MytrackList", TrackList::Type::Playlist, false, user.lockAndGet()}; + ScopedTrack track1 {session, "MyTrack1"}; + ScopedTrack track2 {session, "MyTrack2"}; + ScopedTrack track3 {session, "MyTrack3"}; + + { + Wt::WDateTime now {Wt::WDateTime::currentDateTime()}; + auto transaction {session.createUniqueTransaction()}; + TrackListEntry::create(session, track1.get(), trackList.get(), now); + TrackListEntry::create(session, track2.get(), trackList.get(), now.addSecs(-1)); + TrackListEntry::create(session, track3.get(), trackList.get(), now.addSecs(1)); + } + + { + auto transaction {session.createSharedTransaction()}; + + bool moreResults; + const auto tracks {trackList.get()->getTracksReverse({}, std::nullopt, moreResults)}; + CHECK(tracks.size() == 3); + CHECK(tracks.front().id() == track3.getId()); + CHECK(tracks.back().id() == track2.getId()); + } +} + static void testSingleTrackListMultipleTrackSingleCluster(Session& session) @@ -1512,6 +1535,115 @@ testSingleTrackListMultipleTrackMultiClusters(Session& session) } } +static +void +testSingleTrackListMultipleTrackRecentlyPlayed(Session& session) +{ + ScopedUser user {session, "MyUser"}; + ScopedTrackList trackList {session, "MyTrackList", TrackList::Type::Playlist, false, user.lockAndGet()}; + ScopedClusterType clusterType {session, "MyClusterType"}; + ScopedTrack track1 {session, "MyTrack1"}; + ScopedTrack track2 {session, "MyTrack1"}; + ScopedArtist artist1 {session, "MyArtist1"}; + ScopedArtist artist2 {session, "MyArtist2"}; + ScopedRelease release1 {session, "MyRelease1"}; + ScopedRelease release2 {session, "MyRelease2"}; + + const Wt::WDateTime now {Wt::WDateTime::currentDateTime()}; + + { + auto transaction {session.createUniqueTransaction()}; + + track1.get().modify()->setRelease(release1.get()); + track2.get().modify()->setRelease(release2.get()); + TrackArtistLink::create(session, track1.get(), artist1.get(), TrackArtistLinkType::Artist); + TrackArtistLink::create(session, track2.get(), artist2.get(), TrackArtistLinkType::Artist); + } + { + + auto transaction {session.createSharedTransaction()}; + + bool moreResults {}; + CHECK(trackList->getArtistsReverse({}, std::nullopt, std::nullopt, moreResults).empty()); + CHECK(trackList->getReleasesReverse({}, std::nullopt, moreResults).empty()); + CHECK(trackList->getTracksReverse({}, std::nullopt, moreResults).empty()); + } + + { + auto transaction {session.createUniqueTransaction()}; + + TrackListEntry::create(session, track1.get(), trackList.get(), now); + } + + { + auto transaction {session.createSharedTransaction()}; + + bool moreResults {}; + const auto artists {trackList->getArtistsReverse({}, std::nullopt, std::nullopt, moreResults)}; + CHECK(artists.size() == 1); + CHECK(artists.front().id() == artist1.getId()); + + const auto releases {trackList->getReleasesReverse({}, std::nullopt, moreResults)}; + CHECK(releases.size() == 1); + CHECK(releases.front().id() == release1.getId()); + + const auto tracks {trackList->getTracksReverse({}, std::nullopt, moreResults)}; + CHECK(tracks.size() == 1); + } + + { + auto transaction {session.createUniqueTransaction()}; + + TrackListEntry::create(session, track2.get(), trackList.get(), now.addSecs(1)); + } + + { + auto transaction {session.createSharedTransaction()}; + + bool moreResults {}; + const auto artists {trackList->getArtistsReverse({}, std::nullopt, std::nullopt, moreResults)}; + CHECK(artists.size() == 2); + CHECK(artists[0].id() == artist2.getId()); + CHECK(artists[1].id() == artist1.getId()); + + const auto releases {trackList->getReleasesReverse({}, std::nullopt, moreResults)}; + CHECK(releases.size() == 2); + CHECK(releases[0].id() == release2.getId()); + CHECK(releases[1].id() == release1.getId()); + + const auto tracks {trackList->getTracksReverse({}, std::nullopt, moreResults)}; + CHECK(tracks.size() == 2); + CHECK(tracks[0].id() == track2.getId()); + CHECK(tracks[1].id() == track1.getId()); + } + + { + auto transaction {session.createUniqueTransaction()}; + + TrackListEntry::create(session, track1.get(), trackList.get(), now.addSecs(2)); + } + + { + auto transaction {session.createSharedTransaction()}; + + bool moreResults {}; + const auto artists {trackList->getArtistsReverse({}, std::nullopt, std::nullopt, moreResults)}; + CHECK(artists.size() == 2); + CHECK(artists[0].id() == artist1.getId()); + CHECK(artists[1].id() == artist2.getId()); + + const auto releases {trackList->getReleasesReverse({}, std::nullopt, moreResults)}; + CHECK(releases.size() == 2); + CHECK(releases[0].id() == release1.getId()); + CHECK(releases[1].id() == release2.getId()); + + const auto tracks {trackList->getTracksReverse({}, std::nullopt, moreResults)}; + CHECK(tracks.size() == 2); + CHECK(tracks[0].id() == track1.getId()); + CHECK(tracks[1].id() == track2.getId()); + } +} + static void testSingleTrackListMultipleTrackMultiClustersRecentlyPlayed(Session& session) @@ -1529,6 +1661,8 @@ testSingleTrackListMultipleTrackMultiClustersRecentlyPlayed(Session& session) ScopedRelease release1 {session, "MyRelease1"}; ScopedRelease release2 {session, "MyRelease2"}; + const Wt::WDateTime now {Wt::WDateTime::currentDateTime()}; + { auto transaction {session.createUniqueTransaction()}; @@ -1555,7 +1689,7 @@ testSingleTrackListMultipleTrackMultiClustersRecentlyPlayed(Session& session) { auto transaction {session.createUniqueTransaction()}; - TrackListEntry::create(session, track1.get(), trackList.get()); + TrackListEntry::create(session, track1.get(), trackList.get(), now); } { @@ -1641,7 +1775,7 @@ testSingleTrackListMultipleTrackMultiClustersRecentlyPlayed(Session& session) { auto transaction {session.createUniqueTransaction()}; - TrackListEntry::create(session, track2.get(), trackList.get()); + TrackListEntry::create(session, track2.get(), trackList.get(), now.addSecs(1)); } { @@ -1721,7 +1855,7 @@ testSingleTrackListMultipleTrackMultiClustersRecentlyPlayed(Session& session) { auto transaction {session.createUniqueTransaction()}; - TrackListEntry::create(session, track1.get(), trackList.get()); + TrackListEntry::create(session, track1.get(), trackList.get(), now.addSecs(2)); } { @@ -1750,9 +1884,8 @@ testSingleTrackListMultipleTrackMultiClustersRecentlyPlayed(Session& session) bool moreResults {}; const auto artists {trackList->getArtistsReverse({cluster3.getId()}, std::nullopt, std::nullopt, moreResults)}; CHECK(artists.size() == 2); - // TODO investigate - // CHECK(artists[0].id() == artist1.getId()); - // CHECK(artists[1].id() == artist2.getId()); + CHECK(artists[0].id() == artist1.getId()); + CHECK(artists[1].id() == artist2.getId()); const auto releases {trackList->getReleasesReverse({cluster3.getId()}, std::nullopt, moreResults)}; CHECK(releases.size() == 2); @@ -2036,8 +2169,10 @@ int main() RUN_TEST(testSingleTrackList); RUN_TEST(testSingleTrackListMultipleTrack); + RUN_TEST(testSingleTrackListMultipleTrackDateTime); RUN_TEST(testSingleTrackListMultipleTrackSingleCluster); RUN_TEST(testSingleTrackListMultipleTrackMultiClusters); + RUN_TEST(testSingleTrackListMultipleTrackRecentlyPlayed); RUN_TEST(testSingleTrackListMultipleTrackMultiClustersRecentlyPlayed); RUN_TEST(testMultipleTracksMultipleArtistsMultiClusters); RUN_TEST(testMultipleTracksMultipleReleasesMultiClusters); diff --git a/src/tools/metadata/LmsMetadata.cpp b/src/tools/metadata/LmsMetadata.cpp index f7103ee1..04d92a1f 100644 --- a/src/tools/metadata/LmsMetadata.cpp +++ b/src/tools/metadata/LmsMetadata.cpp @@ -102,11 +102,11 @@ void parse(MetaData::IParser& parser, const std::filesystem::path& file) std::cout << "Title: " << track->title << std::endl; - if (track->musicBrainzTrackID) - std::cout << "MB TrackID = " << track->musicBrainzTrackID->getAsString() << std::endl; + if (track->trackMBID) + std::cout << "track MBID = " << track->trackMBID->getAsString() << std::endl; - if (track->musicBrainzRecordID) - std::cout << "MB RecordID = " << track->musicBrainzRecordID->getAsString() << std::endl; + if (track->recordingMBID) + std::cout << "recording MBID = " << track->recordingMBID->getAsString() << std::endl; for (const auto& cluster : track->clusters) {