Implemented a Scrobbling service + ListenBrainz scrobbler. fixes #118
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)");
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -23,6 +23,7 @@ target_link_libraries(lmssubsonic PRIVATE
|
||||
lmsdatabase
|
||||
lmsrecommendation
|
||||
lmsscanner
|
||||
lmsscrobbling
|
||||
lmsutils
|
||||
std::filesystem
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -46,6 +46,7 @@ enum class Module
|
||||
MAIN,
|
||||
METADATA,
|
||||
REMOTE,
|
||||
SCROBBLING,
|
||||
SERVICE,
|
||||
RECOMMENDATION,
|
||||
TRANSCODE,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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")))};
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user