diff --git a/docroot/js/mediaplayer.js b/docroot/js/mediaplayer.js index 2a588621..279554c2 100644 --- a/docroot/js/mediaplayer.js +++ b/docroot/js/mediaplayer.js @@ -38,10 +38,6 @@ LMS.mediaplayer = function () { _initAudioCtx(); }; - document.addEventListener("touchstart", _unlock); - document.addEventListener("touchend", _unlock); - document.addEventListener("click", _unlock); - let _initAudioCtx = function() { if (_audioIsInit) { _audioCtx.resume(); // not sure of this @@ -321,6 +317,9 @@ LMS.mediaplayer = function () { event.preventDefault(); }); + document.addEventListener("touchstart", _unlock); + document.addEventListener("touchend", _unlock); + document.addEventListener("click", _unlock); } let _removeAudioSources = function() { diff --git a/src/libs/services/database/impl/Session.cpp b/src/libs/services/database/impl/Session.cpp index ec41e43c..a1ff5149 100644 --- a/src/libs/services/database/impl/Session.cpp +++ b/src/libs/services/database/impl/Session.cpp @@ -158,9 +158,13 @@ Session::prepareTables() _session.execute("CREATE INDEX IF NOT EXISTS track_bookmark_user_track_idx ON track_bookmark(user_id,track_id)"); _session.execute("CREATE INDEX IF NOT EXISTS listen_scrobbler_idx ON listen(scrobbler)"); _session.execute("CREATE INDEX IF NOT EXISTS listen_user_scrobbler_idx ON listen(user_id,scrobbler)"); + _session.execute("CREATE INDEX IF NOT EXISTS listen_user_track_scrobbler_date_time_idx ON listen(user_id,track_id,scrobbler,date_time)"); _session.execute("CREATE INDEX IF NOT EXISTS starred_artist_user_scrobbler_idx ON starred_artist(user_id,scrobbler)"); + _session.execute("CREATE INDEX IF NOT EXISTS starred_artist_artist_user_scrobbler_idx ON starred_artist(artist_id,user_id,scrobbler)"); _session.execute("CREATE INDEX IF NOT EXISTS starred_release_user_scrobbler_idx ON starred_release(user_id,scrobbler)"); + _session.execute("CREATE INDEX IF NOT EXISTS starred_release_release_user_scrobbler_idx ON starred_release(release_id,user_id,scrobbler)"); _session.execute("CREATE INDEX IF NOT EXISTS starred_track_user_scrobbler_idx ON starred_track(user_id,scrobbler)"); + _session.execute("CREATE INDEX IF NOT EXISTS starred_track_track_user_scrobbler_idx ON starred_track(track_id,user_id,scrobbler)"); } // Initial settings tables diff --git a/src/libs/services/database/impl/Track.cpp b/src/libs/services/database/impl/Track.cpp index 269ba3a9..cb2fad61 100644 --- a/src/libs/services/database/impl/Track.cpp +++ b/src/libs/services/database/impl/Track.cpp @@ -45,9 +45,13 @@ createQuery(Session& session, const Track::FindParameters& params) auto query {session.getDboSession().query(params.distinct ? "SELECT DISTINCT t.id FROM track t" : "SELECT t.id FROM track t")}; + assert(params.keywords.empty() || params.name.empty()); for (std::string_view keyword : params.keywords) query.where("t.name LIKE ? ESCAPE '" ESCAPE_CHAR_STR "'").bind("%" + Utils::escapeLikeKeyword(keyword) + "%"); + if (!params.name.empty()) + query.where("t.name = ?").bind(params.name); + if (params.writtenAfter.isValid()) query.where("t.file_last_write > ?").bind(params.writtenAfter); @@ -80,11 +84,15 @@ createQuery(Session& session, const Track::FindParameters& params) query.where(oss.str()); } - if (params.artist.isValid()) + if (params.artist.isValid() || !params.artistName.empty()) { query.join("artist a ON a.id = t_a_l.artist_id") - .join("track_artist_link t_a_l ON t_a_l.track_id = t.id") - .where("a.id = ?").bind(params.artist); + .join("track_artist_link t_a_l ON t_a_l.track_id = t.id"); + + if (params.artist.isValid()) + query.where("a.id = ?").bind(params.artist); + if (!params.artistName.empty()) + query.where("a.name = ?").bind(params.artistName); if (!params.trackArtistLinkTypes.empty()) { @@ -109,6 +117,11 @@ createQuery(Session& session, const Track::FindParameters& params) query.where("t.release_id IS NULL"); else if (params.release.isValid()) query.where("t.release_id = ?").bind(params.release); + else if (!params.releaseName.empty()) + { + query.join("release r ON t.release_id = r.id"); + query.where("r.name = ?").bind(params.releaseName); + } if (params.trackList.isValid()) { @@ -117,6 +130,9 @@ createQuery(Session& session, const Track::FindParameters& params) query.where("t_l.id = ?").bind(params.trackList); } + if (params.trackNumber) + query.where("t.track_number = ?").bind(*params.trackNumber); + switch (params.sortMethod) { case TrackSortMethod::None: @@ -282,19 +298,6 @@ Track::find(Session& session, const FindParameters& parameters) return Utils::execQuery(query, parameters.range); } -RangeResults -Track::findByNameAndReleaseName(Session& session, std::string_view trackName, std::string_view releaseName) -{ - session.checkSharedLocked(); - - auto query {session.getDboSession().query("SELECT t.id from track t") - .join("release r ON t.release_id = r.id") - .where("t.name = ?").bind(trackName) - .where("r.name = ?").bind(releaseName)}; - - return Utils::execQuery(query, Range {}); -} - RangeResults Track::findSimilarTracks(Session& session, const std::vector& tracks, Range range) { @@ -517,5 +520,33 @@ Track::getClusterGroups(const std::vector& clusterTypes, s return res; } +namespace Debug +{ + std::ostream& + operator<<(std::ostream& os, const TrackInfo& trackInfo) + { + auto transaction {trackInfo.session.createSharedTransaction()}; + + const Track::pointer track {Track::find(trackInfo.session, trackInfo.trackId)}; + if (track) + { + os << track->getName(); + + if (const Release::pointer release {track->getRelease()}) + os << " [" << release->getName() << "]"; + for (auto artist : track->getArtists({TrackArtistLinkType::Artist})) + os << " - " << artist->getName(); + for (auto cluster : track->getClusters()) + os << " {" + cluster->getType()->getName() << "-" << cluster->getName() << "}"; + } + else + { + os << "*unknown*"; + } + + return os; + } +} + } // namespace Database diff --git a/src/libs/services/database/include/services/database/Track.hpp b/src/libs/services/database/include/services/database/Track.hpp index 9f5430d7..d7f211e2 100644 --- a/src/libs/services/database/include/services/database/Track.hpp +++ b/src/libs/services/database/include/services/database/Track.hpp @@ -21,10 +21,12 @@ #include #include +#include #include #include #include #include +#include #include #include @@ -59,30 +61,38 @@ class Track : public Object public: struct FindParameters { - std::vector clusters; // if non empty, tracks that belong to these clusters - std::vector keywords; // if non empty, name must match all of these keywords + std::vector clusters; // if non empty, tracks that belong to these clusters + std::vector keywords; // if non empty, name must match all of these keywords + std::string name; // if non empty, must match this name TrackSortMethod sortMethod {TrackSortMethod::None}; Range range; Wt::WDateTime writtenAfter; UserId starringUser; // only tracks starred by this user std::optional scrobbler; // and for this scrobbler - ArtistId artist; // only tracks that involve this user + ArtistId artist; // only tracks that involve this artist + std::string artistName; // only tracks that involve this artist name EnumSet trackArtistLinkTypes; // and for these link types bool nonRelease {}; // only tracks that do not belong to a release ReleaseId release; // matching this release + std::string releaseName; // matching this release name TrackListId trackList; // matching this trackList + std::optional trackNumber; // matching this track number bool distinct {true}; FindParameters& setClusters(const std::vector& _clusters) { clusters = _clusters; return *this; } FindParameters& setKeywords(const std::vector& _keywords) { keywords = _keywords; return *this; } + FindParameters& setName(std::string_view _name) { name = _name; return *this; } FindParameters& setSortMethod(TrackSortMethod _method) { sortMethod = _method; return *this; } FindParameters& setRange(Range _range) { range = _range; return *this; } FindParameters& setWrittenAfter(const Wt::WDateTime& _after) { writtenAfter = _after; return *this; } FindParameters& setStarringUser(UserId _user, Scrobbler _scrobbler) { starringUser = _user; scrobbler = _scrobbler; return *this; } FindParameters& setArtist(ArtistId _artist, EnumSet _trackArtistLinkTypes = {}) { artist = _artist; trackArtistLinkTypes = _trackArtistLinkTypes; return *this; } + FindParameters& setArtistName(std::string_view _artistName, EnumSet _trackArtistLinkTypes = {}) { artistName = _artistName; trackArtistLinkTypes = _trackArtistLinkTypes; return *this; } FindParameters& setNonRelease(bool _nonRelease) { nonRelease = _nonRelease; return *this; } FindParameters& setRelease(ReleaseId _release) { release = _release; return *this; } + FindParameters& setReleaseName(std::string_view _releaseName) { releaseName = _releaseName; return *this; } FindParameters& setTrackList(TrackListId _trackList) { trackList = _trackList; return *this; } + FindParameters& setTrackNumber(int _trackNumber) { trackNumber = _trackNumber; return *this; } FindParameters& setDistinct(bool _distinct) { distinct = _distinct; return *this; } }; @@ -103,7 +113,6 @@ class Track : public Object static RangeResults findSimilarTracks(Session& session, const std::vector& trackIds, Range range); static RangeResults find(Session& session, const FindParameters& parameters); - static RangeResults findByNameAndReleaseName(Session& session, std::string_view trackName, std::string_view releaseName); static RangeResults findPaths(Session& session, Range range); static RangeResults findRecordingMBIDDuplicates(Session& session, Range range); static RangeResults findWithRecordingMBIDAndMissingFeatures(Session& session, Range range); @@ -156,8 +165,8 @@ class Track : public Object std::optional getReleaseReplayGain() const { return _releaseReplayGain; } // no artistLinkTypes means get all - std::vector> getArtists(EnumSet artistLinkTypes) const; - std::vector getArtistIds(EnumSet artistLinkTypes) const; + std::vector> getArtists(EnumSet artistLinkTypes) const; // no type means all + std::vector getArtistIds(EnumSet artistLinkTypes) const; // no type means all std::vector> getArtistLinks() const; ObjectPtr getRelease() const { return _release; } std::vector> getClusters() const; @@ -230,6 +239,16 @@ class Track : public Object Wt::Dbo::collection> _clusters; }; +namespace Debug +{ + struct TrackInfo + { + Session& session; + TrackId trackId; + }; + std::ostream& operator<<(std::ostream& os, const TrackInfo& trackInfo); +} + } // namespace database diff --git a/src/libs/services/database/test/Artist.cpp b/src/libs/services/database/test/Artist.cpp index d280bfcc..ce7b1f31 100644 --- a/src/libs/services/database/test/Artist.cpp +++ b/src/libs/services/database/test/Artist.cpp @@ -65,6 +65,7 @@ TEST_F(DatabaseFixture, Artist_singleTrack) { auto transaction {session.createUniqueTransaction()}; + track.get().modify()->setName("MyTrackName"); TrackArtistLink::create(session, track.get(), artist.get(), TrackArtistLinkType::Artist); } @@ -89,6 +90,23 @@ TEST_F(DatabaseFixture, Artist_singleTrack) EXPECT_TRUE(track->getArtists({TrackArtistLinkType::ReleaseArtist}).empty()); EXPECT_EQ(track->getArtists({}).size(), 1); } + + { + auto transaction {session.createUniqueTransaction()}; + auto tracks {Track::find(session, Track::FindParameters{}.setName("MyTrackName").setArtistName("MyArtist"))}; + ASSERT_EQ(tracks.results.size(), 1); + EXPECT_EQ(tracks.results.front(), track.getId()); + } + { + auto transaction {session.createUniqueTransaction()}; + auto tracks {Track::find(session, Track::FindParameters{}.setName("MyTrackName").setArtistName("MyArtistFoo"))}; + EXPECT_EQ(tracks.results.size(), 0); + } + { + auto transaction {session.createUniqueTransaction()}; + auto tracks {Track::find(session, Track::FindParameters{}.setName("MyTrackNameFoo").setArtistName("MyArtist"))}; + EXPECT_EQ(tracks.results.size(), 0); + } } TEST_F(DatabaseFixture, Artist_singleTracktMultiRoles) diff --git a/src/libs/services/database/test/Release.cpp b/src/libs/services/database/test/Release.cpp index 450db6ca..d1e77f44 100644 --- a/src/libs/services/database/test/Release.cpp +++ b/src/libs/services/database/test/Release.cpp @@ -84,18 +84,18 @@ TEST_F(DatabaseFixture, Release_singleTrack) { auto transaction {session.createUniqueTransaction()}; - auto tracks {Track::findByNameAndReleaseName(session, "MyTrackName", "MyReleaseName")}; + auto tracks {Track::find(session, Track::FindParameters{}.setName("MyTrackName").setReleaseName("MyReleaseName"))}; ASSERT_EQ(tracks.results.size(), 1); EXPECT_EQ(tracks.results.front(), track.getId()); } { auto transaction {session.createUniqueTransaction()}; - auto tracks {Track::findByNameAndReleaseName(session, "MyTrackName", "MyReleaseFoo")}; + auto tracks {Track::find(session, Track::FindParameters{}.setName("MyTrackName").setReleaseName("MyReleaseFoo"))}; EXPECT_EQ(tracks.results.size(), 0); } { auto transaction {session.createUniqueTransaction()}; - auto tracks {Track::findByNameAndReleaseName(session, "MyTrackFoo", "MyReleaseName")}; + auto tracks {Track::find(session, Track::FindParameters{}.setName("MyTrackFoo").setReleaseName("MyReleaseName"))}; EXPECT_EQ(tracks.results.size(), 0); } } diff --git a/src/libs/services/recommendation/CMakeLists.txt b/src/libs/services/recommendation/CMakeLists.txt index a001854e..8244679b 100644 --- a/src/libs/services/recommendation/CMakeLists.txt +++ b/src/libs/services/recommendation/CMakeLists.txt @@ -4,6 +4,10 @@ add_library(lmsrecommendation SHARED impl/features/FeaturesEngineCache.cpp impl/features/FeaturesEngine.cpp impl/features/FeaturesDefs.cpp + impl/playlist-constraints/ConsecutiveArtists.cpp + impl/playlist-constraints/ConsecutiveReleases.cpp + impl/playlist-constraints/DuplicateTracks.cpp + impl/PlaylistGeneratorService.cpp impl/RecommendationService.cpp ) diff --git a/src/libs/services/recommendation/impl/PlaylistGeneratorService.cpp b/src/libs/services/recommendation/impl/PlaylistGeneratorService.cpp new file mode 100644 index 00000000..b136e9b4 --- /dev/null +++ b/src/libs/services/recommendation/impl/PlaylistGeneratorService.cpp @@ -0,0 +1,118 @@ +/* + * Copyright (C) 2022 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 "PlaylistGeneratorService.hpp" + +#include "services/database/Db.hpp" +#include "services/database/Session.hpp" +#include "services/database/Track.hpp" +#include "services/recommendation/IRecommendationService.hpp" +#include "playlist-constraints/ConsecutiveArtists.hpp" +#include "playlist-constraints/ConsecutiveReleases.hpp" +#include "playlist-constraints/DuplicateTracks.hpp" +#include "utils/Logger.hpp" + +namespace Recommendation +{ + using namespace Database; + + std::unique_ptr + createPlaylistGeneratorService(Db& db, Recommendation::IRecommendationService& recommendationService) + { + return std::make_unique(db, recommendationService); + } + + PlaylistGeneratorService::PlaylistGeneratorService(Db& db, Recommendation::IRecommendationService& recommendationService) + : _db {db} + , _recommendationService {recommendationService} + { + _constraints.push_back(std::make_unique(_db)); + _constraints.push_back(std::make_unique(_db)); + _constraints.push_back(std::make_unique()); + } + + std::vector + PlaylistGeneratorService::extendPlaylist(TrackListId tracklistId, std::size_t maxCount) const + { + LMS_LOG(RECOMMENDATION, DEBUG) << "Requested to extend playlist by " << maxCount << " similar tracks"; + + // supposed to be ordered from most similar to least similar + std::vector similarTracks {_recommendationService.findSimilarTracks(tracklistId, maxCount * 2)}; // ask for more tracks than we need as it will be easier to respect constraints + + const std::vector startingTracks {getTracksFromTrackList(tracklistId)}; + + std::vector finalResult = startingTracks; + finalResult.reserve(startingTracks.size() + maxCount); + + std::vector scores; + for (std::size_t i {}; i < maxCount; ++i) + { + if (similarTracks.empty()) + break; + + scores.resize(similarTracks.size(), {}); + + // select the similar track that has the best score + for (std::size_t trackIndex {}; trackIndex < similarTracks.size(); ++trackIndex) + { + using namespace Database::Debug; + + finalResult.push_back(similarTracks[trackIndex]); + + scores[trackIndex] = 0; + for (const auto& constraint : _constraints) + scores[trackIndex] += constraint->computeScore(finalResult, finalResult.size() - 1); + + finalResult.pop_back(); + + // early exit if we consider we found a track with no constraint violation (since similarTracks sorted from most to least similar) + if (scores[trackIndex] < 0.01) + break; + } + + // get the best score + const std::size_t bestScoreIndex {static_cast(std::distance(std::cbegin(scores), std::min_element(std::cbegin(scores), std::cend(scores))))}; + + finalResult.push_back(similarTracks[bestScoreIndex]); + similarTracks.erase(std::begin(similarTracks) + bestScoreIndex); + } + + // for now, just get some more similar tracks + return std::vector(std::cbegin(finalResult) + startingTracks.size(), std::cend(finalResult)); + } + + TrackContainer + PlaylistGeneratorService::getTracksFromTrackList(Database::TrackListId tracklistId) const + { + TrackContainer tracks; + + Session& dbSession {_db.getTLSSession()}; + auto transaction {dbSession.createSharedTransaction()}; + + Track::FindParameters params; + params.setTrackList(tracklistId); + params.setSortMethod(TrackSortMethod::TrackList); + params.setDistinct(false); + + for (const TrackId trackId : Track::find(dbSession, params).results) + tracks.push_back(trackId); + + return tracks; + } +} diff --git a/src/libs/services/recommendation/impl/PlaylistGeneratorService.hpp b/src/libs/services/recommendation/impl/PlaylistGeneratorService.hpp new file mode 100644 index 00000000..8dd9ee03 --- /dev/null +++ b/src/libs/services/recommendation/impl/PlaylistGeneratorService.hpp @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2022 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 "services/recommendation/IPlaylistGeneratorService.hpp" +#include "services/recommendation/IRecommendationService.hpp" +#include "playlist-constraints/IConstraint.hpp" + +namespace Recommendation +{ + class PlaylistGeneratorService : public IPlaylistGeneratorService + { + public: + PlaylistGeneratorService(Database::Db& db, Recommendation::IRecommendationService& recommendationService); + + private: + TrackContainer extendPlaylist(Database::TrackListId tracklistId, std::size_t maxCount) const override; + + TrackContainer getTracksFromTrackList(Database::TrackListId tracklistId) const; + + Database::Db& _db; + Recommendation::IRecommendationService& _recommendationService; + std::vector> _constraints; + }; +} // namespace Radio diff --git a/src/libs/services/recommendation/impl/RecommendationService.cpp b/src/libs/services/recommendation/impl/RecommendationService.cpp index 67445959..dd302c6b 100644 --- a/src/libs/services/recommendation/impl/RecommendationService.cpp +++ b/src/libs/services/recommendation/impl/RecommendationService.cpp @@ -59,7 +59,7 @@ namespace Recommendation } TrackContainer - RecommendationService::findSimilarTracksFromTrackList(Database::TrackListId trackListId, std::size_t maxCount) const + RecommendationService::findSimilarTracks(Database::TrackListId trackListId, std::size_t maxCount) const { TrackContainer res; diff --git a/src/libs/services/recommendation/impl/RecommendationService.hpp b/src/libs/services/recommendation/impl/RecommendationService.hpp index 25e6b5d4..11b10cfc 100644 --- a/src/libs/services/recommendation/impl/RecommendationService.hpp +++ b/src/libs/services/recommendation/impl/RecommendationService.hpp @@ -56,7 +56,7 @@ namespace Recommendation void load(bool forceReload, const ProgressCallback& progressCallback) override; void cancelLoad() override; - TrackContainer findSimilarTracksFromTrackList(Database::TrackListId tracklistId, std::size_t maxCount) const override; + TrackContainer findSimilarTracks(Database::TrackListId tracklistId, std::size_t maxCount) const override; TrackContainer findSimilarTracks(const std::vector& tracksId, std::size_t maxCount) const override; ReleaseContainer getSimilarReleases(Database::ReleaseId releaseId, std::size_t maxCount) const override; ArtistContainer getSimilarArtists(Database::ArtistId artistId, EnumSet linkTypes, std::size_t maxCount) const override; diff --git a/src/libs/services/recommendation/impl/playlist-constraints/ConsecutiveArtists.cpp b/src/libs/services/recommendation/impl/playlist-constraints/ConsecutiveArtists.cpp new file mode 100644 index 00000000..ff45b7b5 --- /dev/null +++ b/src/libs/services/recommendation/impl/playlist-constraints/ConsecutiveArtists.cpp @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2022 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 "ConsecutiveArtists.hpp" + +#include + +#include "services/database/Db.hpp" +#include "services/database/Release.hpp" +#include "services/database/Session.hpp" +#include "services/database/Track.hpp" +#include "utils/Logger.hpp" + +namespace Recommendation::PlaylistGeneratorConstraint +{ + namespace + { + std::size_t + countCommonArtists(const ArtistContainer& artists1, const ArtistContainer& artists2) + { + ArtistContainer intersection; + + std::set_intersection(std::cbegin(artists1), std::cend(artists1), + std::cbegin(artists2), std::cend(artists2), + std::back_inserter(intersection)); + + return intersection.size(); + } + } + + ConsecutiveArtists::ConsecutiveArtists(Database::Db& db) + : _db {db} + {} + + float + ConsecutiveArtists::computeScore(const std::vector& trackIds, std::size_t trackIndex) + { + assert(!trackIds.empty()); + assert(trackIndex <= trackIds.size() - 1); + + const ArtistContainer artists {getArtists(trackIds[trackIndex])}; + + constexpr std::size_t rangeSize{ 3 }; // check up to rangeSize tracks before/after the target track + static_assert(rangeSize > 0); + + float score {}; + for (std::size_t i {1}; i < rangeSize; ++i) + { + if (trackIndex >= i) + score += countCommonArtists(artists, getArtists(trackIds[trackIndex - i])) / static_cast(i); + + if (trackIndex + i < trackIds.size()) + score += countCommonArtists(artists, getArtists(trackIds[trackIndex + i])) / static_cast(i); + } + + return score; + } + + ArtistContainer + ConsecutiveArtists::getArtists(Database::TrackId trackId) + { + using namespace Database; + + ArtistContainer res; + + Session& dbSession {_db.getTLSSession()}; + auto transaction {dbSession.createSharedTransaction()}; + + const Track::pointer track {Track::find(dbSession, trackId)}; + if (!track) + return res; + + res = track->getArtistIds({}); + std::sort(std::begin(res), std::end(res)); + + return res; + } + + +} // namespace Recommendation + diff --git a/src/libs/services/recommendation/impl/playlist-constraints/ConsecutiveArtists.hpp b/src/libs/services/recommendation/impl/playlist-constraints/ConsecutiveArtists.hpp new file mode 100644 index 00000000..34bcd0ab --- /dev/null +++ b/src/libs/services/recommendation/impl/playlist-constraints/ConsecutiveArtists.hpp @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2022 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 "IConstraint.hpp" + +#include "services/database/ReleaseId.hpp" + +namespace Database +{ + class Db; +} + +namespace Recommendation::PlaylistGeneratorConstraint +{ + class ConsecutiveArtists : public IConstraint + { + public: + ConsecutiveArtists(Database::Db& db); + + private: + float computeScore(const TrackContainer& trackIds, std::size_t trackIndex) override; + ArtistContainer getArtists(Database::TrackId trackId); + + Database::Db& _db; + }; +} // namespace Recommendation::PlaylistGeneratorConstraint + diff --git a/src/libs/services/recommendation/impl/playlist-constraints/ConsecutiveReleases.cpp b/src/libs/services/recommendation/impl/playlist-constraints/ConsecutiveReleases.cpp new file mode 100644 index 00000000..d97482ca --- /dev/null +++ b/src/libs/services/recommendation/impl/playlist-constraints/ConsecutiveReleases.cpp @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2022 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 "ConsecutiveReleases.hpp" + +#include "services/database/Db.hpp" +#include "services/database/Release.hpp" +#include "services/database/Session.hpp" +#include "services/database/Track.hpp" +#include "utils/Logger.hpp" + +namespace Recommendation::PlaylistGeneratorConstraint +{ + ConsecutiveReleases::ConsecutiveReleases(Database::Db& db) + : _db {db} + {} + + float + ConsecutiveReleases::computeScore(const std::vector& trackIds, std::size_t trackIndex) + { + assert(!trackIds.empty()); + assert(trackIndex <= trackIds.size() - 1); + + const Database::ReleaseId releaseId {getReleaseId(trackIds[trackIndex])}; + + constexpr std::size_t rangeSize{ 3 }; // check up to rangeSize tracks before/after the target track + static_assert(rangeSize > 0); + + float score {}; + for (std::size_t i {1}; i < rangeSize; ++i) + { + if ((trackIndex >= i) && getReleaseId(trackIds[trackIndex - i]) == releaseId) + score += (1.f / static_cast(i)); + + if ((trackIndex + i < trackIds.size()) && getReleaseId(trackIds[trackIndex + i]) == releaseId) + score += (1.f / static_cast(i)); + } + + return score; + } + + Database::ReleaseId + ConsecutiveReleases::getReleaseId(Database::TrackId trackId) + { + using namespace Database; + + Session& dbSession {_db.getTLSSession()}; + auto transaction {dbSession.createSharedTransaction()}; + + const Track::pointer track {Track::find(dbSession, trackId)}; + if (!track) + return {}; + + const Release::pointer release {track->getRelease()}; + if (!release) + return {}; + + return release->getId(); + } +} // namespace Recommendation + diff --git a/src/libs/services/recommendation/impl/playlist-constraints/ConsecutiveReleases.hpp b/src/libs/services/recommendation/impl/playlist-constraints/ConsecutiveReleases.hpp new file mode 100644 index 00000000..3ce4922a --- /dev/null +++ b/src/libs/services/recommendation/impl/playlist-constraints/ConsecutiveReleases.hpp @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2022 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 "IConstraint.hpp" + +#include "services/database/ReleaseId.hpp" + +namespace Database +{ + class Db; +} + +namespace Recommendation::PlaylistGeneratorConstraint +{ + class ConsecutiveReleases : public IConstraint + { + public: + ConsecutiveReleases(Database::Db& db); + + private: + float computeScore(const std::vector& trackIds, std::size_t trackIndex) override; + + Database::ReleaseId getReleaseId(Database::TrackId trackId); + + Database::Db& _db; + }; +} // namespace Recommendation + diff --git a/src/libs/services/recommendation/impl/playlist-constraints/DuplicateTracks.cpp b/src/libs/services/recommendation/impl/playlist-constraints/DuplicateTracks.cpp new file mode 100644 index 00000000..e315c831 --- /dev/null +++ b/src/libs/services/recommendation/impl/playlist-constraints/DuplicateTracks.cpp @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2022 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 "DuplicateTracks.hpp" + +#include + +namespace Recommendation::PlaylistGeneratorConstraint +{ + float + DuplicateTracks::computeScore(const std::vector& trackIds, std::size_t trackIndex) + { + const auto count {std::count(std::cbegin(trackIds), std::cend(trackIds), trackIds[trackIndex])}; + return count == 1 ? 0 : 1000; + } +} // namespace Recommendation + diff --git a/src/libs/services/recommendation/impl/playlist-constraints/DuplicateTracks.hpp b/src/libs/services/recommendation/impl/playlist-constraints/DuplicateTracks.hpp new file mode 100644 index 00000000..a5f3c821 --- /dev/null +++ b/src/libs/services/recommendation/impl/playlist-constraints/DuplicateTracks.hpp @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2022 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 "IConstraint.hpp" + +namespace Recommendation::PlaylistGeneratorConstraint +{ + class DuplicateTracks : public IConstraint + { + private: + float computeScore(const std::vector& trackIds, std::size_t trackIndex) override; + }; +} // namespace Recommendation::PlaylistGeneratorConstraints + diff --git a/src/libs/services/recommendation/impl/playlist-constraints/IConstraint.hpp b/src/libs/services/recommendation/impl/playlist-constraints/IConstraint.hpp new file mode 100644 index 00000000..be24b393 --- /dev/null +++ b/src/libs/services/recommendation/impl/playlist-constraints/IConstraint.hpp @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2022 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 "services/recommendation/Types.hpp" + +namespace Recommendation::PlaylistGeneratorConstraint +{ + class IConstraint + { + public: + virtual ~IConstraint() = default; + + // compute the score of the track at index trackIndex + // 0: best + // 1: worst + // > 1 : violation + virtual float computeScore(const TrackContainer& trackIds, std::size_t trackIndex) = 0; + }; +} // namespace Recommendation diff --git a/src/libs/services/recommendation/include/services/recommendation/IPlaylistGeneratorService.hpp b/src/libs/services/recommendation/include/services/recommendation/IPlaylistGeneratorService.hpp new file mode 100644 index 00000000..4c87e2cb --- /dev/null +++ b/src/libs/services/recommendation/include/services/recommendation/IPlaylistGeneratorService.hpp @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2019 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include +#include "services/database/TrackListId.hpp" +#include "services/database/Types.hpp" +#include "services/recommendation/Types.hpp" + +namespace Database +{ + class Db; +} + +namespace Recommendation +{ + class IRecommendationService; + class IPlaylistGeneratorService + { + public: + // extend an existing playlist with similar tracks (but use playlist contraints) + virtual TrackContainer extendPlaylist(Database::TrackListId tracklistId, std::size_t maxCount) const = 0; + }; + + std::unique_ptr createPlaylistGeneratorService(Database::Db& db, IRecommendationService& recommandationService); +} // ns Recommendation + diff --git a/src/libs/services/recommendation/include/services/recommendation/IRecommendationService.hpp b/src/libs/services/recommendation/include/services/recommendation/IRecommendationService.hpp index 45704ac9..f38f7bd0 100644 --- a/src/libs/services/recommendation/include/services/recommendation/IRecommendationService.hpp +++ b/src/libs/services/recommendation/include/services/recommendation/IRecommendationService.hpp @@ -40,13 +40,12 @@ namespace Recommendation virtual void load(bool forceReload, const ProgressCallback& progressCallback = {}) = 0; virtual void cancelLoad() = 0; // wait for cancel done - virtual TrackContainer findSimilarTracksFromTrackList(Database::TrackListId tracklistId, std::size_t maxCount) const = 0; + virtual TrackContainer findSimilarTracks(Database::TrackListId tracklistId, std::size_t maxCount) const = 0; virtual TrackContainer findSimilarTracks(const std::vector& tracksId, std::size_t maxCount) const = 0; virtual ReleaseContainer getSimilarReleases(Database::ReleaseId releaseId, std::size_t maxCount) const = 0; virtual ArtistContainer getSimilarArtists(Database::ArtistId artistId, EnumSet linkTypes, std::size_t maxCount) const = 0; }; std::unique_ptr createRecommendationService(Database::Db& db); - } // ns Recommendation diff --git a/src/libs/services/scrobbling/CMakeLists.txt b/src/libs/services/scrobbling/CMakeLists.txt index d39d001e..087a3236 100644 --- a/src/libs/services/scrobbling/CMakeLists.txt +++ b/src/libs/services/scrobbling/CMakeLists.txt @@ -1,8 +1,12 @@ add_library(lmsscrobbling SHARED impl/internal/InternalScrobbler.cpp + impl/listenbrainz/FeedbacksParser.cpp impl/listenbrainz/FeedbacksSynchronizer.cpp + impl/listenbrainz/FeedbackTypes.cpp impl/listenbrainz/ListenBrainzScrobbler.cpp + impl/listenbrainz/ListenTypes.cpp + impl/listenbrainz/ListensParser.cpp impl/listenbrainz/ListensSynchronizer.cpp impl/listenbrainz/Utils.cpp impl/ScrobblingService.cpp @@ -27,3 +31,6 @@ target_link_libraries(lmsscrobbling PUBLIC install(TARGETS lmsscrobbling DESTINATION lib) +if(BUILD_TESTING) + add_subdirectory(test) +endif() diff --git a/src/libs/services/scrobbling/impl/listenbrainz/Exception.hpp b/src/libs/services/scrobbling/impl/listenbrainz/Exception.hpp new file mode 100644 index 00000000..6481bba7 --- /dev/null +++ b/src/libs/services/scrobbling/impl/listenbrainz/Exception.hpp @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2022 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 "services/scrobbling/Exception.hpp" + +namespace Scrobbling::ListenBrainz +{ + class Exception : public Scrobbling::Exception + { + public: + using Scrobbling::Exception::Exception; + }; +} diff --git a/src/libs/services/scrobbling/impl/listenbrainz/FeedbackTypes.cpp b/src/libs/services/scrobbling/impl/listenbrainz/FeedbackTypes.cpp new file mode 100644 index 00000000..0b4d5168 --- /dev/null +++ b/src/libs/services/scrobbling/impl/listenbrainz/FeedbackTypes.cpp @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2022 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 "FeedbackTypes.hpp" + +namespace Scrobbling::ListenBrainz +{ + std::ostream& + operator<<(std::ostream& os, const Feedback& feedback) + { + os << "created = '" << feedback.created.toString() << "', recording MBID = '" << feedback.recordingMBID.getAsString() << "', score = " << static_cast(feedback.score); + return os; + } +} // Scrobbling::ListenBrainz diff --git a/src/libs/services/scrobbling/impl/listenbrainz/FeedbackTypes.hpp b/src/libs/services/scrobbling/impl/listenbrainz/FeedbackTypes.hpp index 782e523d..cb45d992 100644 --- a/src/libs/services/scrobbling/impl/listenbrainz/FeedbackTypes.hpp +++ b/src/libs/services/scrobbling/impl/listenbrainz/FeedbackTypes.hpp @@ -19,6 +19,8 @@ #pragma once +#include +#include #include "utils/UUID.hpp" namespace Scrobbling::ListenBrainz @@ -37,4 +39,7 @@ namespace Scrobbling::ListenBrainz UUID recordingMBID; FeedbackType score; }; + + std::ostream& operator<<(std::ostream& os, const Feedback& feedback); + } // Scrobbling::ListenBrainz diff --git a/src/libs/services/scrobbling/impl/listenbrainz/FeedbacksParser.cpp b/src/libs/services/scrobbling/impl/listenbrainz/FeedbacksParser.cpp new file mode 100644 index 00000000..359e90e6 --- /dev/null +++ b/src/libs/services/scrobbling/impl/listenbrainz/FeedbacksParser.cpp @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2022 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 "FeedbacksParser.hpp" + +#include +#include +#include +#include +//#include + +#include "services/scrobbling/Exception.hpp" +#include "Exception.hpp" +#include "Utils.hpp" + +namespace Scrobbling::ListenBrainz +{ + namespace + { + Feedback + parseFeedback(const Wt::Json::Object& feedbackObj) + { + const std::optional recordingMBID {UUID::fromString(static_cast(feedbackObj.get("recording_mbid")))}; + if (!recordingMBID) + throw Exception {"MBID not found!"}; + + return Feedback + { + Wt::WDateTime::fromTime_t(static_cast(feedbackObj.get("created"))), + *recordingMBID, + static_cast(static_cast(feedbackObj.get("score"))) + }; + } + } + + FeedbacksParser::Result + FeedbacksParser::parse(std::string_view msgBody) + { + Result res; + + try + { + Wt::Json::Object root; + Wt::Json::parse(std::string {msgBody}, root); + + const Wt::Json::Array& feedbacks = root.get("feedback"); + + LOG(DEBUG) << "Got " << feedbacks.size() << " feedbacks"; + + if (feedbacks.empty()) + return res; + + res.feedbackCount = feedbacks.size(); + + for (const Wt::Json::Value& value : feedbacks) + { + try + { + res.feedbacks.push_back(parseFeedback(value)); + } + catch (const Exception& e) + { + LOG(DEBUG) << "Cannot parse feedback: " << e.what() << ", skipping"; + } + catch (const Wt::WException &e) + { + LOG(DEBUG) << "Cannot parse feedback: " << e.what() << ", skipping"; + } + } + } + catch (const Wt::WException& error) + { + LOG(ERROR) << "Cannot parse 'feedback' result: " << error.what(); + } + + return res; + } +} // Scrobbling::ListenBrainz diff --git a/src/libs/services/scrobbling/impl/listenbrainz/FeedbacksParser.hpp b/src/libs/services/scrobbling/impl/listenbrainz/FeedbacksParser.hpp new file mode 100644 index 00000000..4b5644d6 --- /dev/null +++ b/src/libs/services/scrobbling/impl/listenbrainz/FeedbacksParser.hpp @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2022 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include + +#include "FeedbackTypes.hpp" + +namespace Scrobbling::ListenBrainz +{ + class FeedbacksParser + { + public: + struct Result + { + std::size_t feedbackCount {}; // >= feedbacks.size() + std::vector feedbacks; + }; + + static Result parse(std::string_view msgBody); + }; + +} // Scrobbling::ListenBrainz diff --git a/src/libs/services/scrobbling/impl/listenbrainz/FeedbacksSynchronizer.cpp b/src/libs/services/scrobbling/impl/listenbrainz/FeedbacksSynchronizer.cpp index 649303f3..78e3d5e8 100644 --- a/src/libs/services/scrobbling/impl/listenbrainz/FeedbacksSynchronizer.cpp +++ b/src/libs/services/scrobbling/impl/listenbrainz/FeedbacksSynchronizer.cpp @@ -30,11 +30,12 @@ #include "services/database/StarredTrack.hpp" #include "services/database/Track.hpp" #include "services/database/User.hpp" -#include "services/scrobbling/Exception.hpp" #include "utils/IConfig.hpp" #include "utils/http/IClient.hpp" #include "utils/Service.hpp" +#include "Exception.hpp" +#include "FeedbacksParser.hpp" #include "Utils.hpp" using namespace Scrobbling::ListenBrainz; @@ -42,24 +43,6 @@ using namespace Database; namespace { - class Exception : public Scrobbling::Exception - { - public: - using Scrobbling::Exception::Exception; - }; - - class ParseErrorException : public Exception - { - public: - using Exception::Exception; - }; - - class MBIDNotFoundException : public Exception - { - public: - MBIDNotFoundException() : Exception {"MBID not found"} {} - }; - std::optional parseTotalFeedbackCount(std::string_view msgBody) { @@ -76,75 +59,6 @@ namespace return std::nullopt; } } - - Feedback - parseFeedback(const Wt::Json::Object& feedbackObj) - { - try - { - const std::optional recordingMBID {UUID::fromString(static_cast(feedbackObj.get("recording_mbid")))}; - if (!recordingMBID) - throw MBIDNotFoundException {}; - - return Feedback - { - Wt::WDateTime::fromTime_t(static_cast(feedbackObj.get("created"))), - *recordingMBID, - static_cast(static_cast(feedbackObj.get("score"))) - }; - } - catch (const Wt::WException& e) - { - LOG(DEBUG) << "Cannot parse feedback: " << e.what(); - throw Exception {}; - } - } - - struct GetFeedbacksResult - { - std::size_t totalFeedbackCount{}; - std::vector feedbacks; - }; - - GetFeedbacksResult - parseGetFeedbacks(std::string_view msgBody) - { - GetFeedbacksResult res; - - try - { - Wt::Json::Object root; - Wt::Json::parse(std::string {msgBody}, root); - - const Wt::Json::Array& feedbacks = root.get("feedback"); - - LOG(DEBUG) << "Got " << feedbacks.size() << " feedbacks"; - - if (feedbacks.empty()) - return res; - - res.totalFeedbackCount += feedbacks.size(); - - for (const Wt::Json::Value& value : feedbacks) - { - try - { - res.feedbacks.push_back(parseFeedback(value)); - } - catch (const Exception &e) - { - LOG(DEBUG) << "Cannot parse feedback: " << e.what() << ", skipping"; - } - } - } - catch (const Wt::WException& error) - { - LOG(ERROR) << "Cannot parse 'get-feedback' result: " << error.what(); - throw ParseErrorException {error.what()}; - } - - return res; - } } namespace Scrobbling::ListenBrainz @@ -493,23 +407,16 @@ namespace Scrobbling::ListenBrainz std::string msgBodyCopy {msgBody}; _strand.dispatch([this, msgBodyCopy, &context] { - try + const std::size_t fetchedFeedbackCount {processGetFeedbacks(msgBodyCopy, context)}; + if (fetchedFeedbackCount == 0 // no more thing available on server + || context.fetchedFeedbackCount >= context.feedbackCount // we may miss something, but we will get it next time + || context.fetchedFeedbackCount >= _maxSyncFeedbackCount) { - const std::size_t fetchedFeedbackCount {processGetFeedbacks(msgBodyCopy, context)}; - if (fetchedFeedbackCount == 0 // no more thing available on server - || context.fetchedFeedbackCount >= context.feedbackCount // we may miss something, but we will get it next time - || context.fetchedFeedbackCount >= _maxSyncFeedbackCount) - { - onSyncEnded(context); - } - else - { - enqueGetFeedbacks(context); - } + onSyncEnded(context); } - catch (const Exception& e) + else { - onSyncEnded(context); + enqueGetFeedbacks(context); } }); }; @@ -524,17 +431,17 @@ namespace Scrobbling::ListenBrainz std::size_t FeedbacksSynchronizer::processGetFeedbacks(std::string_view msgBody, UserContext& context) { - const GetFeedbacksResult parseResult {parseGetFeedbacks(msgBody)}; + const FeedbacksParser::Result parseResult {FeedbacksParser::parse(msgBody)}; - LOG(DEBUG) << "Parsed " << parseResult.totalFeedbackCount << " feedbacks, found " << parseResult.feedbacks.size() << " usable entries"; - context.fetchedFeedbackCount += parseResult.totalFeedbackCount; + LOG(DEBUG) << "Parsed " << parseResult.feedbackCount << " feedbacks, found " << parseResult.feedbacks.size() << " usable entries"; + context.fetchedFeedbackCount += parseResult.feedbackCount; for (const Feedback& feedback : parseResult.feedbacks) { tryImportFeedback(feedback, context); } - return parseResult.totalFeedbackCount; + return parseResult.feedbackCount; } void @@ -550,11 +457,12 @@ namespace Scrobbling::ListenBrainz const std::vector tracks {Track::findByRecordingMBID(session, feedback.recordingMBID)}; if (tracks.size() > 1) { - LOG(DEBUG) << "Duplicate recording MBIDs found for '" << feedback.recordingMBID.getAsString() << "', using first entry found"; + LOG(DEBUG) << "Too many matches for feedback '" << feedback << "': duplicate recording MBIDs found"; + return; } else if (tracks.empty()) { - LOG(DEBUG) << "No track found for recording MBID '" << feedback.recordingMBID.getAsString() << "'"; + LOG(DEBUG) << "Cannot match feedback '" << feedback << "': no track found for this recording MBID"; return; } @@ -571,6 +479,8 @@ namespace Scrobbling::ListenBrainz if (needImport) { + LOG(DEBUG) << "Importing feedback '" << feedback << "'"; + auto transaction {session.createUniqueTransaction()}; const Track::pointer track {Track::find(session, trackId)}; @@ -583,11 +493,13 @@ namespace Scrobbling::ListenBrainz StarredTrack::pointer starredTrack {session.create(track, user, Database::Scrobbler::ListenBrainz)}; starredTrack.modify()->setScrobblingState(ScrobblingState::Synchronized); + starredTrack.modify()->setDateTime(feedback.created); context.importedFeedbackCount++; } else { + LOG(DEBUG) << "No need to import feedback '" << feedback << "', already imported"; context.matchedFeedbackCount++; } } diff --git a/src/libs/services/scrobbling/impl/listenbrainz/ListenTypes.cpp b/src/libs/services/scrobbling/impl/listenbrainz/ListenTypes.cpp new file mode 100644 index 00000000..53ff4564 --- /dev/null +++ b/src/libs/services/scrobbling/impl/listenbrainz/ListenTypes.cpp @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2022 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 "ListenTypes.hpp" + +namespace Scrobbling::ListenBrainz +{ + std::ostream& + operator<<(std::ostream& os, const Listen& listen) + { + os << "track name = '" << listen.trackName << "', artistName = '" << listen.artistName << "'"; + if (listen.listenedAt.isValid()) + os << ", listenedAt = " << listen.listenedAt.toString(); + if (!listen.releaseName.empty()) + os << ", releaseName = '" << listen.releaseName << "'"; + if (listen.trackNumber) + os << ", trackNumber = " << *listen.trackNumber; + if (listen.recordingMBID) + os << ", recordingMBID = '" << listen.recordingMBID->getAsString() << "'"; + + return os; + } +} // Scrobbling::ListenBrainz diff --git a/src/libs/services/scrobbling/impl/listenbrainz/ListenTypes.hpp b/src/libs/services/scrobbling/impl/listenbrainz/ListenTypes.hpp new file mode 100644 index 00000000..d5a17c9e --- /dev/null +++ b/src/libs/services/scrobbling/impl/listenbrainz/ListenTypes.hpp @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2022 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 "utils/UUID.hpp" + +namespace Scrobbling::ListenBrainz +{ + struct Listen + { + std::string trackName; + std::string releaseName; + std::string artistName; + std::optional recordingMBID; + std::optional releaseMBID; + std::optional trackNumber; + Wt::WDateTime listenedAt; + }; + + std::ostream& operator<<(std::ostream& os, const Listen& listen); +} // Scrobbling::ListenBrainz diff --git a/src/libs/services/scrobbling/impl/listenbrainz/ListensParser.cpp b/src/libs/services/scrobbling/impl/listenbrainz/ListensParser.cpp new file mode 100644 index 00000000..495ac101 --- /dev/null +++ b/src/libs/services/scrobbling/impl/listenbrainz/ListensParser.cpp @@ -0,0 +1,107 @@ +/* + * Copyright (C) 2022 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 "ListensParser.hpp" + +#include +#include +#include +#include + +#include "utils/Logger.hpp" +#include "Utils.hpp" + +namespace +{ + using namespace Scrobbling::ListenBrainz; + + Listen + parseListen(const Wt::Json::Object& listenObject) + { + Listen listen; + + // Mandatory fields + const Wt::Json::Object& metadata = listenObject.get("track_metadata"); + listen.trackName = static_cast(metadata.get("track_name")); + listen.artistName = static_cast(metadata.get("artist_name")); + + // Optional fields + listen.releaseName = static_cast(metadata.get("release_name").orIfNull("")); + if (listenObject.type("listened_at") == Wt::Json::Type::Number) + listen.listenedAt = Wt::WDateTime::fromTime_t(static_cast(listenObject.get("listened_at"))); + if (!listen.listenedAt.isValid()) + LOG(ERROR) << "Invalid or missing 'listened_at' field!"; + + if (metadata.type("additional_info") == Wt::Json::Type::Object) + { + const Wt::Json::Object& additionalInfo = metadata.get("additional_info"); + listen.recordingMBID = UUID::fromString(additionalInfo.get("recording_mbid").orIfNull("")); + listen.releaseMBID = UUID::fromString(additionalInfo.get("release_mbid").orIfNull("")); + + int trackNumber {additionalInfo.get("tracknumber").orIfNull(-1)}; + if (trackNumber > 0) + listen.trackNumber = trackNumber; + } + + return listen; + } +} // namespace + +namespace Scrobbling::ListenBrainz +{ + ListensParser::Result + ListensParser::parse(std::string_view msgBody) + { + Result result; + + try + { + Wt::Json::Object root; + Wt::Json::parse(std::string {msgBody}, root); + + const Wt::Json::Object& payload = root.get("payload"); + const Wt::Json::Array& listens = payload.get("listens"); + + LOG(DEBUG) << "Parsing " << listens.size() << " listens..."; + result.listenCount = listens.size(); + + if (listens.empty()) + return result; + + for (const Wt::Json::Value& value : listens) + { + try + { + const Wt::Json::Object& listen = value; + result.listens.push_back(parseListen(listen)); + } + catch (const Wt::WException& error) + { + LOG(ERROR) << "Cannot parse 'listen': " << error.what(); + } + } + } + catch (const Wt::WException& error) + { + LOG(ERROR) << "Cannot parse 'listens': " << error.what(); + } + + return result; + } +} // Scrobbling::ListenBrainz diff --git a/src/libs/services/scrobbling/impl/listenbrainz/ListensParser.hpp b/src/libs/services/scrobbling/impl/listenbrainz/ListensParser.hpp new file mode 100644 index 00000000..33563b93 --- /dev/null +++ b/src/libs/services/scrobbling/impl/listenbrainz/ListensParser.hpp @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2022 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 "ListenTypes.hpp" + +namespace Scrobbling::ListenBrainz +{ + class ListensParser + { + public: + struct Result + { + std::size_t listenCount {}; // may be > than listens.size() + std::vector listens; // successfully parsed listens + }; + + static Result parse(std::string_view msgBody); + }; +} // Scrobbling::ListenBrainz diff --git a/src/libs/services/scrobbling/impl/listenbrainz/ListensSynchronizer.cpp b/src/libs/services/scrobbling/impl/listenbrainz/ListensSynchronizer.cpp index 79510265..ecd43a50 100644 --- a/src/libs/services/scrobbling/impl/listenbrainz/ListensSynchronizer.cpp +++ b/src/libs/services/scrobbling/impl/listenbrainz/ListensSynchronizer.cpp @@ -36,6 +36,7 @@ #include "utils/IConfig.hpp" #include "utils/http/IClient.hpp" #include "utils/Service.hpp" +#include "ListensParser.hpp" #include "Utils.hpp" @@ -142,127 +143,54 @@ namespace } Database::TrackId - tryMatchListen(Database::Session& session, const Wt::Json::Object& metadata) + tryGetMatchingTrack(Database::Session& session, const Listen& listen) { using namespace Database; - //LOG(DEBUG) << "Trying to match track' " << Wt::Json::serialize(metadata) << "'"; + auto transaction {session.createSharedTransaction()}; - // first try to get the associated track using MBIDs, and then fallback on names - if (metadata.type("additional_info") == Wt::Json::Type::Object) + // first try to match using recording MBID, and then fallback on possibly ambiguous info + if (listen.recordingMBID) { - const Wt::Json::Object& additionalInfo = metadata.get("additional_info"); - if (std::optional recordingMBID {UUID::fromString(additionalInfo.get("recording_mbid").orIfNull(""))}) + const auto tracks {Track::findByRecordingMBID(session, *listen.recordingMBID)}; + // if duplicated files, do not record it (let the user correct its database) + if (tracks.size() == 1) + { + LOG(DEBUG) << "Matched listen '" << listen << "' using recording MBID"; + return tracks.front()->getId(); + } + else if (tracks.size() > 1) { - const auto tracks {Track::findByRecordingMBID(session, *recordingMBID)}; - // if duplicated files, do not record it (let the user correct its database) - if (tracks.size() == 1) - return tracks.front()->getId(); + LOG(DEBUG) << "Too many matches for listen '" << listen << "' using recording MBID!"; + return {}; } } - // these fields are mandatory - const std::string trackName {static_cast(metadata.get("track_name"))}; - const std::string releaseName {static_cast(metadata.get("release_name"))}; - - auto tracks {Track::findByNameAndReleaseName(session, trackName, releaseName)}; - if (tracks.results.size() > 1) - { - tracks.results.erase(std::remove_if(std::begin(tracks.results), std::end(tracks.results), - [&](const TrackId trackId) - { - const Track::pointer track {Track::find(session, trackId)}; - - if (std::string artistName {metadata.get("artist_name").orIfNull("")}; !artistName.empty()) - { - const auto& artists {track->getArtists({TrackArtistLinkType::Artist})}; - if (std::none_of(std::begin(artists), std::end(artists), [&](const Artist::pointer& artist) { return artist->getName() == artistName; })) - return true; - } - if (metadata.type("additional_info") == Wt::Json::Type::Object) - { - const Wt::Json::Object& additionalInfo = metadata.get("additional_info"); - if (track->getTrackNumber()) - { - int otherTrackNumber {additionalInfo.get("tracknumber").orIfNull(-1)}; - if (otherTrackNumber > 0 && static_cast(otherTrackNumber) != *track->getTrackNumber()) - return true; - } - - if (auto releaseMBID {track->getRelease()->getMBID()}) - { - if (std::optional otherReleaseMBID {UUID::fromString(additionalInfo.get("release_mbid").orIfNull(""))}) - { - if (otherReleaseMBID->getAsString() != releaseMBID->getAsString()) - return true; - } - } - } + assert(!listen.trackName.empty() && !listen.artistName.empty()); - return false; - }), std::end(tracks.results)); - } + // TODO check release MBID? + Track::FindParameters params; + params.setName(listen.trackName); + params.setReleaseName(listen.releaseName); + params.setArtistName(listen.artistName); + if (listen.trackNumber) + params.setTrackNumber(*listen.trackNumber); + const auto tracks {Track::find(session, params)}; + // conservative behavior: in case of multiple matches: reject if (tracks.results.size() == 1) - return tracks.results.front(); - - return {}; - } - - struct ParseGetListensResult - { - Wt::WDateTime oldestEntry; - std::size_t listenCount{}; - std::vector matchedListens; - }; - ParseGetListensResult - parseGetListens(Database::Session& session, std::string_view msgBody, Database::UserId userId) - { - ParseGetListensResult result; - - try { - Wt::Json::Object root; - Wt::Json::parse(std::string {msgBody}, root); - - const Wt::Json::Object& payload = root.get("payload"); - const Wt::Json::Array& listens = payload.get("listens"); - - LOG(DEBUG) << "Got " << listens.size() << " listens"; - - if (listens.empty()) - return result; - - auto transaction {session.createSharedTransaction()}; - - for (const Wt::Json::Value& value : listens) - { - const Wt::Json::Object& listen = value; - const Wt::WDateTime listenedAt {Wt::WDateTime::fromTime_t(static_cast(listen.get("listened_at")))}; - const Wt::Json::Object& metadata = listen.get("track_metadata"); - - if (!listenedAt.isValid()) - { - LOG(ERROR) << "bad listened_at field!"; - continue; - } - - result.listenCount++; - if (!result.oldestEntry.isValid()) - result.oldestEntry = listenedAt; - else if (listenedAt < result.oldestEntry) - result.oldestEntry = listenedAt; - - if (Database::TrackId trackId {tryMatchListen(session, metadata)}; trackId.isValid()) - result.matchedListens.emplace_back(Scrobbling::TimedListen {{userId, trackId}, listenedAt}); - } + LOG(DEBUG) << "Matched listen '" << listen << "' using metadata"; + return tracks.results.front(); } - catch (const Wt::WException& error) + else if (tracks.results.size() > 1) { - LOG(ERROR) << "Cannot parse 'get-listens' result: " << error.what(); + LOG(DEBUG) << "Too many matches for listen '" << listen << "' using metadata"; + return {}; } - return result; + LOG(DEBUG) << "No match for listen '" << listen << "'"; + return {}; } } @@ -288,13 +216,13 @@ namespace Scrobbling::ListenBrainz } void - ListensSynchronizer::enqueListenNow(const Listen& listen) + ListensSynchronizer::enqueListenNow(const Scrobbling::Listen& listen) { enqueListen(listen, {}); } void - ListensSynchronizer::enqueListen(const Listen& listen, const Wt::WDateTime& timePoint) + ListensSynchronizer::enqueListen(const Scrobbling::Listen& listen, const Wt::WDateTime& timePoint) { Http::ClientPOSTRequestParameters request; request.relativeUrl = "/1/submit-listens"; @@ -619,16 +547,30 @@ namespace Scrobbling::ListenBrainz { Database::Session& session {_db.getTLSSession()}; - const ParseGetListensResult parseResult {parseGetListens(session, msgBody, context.userId)}; + context.maxDateTime = {}; // invalidate to break in case no more listens are fetched + ListensParser::Result result {ListensParser::parse(msgBody)}; + context.fetchedListenCount += result.listenCount; - context.fetchedListenCount += parseResult.listenCount; - context.matchedListenCount += parseResult.matchedListens.size(); - context.maxDateTime = parseResult.oldestEntry; - - for (const TimedListen& listen : parseResult.matchedListens) + for (const Listen& parsedListen : result.listens) { - if (saveListen(listen, Database::ScrobblingState::Synchronized)) - context.importedListenCount++; + // update oldest listen for the next query + if (!parsedListen.listenedAt.isValid()) + { + LOG(DEBUG) << "Skipping entry due to invalid listenedAt"; + continue; + } + + if (!context.maxDateTime.isValid() || context.maxDateTime > parsedListen.listenedAt) + context.maxDateTime = parsedListen.listenedAt; + + if (const Database::TrackId trackId {tryGetMatchingTrack(session, parsedListen)}; trackId.isValid()) + { + context.matchedListenCount++; + + const Scrobbling::TimedListen listen {{context.userId, trackId}, parsedListen.listenedAt}; + if (saveListen(listen, Database::ScrobblingState::Synchronized)) + context.importedListenCount++; + } } } } // namespace Scrobbling::ListenBrainz diff --git a/src/libs/services/scrobbling/test/CMakeLists.txt b/src/libs/services/scrobbling/test/CMakeLists.txt new file mode 100644 index 00000000..b8a36f1b --- /dev/null +++ b/src/libs/services/scrobbling/test/CMakeLists.txt @@ -0,0 +1,20 @@ + +add_executable(test-scrobbling + Listenbrainz.cpp + Scrobbling.cpp + ) + +target_link_libraries(test-scrobbling PRIVATE + lmsutils + lmsscrobbling + GTest::GTest + ) + +target_include_directories(test-scrobbling PRIVATE + ../impl + ) + +if (NOT CMAKE_CROSSCOMPILING) + gtest_discover_tests(test-scrobbling) +endif() + diff --git a/src/libs/services/scrobbling/test/Listenbrainz.cpp b/src/libs/services/scrobbling/test/Listenbrainz.cpp new file mode 100644 index 00000000..4d0684c9 --- /dev/null +++ b/src/libs/services/scrobbling/test/Listenbrainz.cpp @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2022 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include + +#include "listenbrainz/ListensParser.hpp" + +using namespace Scrobbling::ListenBrainz; + +TEST(Listenbrainz, parseListens_empty) +{ + ListensParser::Result result {ListensParser::parse("")}; + EXPECT_EQ(result.listenCount, 0); + EXPECT_EQ(result.listens.size(), 0); + + result = ListensParser::parse(R"({"payload":{"count":0,"latest_listen_ts":1664105200,"listens":[],"user_id":"epoupon"}})"); + EXPECT_EQ(result.listenCount, 0); + EXPECT_EQ(result.listens.size(), 0); +} + +TEST(Listenbrainz, parseListens_single_missingMBID) +{ + ListensParser::Result result {ListensParser::parse(R"({"payload":{"count":1,"latest_listen_ts":1663159479,"listens":[{"inserted_at":1650541124,"listened_at":1650541124,"recording_msid":"0e1418e3-b485-413a-84af-6316312cb116","track_metadata":{"additional_info":{"artist_msid":"ab5b27ad-e579-441c-ac60-d5dd9975c044","listening_from":"LMS","recording_msid":"0e1418e3-b485-413a-84af-6316312cb116","release_msid":"3f22f274-a9ee-4cb2-8dd1-f3bd18407099","tracknumber":8},"artist_name":"Broke For Free","release_name":"YEKOMS","track_name":"U2B"},"user_name":"epoupon"}],"user_id":"epoupon"}})")}; + EXPECT_EQ(result.listenCount, 1); + ASSERT_EQ(result.listens.size(), 1); + EXPECT_EQ(result.listens[0].trackName, "U2B"); + EXPECT_EQ(result.listens[0].releaseName, "YEKOMS"); + EXPECT_EQ(result.listens[0].artistName, "Broke For Free"); + EXPECT_EQ(result.listens[0].recordingMBID, std::nullopt); + EXPECT_EQ(result.listens[0].releaseMBID, std::nullopt); + EXPECT_EQ(result.listens[0].trackNumber, 8); + + Wt::WDateTime listenedAt; + listenedAt.setTime_t(1650541124); + EXPECT_EQ(result.listens[0].listenedAt, listenedAt); +} + +TEST(Listenbrainz, parseListens_tworesults) +{ + ListensParser::Result result {ListensParser::parse(R"({"payload":{"count":2,"latest_listen_ts":1664028167,"listens":[{"inserted_at":1664028167,"listened_at":1664028167,"recording_msid":"29c11137-e40b-4875-9ec0-9a20a4bdc2d3","track_metadata":{"additional_info":{"artist_mbids":["069a1c1f-14eb-4d36-b0a0-77dffbd67713"],"artist_msid":null,"listening_from":"LMS","recording_mbid":"46ae879f-2dbe-46d3-99ad-05c116f97a30","recording_msid":"29c11137-e40b-4875-9ec0-9a20a4bdc2d3","release_mbid":"44915500-fbb9-4060-98ce-59a57a429edc","release_msid":null,"track_mbid":"5427a943-a096-4d0b-8b9a-53aca9ed61ac","tracknumber":5},"artist_name":"Broke For Free","mbid_mapping":{"artist_mbids":["069a1c1f-14eb-4d36-b0a0-77dffbd67713"],"recording_mbid":"46ae879f-2dbe-46d3-99ad-05c116f97a30","release_mbid":"44915500-fbb9-4060-98ce-59a57a429edc"},"release_name":"Petal","track_name":"Juparo"},"user_name":"epoupon"},{"inserted_at":1664027919,"listened_at":1664027918,"recording_msid":"fe5abc47-89cd-4235-80b5-00f47cecbe01","track_metadata":{"additional_info":{"artist_mbids":["069a1c1f-14eb-4d36-b0a0-77dffbd67713"],"artist_msid":null,"listening_from":"LMS","recording_mbid":"d89d042c-8cc1-4526-9080-5bab728ee15f","recording_msid":"fe5abc47-89cd-4235-80b5-00f47cecbe01","release_mbid":"44915500-fbb9-4060-98ce-59a57a429edc","release_msid":null,"track_mbid":"9f33a17f-e33e-492f-85a4-7b2e9e09613e","tracknumber":4},"artist_name":"Broke For Free","mbid_mapping":{"artist_mbids":["069a1c1f-14eb-4d36-b0a0-77dffbd67713"],"recording_mbid":"d89d042c-8cc1-4526-9080-5bab728ee15f","release_mbid":"44915500-fbb9-4060-98ce-59a57a429edc"},"release_name":"Petal","track_name":"Melt"},"user_name":"epoupon"}],"user_id":"epoupon"}})")}; + EXPECT_EQ(result.listenCount, 2); + ASSERT_EQ(result.listens.size(), 2); + EXPECT_EQ(result.listens[0].trackName, "Juparo"); + EXPECT_EQ(result.listens[0].releaseName, "Petal"); + EXPECT_EQ(result.listens[0].artistName, "Broke For Free"); + ASSERT_TRUE(result.listens[0].recordingMBID.has_value()); + EXPECT_EQ(result.listens[0].recordingMBID->getAsString(), "46ae879f-2dbe-46d3-99ad-05c116f97a30"); + ASSERT_TRUE(result.listens[0].releaseMBID.has_value()); + EXPECT_EQ(result.listens[0].releaseMBID->getAsString(), "44915500-fbb9-4060-98ce-59a57a429edc"); + EXPECT_EQ(result.listens[0].trackNumber, 5); + + EXPECT_EQ(result.listens[1].trackName, "Melt"); + EXPECT_EQ(result.listens[1].releaseName, "Petal"); + EXPECT_EQ(result.listens[1].artistName, "Broke For Free"); + ASSERT_TRUE(result.listens[1].recordingMBID.has_value()); + EXPECT_EQ(result.listens[1].recordingMBID->getAsString(), "d89d042c-8cc1-4526-9080-5bab728ee15f"); + ASSERT_TRUE(result.listens[1].releaseMBID.has_value()); + EXPECT_EQ(result.listens[1].releaseMBID->getAsString(), "44915500-fbb9-4060-98ce-59a57a429edc"); + EXPECT_EQ(result.listens[1].trackNumber, 4); +} + +TEST(Listenbrainz, parseListens_tworesults_butinvalid) +{ + ListensParser::Result result {ListensParser::parse(R"({"payload":{"count":2,"latest_listen_ts":1664028167,"listens":[{"inserted_at":1664028167,"listened_at":1664028167,"recording_msid":"29c11137-e40b-4875-9ec0-9a20a4bdc2d3","track_metadata":{"additional_info":{"artist_mbids":["069a1c1f-14eb-4d36-b0a0-77dffbd67713"],"artist_msid":null,"listening_from":"LMS","recording_mbid":"46ae879f-2dbe-46d3-99ad-05c116f97a30","recording_msid":"29c11137-e40b-4875-9ec0-9a20a4bdc2d3","release_mbid":"44915500-fbb9-4060-98ce-59a57a429edc","release_msid":null,"track_mbid":"5427a943-a096-4d0b-8b9a-53aca9ed61ac","tracknumber":5},"artist_name":"Broke For Free","mbid_mapping":{"artist_mbids":["069a1c1f-14eb-4d36-b0a0-77dffbd67713"],"recording_mbid":"46ae879f-2dbe-46d3-99ad-05c116f97a30","release_mbid":"44915500-fbb9-4060-98ce-59a57a429edc"},"release_name":"Petal","track_name":"Juparo"},"user_name":"epoupon"},{"inserted_at":1664027919}],"user_id":"epoupon"}})")}; + EXPECT_EQ(result.listenCount, 2); + ASSERT_EQ(result.listens.size(), 1); + EXPECT_EQ(result.listens[0].trackName, "Juparo"); + EXPECT_EQ(result.listens[0].releaseName, "Petal"); + EXPECT_EQ(result.listens[0].artistName, "Broke For Free"); + ASSERT_TRUE(result.listens[0].recordingMBID.has_value()); + EXPECT_EQ(result.listens[0].recordingMBID->getAsString(), "46ae879f-2dbe-46d3-99ad-05c116f97a30"); + ASSERT_TRUE(result.listens[0].releaseMBID.has_value()); + EXPECT_EQ(result.listens[0].releaseMBID->getAsString(), "44915500-fbb9-4060-98ce-59a57a429edc"); + EXPECT_EQ(result.listens[0].trackNumber, 5); +} + +TEST(Listenbrainz, parseListens_entryNotFromLms) +{ + ListensParser::Result result {ListensParser::parse(R"({"payload":{"count":1,"latest_listen_ts":1664105730,"listens":[{"inserted_at":1664105730,"listened_at":1664105730,"recording_msid":"6a11ff4d-0623-4b2e-98e0-0e172f1f28d7","track_metadata":{"additional_info":{"artist_msid":null,"media_player":"BrainzPlayer","music_service":"youtube.com","music_service_name":"youtube","origin_url":"https://www.youtube.com/watch?v=EBP5vL3YWTI","recording_msid":"6a11ff4d-0623-4b2e-98e0-0e172f1f28d7","release_msid":null,"submission_client":"BrainzPlayer"},"artist_name":"Dio","brainzplayer_metadata":{"track_name":"Dio - Breathless"},"mbid_mapping":{"artist_mbids":["c55193fb-f5d2-4839-a263-4c044fca1456"],"recording_mbid":"92929526-21d7-4e75-b759-1072951664c4","release_mbid":"16cbf9ba-2e38-3893-9f23-f8567e26c18b"},"release_name":"The Last in Line","track_name":"Breathless"},"user_name":"epoupon"}],"user_id":"epoupon"}})")}; + EXPECT_EQ(result.listenCount, 1); + ASSERT_EQ(result.listens.size(), 1); + EXPECT_EQ(result.listens[0].trackName, "Breathless"); + EXPECT_EQ(result.listens[0].releaseName, "The Last in Line"); + EXPECT_EQ(result.listens[0].artistName, "Dio"); + EXPECT_FALSE(result.listens[0].recordingMBID.has_value()); + EXPECT_FALSE(result.listens[0].releaseMBID.has_value()); +} + +TEST(Listenbrainz, parseListens_multiArtists) +{ + ListensParser::Result result {ListensParser::parse(R"({"payload":{"count":1,"latest_listen_ts":1664106427,"listens":[{"inserted_at":1664106427,"listened_at":1664106427,"recording_msid":"b1dad0df-329b-443d-bacf-cdbebdddbfd0","track_metadata":{"additional_info":{"artist_mbids":["04ce0202-043d-4cbe-8f09-8abaf3b80c71","79311c51-9748-49df-baa1-d925fd29f4e8"],"artist_msid":null,"listening_from":"LMS","recording_mbid":"a5f380bc-0a85-4a9f-88db-d41bb9aa2a4b","recording_msid":"b1dad0df-329b-443d-bacf-cdbebdddbfd0","release_mbid":"147b4669-3d20-43f8-89c0-ba1da8b87dd3","release_msid":null,"track_mbid":"a20dd067-29b6-3d38-a0be-eeb86b4671c1","tracknumber":1},"artist_name":"Gloom","release_name":"Demovibes 9: Party, people going","track_name":"Stargazer of Disgrace"},"user_name":"epoupon"}],"user_id":"epoupon"}})")}; + EXPECT_EQ(result.listenCount, 1); + ASSERT_EQ(result.listens.size(), 1); +} + +TEST(Listenbrainz, parseListens_minPayload) +{ + ListensParser::Result result {ListensParser::parse(R"({"payload":{"count":1,"latest_listen_ts":1664106427,"listens":[{"track_metadata":{"artist_name":"Gloom","track_name":"Stargazer of Disgrace"},"user_name":"epoupon"}],"user_id":"epoupon"}})")}; + EXPECT_EQ(result.listenCount, 1); + ASSERT_EQ(result.listens.size(), 1); + EXPECT_FALSE(result.listens[0].listenedAt.isValid()); + EXPECT_EQ(result.listens[0].trackName, "Stargazer of Disgrace"); + EXPECT_EQ(result.listens[0].artistName, "Gloom"); + EXPECT_EQ(result.listens[0].releaseName, ""); + EXPECT_FALSE(result.listens[0].recordingMBID.has_value()); + EXPECT_FALSE(result.listens[0].releaseMBID.has_value()); +} diff --git a/src/libs/services/scrobbling/test/Scrobbling.cpp b/src/libs/services/scrobbling/test/Scrobbling.cpp new file mode 100644 index 00000000..d4a68962 --- /dev/null +++ b/src/libs/services/scrobbling/test/Scrobbling.cpp @@ -0,0 +1,34 @@ +/* + * 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 + +#include "utils/Logger.hpp" +#include "utils/Service.hpp" +#include "utils/StreamLogger.hpp" + +int main(int argc, char **argv) +{ + // log to stdout + Service logger {std::make_unique(std::cout, EnumSet {Severity::FATAL, Severity::ERROR})}; + + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} + diff --git a/src/libs/utils/impl/StreamLogger.cpp b/src/libs/utils/impl/StreamLogger.cpp index 94434332..4b1cebd1 100644 --- a/src/libs/utils/impl/StreamLogger.cpp +++ b/src/libs/utils/impl/StreamLogger.cpp @@ -19,14 +19,16 @@ #include "utils/StreamLogger.hpp" -StreamLogger::StreamLogger(std::ostream& os) +StreamLogger::StreamLogger(std::ostream& os, EnumSet severities) : _os {os} +, _severities {severities} { } void StreamLogger::processLog(const Log& log) { - _os << "[" << getSeverityName(log.getSeverity()) << "] [" << getModuleName(log.getModule()) << "] " << log.getMessage() << std::endl; + if (_severities.contains(log.getSeverity())) + _os << "[" << getSeverityName(log.getSeverity()) << "] [" << getModuleName(log.getModule()) << "] " << log.getMessage() << std::endl; } diff --git a/src/libs/utils/include/utils/EnumSet.hpp b/src/libs/utils/include/utils/EnumSet.hpp index 38d42c67..2d4361c6 100644 --- a/src/libs/utils/include/utils/EnumSet.hpp +++ b/src/libs/utils/include/utils/EnumSet.hpp @@ -20,6 +20,9 @@ #pragma once #include +#include +#include +#include #include template @@ -47,13 +50,13 @@ class EnumSet constexpr void insert(T value) { - assert(static_cast(value) < sizeof(_bitfield) * 8); + assert(static_cast(value) < sizeof(_bitfield) * 8); _bitfield |= (underlying_type{ 1 } << static_cast(value)); } constexpr void erase(T value) { - assert(static_cast(value) < sizeof(_bitfield) * 8); + assert(static_cast(value) < sizeof(_bitfield) * 8); _bitfield &= ~(underlying_type{ 1 } << static_cast(value)); } @@ -64,7 +67,7 @@ class EnumSet constexpr bool contains(T value) const { - assert(static_cast(value) < sizeof(_bitfield) * 8); + assert(static_cast(value) < sizeof(_bitfield) * 8); return _bitfield & (underlying_type{ 1 } << static_cast(value)); } diff --git a/src/libs/utils/include/utils/StreamLogger.hpp b/src/libs/utils/include/utils/StreamLogger.hpp index 8e1af39a..f41c7eb2 100644 --- a/src/libs/utils/include/utils/StreamLogger.hpp +++ b/src/libs/utils/include/utils/StreamLogger.hpp @@ -19,16 +19,20 @@ #pragma once -#include "Logger.hpp" +#include "utils/EnumSet.hpp" +#include "utils/Logger.hpp" class StreamLogger final : public Logger { public: - StreamLogger(std::ostream& oss); + static constexpr EnumSet defaultSeverities {Severity::FATAL, Severity::ERROR, Severity::WARNING, Severity::INFO}; + + StreamLogger(std::ostream& oss, EnumSet severities = defaultSeverities); void processLog(const Log& log); private: std::ostream& _os; + const EnumSet _severities; }; diff --git a/src/lms/main.cpp b/src/lms/main.cpp index d7ecd162..185fc208 100644 --- a/src/lms/main.cpp +++ b/src/lms/main.cpp @@ -32,6 +32,7 @@ #include "services/cover/ICoverService.hpp" #include "services/database/Db.hpp" #include "services/database/Session.hpp" +#include "services/recommendation/IPlaylistGeneratorService.hpp" #include "services/recommendation/IRecommendationService.hpp" #include "services/scanner/IScannerService.hpp" #include "services/scrobbling/IScrobblingService.hpp" @@ -258,6 +259,7 @@ int main(int argc, char* argv[]) Image::init(argv[0]); Service coverService {Cover::createCoverService(database, argv[0], server.appRoot() + "/images/unknown-cover.jpg")}; Service recommendationService {Recommendation::createRecommendationService(database)}; + Service playlistGeneratorService {Recommendation::createPlaylistGeneratorService(database, *recommendationService.get())}; Service scannerService {Scanner::createScannerService(database, *recommendationService)}; scannerService->getEvents().scanComplete.connect([&] diff --git a/src/lms/ui/LmsApplication.cpp b/src/lms/ui/LmsApplication.cpp index c9992c5c..e5108f36 100644 --- a/src/lms/ui/LmsApplication.cpp +++ b/src/lms/ui/LmsApplication.cpp @@ -133,6 +133,12 @@ LmsApplication::instance() return reinterpret_cast(Wt::WApplication::instance()); } +Database::Db& +LmsApplication::getDb() +{ + return _db; +} + Database::Session& LmsApplication::getDbSession() { diff --git a/src/lms/ui/LmsApplication.hpp b/src/lms/ui/LmsApplication.hpp index 35a58747..83d07dfb 100644 --- a/src/lms/ui/LmsApplication.hpp +++ b/src/lms/ui/LmsApplication.hpp @@ -59,9 +59,9 @@ class LmsApplication : public Wt::WApplication static std::unique_ptr create(const Wt::WEnvironment& env, Database::Db& db, LmsApplicationManager& appManager); static LmsApplication* instance(); - // Session application data std::shared_ptr getCoverResource() { return _coverResource; } + Database::Db& getDb(); Database::Session& getDbSession(); // always thread safe Database::ObjectPtr getUser(); diff --git a/src/lms/ui/PlayQueue.cpp b/src/lms/ui/PlayQueue.cpp index 9731b291..4b37bb1d 100644 --- a/src/lms/ui/PlayQueue.cpp +++ b/src/lms/ui/PlayQueue.cpp @@ -35,7 +35,7 @@ #include "services/database/TrackList.hpp" #include "services/database/User.hpp" #include "services/scrobbling/IScrobblingService.hpp" -#include "services/recommendation/IRecommendationService.hpp" +#include "services/recommendation/IPlaylistGeneratorService.hpp" #include "utils/Logger.hpp" #include "utils/Random.hpp" #include "utils/Service.hpp" @@ -118,6 +118,8 @@ namespace PlayQueue::PlayQueue() : Template {Wt::WString::tr("Lms.PlayQueue.template")} { + initTrackLists(); + addFunction("id", &Wt::WTemplate::Functions::id); addFunction("tr", &Wt::WTemplate::Functions::tr); @@ -146,13 +148,13 @@ PlayQueue::PlayQueue() { auto transaction {LmsApp->getDbSession().createUniqueTransaction()}; - Database::TrackList::pointer trackList {getTrackList()}; - auto entries {trackList->getEntries()}; + Database::TrackList::pointer queue {getQueue()}; + auto entries {queue->getEntries()}; Random::shuffleContainer(entries); - getTrackList().modify()->clear(); - for (const auto& entry : entries) - LmsApp->getDbSession().create(entry->getTrack(), trackList); + queue.modify()->clear(); + for (const Database::TrackListEntry::pointer& entry : entries) + LmsApp->getDbSession().create(entry->getTrack(), queue); } _entriesContainer->clear(); addSome(); @@ -175,70 +177,58 @@ PlayQueue::PlayQueue() _radioBtn = bindNew("radio-btn"); _radioBtn->clicked().connect([=] { - auto transaction {LmsApp->getDbSession().createUniqueTransaction()}; + { + auto transaction {LmsApp->getDbSession().createUniqueTransaction()}; - if (!LmsApp->getUser()->isDemo()) - LmsApp->getUser().modify()->setRadio(isRadioModeSet()); + if (!LmsApp->getUser()->isDemo()) + LmsApp->getUser().modify()->setRadio(isRadioModeSet()); + } + if (isRadioModeSet()) + enqueueRadioTracksIfNeeded(); }); + + bool isRadioModeSet {}; { auto transaction {LmsApp->getDbSession().createSharedTransaction()}; - if (LmsApp->getUser()->isRadioSet()) - _radioBtn->setCheckState(Wt::CheckState::Checked); + isRadioModeSet = LmsApp->getUser()->isRadioSet(); + } + if (isRadioModeSet) + { + _radioBtn->setCheckState(Wt::CheckState::Checked); + enqueueRadioTracksIfNeeded(); } _nbTracks = bindNew("track-count"); _duration = bindNew("duration"); - LmsApp->preQuit().connect([=] + LmsApp->getMediaPlayer().settingsLoaded.connect([=] { - auto transaction {LmsApp->getDbSession().createUniqueTransaction()}; + if (_mediaPlayerSettingsLoaded) + return; + + _mediaPlayerSettingsLoaded = true; + + std::size_t trackPos {}; - if (LmsApp->getUser()->isDemo()) { - LMS_LOG(UI, DEBUG) << "Removing tracklist id " << _tracklistId.toString(); - auto tracklist = Database::TrackList::find(LmsApp->getDbSession(), _tracklistId); - if (tracklist) - tracklist.remove(); + auto transaction {LmsApp->getDbSession().createSharedTransaction()}; + trackPos = LmsApp->getUser()->getCurPlayingTrackPos(); } + + loadTrack(trackPos, false); }); + LmsApp->preQuit().connect([=] { auto transaction {LmsApp->getDbSession().createUniqueTransaction()}; - Database::TrackList::pointer trackList; - - if (!LmsApp->getUser()->isDemo()) - { - LmsApp->getMediaPlayer().settingsLoaded.connect([=] - { - if (_mediaPlayerSettingsLoaded) - return; - - _mediaPlayerSettingsLoaded = true; - - std::size_t trackPos {}; - - { - auto transaction {LmsApp->getDbSession().createSharedTransaction()}; - trackPos = LmsApp->getUser()->getCurPlayingTrackPos(); - } - - loadTrack(trackPos, false); - }); - - static const std::string queuedListName {"__queued_tracks__"}; - trackList = Database::TrackList::find(LmsApp->getDbSession(), queuedListName, Database::TrackListType::Internal, LmsApp->getUserId()); - if (!trackList) - trackList = LmsApp->getDbSession().create(queuedListName, Database::TrackListType::Internal, false, LmsApp->getUser()); - } - else + if (LmsApp->getUser()->isDemo()) { - static const std::string currentPlayQueueName {"__current__playqueue__"}; - trackList = LmsApp->getDbSession().create(currentPlayQueueName, Database::TrackListType::Internal, false, LmsApp->getUser()); + LMS_LOG(UI, DEBUG) << "Removing queue (tracklist id " << _queueId.toString() << ")"; + if (Database::TrackList::pointer queue {getQueue()}) + queue.remove(); } - - _tracklistId = trackList->getId(); - } + }); updateInfo(); addSome(); @@ -257,16 +247,16 @@ PlayQueue::isRadioModeSet() const } Database::TrackList::pointer -PlayQueue::getTrackList() const +PlayQueue::getQueue() const { - return Database::TrackList::find(LmsApp->getDbSession(), _tracklistId); + return Database::TrackList::find(LmsApp->getDbSession(), _queueId); } bool PlayQueue::isFull() const { auto transaction {LmsApp->getDbSession().createSharedTransaction()}; - return getTrackList()->getCount() == getCapacity(); + return getQueue()->getCount() == getCapacity(); } void @@ -274,7 +264,7 @@ PlayQueue::clearTracks() { { auto transaction {LmsApp->getDbSession().createUniqueTransaction()}; - getTrackList().modify()->clear(); + getQueue().modify()->clear(); } _entriesContainer->clear(); @@ -295,17 +285,16 @@ PlayQueue::loadTrack(std::size_t pos, bool play) updateCurrentTrack(false); Database::TrackId trackId {}; - bool addRadioTrack {}; std::optional replayGain {}; { auto transaction {LmsApp->getDbSession().createSharedTransaction()}; - Database::TrackList::pointer tracklist {getTrackList()}; + const Database::TrackList::pointer queue {getQueue()}; // If out of range, stop playing - if (pos >= tracklist->getCount()) + if (pos >= queue->getCount()) { - if (!isRepeatAllSet() || tracklist->getCount() == 0) + if (!isRepeatAllSet() || queue->getCount() == 0) { stop(); return; @@ -314,12 +303,8 @@ PlayQueue::loadTrack(std::size_t pos, bool play) pos = 0; } - // If last and radio mode, fill the next song - if (isRadioModeSet() && pos == tracklist->getCount() - 1) - addRadioTrack = true; - _trackPos = pos; - auto track = tracklist->getEntry(*_trackPos)->getTrack(); + const Database::Track::pointer track {queue->getEntry(*_trackPos)->getTrack()}; trackId = track->getId(); @@ -329,11 +314,8 @@ PlayQueue::loadTrack(std::size_t pos, bool play) LmsApp->getUser().modify()->setCurPlayingTrackPos(pos); } - if (addRadioTrack) - enqueueRadioTracks(); - + enqueueRadioTracksIfNeeded(); updateCurrentTrack(true); - trackSelected.emit(trackId, play, replayGain ? *replayGain : 0); } @@ -361,14 +343,39 @@ PlayQueue::playNext() loadTrack(*_trackPos + 1, true); } +void +PlayQueue::initTrackLists() +{ + auto transaction {LmsApp->getDbSession().createUniqueTransaction()}; + + Database::TrackList::pointer queue; + Database::TrackList::pointer radioStartingTracks; + + if (!LmsApp->getUser()->isDemo()) + { + static const std::string queueName {"__queued_tracks__"}; + queue = Database::TrackList::find(LmsApp->getDbSession(), queueName, Database::TrackListType::Internal, LmsApp->getUserId()); + if (!queue) + queue = LmsApp->getDbSession().create(queueName, Database::TrackListType::Internal, false, LmsApp->getUser()); + } + else + { + static const std::string queueName {"__temp_queue__"}; + queue = LmsApp->getDbSession().create(queueName, Database::TrackListType::Internal, false, LmsApp->getUser()); + } + + _queueId = queue->getId(); +} + void PlayQueue::updateInfo() { auto transaction {LmsApp->getDbSession().createSharedTransaction()}; - const auto trackCount {getTrackList()->getCount()}; + const Database::TrackList::pointer queue {getQueue()}; + const auto trackCount {queue->getCount()}; _nbTracks->setText(Wt::WString::trn("Lms.track-count", trackCount).arg(trackCount)); - _duration->setText(Utils::durationToString(getTrackList()->getDuration())); + _duration->setText(Utils::durationToString(queue->getDuration())); } void @@ -392,9 +399,10 @@ PlayQueue::enqueueTracks(const std::vector& trackIds) { auto transaction {LmsApp->getDbSession().createUniqueTransaction()}; - auto tracklist {getTrackList()}; + Database::TrackList::pointer queue {getQueue()}; + const std::size_t queueSize {queue->getCount()}; - std::size_t nbTracksToEnqueue {tracklist->getCount() + trackIds.size() > getCapacity() ? getCapacity() - tracklist->getCount() : trackIds.size()}; + std::size_t nbTracksToEnqueue {queueSize + trackIds.size() > getCapacity() ? getCapacity() - queueSize : trackIds.size()}; for (const Database::TrackId trackId : trackIds) { Database::Track::pointer track {Database::Track::find(LmsApp->getDbSession(), trackId)}; @@ -404,7 +412,7 @@ PlayQueue::enqueueTracks(const std::vector& trackIds) if (nbTracksQueued == nbTracksToEnqueue) break; - LmsApp->getDbSession().create(track, tracklist); + LmsApp->getDbSession().create(track, queue); nbTracksQueued++; } } @@ -468,13 +476,12 @@ PlayQueue::addSome() { auto transaction {LmsApp->getDbSession().createSharedTransaction()}; - auto tracklist = getTrackList(); - - auto tracklistEntries = tracklist->getEntries(_entriesContainer->getCount(), _batchSize); + const Database::TrackList::pointer queue {getQueue()}; + const auto tracklistEntries {queue->getEntries(_entriesContainer->getCount(), _batchSize)}; for (const Database::TrackListEntry::pointer& tracklistEntry : tracklistEntries) addEntry(tracklistEntry); - _entriesContainer->setHasMore(_entriesContainer->getCount() < tracklist->getCount()); + _entriesContainer->setHasMore(_entriesContainer->getCount() < queue->getCount()); } void @@ -567,13 +574,31 @@ PlayQueue::addEntry(const Database::TrackListEntry::pointer& tracklistEntry) } void -PlayQueue::enqueueRadioTracks() +PlayQueue::enqueueRadioTracksIfNeeded() { - const auto similarTrackIds {Service::get()->findSimilarTracksFromTrackList(_tracklistId, 3)}; + if (!isRadioModeSet()) + return; + + bool addTracks {}; + { + auto transaction {LmsApp->getDbSession().createSharedTransaction()}; + + const Database::TrackList::pointer queue {getQueue()}; + + // If out of range, stop playing + if (_trackPos >= queue->getCount() - 1) + addTracks = true; + } + + if (addTracks) + enqueueRadioTracks(); +} - std::vector trackToAddIds(std::cbegin(similarTrackIds), std::cend(similarTrackIds)); - Random::shuffleContainer(trackToAddIds); - enqueueTracks(trackToAddIds); +void +PlayQueue::enqueueRadioTracks() +{ + std::vector trackIds = Service::get()->extendPlaylist(_queueId, 15); + enqueueTracks(trackIds); } std::optional @@ -602,9 +627,9 @@ PlayQueue::getReplayGain(std::size_t pos, const Database::Track::pointer& track) case MediaPlayer::Settings::ReplayGain::Mode::Auto: { - const auto trackList {getTrackList()}; - const auto prevEntry {pos > 0 ? trackList->getEntry(pos - 1) : Database::TrackListEntry::pointer {}}; - const auto nextEntry {trackList->getEntry(pos + 1)}; + const Database::TrackList::pointer queue {getQueue()}; + const Database::TrackListEntry::pointer prevEntry {pos > 0 ? queue->getEntry(pos - 1) : Database::TrackListEntry::pointer {}}; + const Database::TrackListEntry::pointer nextEntry {queue->getEntry(pos + 1)}; const Database::Track::pointer prevTrack {prevEntry ? prevEntry->getTrack() : Database::Track::pointer {}}; const Database::Track::pointer nextTrack {nextEntry ? nextEntry->getTrack() : Database::Track::pointer {}}; @@ -747,12 +772,11 @@ PlayQueue::exportToTrackList(Database::TrackListId trackListId) trackList.modify()->clear(); Track::FindParameters params; - params.setTrackList(_tracklistId); + params.setTrackList(_queueId); params.setDistinct(false); params.setSortMethod(TrackSortMethod::TrackList); const auto tracks {Track::find(session, params)}; - for (const TrackId trackId : tracks.results) session.create(Track::find(session, trackId), trackList); } diff --git a/src/lms/ui/PlayQueue.hpp b/src/lms/ui/PlayQueue.hpp index f6e21076..bef41f68 100644 --- a/src/lms/ui/PlayQueue.hpp +++ b/src/lms/ui/PlayQueue.hpp @@ -32,10 +32,6 @@ #include "common/Template.hpp" -namespace Similarity -{ - class Finder; -} namespace Database { @@ -73,14 +69,17 @@ class PlayQueue : public Template constexpr std::size_t getCapacity() const { return _capacity; } private: + void initTrackLists(); + void notifyAddedTracks(std::size_t nbAddedTracks) const; - Database::ObjectPtr getTrackList() const; + Database::ObjectPtr getQueue() const; bool isFull() const; void clearTracks(); std::size_t enqueueTracks(const std::vector& trackIds); void addSome(); void addEntry(const Database::ObjectPtr& entry); + void enqueueRadioTracksIfNeeded(); void enqueueRadioTracks(); void updateInfo(); void updateCurrentTrack(bool selected); @@ -90,8 +89,6 @@ class PlayQueue : public Template void loadTrack(std::size_t pos, bool play); void stop(); - void addRadioTrackFromSimilarity(std::shared_ptr similarityFinder); - void addRadioTrackFromClusters(); std::optional getReplayGain(std::size_t pos, const Database::ObjectPtr& track) const; void saveAsTrackList(); @@ -102,7 +99,7 @@ class PlayQueue : public Template static inline constexpr std::size_t _batchSize {12}; bool _mediaPlayerSettingsLoaded {}; - Database::TrackListId _tracklistId {}; + Database::TrackListId _queueId {}; InfiniteScrollingContainer* _entriesContainer {}; Wt::WText* _nbTracks {}; Wt::WText* _duration {}; diff --git a/src/lms/ui/explore/Explore.cpp b/src/lms/ui/explore/Explore.cpp index 1c0ed889..0a4c9eac 100644 --- a/src/lms/ui/explore/Explore.cpp +++ b/src/lms/ui/explore/Explore.cpp @@ -93,10 +93,9 @@ namespace UserInterface { contentsStack->addWidget(std::move(artist)); auto trackLists {std::make_unique(filters)}; - contentsStack->addWidget(std::move(trackLists)); - auto trackList {std::make_unique(filters, _playQueueController)}; trackList->trackListDeleted.connect(trackLists.get(), &TrackLists::onTrackListDeleted); + contentsStack->addWidget(std::move(trackLists)); contentsStack->addWidget(std::move(trackList)); auto releases = std::make_unique(filters, _playQueueController);