Implemented a Scrobbling service + ListenBrainz scrobbler. fixes #118

This commit is contained in:
emeric
2021-04-06 19:59:12 +02:00
parent c29c4d576a
commit acf81e3f1a
49 changed files with 1662 additions and 177 deletions
+1
View File
@@ -6,6 +6,7 @@ add_subdirectory(database)
add_subdirectory(metadata)
add_subdirectory(recommendation)
add_subdirectory(scanner)
add_subdirectory(scrobbling)
add_subdirectory(som)
add_subdirectory(subsonic)
add_subdirectory(utils)
+10 -1
View File
@@ -318,8 +318,16 @@ CREATE TABLE "user_backup" (
}
else if (version == 29)
{
// new field data_time in tracklist_entry (used by async scrobble or to make stats)
_session.execute("ALTER TABLE tracklist_entry ADD date_time TEXT");
_session.execute("ALTER TABLE user ADD listenbrainz_token TEXT");
_session.execute("ALTER TABLE user ADD scrobbler INTEGER NOT NULL DEFAULT(" + std::to_string(static_cast<int>(User::defaultScrobbler)) + ")");
_session.execute("ALTER TABLE track ADD recording_mbid TEXT");
_session.execute("DELETE from tracklist WHERE name = ?").bind("__played_tracks__");
// MBID changes
// Just increment the scan version of the settings to make the next scheduled scan rescan everything
ScanSettings::get(*this).modify()->incScanVersion();
}
else
{
@@ -432,6 +440,7 @@ Session::prepareTables()
_session.execute("CREATE INDEX IF NOT EXISTS track_name_idx ON track(name)");
_session.execute("CREATE INDEX IF NOT EXISTS track_name_nocase_idx ON track(name COLLATE NOCASE)");
_session.execute("CREATE INDEX IF NOT EXISTS track_mbid_idx ON track(mbid)");
_session.execute("CREATE INDEX IF NOT EXISTS track_recording_mbid_idx ON track(recording_mbid)");
_session.execute("CREATE INDEX IF NOT EXISTS track_release_idx ON track(release_id)");
_session.execute("CREATE INDEX IF NOT EXISTS track_year_idx ON track(year)");
_session.execute("CREATE INDEX IF NOT EXISTS track_original_year_idx ON track(original_year)");
+2 -2
View File
@@ -229,13 +229,13 @@ Track::getLastWritten(Session& session, std::optional<Wt::WDateTime> after, cons
}
std::vector<Track::pointer>
Track::getAllWithMBIDAndMissingFeatures(Session& session)
Track::getAllWithRecordingMBIDAndMissingFeatures(Session& session)
{
session.checkSharedLocked();
Wt::Dbo::collection<pointer> res = session.getDboSession().query<pointer>
("SELECT t FROM track t")
.where("LENGTH(t.mbid) > 0")
.where("LENGTH(t.recording_mbid) > 0")
.where("NOT EXISTS (SELECT * FROM track_features t_f WHERE t_f.track_id = t.id)");
return std::vector<pointer>(res.begin(), res.end());
}
+19 -31
View File
@@ -29,10 +29,11 @@
#include "database/User.hpp"
#include "database/Track.hpp"
#include "SqlQuery.hpp"
#include "StringViewTraits.hpp"
namespace Database {
TrackList::TrackList(const std::string& name, Type type, bool isPublic, Wt::Dbo::ptr<User> user)
TrackList::TrackList(std::string_view name, Type type, bool isPublic, Wt::Dbo::ptr<User> user)
: _name {name},
_type {type},
_isPublic {isPublic},
@@ -42,7 +43,7 @@ TrackList::TrackList(const std::string& name, Type type, bool isPublic, Wt::Dbo:
}
TrackList::pointer
TrackList::create(Session& session, const std::string& name, Type type, bool isPublic, Wt::Dbo::ptr<User> user)
TrackList::create(Session& session, std::string_view name, Type type, bool isPublic, Wt::Dbo::ptr<User> user)
{
session.checkUniqueLocked();
assert(user);
@@ -54,7 +55,7 @@ TrackList::create(Session& session, const std::string& name, Type type, bool isP
}
TrackList::pointer
TrackList::get(Session& session, const std::string& name, Type type, Wt::Dbo::ptr<User> user)
TrackList::get(Session& session, std::string_view name, Type type, Wt::Dbo::ptr<User> user)
{
session.checkSharedLocked();
assert(user);
@@ -147,22 +148,6 @@ TrackList::getEntries(std::optional<std::size_t> offset, std::optional<std::size
return std::vector<Wt::Dbo::ptr<TrackListEntry>>(entries.begin(), entries.end());
}
std::vector<Wt::Dbo::ptr<TrackListEntry>>
TrackList::getEntriesReverse(std::optional<std::size_t> offset, std::optional<std::size_t> size) const
{
assert(session());
assert(IdIsValid(self()->id()));
Wt::Dbo::collection<Wt::Dbo::ptr<TrackListEntry>> entries =
session()->find<TrackListEntry>()
.where("tracklist_id = ?").bind(self().id())
.orderBy("id DESC")
.limit(size ? static_cast<int>(*size) : -1)
.offset(offset ? static_cast<int>(*offset) : -1);
return std::vector<Wt::Dbo::ptr<TrackListEntry>>(entries.begin(), entries.end());
}
static
Wt::Dbo::Query<Artist::pointer>
createArtistsQuery(Wt::Dbo::Session& session, const std::string& queryStr, IdType tracklistId, const std::set<IdType>& clusterIds, std::optional<TrackArtistLinkType> linkType)
@@ -275,8 +260,9 @@ TrackList::getArtistsReverse(const std::set<IdType>& clusterIds, std::optional<T
assert(session());
assert(IdIsValid(self()->id()));
Wt::Dbo::collection<Artist::pointer> collection = createArtistsQuery(*session(), "SELECT DISTINCT a from artist a", self()->id(), clusterIds, linkType)
.orderBy("p_e.id DESC")
Wt::Dbo::collection<Artist::pointer> collection = createArtistsQuery(*session(), "SELECT a from artist a", self()->id(), clusterIds, linkType)
.groupBy("a.id").having("p_e.date_time = MAX(p_e.date_time)")
.orderBy("p_e.date_time DESC")
.limit(range ? static_cast<int>(range->limit) + 1 : -1)
.offset(range ? static_cast<int>(range->offset) : -1);
@@ -298,8 +284,9 @@ TrackList::getReleasesReverse(const std::set<IdType>& clusterIds, std::optional<
assert(session());
assert(IdIsValid(self()->id()));
Wt::Dbo::collection<Release::pointer> collection = createReleasesQuery(*session(), "SELECT DISTINCT r from release r", self()->id(), clusterIds)
.orderBy("p_e.id DESC")
Wt::Dbo::collection<Release::pointer> collection = createReleasesQuery(*session(), "SELECT r from release r", self()->id(), clusterIds)
.groupBy("r.id").having("p_e.date_time = MAX(p_e.date_time)")
.orderBy("p_e.date_time DESC")
.limit(range ? static_cast<int>(range->limit) + 1 : -1)
.offset(range ? static_cast<int>(range->offset) : -1);
@@ -322,8 +309,8 @@ TrackList::getTracksReverse(const std::set<IdType>& clusterIds, std::optional<Ra
assert(IdIsValid(self()->id()));
Wt::Dbo::collection<Track::pointer> collection = createTracksQuery(*session(), self()->id(), clusterIds)
.orderBy("p_e.id DESC")
.groupBy("t.id")
.groupBy("t.id").having("p_e.date_time = MAX(p_e.date_time)")
.orderBy("p_e.date_time DESC")
.limit(range ? static_cast<int>(range->limit) + 1 : -1)
.offset(range ? static_cast<int>(range->offset) : -1);
@@ -498,21 +485,22 @@ TrackList::getTopTracks(const std::set<IdType>& clusterIds, std::optional<Range>
return res;
}
TrackListEntry::TrackListEntry(Wt::Dbo::ptr<Track> track, Wt::Dbo::ptr<TrackList> tracklist)
: _track(track),
_tracklist(tracklist)
TrackListEntry::TrackListEntry(Wt::Dbo::ptr<Track> track, Wt::Dbo::ptr<TrackList> tracklist, const Wt::WDateTime& dateTime)
: _dateTime {Wt::WDateTime::fromTime_t(dateTime.toTime_t())} // force second resolution
, _track {track}
, _tracklist {tracklist}
{
assert(_dateTime.isValid());
}
TrackListEntry::pointer
TrackListEntry::create(Session& session, Wt::Dbo::ptr<Track> track, Wt::Dbo::ptr<TrackList> tracklist)
TrackListEntry::create(Session& session, Wt::Dbo::ptr<Track> track, Wt::Dbo::ptr<TrackList> tracklist, const Wt::WDateTime& dateTime)
{
session.checkUniqueLocked();
assert(track);
assert(tracklist);
auto res = session.getDboSession().add( std::make_unique<TrackListEntry>( track, tracklist) );
auto res = session.getDboSession().add(std::make_unique<TrackListEntry>( track, tracklist, dateTime));
session.getDboSession().flush();
return res;
-11
View File
@@ -68,7 +68,6 @@ AuthToken::getByValue(Session& session, const std::string& value)
.where("value = ?").bind(value);
}
static const std::string playedListName {"__played_tracks__"};
static const std::string queuedListName {"__queued_tracks__"};
User::User(std::string_view loginName)
@@ -109,7 +108,6 @@ User::create(Session& session, std::string_view loginName)
User::pointer user {session.getDboSession().add(std::make_unique<User>(loginName))};
TrackList::create(session, playedListName, TrackList::Type::Internal, false, user);
TrackList::create(session, queuedListName, TrackList::Type::Internal, false, user);
session.getDboSession().flush();
@@ -143,15 +141,6 @@ User::clearAuthTokens()
_authTokens.clear();
}
Wt::Dbo::ptr<TrackList>
User::getPlayedTrackList(Session& session) const
{
assert(self());
session.checkSharedLocked();
return TrackList::get(session, playedListName, TrackList::Type::Internal, self());
}
Wt::Dbo::ptr<TrackList>
User::getQueuedTrackList(Session& session) const
{
+9 -5
View File
@@ -81,7 +81,7 @@ class Track : public Wt::Dbo::Dbo<Track>
static std::vector<std::pair<IdType, std::filesystem::path>> getAllPaths(Session& session, std::optional<std::size_t> offset = std::nullopt, std::optional<std::size_t> size = std::nullopt);
static std::vector<pointer> getMBIDDuplicates(Session& session);
static std::vector<pointer> getLastWritten(Session& session, std::optional<Wt::WDateTime> after, const std::set<IdType>& clusters, std::optional<Range> range, bool& moreResults);
static std::vector<pointer> getAllWithMBIDAndMissingFeatures(Session& session);
static std::vector<pointer> getAllWithRecordingMBIDAndMissingFeatures(Session& session);
static std::vector<IdType> getAllIdsWithFeatures(Session& session, std::optional<std::size_t> limit = {});
static std::vector<IdType> getAllIdsWithClusters(Session& session, std::optional<std::size_t> limit = {});
static std::vector<pointer> getStarred(Session& session,
@@ -106,7 +106,8 @@ class Track : public Wt::Dbo::Dbo<Track>
void setYear(int year) { _year = year; }
void setOriginalYear(int year) { _originalYear = year; }
void setHasCover(bool hasCover) { _hasCover = hasCover; }
void setMBID(const std::optional<UUID>& MBID) { _MBID = MBID ? MBID->getAsString() : ""; }
void setTrackMBID(const std::optional<UUID>& MBID) { _trackMBID = MBID ? MBID->getAsString() : ""; }
void setRecordingMBID(const std::optional<UUID>& MBID) { _recordingMBID = MBID ? MBID->getAsString() : ""; }
void setCopyright(const std::string& copyright) { _copyright = std::string(copyright, 0, _maxCopyrightLength); }
void setCopyrightURL(const std::string& copyrightURL) { _copyrightURL = std::string(copyrightURL, 0, _maxCopyrightURLLength); }
void setTrackReplayGain(float replayGain) { _trackReplayGain = replayGain; }
@@ -131,7 +132,8 @@ class Track : public Wt::Dbo::Dbo<Track>
Wt::WDateTime getLastWriteTime() const { return _fileLastWrite; }
Wt::WDateTime getAddedTime() const { return _fileAdded; }
bool hasCover() const { return _hasCover; }
std::optional<UUID> getMBID() const { return UUID::fromString(_MBID); }
std::optional<UUID> getTrackMBID() const { return UUID::fromString(_trackMBID); }
std::optional<UUID> getRecordingMBID() const { return UUID::fromString(_recordingMBID); }
std::optional<std::string> getCopyright() const;
std::optional<std::string> getCopyrightURL() const;
std::optional<float> getTrackReplayGain() const { return _trackReplayGain; }
@@ -166,7 +168,8 @@ class Track : public Wt::Dbo::Dbo<Track>
Wt::Dbo::field(a, _fileLastWrite, "file_last_write");
Wt::Dbo::field(a, _fileAdded, "file_added");
Wt::Dbo::field(a, _hasCover, "has_cover");
Wt::Dbo::field(a, _MBID, "mbid");
Wt::Dbo::field(a, _trackMBID, "mbid");
Wt::Dbo::field(a, _recordingMBID, "recording_mbid");
Wt::Dbo::field(a, _copyright, "copyright");
Wt::Dbo::field(a, _copyrightURL, "copyright_url");
Wt::Dbo::field(a, _trackReplayGain, "track_replay_gain");
@@ -201,7 +204,8 @@ class Track : public Wt::Dbo::Dbo<Track>
Wt::WDateTime _fileLastWrite;
Wt::WDateTime _fileAdded;
bool _hasCover {};
std::string _MBID; // Musicbrainz Identifier
std::string _trackMBID;
std::string _recordingMBID;
std::string _copyright;
std::string _copyrightURL;
std::optional<float> _trackReplayGain;
@@ -47,11 +47,11 @@ class TrackList : public Wt::Dbo::Dbo<TrackList>
enum class Type
{
Playlist, // user controlled playlists
Internal, // current playqueue, history
Internal, // internal usage (current playqueue, history, ...)
};
TrackList() = default;
TrackList(const std::string& name, Type type, bool isPublic, Wt::Dbo::ptr<User> user);
TrackList(std::string_view name, Type type, bool isPublic, Wt::Dbo::ptr<User> user);
// Stats utility
std::vector<Wt::Dbo::ptr<Artist>> getTopArtists(const std::set<IdType>& clusterIds, std::optional<TrackArtistLinkType> linkType, std::optional<Range> range, bool& moreResults) const;
@@ -59,14 +59,14 @@ class TrackList : public Wt::Dbo::Dbo<TrackList>
std::vector<Wt::Dbo::ptr<Track>> getTopTracks(const std::set<IdType>& clusterIds, std::optional<Range> range, bool& moreResults) const;
// Search utility
static pointer get(Session& session, const std::string& name, Type type, Wt::Dbo::ptr<User> user);
static pointer get(Session& session, std::string_view name, Type type, Wt::Dbo::ptr<User> user);
static pointer getById(Session& session, IdType tracklistId);
static std::vector<pointer> getAll(Session& session);
static std::vector<pointer> getAll(Session& session, Wt::Dbo::ptr<User> user);
static std::vector<pointer> getAll(Session& session, Wt::Dbo::ptr<User> user, Type type);
// Create utility
static pointer create(Session& session, const std::string& name, Type type, bool isPublic, Wt::Dbo::ptr<User> user);
static pointer create(Session& session, std::string_view name, Type type, bool isPublic, Wt::Dbo::ptr<User> user);
// Accessors
std::string getName() const { return _name; }
@@ -84,7 +84,6 @@ class TrackList : public Wt::Dbo::Dbo<TrackList>
std::size_t getCount() const;
Wt::Dbo::ptr<TrackListEntry> getEntry(std::size_t pos) const;
std::vector<Wt::Dbo::ptr<TrackListEntry>> getEntries(std::optional<std::size_t> offset = {}, std::optional<std::size_t> size = {}) const;
std::vector<Wt::Dbo::ptr<TrackListEntry>> getEntriesReverse(std::optional<std::size_t> offset = {}, std::optional<std::size_t> size = {}) const;
std::vector<Wt::Dbo::ptr<Artist>> getArtistsReverse(const std::set<IdType>& clusterIds, std::optional<TrackArtistLinkType> linkType, std::optional<Range> range, bool& moreResults) const;
std::vector<Wt::Dbo::ptr<Release>> getReleasesReverse(const std::set<IdType>& clusterIds, std::optional<Range> range, bool& moreResults) const;
@@ -131,15 +130,17 @@ class TrackListEntry : public Wt::Dbo::Dbo<TrackListEntry>
using pointer = Wt::Dbo::ptr<TrackListEntry>;
TrackListEntry() = default;
TrackListEntry(Wt::Dbo::ptr<Track> track, Wt::Dbo::ptr<TrackList> tracklist);
TrackListEntry(Wt::Dbo::ptr<Track> track, Wt::Dbo::ptr<TrackList> tracklist, const Wt::WDateTime& dateTime);
// find utility
static pointer getById(Session& session, IdType id);
// Create utility
static pointer create(Session& session, Wt::Dbo::ptr<Track> track, Wt::Dbo::ptr<TrackList> tracklist);
static pointer create(Session& session, Wt::Dbo::ptr<Track> track, Wt::Dbo::ptr<TrackList> tracklist, const Wt::WDateTime& dateTime = Wt::WDateTime::currentDateTime());
// Accessors
Wt::Dbo::ptr<Track> getTrack() const { return _track; }
const Wt::WDateTime& getDateTime() const { return _dateTime; }
template<class Action>
void persist(Action& a)
@@ -19,6 +19,7 @@
#pragma once
#include <cstdint>
#include <Wt/Dbo/ptr.h>
namespace Database
@@ -51,5 +52,23 @@ namespace Database
Writer,
};
// User selectable audio file formats
// Do not change values
enum class AudioFormat
{
MP3 = 1,
OGG_OPUS = 2,
OGG_VORBIS = 3,
WEBM_VORBIS = 4,
MATROSKA_OPUS = 5,
};
using Bitrate = std::uint32_t;
// Do not change enum values!
enum class Scrobbler
{
Internal = 0,
ListenBrainz = 1,
};
}
+13 -15
View File
@@ -26,6 +26,7 @@
#include <Wt/Dbo/Dbo.h>
#include <Wt/WDateTime.h>
#include "utils/UUID.hpp"
#include "Types.hpp"
namespace Database {
@@ -37,19 +38,6 @@ class Session;
class TrackList;
class Track;
// User selectable audio file formats
// Do not change values
enum class AudioFormat
{
MP3 = 1,
OGG_OPUS = 2,
OGG_VORBIS = 3,
WEBM_VORBIS = 4,
MATROSKA_OPUS = 5,
};
using Bitrate = std::size_t;
class User;
class AuthToken
{
@@ -139,6 +127,7 @@ class User : public Wt::Dbo::Dbo<User>
static inline const Bitrate defaultSubsonicTranscodeBitrate {128000};
static inline const UITheme defaultUITheme {UITheme::Dark};
static inline const SubsonicArtistListMode defaultSubsonicArtistListMode {SubsonicArtistListMode::AllArtists};
static inline const Scrobbler defaultScrobbler {Scrobbler::Internal};
User() = default;
@@ -172,6 +161,8 @@ class User : public Wt::Dbo::Dbo<User>
void setUITheme(UITheme uiTheme) { _uiTheme = uiTheme; }
void clearAuthTokens();
void setSubsonicArtistListMode(SubsonicArtistListMode mode) { _subsonicArtistListMode = mode; }
void setScrobbler(Scrobbler scrobbler) { _scrobbler = scrobbler; }
void setListenBrainzToken(const std::optional<UUID>& MBID) { _listenbrainzToken = MBID ? MBID->getAsString() : ""; }
// read
bool isAdmin() const { return _type == Type::ADMIN; }
@@ -184,8 +175,9 @@ class User : public Wt::Dbo::Dbo<User>
bool isRadioSet() const { return _radio; }
UITheme getUITheme() const { return _uiTheme; }
SubsonicArtistListMode getSubsonicArtistListMode() const { return _subsonicArtistListMode; }
Scrobbler getScrobbler() const { return _scrobbler; }
std::optional<UUID> getListenBrainzToken() const { return UUID::fromString(_listenbrainzToken); }
Wt::Dbo::ptr<TrackList> getPlayedTrackList(Session& session) const;
Wt::Dbo::ptr<TrackList> getQueuedTrackList(Session& session) const;
void starArtist(Wt::Dbo::ptr<Artist> artist);
@@ -214,10 +206,14 @@ class User : public Wt::Dbo::Dbo<User>
Wt::Dbo::field(a, _subsonicTranscodeBitrate, "subsonic_transcode_bitrate");
Wt::Dbo::field(a, _subsonicArtistListMode, "subsonic_artist_list_mode");
Wt::Dbo::field(a, _uiTheme, "ui_theme");
// User's dynamic data
Wt::Dbo::field(a, _scrobbler, "scrobbler");
Wt::Dbo::field(a, _listenbrainzToken, "listenbrainz_token");
// UI settings
Wt::Dbo::field(a, _curPlayingTrackPos, "cur_playing_track_pos");
Wt::Dbo::field(a, _repeatAll, "repeat_all");
Wt::Dbo::field(a, _radio, "radio");
Wt::Dbo::hasMany(a, _tracklists, Wt::Dbo::ManyToOne, "user");
Wt::Dbo::hasMany(a, _starredArtists, Wt::Dbo::ManyToMany, "user_artist_starred", "", Wt::Dbo::OnDeleteCascade);
Wt::Dbo::hasMany(a, _starredReleases, Wt::Dbo::ManyToMany, "user_release_starred", "", Wt::Dbo::OnDeleteCascade);
@@ -232,6 +228,8 @@ class User : public Wt::Dbo::Dbo<User>
std::string _passwordHash;
Wt::WDateTime _lastLogin;
UITheme _uiTheme {defaultUITheme};
Scrobbler _scrobbler {defaultScrobbler};
std::string _listenbrainzToken; // Musicbrainz Identifier
// Admin defined settings
Type _type {Type::REGULAR};
+6 -3
View File
@@ -200,11 +200,14 @@ AvFormatParser::parse(const std::filesystem::path& p, bool debug)
track.acoustID = UUID::fromString(value);
}
else if (tag == "MUSICBRAINZ RELEASE TRACK ID"
|| tag == "MUSICBRAINZ_RELEASETRACKID"
|| tag == "MUSICBRAINZ_TRACKID"
|| tag == "MUSICBRAINZ_RELEASETRACKID")
{
track.trackMBID = UUID::fromString(value);
}
else if (tag == "MUSICBRAINZ_TRACKID"
|| tag == "MUSICBRAINZ/TRACK ID")
{
track.musicBrainzTrackID = UUID::fromString(value);
track.recordingMBID = UUID::fromString(value);
}
else if (tag == "TSST"
|| tag == "DISCSUBTITLE"
+2 -3
View File
@@ -149,7 +149,6 @@ void
TagLibParser::processTag(Track& track, const std::string& tag, const TagLib::StringList& values, bool debug)
{
// TODO validate MBID format
if (debug)
{
std::vector<std::string> strs;
@@ -168,11 +167,11 @@ TagLibParser::processTag(Track& track, const std::string& tag, const TagLib::Str
else if (tag == "MUSICBRAINZ_RELEASETRACKID"
|| tag == "MUSICBRAINZ RELEASE TRACK ID")
{
track.musicBrainzTrackID = UUID::fromString(value);
track.trackMBID = UUID::fromString(value);
}
else if (tag == "MUSICBRAINZ_TRACKID"
|| tag == "MUSICBRAINZ TRACK ID")
track.musicBrainzRecordID = UUID::fromString(value);
track.recordingMBID = UUID::fromString(value);
else if (tag == "ACOUSTID_ID")
track.acoustID = UUID::fromString(value);
else if (tag == "TRACKTOTAL")
@@ -59,8 +59,8 @@ namespace MetaData
std::vector<Artist> artists;
std::vector<Artist> albumArtists;
std::string title;
std::optional<UUID> musicBrainzTrackID;
std::optional<UUID> musicBrainzRecordID;
std::optional<UUID> trackMBID;
std::optional<UUID> recordingMBID;
std::optional<Album> album;
Clusters clusters;
std::chrono::milliseconds duration;
@@ -79,9 +79,9 @@ getJsonData(const UUID& mbid)
}
std::string
extractLowLevelFeatures(const UUID& mbid)
extractLowLevelFeatures(const UUID& recordingMBID)
{
return getJsonData(mbid);
return getJsonData(recordingMBID);
}
} // namespace Scanner::AcousticBrainz
@@ -25,6 +25,6 @@ class UUID;
namespace AcousticBrainz
{
std::string extractLowLevelFeatures(const UUID& MBID);
std::string extractLowLevelFeatures(const UUID& recordingMBID);
}
+12 -11
View File
@@ -543,15 +543,15 @@ Scanner::scan(bool forceScan)
}
bool
Scanner::fetchTrackFeatures(Database::IdType trackId, const UUID& MBID)
Scanner::fetchTrackFeatures(Database::IdType trackId, const UUID& recordingMBID)
{
std::map<std::string, double> features;
LMS_LOG(DBUPDATER, INFO) << "Fetching low level features for track '" << MBID.getAsString() << "'";
const std::string data {AcousticBrainz::extractLowLevelFeatures(MBID)};
LMS_LOG(DBUPDATER, INFO) << "Fetching low level features for recording '" << recordingMBID.getAsString() << "'";
const std::string data {AcousticBrainz::extractLowLevelFeatures(recordingMBID)};
if (data.empty())
{
LMS_LOG(DBUPDATER, ERROR) << "Track " << trackId << ", MBID = '" << MBID.getAsString() << "': cannot extract features using AcousticBrainz";
LMS_LOG(DBUPDATER, ERROR) << "Track " << trackId << ", recording MBID = '" << recordingMBID.getAsString() << "': cannot extract features using AcousticBrainz";
return false;
}
@@ -581,7 +581,7 @@ Scanner::fetchTrackFeatures(ScanStats& stats)
struct TrackInfo
{
Database::IdType id;
UUID mbid;
UUID recordingMBID;
};
const auto tracksToFetch {[&]()
@@ -590,9 +590,9 @@ Scanner::fetchTrackFeatures(ScanStats& stats)
auto transaction {_dbSession.createSharedTransaction()};
auto tracks {Database::Track::getAllWithMBIDAndMissingFeatures(_dbSession)};
auto tracks {Database::Track::getAllWithRecordingMBIDAndMissingFeatures(_dbSession)};
for (const auto& track : tracks)
res.emplace_back(TrackInfo {track.id(), *track->getMBID()});
res.emplace_back(TrackInfo {track.id(), *track->getRecordingMBID()});
return res;
}()};
@@ -607,7 +607,7 @@ Scanner::fetchTrackFeatures(ScanStats& stats)
if (_abortScan)
return;
if (fetchTrackFeatures(trackToFetch.id, trackToFetch.mbid))
if (fetchTrackFeatures(trackToFetch.id, trackToFetch.recordingMBID))
stats.featuresFetched++;
stepStats.processedElems++;
@@ -824,7 +824,8 @@ Scanner::scanAudioFile(const std::filesystem::path& file, bool forceScan, ScanSt
if (!trackInfo->year && trackInfo->originalYear)
track.modify()->setYear(*trackInfo->originalYear);
track.modify()->setMBID(trackInfo->musicBrainzRecordID);
track.modify()->setRecordingMBID(trackInfo->recordingMBID);
track.modify()->setTrackMBID(trackInfo->trackMBID);
track.modify()->setFeatures({}); // TODO: only if MBID changed?
track.modify()->setHasCover(trackInfo->hasCover);
track.modify()->setCopyright(trackInfo->copyright);
@@ -1022,9 +1023,9 @@ Scanner::checkDuplicatedAudioFiles(ScanStats& stats)
const std::vector<Track::pointer> tracks = Database::Track::getMBIDDuplicates(_dbSession);
for (const Track::pointer& track : tracks)
{
if (track->getMBID())
if (auto trackMBID {track->getTrackMBID()})
{
LMS_LOG(DBUPDATER, INFO) << "Found duplicated MBID [" << track->getMBID()->getAsString() << "], file: " << track->getPath().string() << " - " << track->getName();
LMS_LOG(DBUPDATER, INFO) << "Found duplicated Track MBID [" << trackMBID->getAsString() << "], file: " << track->getPath().string() << " - " << track->getName();
stats.duplicates.emplace_back(ScanDuplicate {track.id(), DuplicateReason::SameMBID});
}
}
+23
View File
@@ -0,0 +1,23 @@
add_library(lmsscrobbling SHARED
impl/internal/InternalScrobbler.cpp
impl/listenbrainz/ListenBrainzScrobbler.cpp
impl/Scrobbling.cpp
)
target_include_directories(lmsscrobbling INTERFACE
include
)
target_include_directories(lmsscrobbling PRIVATE
include
impl
)
target_link_libraries(lmsscrobbling PUBLIC
lmsdatabase
lmsutils
)
install(TARGETS lmsscrobbling DESTINATION lib)
+56
View File
@@ -0,0 +1,56 @@
/*
* Copyright (C) 2021 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <memory>
#include <vector>
#include <Wt/WDateTime.h>
#include "scrobbling/Listen.hpp"
namespace Database
{
class Db;
class Session;
class TrackList;
class User;
}
namespace Scrobbling
{
class IScrobbler
{
public:
virtual ~IScrobbler() = default;
virtual void listenStarted(const Listen& listen) = 0;
virtual void listenFinished(const Listen& listen, std::chrono::seconds duration) = 0;
virtual void addListen(const Listen& listen, const Wt::WDateTime& timePoint) = 0;
virtual Wt::Dbo::ptr<Database::TrackList> getListensTrackList(Database::Session& session, Wt::Dbo::ptr<Database::User> user) = 0;
};
std::unique_ptr<IScrobbler> createScrobbler(std::string_view backendName);
} // ns Scrobbling
+186
View File
@@ -0,0 +1,186 @@
/*
* Copyright (C) 2021 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include "Scrobbling.hpp"
#include "database/Db.hpp"
#include "database/Session.hpp"
#include "database/TrackList.hpp"
#include "database/User.hpp"
#include "internal/InternalScrobbler.hpp"
#include "listenbrainz/ListenBrainzScrobbler.hpp"
namespace Scrobbling
{
std::unique_ptr<IScrobbling>
createScrobbling(Database::Db& db)
{
return std::make_unique<Scrobbling>(db);
}
Scrobbling::Scrobbling(Database::Db& db)
: _db {db}
{
_scrobblers.emplace(Database::Scrobbler::Internal, std::make_unique<InternalScrobbler>(_db));
_scrobblers.emplace(Database::Scrobbler::ListenBrainz, std::make_unique<ListenBrainzScrobbler>(_db));
}
void
Scrobbling::listenStarted(const Listen& listen)
{
if (auto scrobbler {getUserScrobbler(listen.userId)})
_scrobblers[*scrobbler]->listenStarted(listen);
}
void
Scrobbling::listenFinished(const Listen& listen, std::chrono::seconds duration)
{
if (auto scrobbler {getUserScrobbler(listen.userId)})
_scrobblers[*scrobbler]->listenFinished(listen, duration);
}
void
Scrobbling::addListen(const Listen& listen, Wt::WDateTime timePoint)
{
if (auto scrobbler {getUserScrobbler(listen.userId)})
_scrobblers[*scrobbler]->addListen(listen, timePoint);
}
std::optional<Database::Scrobbler>
Scrobbling::getUserScrobbler(Database::IdType userId)
{
std::optional<Database::Scrobbler> scrobbler;
Database::Session& session {_db.getTLSSession()};
auto transaction {session.createSharedTransaction()};
if (const Database::User::pointer user {Database::User::getById(session, userId)})
scrobbler = user->getScrobbler();
return scrobbler;
}
std::vector<Wt::Dbo::ptr<Database::Artist>>
Scrobbling::getRecentArtists(Database::Session& session,
Wt::Dbo::ptr<Database::User> user,
const std::set<Database::IdType>& clusterIds,
std::optional<Database::TrackArtistLinkType> linkType,
std::optional<Database::Range> range,
bool& moreResults)
{
const Wt::Dbo::ptr<Database::TrackList> history {getListensTrackList(session, user)};
std::vector<Wt::Dbo::ptr<Database::Artist>> res;
if (history)
res = history->getArtistsReverse(clusterIds, linkType, range, moreResults);
return res;
}
std::vector<Wt::Dbo::ptr<Database::Release>>
Scrobbling::getRecentReleases(Database::Session& session,
Wt::Dbo::ptr<Database::User> user,
const std::set<Database::IdType>& clusterIds,
std::optional<Database::Range> range,
bool& moreResults)
{
const Wt::Dbo::ptr<Database::TrackList> history {getListensTrackList(session, user)};
std::vector<Wt::Dbo::ptr<Database::Release>> res;
if (history)
res = history->getReleasesReverse(clusterIds, range, moreResults);
return res;
}
std::vector<Wt::Dbo::ptr<Database::Track>>
Scrobbling::getRecentTracks(Database::Session& session,
Wt::Dbo::ptr<Database::User> user,
const std::set<Database::IdType>& clusterIds,
std::optional<Database::Range> range,
bool& moreResults)
{
const Wt::Dbo::ptr<Database::TrackList> history {getListensTrackList(session, user)};
std::vector<Wt::Dbo::ptr<Database::Track>> res;
if (history)
res = history->getTracksReverse(clusterIds, range, moreResults);
return res;
}
// Top
std::vector<Wt::Dbo::ptr<Database::Artist>>
Scrobbling::getTopArtists(Database::Session& session,
Wt::Dbo::ptr<Database::User> user,
const std::set<Database::IdType>& clusterIds,
std::optional<Database::TrackArtistLinkType> linkType,
std::optional<Database::Range> range,
bool& moreResults)
{
const Wt::Dbo::ptr<Database::TrackList> history {getListensTrackList(session, user)};
std::vector<Wt::Dbo::ptr<Database::Artist>> res;
if (history)
res = history->getTopArtists(clusterIds, linkType, range, moreResults);
return res;
}
std::vector<Wt::Dbo::ptr<Database::Release>>
Scrobbling::getTopReleases(Database::Session& session,
Wt::Dbo::ptr<Database::User> user,
const std::set<Database::IdType>& clusterIds,
std::optional<Database::Range> range,
bool& moreResults)
{
const Wt::Dbo::ptr<Database::TrackList> history {getListensTrackList(session, user)};
std::vector<Wt::Dbo::ptr<Database::Release>> res;
if (history)
res = history->getTopReleases(clusterIds, range, moreResults);
return res;
}
std::vector<Wt::Dbo::ptr<Database::Track>>
Scrobbling::getTopTracks(Database::Session& session,
Wt::Dbo::ptr<Database::User> user,
const std::set<Database::IdType>& clusterIds,
std::optional<Database::Range> range,
bool& moreResults)
{
const Wt::Dbo::ptr<Database::TrackList> history {getListensTrackList(session, user)};
std::vector<Wt::Dbo::ptr<Database::Track>> res;
if (history)
res = history->getTopTracks(clusterIds, range, moreResults);
return res;
}
Wt::Dbo::ptr<Database::TrackList>
Scrobbling::getListensTrackList(Database::Session& session, Wt::Dbo::ptr<Database::User> user)
{
return _scrobblers[user->getScrobbler()]->getListensTrackList(session, user);
}
} // ns Scrobbling
+89
View File
@@ -0,0 +1,89 @@
/*
* Copyright (C) 2021 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <memory>
#include <optional>
#include <unordered_map>
#include "scrobbling/IScrobbling.hpp"
#include "IScrobbler.hpp"
namespace Scrobbling
{
class Scrobbling : public IScrobbling
{
public:
Scrobbling(Database::Db& db);
private:
void listenStarted(const Listen& listen) override;
void listenFinished(const Listen& listen, std::chrono::seconds duration) override;
void addListen(const Listen& listen, Wt::WDateTime timePoint) override;
std::vector<Wt::Dbo::ptr<Database::Artist>> getRecentArtists(Database::Session& session,
Wt::Dbo::ptr<Database::User> user,
const std::set<Database::IdType>& clusterIds,
std::optional<Database::TrackArtistLinkType> linkType,
std::optional<Database::Range> range,
bool& moreResults) override;
std::vector<Wt::Dbo::ptr<Database::Release>> getRecentReleases(Database::Session& session,
Wt::Dbo::ptr<Database::User> user,
const std::set<Database::IdType>& clusterIds,
std::optional<Database::Range> range,
bool& moreResults) override;
std::vector<Wt::Dbo::ptr<Database::Track>> getRecentTracks(Database::Session& session,
Wt::Dbo::ptr<Database::User> user,
const std::set<Database::IdType>& clusterIds,
std::optional<Database::Range> range,
bool& moreResults) override;
std::vector<Wt::Dbo::ptr<Database::Artist>> getTopArtists(Database::Session& session,
Wt::Dbo::ptr<Database::User> user,
const std::set<Database::IdType>& clusterIds,
std::optional<Database::TrackArtistLinkType> linkType,
std::optional<Database::Range> range,
bool& moreResults) override;
std::vector<Wt::Dbo::ptr<Database::Release>> getTopReleases(Database::Session& session,
Wt::Dbo::ptr<Database::User> user,
const std::set<Database::IdType>& clusterIds,
std::optional<Database::Range> range,
bool& moreResults) override;
std::vector<Wt::Dbo::ptr<Database::Track>> getTopTracks(Database::Session& session,
Wt::Dbo::ptr<Database::User> user,
const std::set<Database::IdType>& clusterIds,
std::optional<Database::Range> range,
bool& moreResults) override;
Wt::Dbo::ptr<Database::TrackList> getListensTrackList(Database::Session& session, Wt::Dbo::ptr<Database::User> user);
std::optional<Database::Scrobbler> getUserScrobbler(Database::IdType userId);
Database::Db& _db;
std::unordered_map<Database::Scrobbler, std::unique_ptr<IScrobbler>> _scrobblers;
};
} // ns Scrobbling
@@ -0,0 +1,78 @@
/*
* Copyright (C) 2021 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include "InternalScrobbler.hpp"
#include "database/Db.hpp"
#include "database/Session.hpp"
#include "database/Track.hpp"
#include "database/TrackList.hpp"
#include "database/User.hpp"
#include "utils/Logger.hpp"
namespace Scrobbling
{
static const std::string historyTracklistName {"__scrobbler_internal_history__"};
InternalScrobbler::InternalScrobbler(Database::Db& db)
: _db {db}
{}
void
InternalScrobbler::listenStarted(const Listen& listen)
{
addListen(listen, Wt::WDateTime::currentDateTime());
}
void
InternalScrobbler::listenFinished(const Listen& /*event*/, std::chrono::seconds /* duration */)
{
// nothing to do
}
void
InternalScrobbler::addListen(const Listen& listen, const Wt::WDateTime& timePoint)
{
Database::Session& session {_db.getTLSSession()};
auto transaction {session.createUniqueTransaction()};
const Database::User::pointer user {Database::User::getById(session, listen.userId)};
if (!user)
return;
Wt::Dbo::ptr<Database::TrackList> tracklist {getListensTrackList(session, user)};
if (!tracklist)
tracklist = Database::TrackList::create(session, historyTracklistName, Database::TrackList::Type::Internal, false, user);
const Database::Track::pointer track {Database::Track::getById(session, listen.trackId)};
if (!track)
return;
Database::TrackListEntry::create(session, track, getListensTrackList(session, user), timePoint);
}
Wt::Dbo::ptr<Database::TrackList>
InternalScrobbler::getListensTrackList(Database::Session& session, Wt::Dbo::ptr<Database::User> user)
{
return Database::TrackList::get(session, historyTracklistName, Database::TrackList::Type::Internal, user);
}
} // Scrobbling
@@ -0,0 +1,43 @@
/*
* Copyright (C) 2021 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include "IScrobbler.hpp"
namespace Scrobbling
{
class InternalScrobbler final : public IScrobbler
{
public:
InternalScrobbler(Database::Db& db);
private:
void listenStarted(const Listen& listen) override;
void listenFinished(const Listen& listen, std::chrono::seconds duration) override;
void addListen(const Listen& listen, const Wt::WDateTime& timePoint) override;
Wt::Dbo::ptr<Database::TrackList> getListensTrackList(Database::Session& session, Wt::Dbo::ptr<Database::User> user) override;
Database::Db& _db;
};
} // Scrobbling
@@ -0,0 +1,400 @@
/*
* Copyright (C) 2021 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include "ListenBrainzScrobbler.hpp"
#include <Wt/Json/Array.h>
#include <Wt/Json/Object.h>
#include <Wt/Json/Value.h>
#include <Wt/Json/Serializer.h>
#include "database/Artist.hpp"
#include "database/Db.hpp"
#include "database/Release.hpp"
#include "database/Session.hpp"
#include "database/Track.hpp"
#include "database/TrackList.hpp"
#include "database/User.hpp"
#include "utils/IConfig.hpp"
#include "utils/Logger.hpp"
#include "utils/Service.hpp"
#define LOG(sev) LMS_LOG(SCROBBLING, sev) << "[listenbrainz] - "
namespace StringUtils
{
template<>
std::optional<std::chrono::seconds>
readAs(const std::string& str)
{
std::optional<std::chrono::seconds> res;
if (const std::optional<std::size_t> value {StringUtils::readAs<std::size_t>(str)})
res = std::chrono::seconds {*value};
return res;
}
}
namespace
{
std::optional<UUID>
getListenBrainzToken(Database::Session& session, Database::IdType userId)
{
auto transaction {session.createSharedTransaction()};
const Database::User::pointer user {Database::User::getById(session, userId)};
if (!user)
return std::nullopt;
if (user->getScrobbler() != Database::Scrobbler::ListenBrainz)
return std::nullopt;
return user->getListenBrainzToken();
}
bool
canBeScrobbled(Database::Session& session, Database::IdType trackId, std::chrono::seconds duration)
{
auto transaction {session.createSharedTransaction()};
const Database::Track::pointer track {Database::Track::getById(session, trackId)};
if (!track)
return false;
const bool res {duration >= std::chrono::minutes(4) || (duration >= track->getDuration() / 2)};
if (!res)
LOG(DEBUG) << "Track cannot be scrobbled since played duration is too short: " << duration.count() << "s, total duration = " << std::chrono::duration_cast<std::chrono::seconds>(track->getDuration()).count() << "s";
return res;
}
std::optional<Wt::Json::Object>
listenToJsonPayload(Database::Session& session, const Scrobbling::Listen& listen, const Wt::WDateTime& timePoint)
{
auto transaction {session.createSharedTransaction()};
const Database::Track::pointer track {Database::Track::getById(session, listen.trackId)};
if (!track)
return std::nullopt;
auto artists {track->getArtists({Database::TrackArtistLinkType::Artist})};
if (artists.empty())
artists = track->getArtists({Database::TrackArtistLinkType::ReleaseArtist});
Wt::Json::Object additionalInfo;
additionalInfo["listening_from"] = "LMS";
if (track->getRelease())
{
if (auto MBID {track->getRelease()->getMBID()})
additionalInfo["release_mbid"] = Wt::Json::Value {std::string {MBID->getAsString()}};
}
if (!artists.empty())
{
Wt::Json::Array artistMBIDs;
for (const Database::Artist::pointer& artist : artists)
{
if (auto MBID {artist->getMBID()})
artistMBIDs.push_back(Wt::Json::Value {std::string {MBID->getAsString()}});
}
if (!artistMBIDs.empty())
additionalInfo["artist_mbids"] = std::move(artistMBIDs);
}
if (auto MBID {track->getTrackMBID()})
additionalInfo["track_mbid"] = Wt::Json::Value {std::string {MBID->getAsString()}};
if (auto MBID {track->getRecordingMBID()})
additionalInfo["recording_mbid"] = Wt::Json::Value {std::string {MBID->getAsString()}};
if (const std::optional<std::size_t> trackNumber {track->getTrackNumber()})
additionalInfo["tracknumber"] = Wt::Json::Value {static_cast<long long int>(*trackNumber)};
Wt::Json::Object trackMetadata;
trackMetadata["additional_info"] = std::move(additionalInfo);
if (!artists.empty())
trackMetadata["artist_name"] = Wt::Json::Value {artists.front()->getName()};
trackMetadata["track_name"] = Wt::Json::Value {track->getName()};
if (track->getRelease())
trackMetadata["release_name"] = Wt::Json::Value {track->getRelease()->getName()};
Wt::Json::Object payload;
payload["track_metadata"] = std::move(trackMetadata);
if (timePoint.isValid())
payload["listened_at"] = Wt::Json::Value {static_cast<long long int>(timePoint.toTime_t())};
return payload;
}
std::string
listenToJsonString(Database::Session& session, const Scrobbling::Listen& listen, const Wt::WDateTime& timePoint, std::string_view listenType)
{
std::string res;
std::optional<Wt::Json::Object> payload {listenToJsonPayload(session, listen, timePoint)};
if (!payload)
return res;
Wt::Json::Object root;
root["listen_type"] = Wt::Json::Value {std::string {listenType}};
root["payload"] = Wt::Json::Array {std::move(*payload)};
res = Wt::Json::serialize(root);
return res;
}
template <typename T>
std::optional<T>
headerReadAs(const Wt::Http::Message& msg, std::string_view headerName)
{
std::optional<T> res;
if (const std::string* headerValue {msg.getHeader(std::string {headerName})})
res = StringUtils::readAs<T>(*headerValue);
return res;
}
}
namespace Scrobbling
{
static const std::string historyTracklistName {"__scrobbler_listenbrainz_history__"};
ListenBrainzScrobbler::ListenBrainzScrobbler(Database::Db& db)
: _apiEndpoint {Service<IConfig>::get()->getString("listenbrainz-api-url", "https://api.listenbrainz.org/1/")}
, _db {db}
{
LOG(INFO) << "Starting ListenBrainz scrobbler... API endpoint = '" << _apiEndpoint << "'";
_client.done().connect([this](Wt::AsioWrapper::error_code ec, Wt::Http::Message msg)
{
onClientDone(ec, msg);
});
_ioService.setThreadCount(1);
_ioService.start();
}
ListenBrainzScrobbler::~ListenBrainzScrobbler()
{
_ioService.stop();
LOG(INFO) << "Stopped ListenBrainz scrobbler";
}
void
ListenBrainzScrobbler::listenStarted(const Listen& listen)
{
_ioService.post([=]
{
enqueListen(listen, Wt::WDateTime {});
});
}
void
ListenBrainzScrobbler::listenFinished(const Listen& listen, std::chrono::seconds duration)
{
if (!canBeScrobbled(_db.getTLSSession(), listen.trackId, duration))
return;
Listen timedListen {listen};
const Wt::WDateTime now {Wt::WDateTime::currentDateTime().addSecs(-duration.count())};
_ioService.post([=]
{
enqueListen(timedListen, now);
});
}
void
ListenBrainzScrobbler::addListen(const Listen& listen, const Wt::WDateTime& timePoint)
{
assert(timePoint.isValid());
_ioService.post([=]
{
enqueListen(listen, timePoint);
});
}
Wt::Dbo::ptr<Database::TrackList>
ListenBrainzScrobbler::getListensTrackList(Database::Session& session, Wt::Dbo::ptr<Database::User> user)
{
return Database::TrackList::get(session, historyTracklistName, Database::TrackList::Type::Internal, user);
}
void
ListenBrainzScrobbler::enqueListen(const Listen& listen, const Wt::WDateTime& timePoint)
{
if (!timePoint.isValid())
{
// If we are currently throttled, just replace the entry if it has no timePoint
// in order to only report the newest track listened to
// If not throttled, just search past the next current first message as it is being sent
const std::size_t offset {_state == State::Throttled ? std::size_t {0} : std::size_t {1}};
if (_sendQueue.size() > offset)
{
_sendQueue.erase(std::remove_if(std::next(std::begin(_sendQueue), offset), std::end(_sendQueue),
[&](const QueuedListen& queuedListen) { return queuedListen.listen.userId == listen.userId && !queuedListen.timePoint.isValid(); }), std::end(_sendQueue));
}
}
_sendQueue.emplace_back(QueuedListen {listen, timePoint});
if (_state == State::Idle)
sendNextQueuedListen();
}
void
ListenBrainzScrobbler::sendNextQueuedListen()
{
assert(_state == State::Idle);
if (_sendQueue.empty())
return;
sendListen(_sendQueue.front().listen, _sendQueue.front().timePoint);
_state = State::Sending;
}
void
ListenBrainzScrobbler::sendListen(const Listen& listen, const Wt::WDateTime& timePoint)
{
Database::Session& session {_db.getTLSSession()};
const std::optional<UUID> listenBrainzToken {getListenBrainzToken(session, listen.userId)};
if (!listenBrainzToken)
return;
std::string payload {listenToJsonString(session, listen, timePoint, timePoint.isValid() ? "single" : "playing_now")};
if (payload.empty())
{
LOG(DEBUG) << "Cannot convert listen to json: skipping";
return;
}
// now send this
Wt::Http::Message message;
message.addHeader("Authorization", "Token " + std::string {listenBrainzToken->getAsString()});
message.addBodyText(payload);
const std::string endPoint {_apiEndpoint + "submit-listens"};
if (!_client.post(endPoint, message))
LOG(ERROR) << "Cannot post to '" << endPoint << "': invalid scheme or URL?";
LOG(DEBUG) << "POST done to '" << endPoint << "'";
}
void
ListenBrainzScrobbler::onClientDone(Wt::AsioWrapper::error_code ec, const Wt::Http::Message& msg)
{
assert(!_sendQueue.empty());
QueuedListen& queuedListen {_sendQueue.front()};
_state = State::Idle;
LOG(DEBUG) << "POST done. status = " << msg.status() << ", msg = '" << msg.body() << "'";
if (ec)
{
LOG(ERROR) << "Client error: " << ec.message();
// may be a network error, try again later
if (++queuedListen.retryCount > _maxRetryCount)
_sendQueue.pop_front();
throttle(_defaultRetryWaitDuration);
return;
}
bool mustThrottle{};
switch (msg.status())
{
case 429:
mustThrottle = true;
break;
case 200:
if (queuedListen.timePoint.isValid())
cacheListen(queuedListen.listen, queuedListen.timePoint);
_sendQueue.pop_front();
break;
default:
LOG(ERROR) << "Submit error: '" << msg.body() << "'";
_sendQueue.pop_front();
break;
}
const auto remainingCount {headerReadAs<std::size_t>(msg, "X-RateLimit-Remaining")};
LOG(DEBUG) << "Remaining messages = " << (remainingCount ? *remainingCount : 0);
if (mustThrottle || (remainingCount && *remainingCount == 0))
{
const auto waitDuration {headerReadAs<std::chrono::seconds>(msg, "X-RateLimit-Reset-In")};
throttle(waitDuration.value_or(_defaultRetryWaitDuration));
}
else
{
sendNextQueuedListen();
}
}
void
ListenBrainzScrobbler::throttle(std::chrono::seconds requestedDuration)
{
assert(_state == State::Idle);
const std::chrono::seconds duration {requestedDuration.count() > 0 ? requestedDuration : std::chrono::seconds {1}};
LOG(DEBUG) << "Throttling for " << duration.count() << " seconds";
_ioService.schedule(duration, [this]
{
_state = State::Idle;
sendNextQueuedListen();
});
_state = State::Throttled;
}
void
ListenBrainzScrobbler::cacheListen(const Listen& listen, const Wt::WDateTime& timePoint)
{
Database::Session& session {_db.getTLSSession()};
auto transaction {session.createUniqueTransaction()};
const Database::User::pointer user {Database::User::getById(session, listen.userId)};
if (!user)
return;
const Database::Track::pointer track {Database::Track::getById(session, listen.trackId)};
if (!track)
return;
Database::TrackList::pointer tracklist {getListensTrackList(session, user)};
if (!tracklist)
tracklist = Database::TrackList::create(session, historyTracklistName, Database::TrackList::Type::Internal, false, user);
Database::TrackListEntry::create(session, track, getListensTrackList(session, user), timePoint);
}
} // Scrobbling
@@ -0,0 +1,91 @@
/*
* Copyright (C) 2021 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <chrono>
#include <deque>
#include <mutex>
#include <Wt/Http/Client.h>
#include <Wt/WIOService.h>
#include "IScrobbler.hpp"
namespace Database
{
class Db;
class Session;
class TrackList;
}
namespace Scrobbling
{
class ListenBrainzScrobbler final : public IScrobbler
{
public:
ListenBrainzScrobbler(Database::Db& db);
~ListenBrainzScrobbler();
ListenBrainzScrobbler(const ListenBrainzScrobbler&) = delete;
ListenBrainzScrobbler(const ListenBrainzScrobbler&&) = delete;
ListenBrainzScrobbler& operator=(const ListenBrainzScrobbler&) = delete;
ListenBrainzScrobbler& operator=(const ListenBrainzScrobbler&&) = delete;
private:
void listenStarted(const Listen& listen) override;
void listenFinished(const Listen& listen, std::chrono::seconds duration) override;
void addListen(const Listen& listen, const Wt::WDateTime& timePoint) override;
Wt::Dbo::ptr<Database::TrackList> getListensTrackList(Database::Session& session, Wt::Dbo::ptr<Database::User> user) override;
void enqueListen(const Listen& listen, const Wt::WDateTime& timePoint);
void sendNextQueuedListen();
void sendListen(const Listen& listen, const Wt::WDateTime& timePoint);
void onClientDone(Wt::AsioWrapper::error_code ec, const Wt::Http::Message& msg);
void throttle(std::chrono::seconds duration);
void cacheListen(const Listen& listen, const Wt::WDateTime& timePoint);
enum class State
{
Idle,
Throttled,
Sending,
};
State _state {State::Idle};
const std::string _apiEndpoint;
const std::size_t _maxRetryCount {2};
const std::chrono::seconds _defaultRetryWaitDuration {30};
Database::Db& _db;
Wt::WIOService _ioService;
Wt::Http::Client _client {_ioService};
struct QueuedListen
{
Listen listen;
Wt::WDateTime timePoint;
std::size_t retryCount {};
};
std::deque<QueuedListen> _sendQueue;
};
} // Scrobbling
@@ -0,0 +1,101 @@
/*
* Copyright (C) 2021 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <chrono>
#include <memory>
#include <vector>
#include <set>
#include <string_view>
#include <Wt/WDateTime.h>
#include "scrobbling/Listen.hpp"
namespace Database
{
class Artist;
class Db;
class Release;
class Session;
class Track;
class User;
}
namespace Scrobbling
{
class IScrobbling
{
public:
virtual ~IScrobbling() = default;
// Scrobbling
virtual void listenStarted(const Listen& listen) = 0;
virtual void listenFinished(const Listen& listen, std::chrono::seconds duration) = 0;
virtual void addListen(const Listen& listen, Wt::WDateTime timePoint) = 0;
// Stats
// From most recent to oldest
virtual std::vector<Wt::Dbo::ptr<Database::Artist>> getRecentArtists(Database::Session& session,
Wt::Dbo::ptr<Database::User> user,
const std::set<Database::IdType>& clusterIds,
std::optional<Database::TrackArtistLinkType> linkType,
std::optional<Database::Range> range,
bool& moreResults) = 0;
virtual std::vector<Wt::Dbo::ptr<Database::Release>> getRecentReleases(Database::Session& session,
Wt::Dbo::ptr<Database::User> user,
const std::set<Database::IdType>& clusterIds,
std::optional<Database::Range> range,
bool& moreResults) = 0;
virtual std::vector<Wt::Dbo::ptr<Database::Track>> getRecentTracks(Database::Session& session,
Wt::Dbo::ptr<Database::User> user,
const std::set<Database::IdType>& clusterIds,
std::optional<Database::Range> range,
bool& moreResults) = 0;
// Top
virtual std::vector<Wt::Dbo::ptr<Database::Artist>> getTopArtists(Database::Session& session,
Wt::Dbo::ptr<Database::User> user,
const std::set<Database::IdType>& clusterIds,
std::optional<Database::TrackArtistLinkType> linkType,
std::optional<Database::Range> range,
bool& moreResults) = 0;
virtual std::vector<Wt::Dbo::ptr<Database::Release>> getTopReleases(Database::Session& session,
Wt::Dbo::ptr<Database::User> user,
const std::set<Database::IdType>& clusterIds,
std::optional<Database::Range> range,
bool& moreResults) = 0;
virtual std::vector<Wt::Dbo::ptr<Database::Track>> getTopTracks(Database::Session& session,
Wt::Dbo::ptr<Database::User> user,
const std::set<Database::IdType>& clusterIds,
std::optional<Database::Range> range,
bool& moreResults) = 0;
};
std::unique_ptr<IScrobbling> createScrobbling(Database::Db& db);
} // ns Scrobbling
@@ -0,0 +1,32 @@
/*
* Copyright (C) 2021 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include "database/Types.hpp"
namespace Scrobbling
{
struct Listen
{
Database::IdType userId {};
Database::IdType trackId {};
};
} // ns Scrobbling
+1
View File
@@ -23,6 +23,7 @@ target_link_libraries(lmssubsonic PRIVATE
lmsdatabase
lmsrecommendation
lmsscanner
lmsscrobbling
lmsutils
std::filesystem
)
+40 -13
View File
@@ -38,6 +38,7 @@
#include "database/TrackList.hpp"
#include "database/User.hpp"
#include "recommendation/IEngine.hpp"
#include "scrobbling/IScrobbling.hpp"
#include "utils/Logger.hpp"
#include "utils/Random.hpp"
#include "utils/Service.hpp"
@@ -815,7 +816,7 @@ handleGetAlbumListRequestCommon(const RequestContext& context, bool id3)
else if (type == "frequent")
{
bool moreResults {};
releases = user->getPlayedTrackList(context.dbSession)->getTopReleases({}, range, moreResults);
releases = Service<Scrobbling::IScrobbling>::get()->getTopReleases(context.dbSession, user, {}, range, moreResults);
}
else if (type == "newest")
{
@@ -830,7 +831,7 @@ handleGetAlbumListRequestCommon(const RequestContext& context, bool id3)
else if (type == "recent")
{
bool moreResults {};
releases = user->getPlayedTrackList(context.dbSession)->getReleasesReverse({}, range, moreResults);
releases = Service<Scrobbling::IScrobbling>::get()->getRecentReleases(context.dbSession, user, {}, range, moreResults);
}
else if (type == "starred")
{
@@ -1643,26 +1644,52 @@ Response
handleScrobble(RequestContext& context)
{
const std::vector<Id> ids {getMandatoryMultiParametersAs<Id>(context.parameters, "id")};
// TODO handle time in some way (need underlying refacto)
const std::vector<unsigned long> times {getMandatoryMultiParametersAs<unsigned long>(context.parameters, "time")};
if (!std::all_of(std::cbegin(ids), std::cend(ids), [](const Id& id) { return id.type == Id::Type::Track; }))
throw BadParameterGenericError {"id"};
auto transaction {context.dbSession.createUniqueTransaction()};
if (ids.size() != times.size())
throw BadParameterGenericError {"time"};
User::pointer user {User::getById(context.dbSession, context.userId)};
if (!user)
throw RequestedDataNotFoundError {};
for (Id id : ids)
struct Scrobble
{
Track::pointer track {Track::getById(context.dbSession, id.value)};
if (!track)
continue;
Scrobbling::Listen listen;
Wt::WDateTime timePoint;
};
TrackListEntry::create(context.dbSession, track, user->getPlayedTrackList(context.dbSession));
std::vector<Scrobble> scrobbles;
scrobbles.reserve(ids.size());
{
auto transaction {context.dbSession.createSharedTransaction()};
User::pointer user {User::getById(context.dbSession, context.userId)};
if (!user)
throw RequestedDataNotFoundError {};
Scrobble scrobble;
scrobble.listen.userId = context.userId;
for (std::size_t i {}; i < ids.size(); ++i)
{
const Id id {ids[i]};
const unsigned long time {times[i]};
const Track::pointer track {Track::getById(context.dbSession, id.value)};
if (!track)
continue;
scrobble.listen.trackId = id.value;
scrobble.timePoint.setTime_t(static_cast<std::time_t>(time / 1000));
scrobbles.emplace_back(scrobble);
}
}
for (const Scrobble& scrobble : scrobbles)
Service<Scrobbling::IScrobbling>::get()->addListen(scrobble.listen, scrobble.timePoint);
return Response::createOkResponse(context);
}
+1
View File
@@ -34,6 +34,7 @@ const char* getModuleName(Module mod)
case Module::MAIN: return "MAIN";
case Module::METADATA: return "METADATA";
case Module::REMOTE: return "REMOTE";
case Module::SCROBBLING: return "SCROBBLING";
case Module::SERVICE: return "SERVICE";
case Module::RECOMMENDATION: return "RECOMMENDATION";
case Module::TRANSCODE: return "TRANSCODE";
+1
View File
@@ -46,6 +46,7 @@ enum class Module
MAIN,
METADATA,
REMOTE,
SCROBBLING,
SERVICE,
RECOMMENDATION,
TRANSCODE,