Implemented a Scrobbling service + ListenBrainz scrobbler. fixes #118

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