Separated feedback services (stars / love for LB) from scrobbling services, to ease last.fm integration

This commit is contained in:
emeric
2023-10-31 13:37:14 +01:00
parent 3920c36896
commit 069470194b
89 changed files with 3763 additions and 3616 deletions
@@ -23,9 +23,6 @@
#include <memory>
#include <optional>
#include "services/database/StarredArtistId.hpp"
#include "services/database/StarredReleaseId.hpp"
#include "services/database/StarredTrackId.hpp"
#include "services/scrobbling/Listen.hpp"
namespace Database
@@ -37,26 +34,15 @@ namespace Database
namespace Scrobbling
{
class IScrobbler
class IScrobblingBackend
{
public:
virtual ~IScrobbler() = default;
virtual ~IScrobblingBackend() = default;
// Listens
virtual void listenStarted(const Listen& listen) = 0;
virtual void listenFinished(const Listen& listen, std::optional<std::chrono::seconds> duration) = 0;
virtual void addTimedListen(const TimedListen& listen) = 0;
// Feedbacks
virtual void onStarred(Database::StarredArtistId) = 0;
virtual void onUnstarred(Database::StarredArtistId) = 0;
virtual void onStarred(Database::StarredReleaseId) = 0;
virtual void onUnstarred(Database::StarredReleaseId) = 0;
virtual void onStarred(Database::StarredTrackId) = 0;
virtual void onUnstarred(Database::StarredTrackId) = 0;
};
std::unique_ptr<IScrobbler> createScrobbler(std::string_view backendName);
} // ns Scrobbling
@@ -18,22 +18,18 @@
*/
#include "ScrobblingService.hpp"
#include "ScrobblingService.impl.hpp"
#include "services/database/Artist.hpp"
#include "services/database/Db.hpp"
#include "services/database/Listen.hpp"
#include "services/database/Release.hpp"
#include "services/database/Session.hpp"
#include "services/database/StarredArtist.hpp"
#include "services/database/StarredRelease.hpp"
#include "services/database/StarredTrack.hpp"
#include "services/database/Track.hpp"
#include "services/database/User.hpp"
#include "utils/Logger.hpp"
#include "internal/InternalScrobbler.hpp"
#include "listenbrainz/ListenBrainzScrobbler.hpp"
#include "internal/InternalBackend.hpp"
#include "listenbrainz/ListenBrainzBackend.hpp"
namespace Scrobbling
{
@@ -48,8 +44,8 @@ namespace Scrobbling
: _db{ db }
{
LMS_LOG(SCROBBLING, INFO) << "Starting service...";
_scrobblers.emplace(Scrobbler::Internal, std::make_unique<InternalScrobbler>(_db));
_scrobblers.emplace(Scrobbler::ListenBrainz, std::make_unique<ListenBrainz::Scrobbler>(ioContext, _db));
_scrobblingBackends.emplace(ScrobblingBackend::Internal, std::make_unique<InternalBackend>(_db));
_scrobblingBackends.emplace(ScrobblingBackend::ListenBrainz, std::make_unique<ListenBrainz::ListenBrainzBackend>(ioContext, _db));
LMS_LOG(SCROBBLING, INFO) << "Service started!";
}
@@ -60,46 +56,46 @@ namespace Scrobbling
void ScrobblingService::listenStarted(const Listen& listen)
{
if (std::optional<Scrobbler> scrobbler{ getUserScrobbler(listen.userId) })
_scrobblers[*scrobbler]->listenStarted(listen);
if (std::optional<ScrobblingBackend> backend{ getUserBackend(listen.userId) })
_scrobblingBackends[*backend]->listenStarted(listen);
}
void ScrobblingService::listenFinished(const Listen& listen, std::optional<std::chrono::seconds> duration)
{
if (std::optional<Scrobbler> scrobbler{ getUserScrobbler(listen.userId) })
_scrobblers[*scrobbler]->listenFinished(listen, duration);
if (std::optional<ScrobblingBackend> backend{ getUserBackend(listen.userId) })
_scrobblingBackends[*backend]->listenFinished(listen, duration);
}
void ScrobblingService::addTimedListen(const TimedListen& listen)
{
if (std::optional<Scrobbler> scrobbler{ getUserScrobbler(listen.userId) })
_scrobblers[*scrobbler]->addTimedListen(listen);
if (std::optional<ScrobblingBackend> backend{ getUserBackend(listen.userId) })
_scrobblingBackends[*backend]->addTimedListen(listen);
}
std::optional<Scrobbler> ScrobblingService::getUserScrobbler(UserId userId)
std::optional<ScrobblingBackend> ScrobblingService::getUserBackend(UserId userId)
{
std::optional<Scrobbler> scrobbler;
std::optional<ScrobblingBackend> backend;
Session& session{ _db.getTLSSession() };
auto transaction{ session.createSharedTransaction() };
if (const User::pointer user{ User::find(session, userId) })
scrobbler = user->getScrobbler();
backend = user->getScrobblingBackend();
return scrobbler;
return backend;
}
ScrobblingService::ArtistContainer ScrobblingService::getRecentArtists(UserId userId, const std::vector<ClusterId>& clusterIds, std::optional<TrackArtistLinkType> linkType, Range range)
{
ArtistContainer res;
auto scrobbler{ getUserScrobbler(userId) };
if (!scrobbler)
const auto backend{ getUserBackend(userId) };
if (!backend)
return res;
Session& session{ _db.getTLSSession() };
auto transaction{ session.createSharedTransaction() };
res = Database::Listen::getRecentArtists(session, userId, *scrobbler, clusterIds, linkType, range);
res = Database::Listen::getRecentArtists(session, userId, *backend, clusterIds, linkType, range);
return res;
}
@@ -107,14 +103,14 @@ namespace Scrobbling
{
ReleaseContainer res;
auto scrobbler{ getUserScrobbler(userId) };
if (!scrobbler)
const auto backend{ getUserBackend(userId) };
if (!backend)
return res;
Session& session{ _db.getTLSSession() };
auto transaction{ session.createSharedTransaction() };
res = Database::Listen::getRecentReleases(session, userId, *scrobbler, clusterIds, range);
res = Database::Listen::getRecentReleases(session, userId, *backend, clusterIds, range);
return res;
}
@@ -122,40 +118,40 @@ namespace Scrobbling
{
TrackContainer res;
auto scrobbler{ getUserScrobbler(userId) };
if (!scrobbler)
const auto backend{ getUserBackend(userId) };
if (!backend)
return res;
Session& session{ _db.getTLSSession() };
auto transaction{ session.createSharedTransaction() };
res = Database::Listen::getRecentTracks(session, userId, *scrobbler, clusterIds, range);
res = Database::Listen::getRecentTracks(session, userId, *backend, clusterIds, range);
return res;
}
Wt::WDateTime ScrobblingService::getLastListenDateTime(Database::UserId userId, Database::ReleaseId releaseId)
{
auto scrobbler{ getUserScrobbler(userId) };
if (!scrobbler)
const auto backend{ getUserBackend(userId) };
if (!backend)
return {};
Session& session{ _db.getTLSSession() };
auto transaction{ session.createSharedTransaction() };
const Database::Listen::pointer listen{ Database::Listen::getMostRecentListen(session, userId, *scrobbler, releaseId) };
const Database::Listen::pointer listen{ Database::Listen::getMostRecentListen(session, userId, *backend, releaseId) };
return listen ? listen->getDateTime() : Wt::WDateTime{};
}
Wt::WDateTime ScrobblingService::getLastListenDateTime(Database::UserId userId, Database::TrackId trackId)
{
auto scrobbler{ getUserScrobbler(userId) };
if (!scrobbler)
const auto backend{ getUserBackend(userId) };
if (!backend)
return {};
Session& session{ _db.getTLSSession() };
auto transaction{ session.createSharedTransaction() };
const Database::Listen::pointer listen{ Database::Listen::getMostRecentListen(session, userId, *scrobbler, trackId) };
const Database::Listen::pointer listen{ Database::Listen::getMostRecentListen(session, userId, *backend, trackId) };
return listen ? listen->getDateTime() : Wt::WDateTime{};
}
@@ -164,14 +160,14 @@ namespace Scrobbling
{
ArtistContainer res;
auto scrobbler{ getUserScrobbler(userId) };
if (!scrobbler)
const auto backend{ getUserBackend(userId) };
if (!backend)
return res;
Session& session{ _db.getTLSSession() };
auto transaction{ session.createSharedTransaction() };
res = Database::Listen::getTopArtists(session, userId, *scrobbler, clusterIds, linkType, range);
res = Database::Listen::getTopArtists(session, userId, *backend, clusterIds, linkType, range);
return res;
}
@@ -179,14 +175,14 @@ namespace Scrobbling
{
ReleaseContainer res;
auto scrobbler{ getUserScrobbler(userId) };
if (!scrobbler)
const auto backend{ getUserBackend(userId) };
if (!backend)
return res;
Session& session{ _db.getTLSSession() };
auto transaction{ session.createSharedTransaction() };
res = Database::Listen::getTopReleases(session, userId, *scrobbler, clusterIds, range);
res = Database::Listen::getTopReleases(session, userId, *backend, clusterIds, range);
return res;
}
@@ -194,133 +190,15 @@ namespace Scrobbling
{
TrackContainer res;
auto scrobbler{ getUserScrobbler(userId) };
if (!scrobbler)
const auto backend{ getUserBackend(userId) };
if (!backend)
return res;
Session& session{ _db.getTLSSession() };
auto transaction{ session.createSharedTransaction() };
res = Database::Listen::getTopTracks(session, userId, *scrobbler, clusterIds, range);
res = Database::Listen::getTopTracks(session, userId, *backend, clusterIds, range);
return res;
}
void ScrobblingService::star(UserId userId, ArtistId artistId)
{
star<Artist, ArtistId, StarredArtist>(userId, artistId);
}
void ScrobblingService::unstar(UserId userId, ArtistId artistId)
{
unstar<Artist, ArtistId, StarredArtist>(userId, artistId);
}
bool ScrobblingService::isStarred(UserId userId, ArtistId artistId)
{
return isStarred<Artist, ArtistId, StarredArtist>(userId, artistId);
}
Wt::WDateTime ScrobblingService::getStarredDateTime(UserId userId, ArtistId artistId)
{
return getStarredDateTime<Artist, ArtistId, StarredArtist>(userId, artistId);
}
ScrobblingService::ArtistContainer ScrobblingService::getStarredArtists(UserId userId, const std::vector<ClusterId>& clusterIds,
std::optional<TrackArtistLinkType> linkType,
ArtistSortMethod sortMethod,
Range range)
{
auto scrobbler{ getUserScrobbler(userId) };
if (!scrobbler)
return {};
Artist::FindParameters params;
params.setStarringUser(userId, *scrobbler);
params.setClusters(clusterIds);
params.setLinkType(linkType);
params.setSortMethod(sortMethod);
params.setRange(range);
Session& session{ _db.getTLSSession() };
auto transaction{ session.createSharedTransaction() };
return Artist::find(session, params);
}
void ScrobblingService::star(UserId userId, ReleaseId releaseId)
{
star<Release, ReleaseId, StarredRelease>(userId, releaseId);
}
void ScrobblingService::unstar(UserId userId, ReleaseId releaseId)
{
unstar<Release, ReleaseId, StarredRelease>(userId, releaseId);
}
bool ScrobblingService::isStarred(UserId userId, ReleaseId releaseId)
{
return isStarred<Release, ReleaseId, StarredRelease>(userId, releaseId);
}
Wt::WDateTime ScrobblingService::getStarredDateTime(UserId userId, ReleaseId releaseId)
{
return getStarredDateTime<Release, ReleaseId, StarredRelease>(userId, releaseId);
}
ScrobblingService::ReleaseContainer ScrobblingService::getStarredReleases(UserId userId, const std::vector<ClusterId>& clusterIds, Range range)
{
auto scrobbler{ getUserScrobbler(userId) };
if (!scrobbler)
return {};
Release::FindParameters params;
params.setStarringUser(userId, *scrobbler);
params.setClusters(clusterIds);
params.setSortMethod(ReleaseSortMethod::StarredDateDesc);
params.setRange(range);
Session& session{ _db.getTLSSession() };
auto transaction{ session.createSharedTransaction() };
return Release::find(session, params);
}
void ScrobblingService::star(UserId userId, TrackId trackId)
{
star<Track, TrackId, StarredTrack>(userId, trackId);
}
void ScrobblingService::unstar(UserId userId, TrackId trackId)
{
unstar<Track, TrackId, StarredTrack>(userId, trackId);
}
bool ScrobblingService::isStarred(UserId userId, TrackId trackId)
{
return isStarred<Track, TrackId, StarredTrack>(userId, trackId);
}
Wt::WDateTime ScrobblingService::getStarredDateTime(UserId userId, TrackId trackId)
{
return getStarredDateTime<Track, TrackId, StarredTrack>(userId, trackId);
}
ScrobblingService::TrackContainer ScrobblingService::getStarredTracks(UserId userId, const std::vector<ClusterId>& clusterIds, Range range)
{
auto scrobbler{ getUserScrobbler(userId) };
if (!scrobbler)
return {};
Track::FindParameters params;
params.setStarringUser(userId, *scrobbler);
params.setClusters(clusterIds);
params.setSortMethod(TrackSortMethod::StarredDateDesc);
params.setRange(range);
Session& session{ _db.getTLSSession() };
auto transaction{ session.createSharedTransaction() };
return Track::find(session, params);
}
} // ns Scrobbling
@@ -24,7 +24,7 @@
#include <unordered_map>
#include "services/scrobbling/IScrobblingService.hpp"
#include "IScrobbler.hpp"
#include "IScrobblingBackend.hpp"
namespace Scrobbling
{
@@ -39,70 +39,21 @@ namespace Scrobbling
void listenFinished(const Listen& listen, std::optional<std::chrono::seconds> duration) override;
void addTimedListen(const TimedListen& listen) override;
ArtistContainer getRecentArtists(Database::UserId userId,
const std::vector<Database::ClusterId>& clusterIds,
std::optional<Database::TrackArtistLinkType> linkType,
Database::Range range) override;
ReleaseContainer getRecentReleases(Database::UserId userId,
const std::vector<Database::ClusterId>& clusterIds,
Database::Range range) override;
TrackContainer getRecentTracks(Database::UserId userId,
const std::vector<Database::ClusterId>& clusterIds,
Database::Range range) override;
ArtistContainer getRecentArtists(Database::UserId userId, const std::vector<Database::ClusterId>& clusterIds, std::optional<Database::TrackArtistLinkType> linkType,Database::Range range) override;
ReleaseContainer getRecentReleases(Database::UserId userId, const std::vector<Database::ClusterId>& clusterIds,Database::Range range) override;
TrackContainer getRecentTracks(Database::UserId userId, const std::vector<Database::ClusterId>& clusterIds, Database::Range range) override;
Wt::WDateTime getLastListenDateTime(Database::UserId userId, Database::ReleaseId releaseId) override;
Wt::WDateTime getLastListenDateTime(Database::UserId userId, Database::TrackId trackId) override;
ArtistContainer getTopArtists(Database::UserId userId,
const std::vector<Database::ClusterId>& clusterIds,
std::optional<Database::TrackArtistLinkType> linkType,
Database::Range range) override;
ArtistContainer getTopArtists(Database::UserId userId, const std::vector<Database::ClusterId>& clusterIds, std::optional<Database::TrackArtistLinkType> linkType, Database::Range range) override;
ReleaseContainer getTopReleases(Database::UserId userId, const std::vector<Database::ClusterId>& clusterIds, Database::Range range) override;
TrackContainer getTopTracks(Database::UserId userId, const std::vector<Database::ClusterId>& clusterIds, Database::Range range) override;
ReleaseContainer getTopReleases(Database::UserId userId,
const std::vector<Database::ClusterId>& clusterIds,
Database::Range range) override;
TrackContainer getTopTracks(Database::UserId userId,
const std::vector<Database::ClusterId>& clusterIds,
Database::Range range) override;
void star(Database::UserId userId, Database::ArtistId artistId) override;
void unstar(Database::UserId userId, Database::ArtistId artistId) override;
bool isStarred(Database::UserId userId, Database::ArtistId artistId) override;
Wt::WDateTime getStarredDateTime(Database::UserId userId, Database::ArtistId artistId) override;
ArtistContainer getStarredArtists(Database::UserId userId,
const std::vector<Database::ClusterId>& clusterIds,
std::optional<Database::TrackArtistLinkType> linkType,
Database::ArtistSortMethod sortMethod,
Database::Range range) override;
void star(Database::UserId userId, Database::ReleaseId releaseId) override;
void unstar(Database::UserId userId, Database::ReleaseId releaseId) override;
bool isStarred(Database::UserId userId, Database::ReleaseId releasedId) override;
Wt::WDateTime getStarredDateTime(Database::UserId userId, Database::ReleaseId releasedId) override;
ReleaseContainer getStarredReleases(Database::UserId userId, const std::vector<Database::ClusterId>& clusterIds, Database::Range range) override;
void star(Database::UserId userId, Database::TrackId trackId) override;
void unstar(Database::UserId userId, Database::TrackId trackId) override;
bool isStarred(Database::UserId userId, Database::TrackId trackId) override;
Wt::WDateTime getStarredDateTime(Database::UserId userId, Database::TrackId trackId) override;
TrackContainer getStarredTracks(Database::UserId userId, const std::vector<Database::ClusterId>& clusterIds, Database::Range range) override;
std::optional<Database::Scrobbler> getUserScrobbler(Database::UserId userId);
template <typename ObjType, typename ObjIdType, typename StarredObjType>
void star(Database::UserId userId, ObjIdType id);
template <typename ObjType, typename ObjIdType, typename StarredObjType>
void unstar(Database::UserId userId, ObjIdType id);
template <typename ObjType, typename ObjIdType, typename StarredObjType>
bool isStarred(Database::UserId userId, ObjIdType id);
template <typename ObjType, typename ObjIdType, typename StarredObjType>
Wt::WDateTime getStarredDateTime(Database::UserId userId, ObjIdType id);
std::optional<Database::ScrobblingBackend> getUserBackend(Database::UserId userId);
Database::Db& _db;
std::unordered_map<Database::Scrobbler, std::unique_ptr<IScrobbler>> _scrobblers;
std::unordered_map<Database::ScrobblingBackend, std::unique_ptr<IScrobblingBackend>> _scrobblingBackends;
};
} // ns Scrobbling
@@ -1,114 +0,0 @@
/*
* 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 "services/database/Db.hpp"
#include "services/database/Session.hpp"
#include "services/database/User.hpp"
namespace Scrobbling
{
using namespace Database;
template <typename ObjType, typename ObjIdType, typename StarredObjType>
void ScrobblingService::star(UserId userId, ObjIdType objId)
{
auto scrobbler {getUserScrobbler(userId)};
if (!scrobbler)
return;
typename StarredObjType::IdType starredObjId;
{
Session& session {_db.getTLSSession()};
auto transaction {session.createUniqueTransaction()};
typename StarredObjType::pointer starredObj {StarredObjType::find(session, objId, userId, *scrobbler)};
if (!starredObj)
{
const typename ObjType::pointer obj {ObjType::find(session, objId)};
if (!obj)
return;
const User::pointer user {User::find(session, userId)};
if (!user)
return;
starredObj = session.create<StarredObjType>(obj, user, *scrobbler);
}
starredObj.modify()->setDateTime(Wt::WDateTime::currentDateTime());
starredObjId = starredObj->getId();
}
_scrobblers[*scrobbler]->onStarred(starredObjId);
}
template <typename ObjType, typename ObjIdType, typename StarredObjType>
void ScrobblingService::unstar(UserId userId, ObjIdType objId)
{
auto scrobbler {getUserScrobbler(userId)};
if (!scrobbler)
return;
typename StarredObjType::IdType starredObjId;
{
Session& session {_db.getTLSSession()};
auto transaction {session.createSharedTransaction()};
typename StarredObjType::pointer starredObj {StarredObjType::find(session, objId, userId, *scrobbler)};
if (!starredObj)
return;
starredObjId = starredObj->getId();
}
_scrobblers[*scrobbler]->onUnstarred(starredObjId);
}
template <typename ObjType, typename ObjIdType, typename StarredObjType>
bool ScrobblingService::isStarred(UserId userId, ObjIdType objId)
{
auto scrobbler {getUserScrobbler(userId)};
if (!scrobbler)
return false;
Session& session {_db.getTLSSession()};
auto transaction {session.createSharedTransaction()};
typename StarredObjType::pointer starredObj {StarredObjType::find(session, objId, userId, *scrobbler)};
return starredObj && (starredObj->getScrobblingState() != ScrobblingState::PendingRemove);
}
template <typename ObjType, typename ObjIdType, typename StarredObjType>
Wt::WDateTime ScrobblingService::getStarredDateTime(UserId userId, ObjIdType objId)
{
auto scrobbler {getUserScrobbler(userId)};
if (!scrobbler)
return {};
Session& session {_db.getTLSSession()};
auto transaction {session.createSharedTransaction()};
typename StarredObjType::pointer starredObj {StarredObjType::find(session, objId, userId, *scrobbler)};
if (starredObj && (starredObj->getScrobblingState() != ScrobblingState::PendingRemove))
return starredObj->getDateTime();
return {};
}
} // ns Scrobbling
@@ -0,0 +1,68 @@
/*
* 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 "InternalBackend.hpp"
#include "services/database/Db.hpp"
#include "services/database/Listen.hpp"
#include "services/database/Session.hpp"
#include "services/database/Track.hpp"
#include "services/database/User.hpp"
namespace Scrobbling
{
InternalBackend::InternalBackend(Database::Db& db)
: _db{ db }
{}
void InternalBackend::listenStarted(const Listen&)
{
// nothing to do
}
void InternalBackend::listenFinished(const Listen& listen, std::optional<std::chrono::seconds> duration)
{
// only record tracks that have been played for at least of few seconds...
if (duration && *duration < std::chrono::seconds{ 5 })
return;
addTimedListen({ listen, Wt::WDateTime::currentDateTime() });
}
void InternalBackend::addTimedListen(const TimedListen& listen)
{
Database::Session& session{ _db.getTLSSession() };
auto transaction{ session.createUniqueTransaction() };
if (Database::Listen::find(session, listen.userId, listen.trackId, Database::ScrobblingBackend::Internal, listen.listenedAt))
return;
const Database::User::pointer user{ Database::User::find(session, listen.userId) };
if (!user)
return;
const Database::Track::pointer track{ Database::Track::find(session, listen.trackId) };
if (!track)
return;
auto dbListen{ session.create<Database::Listen>(user, track, Database::ScrobblingBackend::Internal, listen.listenedAt) };
dbListen.modify()->setSyncState(Database::SyncState::Synchronized);
}
} // Scrobbling
@@ -19,7 +19,7 @@
#pragma once
#include "IScrobbler.hpp"
#include "IScrobblingBackend.hpp"
namespace Database
{
@@ -28,24 +28,16 @@ namespace Database
namespace Scrobbling
{
class InternalScrobbler final : public IScrobbler
class InternalBackend final : public IScrobblingBackend
{
public:
InternalScrobbler(Database::Db& db);
InternalBackend(Database::Db& db);
private:
// IScrobbler
void listenStarted(const Listen& listen) override;
void listenFinished(const Listen& listen, std::optional<std::chrono::seconds> duration) override;
void addTimedListen(const TimedListen& listen) override;
void onStarred(Database::StarredArtistId) override;
void onUnstarred(Database::StarredArtistId) override;
void onStarred(Database::StarredReleaseId) override;
void onUnstarred(Database::StarredReleaseId) override;
void onStarred(Database::StarredTrackId) override;
void onUnstarred(Database::StarredTrackId) override;
Database::Db& _db;
};
} // Scrobbling
@@ -1,122 +0,0 @@
/*
* 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 "services/database/Db.hpp"
#include "services/database/Listen.hpp"
#include "services/database/Session.hpp"
#include "services/database/StarredArtist.hpp"
#include "services/database/StarredRelease.hpp"
#include "services/database/StarredTrack.hpp"
#include "services/database/Track.hpp"
#include "services/database/User.hpp"
namespace
{
template <typename StarredObjType>
void onStarred(Database::Session& session, typename StarredObjType::IdType id)
{
auto transaction{ session.createUniqueTransaction() };
if (auto starredObj{ StarredObjType::find(session, id) })
starredObj.modify()->setScrobblingState(Database::ScrobblingState::Synchronized);
}
template <typename StarredObjType>
void onUnstarred(Database::Session& session, typename StarredObjType::IdType id)
{
auto transaction{ session.createUniqueTransaction() };
if (auto starredObj{ StarredObjType::find(session, id) })
starredObj.remove();
}
}
namespace Scrobbling
{
InternalScrobbler::InternalScrobbler(Database::Db& db)
: _db{ db }
{}
void InternalScrobbler::listenStarted(const Listen&)
{
// nothing to do
}
void InternalScrobbler::listenFinished(const Listen& listen, std::optional<std::chrono::seconds> duration)
{
// only record tracks that have been played for at least of few seconds...
if (duration && *duration < std::chrono::seconds{ 5 })
return;
addTimedListen({ listen, Wt::WDateTime::currentDateTime() });
}
void InternalScrobbler::addTimedListen(const TimedListen& listen)
{
Database::Session& session{ _db.getTLSSession() };
auto transaction{ session.createUniqueTransaction() };
if (Database::Listen::find(session, listen.userId, listen.trackId, Database::Scrobbler::Internal, listen.listenedAt))
return;
const Database::User::pointer user{ Database::User::find(session, listen.userId) };
if (!user)
return;
const Database::Track::pointer track{ Database::Track::find(session, listen.trackId) };
if (!track)
return;
auto dbListen{ session.create<Database::Listen>(user, track, Database::Scrobbler::Internal, listen.listenedAt) };
dbListen.modify()->setScrobblingState(Database::ScrobblingState::Synchronized);
}
void InternalScrobbler::onStarred(Database::StarredArtistId starredArtistId)
{
::onStarred<Database::StarredArtist>(_db.getTLSSession(), starredArtistId);
}
void InternalScrobbler::onUnstarred(Database::StarredArtistId starredArtistId)
{
::onUnstarred<Database::StarredArtist>(_db.getTLSSession(), starredArtistId);
}
void InternalScrobbler::onStarred(Database::StarredReleaseId starredReleaseId)
{
::onStarred<Database::StarredRelease>(_db.getTLSSession(), starredReleaseId);
}
void InternalScrobbler::onUnstarred(Database::StarredReleaseId starredReleaseId)
{
::onUnstarred<Database::StarredRelease>(_db.getTLSSession(), starredReleaseId);
}
void InternalScrobbler::onStarred(Database::StarredTrackId starredTrackId)
{
::onStarred<Database::StarredTrack>(_db.getTLSSession(), starredTrackId);
}
void InternalScrobbler::onUnstarred(Database::StarredTrackId starredTrackId)
{
::onUnstarred<Database::StarredTrack>(_db.getTLSSession(), starredTrackId);
}
} // Scrobbling
@@ -1,30 +0,0 @@
/*
* Copyright (C) 2022 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include "FeedbackTypes.hpp"
namespace Scrobbling::ListenBrainz
{
std::ostream&
operator<<(std::ostream& os, const Feedback& feedback)
{
os << "created = '" << feedback.created.toString() << "', recording MBID = '" << feedback.recordingMBID.getAsString() << "', score = " << static_cast<int>(feedback.score);
return os;
}
} // Scrobbling::ListenBrainz
@@ -1,45 +0,0 @@
/*
* Copyright (C) 2022 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <ostream>
#include <Wt/WDateTime.h>
#include "utils/UUID.hpp"
namespace Scrobbling::ListenBrainz
{
// See https://listenbrainz.readthedocs.io/en/production/dev/feedback-json/#feedback-json-doc
enum class FeedbackType
{
Love = 1,
Hate = -1,
Erase = 0,
};
struct Feedback
{
Wt::WDateTime created;
UUID recordingMBID;
FeedbackType score;
};
std::ostream& operator<<(std::ostream& os, const Feedback& feedback);
} // Scrobbling::ListenBrainz
@@ -1,93 +0,0 @@
/*
* Copyright (C) 2022 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include "FeedbacksParser.hpp"
#include <Wt/Json/Array.h>
#include <Wt/Json/Object.h>
#include <Wt/Json/Value.h>
#include <Wt/Json/Parser.h>
#include "services/scrobbling/Exception.hpp"
#include "Exception.hpp"
#include "Utils.hpp"
namespace Scrobbling::ListenBrainz
{
namespace
{
Feedback
parseFeedback(const Wt::Json::Object& feedbackObj)
{
const std::optional<UUID> recordingMBID {UUID::fromString(static_cast<std::string>(feedbackObj.get("recording_mbid")))};
if (!recordingMBID)
throw Exception {"MBID not found!"};
return Feedback
{
Wt::WDateTime::fromTime_t(static_cast<int>(feedbackObj.get("created"))),
*recordingMBID,
static_cast<FeedbackType>(static_cast<int>(feedbackObj.get("score")))
};
}
}
FeedbacksParser::Result
FeedbacksParser::parse(std::string_view msgBody)
{
Result res;
try
{
Wt::Json::Object root;
Wt::Json::parse(std::string {msgBody}, root);
const Wt::Json::Array& feedbacks = root.get("feedback");
LOG(DEBUG) << "Got " << feedbacks.size() << " feedbacks";
if (feedbacks.empty())
return res;
res.feedbackCount = feedbacks.size();
for (const Wt::Json::Value& value : feedbacks)
{
try
{
res.feedbacks.push_back(parseFeedback(value));
}
catch (const Exception& e)
{
LOG(DEBUG) << "Cannot parse feedback: " << e.what() << ", skipping";
}
catch (const Wt::WException &e)
{
LOG(DEBUG) << "Cannot parse feedback: " << e.what() << ", skipping";
}
}
}
catch (const Wt::WException& error)
{
LOG(ERROR) << "Cannot parse 'feedback' result: " << error.what();
}
return res;
}
} // Scrobbling::ListenBrainz
@@ -1,40 +0,0 @@
/*
* Copyright (C) 2022 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <string_view>
#include "FeedbackTypes.hpp"
namespace Scrobbling::ListenBrainz
{
class FeedbacksParser
{
public:
struct Result
{
std::size_t feedbackCount {}; // >= feedbacks.size()
std::vector<Feedback> feedbacks;
};
static Result parse(std::string_view msgBody);
};
} // Scrobbling::ListenBrainz
@@ -1,505 +0,0 @@
/*
* 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 <tuple>
#include <boost/asio/bind_executor.hpp>
#include <Wt/Json/Array.h>
#include <Wt/Json/Object.h>
#include <Wt/Json/Value.h>
#include <Wt/Json/Serializer.h>
#include "services/database/Db.hpp"
#include "services/database/Session.hpp"
#include "services/database/StarredTrack.hpp"
#include "services/database/Track.hpp"
#include "services/database/User.hpp"
#include "utils/IConfig.hpp"
#include "utils/http/IClient.hpp"
#include "utils/Service.hpp"
#include "Exception.hpp"
#include "FeedbacksParser.hpp"
#include "Utils.hpp"
using namespace Scrobbling::ListenBrainz;
using namespace Database;
namespace
{
std::optional<std::size_t>
parseTotalFeedbackCount(std::string_view msgBody)
{
try
{
Wt::Json::Object root;
Wt::Json::parse(std::string {msgBody}, root);
return static_cast<int>(root.get("total_count"));
}
catch (const Wt::WException& e)
{
LOG(ERROR) << "Cannot parse listen count response: " << e.what();
return std::nullopt;
}
}
}
namespace Scrobbling::ListenBrainz
{
FeedbacksSynchronizer::FeedbacksSynchronizer(boost::asio::io_context& ioContext, Database::Db& db, Http::IClient& client)
: _ioContext {ioContext}
, _db {db}
, _client {client}
, _maxSyncFeedbackCount {Service<IConfig>::get()->getULong("listenbrainz-max-sync-feedback-count", 1000)}
, _syncFeedbacksPeriod {Service<IConfig>::get()->getULong("listenbrainz-sync-feedbacks-period-hours", 1)}
{
LOG(INFO) << "Starting Feedbacks synchronizer, maxSyncFeedbackCount = " << _maxSyncFeedbackCount << ", _syncFeedbacksPeriod = " << _syncFeedbacksPeriod.count() << " hours";
scheduleSync(std::chrono::seconds {30});
}
void
FeedbacksSynchronizer::enqueFeedback(FeedbackType type, Database::StarredTrackId starredTrackId)
{
try
{
Database::Session& session {_db.getTLSSession()};
auto transaction {session.createUniqueTransaction()};
StarredTrack::pointer starredTrack {StarredTrack::find(session, starredTrackId)};
if (!starredTrack)
return;
std::optional<UUID> recordingMBID {starredTrack->getTrack()->getRecordingMBID()};
switch (type)
{
case FeedbackType::Love:
if (starredTrack->getScrobblingState() != ScrobblingState::PendingAdd)
starredTrack.modify()->setScrobblingState(ScrobblingState::PendingAdd);
break;
case FeedbackType::Erase:
if (!recordingMBID)
{
LOG(DEBUG) << "Track has no recording MBID: erasing star";
starredTrack.remove();
}
else
{
// Send the erase order even if it is not on the remote LB server (it may be
// queued for add, or not)
starredTrack.modify()->setScrobblingState(ScrobblingState::PendingRemove);
}
break;
default:
throw Exception {"Unhandled feedback type"};
}
if (!recordingMBID)
{
LOG(DEBUG) << "Track has no recording MBID: skipping";
return;
}
const std::optional<UUID> listenBrainzToken {starredTrack->getUser()->getListenBrainzToken()};
if (!listenBrainzToken)
return;
Http::ClientPOSTRequestParameters request;
request.relativeUrl = "/1/feedback/recording-feedback";
request.message.addHeader("Authorization", "Token " + std::string {listenBrainzToken->getAsString()});
Wt::Json::Object root;
root["recording_mbid"] = Wt::Json::Value {std::string {recordingMBID->getAsString()}};
root["score"] = Wt::Json::Value {static_cast<int>(type)};
request.message.addBodyText(Wt::Json::serialize(root));
request.message.addHeader("Content-Type", "application/json");
request.onSuccessFunc = [=](std::string_view /*msgBody*/)
{
_strand.dispatch([=]
{
onFeedbackSent(type, starredTrackId);
});
};
_client.sendPOSTRequest(std::move(request));
}
catch (Exception& e)
{
LOG(DEBUG) << "Cannot send feedback: " << e.what();
}
}
void
FeedbacksSynchronizer::onFeedbackSent(FeedbackType type, Database::StarredTrackId starredTrackId)
{
assert(_strand.running_in_this_thread());
Session& session {_db.getTLSSession()};
auto transaction {session.createUniqueTransaction()};
StarredTrack::pointer starredTrack {StarredTrack::find(session, starredTrackId)};
if (!starredTrack)
{
LOG(DEBUG) << "Starred track not found. deleted?";
return;
}
UserContext& userContext {getUserContext(starredTrack->getUser()->getId())};
switch (type)
{
case FeedbackType::Love:
starredTrack.modify()->setScrobblingState(ScrobblingState::Synchronized);
LOG(DEBUG) << "State set to synchronized";
if (userContext.feedbackCount)
{
(*userContext.feedbackCount)++;
LOG(DEBUG) << "Feedback count set to " << *userContext.feedbackCount << " for user '" << userContext.listenBrainzUserName <<"'";
}
break;
case FeedbackType::Erase:
starredTrack.remove();
LOG(DEBUG) << "Removed starred track";
if (userContext.feedbackCount && *userContext.feedbackCount > 0)
{
(*userContext.feedbackCount)--;
LOG(DEBUG) << "Feedback count set to " << *userContext.feedbackCount << " for user '" << userContext.listenBrainzUserName <<"'";
}
break;
default:
throw Exception {"Unhandled feedback type"};
}
}
void
FeedbacksSynchronizer::enquePendingFeedbacks()
{
auto processPendingFeedbacks { [this] (ScrobblingState scrobblingState, FeedbackType feedbackType)
{
RangeResults<StarredTrackId> pendingFeedbacks;
{
Database::Session& session {_db.getTLSSession()};
auto transaction {session.createSharedTransaction()};
StarredTrack::FindParameters params;
params.setScrobbler(Database::Scrobbler::ListenBrainz, scrobblingState)
.setRange(Database::Range {0, 100}); // don't flood too much?
pendingFeedbacks = StarredTrack::find(session, params);
}
LOG(DEBUG) << "Queing " << pendingFeedbacks.results.size() << " pending '" << (feedbackType == FeedbackType::Love ? "love" : "erase") << "' feedbacks";
for (const StarredTrackId starredTrackId : pendingFeedbacks.results)
enqueFeedback(feedbackType, starredTrackId);
}};
processPendingFeedbacks(ScrobblingState::PendingAdd, FeedbackType::Love);
processPendingFeedbacks(ScrobblingState::PendingRemove, FeedbackType::Erase);
}
FeedbacksSynchronizer::UserContext&
FeedbacksSynchronizer::getUserContext(Database::UserId userId)
{
assert(_strand.running_in_this_thread());
auto itContext {_userContexts.find(userId)};
if (itContext == std::cend(_userContexts))
{
std::tie(itContext, std::ignore) = _userContexts.emplace(userId, userId);
}
return itContext->second;
}
bool
FeedbacksSynchronizer::isSyncing() const
{
return std::any_of(std::cbegin(_userContexts), std::cend(_userContexts), [](const auto& contextEntry)
{
return contextEntry.second.syncing;
});
}
void
FeedbacksSynchronizer::scheduleSync(std::chrono::seconds fromNow)
{
if (_syncFeedbacksPeriod.count() == 0 || _maxSyncFeedbackCount == 0)
return;
LOG(DEBUG) << "Scheduled sync in " << fromNow.count() << " seconds...";
_syncTimer.expires_after(fromNow);
_syncTimer.async_wait(boost::asio::bind_executor(_strand, [this] (const boost::system::error_code& ec)
{
if (ec == boost::asio::error::operation_aborted)
{
LOG(DEBUG) << "getFeedbacks aborted";
return;
}
else if (ec)
{
throw Exception {"GetFeedbacks timer failure: " + std::string {ec.message()} };
}
startSync();
}));
}
void
FeedbacksSynchronizer::startSync()
{
LOG(DEBUG) << "Starting sync!";
assert(!isSyncing());
assert(_strand.running_in_this_thread());
enquePendingFeedbacks();
Database::RangeResults<Database::UserId> userIds;
{
Database::Session& session {_db.getTLSSession()};
auto transaction {session.createSharedTransaction()};
userIds = Database::User::find(_db.getTLSSession(), Database::User::FindParameters{}.setScrobbler(Database::Scrobbler::ListenBrainz));
}
for (const Database::UserId userId : userIds.results)
startSync(getUserContext(userId));
if (!isSyncing())
scheduleSync(_syncFeedbacksPeriod);
}
void
FeedbacksSynchronizer::startSync(UserContext& context)
{
context.syncing = true;
context.listenBrainzUserName = "";
context.fetchedFeedbackCount = 0;
context.matchedFeedbackCount = 0;
context.importedFeedbackCount = 0;
enqueValidateToken(context);
}
void
FeedbacksSynchronizer::onSyncEnded(UserContext& context)
{
_strand.dispatch([this, &context]
{
LOG(INFO) << "Feedback sync done for user '" << context.listenBrainzUserName << "', fetched: " << context.fetchedFeedbackCount << ", matched: " << context.matchedFeedbackCount << ", imported: " << context.importedFeedbackCount;
context.syncing = false;
if (!isSyncing())
scheduleSync(_syncFeedbacksPeriod);
});
}
void
FeedbacksSynchronizer::enqueValidateToken(UserContext& context)
{
assert(context.listenBrainzUserName.empty());
const std::optional<UUID> listenBrainzToken {Utils::getListenBrainzToken(_db.getTLSSession(), context.userId)};
if (!listenBrainzToken)
{
onSyncEnded(context);
return;
}
Http::ClientGETRequestParameters request;
request.priority = Http::ClientRequestParameters::Priority::Low;
request.relativeUrl = "/1/validate-token";
request.headers = { {"Authorization", "Token " + std::string {listenBrainzToken->getAsString()}} };
request.onSuccessFunc = [this, &context] (std::string_view msgBody)
{
context.listenBrainzUserName = Utils::parseValidateToken(msgBody);
if (context.listenBrainzUserName.empty())
{
onSyncEnded(context);
return;
}
enqueGetFeedbackCount(context);
};
request.onFailureFunc = [this, &context]
{
onSyncEnded(context);
};
_client.sendGETRequest(std::move(request));
}
void
FeedbacksSynchronizer::enqueGetFeedbackCount(UserContext& context)
{
assert(!context.listenBrainzUserName.empty());
Http::ClientGETRequestParameters request;
request.relativeUrl = "/1/feedback/user/" + std::string {context.listenBrainzUserName} + "/get-feedback?score=1&count=0";
request.priority = Http::ClientRequestParameters::Priority::Low;
request.onSuccessFunc = [this, &context] (std::string_view msgBody)
{
std::string msgBodyCopy {msgBody};
_strand.dispatch([this, msgBodyCopy, &context]
{
LOG(DEBUG) << "Current feedback count = " << (context.feedbackCount ? *context.feedbackCount : 0) << " for user '" << context.listenBrainzUserName << "'";
const auto totalFeedbackCount = parseTotalFeedbackCount(msgBodyCopy);
if (totalFeedbackCount)
LOG(DEBUG) << "Feedback count for listenbrainz user '" << context.listenBrainzUserName << "' = " << *totalFeedbackCount;
bool needSync {totalFeedbackCount && (!context.feedbackCount || *context.feedbackCount != *totalFeedbackCount)};
context.feedbackCount = totalFeedbackCount;
if (needSync)
enqueGetFeedbacks(context);
else
onSyncEnded(context);
});
};
request.onFailureFunc = [this, &context]
{
onSyncEnded(context);
};
_client.sendGETRequest(std::move(request));
}
void
FeedbacksSynchronizer::enqueGetFeedbacks(UserContext& context)
{
assert(!context.listenBrainzUserName.empty());
Http::ClientGETRequestParameters request;
request.relativeUrl = "/1/feedback/user/" + context.listenBrainzUserName + "/get-feedback?offset=" + std::to_string(context.fetchedFeedbackCount);
request.priority = Http::ClientRequestParameters::Priority::Low;
request.onSuccessFunc = [this, &context] (std::string_view msgBody)
{
std::string msgBodyCopy {msgBody};
_strand.dispatch([this, msgBodyCopy, &context]
{
const std::size_t fetchedFeedbackCount {processGetFeedbacks(msgBodyCopy, context)};
if (fetchedFeedbackCount == 0 // no more thing available on server
|| context.fetchedFeedbackCount >= context.feedbackCount // we may miss something, but we will get it next time
|| context.fetchedFeedbackCount >= _maxSyncFeedbackCount)
{
onSyncEnded(context);
}
else
{
enqueGetFeedbacks(context);
}
});
};
request.onFailureFunc = [=, &context]
{
onSyncEnded(context);
};
_client.sendGETRequest(std::move(request));
}
std::size_t
FeedbacksSynchronizer::processGetFeedbacks(std::string_view msgBody, UserContext& context)
{
const FeedbacksParser::Result parseResult {FeedbacksParser::parse(msgBody)};
LOG(DEBUG) << "Parsed " << parseResult.feedbackCount << " feedbacks, found " << parseResult.feedbacks.size() << " usable entries";
context.fetchedFeedbackCount += parseResult.feedbackCount;
for (const Feedback& feedback : parseResult.feedbacks)
{
tryImportFeedback(feedback, context);
}
return parseResult.feedbackCount;
}
void
FeedbacksSynchronizer::tryImportFeedback(const Feedback& feedback, UserContext& context)
{
Database::Session& session {_db.getTLSSession()};
bool needImport{};
TrackId trackId;
{
auto transaction {session.createSharedTransaction()};
const std::vector<Track::pointer> tracks {Track::findByRecordingMBID(session, feedback.recordingMBID)};
if (tracks.size() > 1)
{
LOG(DEBUG) << "Too many matches for feedback '" << feedback << "': duplicate recording MBIDs found";
return;
}
else if (tracks.empty())
{
LOG(DEBUG) << "Cannot match feedback '" << feedback << "': no track found for this recording MBID";
return;
}
trackId = tracks.front()->getId();
const StarredTrack::pointer starredTrack {StarredTrack::find(session, trackId, context.userId, Database::Scrobbler::ListenBrainz)};
needImport = !starredTrack;
// don't update starred date time
// no need to update state if it was found as not synchronized
// pending remove => will be removed later
// pending add => will be resent later
}
if (needImport)
{
LOG(DEBUG) << "Importing feedback '" << feedback << "'";
auto transaction {session.createUniqueTransaction()};
const Track::pointer track {Track::find(session, trackId)};
if (!track)
return;
const User::pointer user {User::find(session, context.userId)};
if (!user)
return;
StarredTrack::pointer starredTrack {session.create<StarredTrack>(track, user, Database::Scrobbler::ListenBrainz)};
starredTrack.modify()->setScrobblingState(ScrobblingState::Synchronized);
starredTrack.modify()->setDateTime(feedback.created);
context.importedFeedbackCount++;
}
else
{
LOG(DEBUG) << "No need to import feedback '" << feedback << "', already imported";
context.matchedFeedbackCount++;
}
}
} // namespace Scrobbling::ListenBrainz
@@ -1,106 +0,0 @@
/*
* 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 <optional>
#include <unordered_map>
#include <boost/asio/io_context.hpp>
#include <boost/asio/io_context_strand.hpp>
#include <boost/asio/steady_timer.hpp>
#include "services/database/Types.hpp"
#include "services/database/UserId.hpp"
#include "services/scrobbling/Listen.hpp"
#include "FeedbackTypes.hpp"
namespace Database
{
class Db;
}
namespace Http
{
class IClient;
}
namespace Scrobbling::ListenBrainz
{
class FeedbacksSynchronizer
{
public:
FeedbacksSynchronizer(boost::asio::io_context& ioContext, Database::Db& db, Http::IClient& client);
void enqueFeedback(FeedbackType type, Database::StarredTrackId starredTrackId);
private:
void onFeedbackSent(FeedbackType type, Database::StarredTrackId starredTrackId);
void enquePendingFeedbacks();
struct UserContext
{
UserContext(Database::UserId id) : userId {id} {}
UserContext(const UserContext&) = delete;
UserContext(UserContext&&) = delete;
UserContext& operator=(const UserContext&) = delete;
UserContext& operator=(UserContext&&) = delete;
const Database::UserId userId;
bool syncing {};
std::optional<std::size_t> feedbackCount {};
// resetted at each sync
std::string listenBrainzUserName; // need to be resolved first
std::size_t currentOffset{};
std::size_t fetchedFeedbackCount{};
std::size_t matchedFeedbackCount{};
std::size_t importedFeedbackCount{};
};
UserContext& getUserContext(Database::UserId userId);
bool isSyncing() const;
void scheduleSync(std::chrono::seconds fromNow);
void startSync();
void startSync(UserContext& context);
void onSyncEnded(UserContext& context);
void enqueValidateToken(UserContext& context);
void enqueGetFeedbackCount(UserContext& context);
void enqueGetFeedbacks(UserContext& context);
std::size_t processGetFeedbacks(std::string_view body, UserContext& context);
void tryImportFeedback(const Feedback& feedback, UserContext& context);
boost::asio::io_context& _ioContext;
boost::asio::io_context::strand _strand {_ioContext};
Database::Db& _db;
boost::asio::steady_timer _syncTimer {_ioContext};
Http::IClient& _client;
std::unordered_map<Database::UserId, UserContext> _userContexts;
const std::size_t _maxSyncFeedbackCount;
const std::chrono::hours _syncFeedbacksPeriod;
};
} // Scrobbling::ListenBrainz
@@ -0,0 +1,87 @@
/*
* 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 "ListenBrainzBackend.hpp"
#include "services/database/Db.hpp"
#include "services/database/Session.hpp"
#include "services/database/Track.hpp"
#include "utils/IConfig.hpp"
#include "utils/http/IClient.hpp"
#include "utils/Logger.hpp"
#include "utils/Service.hpp"
#include "Utils.hpp"
namespace Scrobbling::ListenBrainz
{
using namespace Database;
namespace
{
bool canBeScrobbled(Session& session, TrackId trackId, std::chrono::seconds duration)
{
auto transaction{ session.createSharedTransaction() };
const Track::pointer track{ Track::find(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;
}
}
ListenBrainzBackend::ListenBrainzBackend(boost::asio::io_context& ioContext, Db& db)
: _ioContext{ ioContext }
, _db{ db }
, _baseAPIUrl{ Service<IConfig>::get()->getString("listenbrainz-api-base-url", "https://api.listenbrainz.org") }
, _client{ Http::createClient(_ioContext, _baseAPIUrl) }
, _listensSynchronizer{ _ioContext, db, *_client }
{
LOG(INFO) << "Starting ListenBrainz backend... API endpoint = '" << _baseAPIUrl << "'";
}
ListenBrainzBackend::~ListenBrainzBackend()
{
LOG(INFO) << "Stopped ListenBrainz backend!";
}
void ListenBrainzBackend::listenStarted(const Listen& listen)
{
_listensSynchronizer.enqueListenNow(listen);
}
void ListenBrainzBackend::listenFinished(const Listen& listen, std::optional<std::chrono::seconds> duration)
{
if (duration && !canBeScrobbled(_db.getTLSSession(), listen.trackId, *duration))
return;
const TimedListen timedListen{ listen, Wt::WDateTime::currentDateTime() };
_listensSynchronizer.enqueListen(timedListen);
}
void ListenBrainzBackend::addTimedListen(const TimedListen& timedListen)
{
_listensSynchronizer.enqueListen(timedListen);
}
} // namespace Scrobbling::ListenBrainz
@@ -0,0 +1,60 @@
/*
* 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 <string>
#include <optional>
#include <boost/asio/io_context.hpp>
#include "IScrobblingBackend.hpp"
#include "ListensSynchronizer.hpp"
namespace Database
{
class Db;
}
namespace Scrobbling::ListenBrainz
{
class ListenBrainzBackend final : public IScrobblingBackend
{
public:
ListenBrainzBackend(boost::asio::io_context& ioContext, Database::Db& db);
~ListenBrainzBackend() override;
private:
ListenBrainzBackend(const ListenBrainzBackend&) = delete;
ListenBrainzBackend& operator=(const ListenBrainzBackend&) = delete;
void listenStarted(const Listen& listen) override;
void listenFinished(const Listen& listen, std::optional<std::chrono::seconds> duration) override;
void addTimedListen(const TimedListen& listen) override;
// Submit listens
void enqueListen(const Listen& listen, const Wt::WDateTime& timePoint);
boost::asio::io_context& _ioContext;
Database::Db& _db;
std::string _baseAPIUrl;
std::unique_ptr<Http::IClient> _client;
ListensSynchronizer _listensSynchronizer;
};
} // Scrobbling::ListenBrainz
@@ -1,151 +0,0 @@
/*
* 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 "services/database/Db.hpp"
#include "services/database/Session.hpp"
#include "services/database/StarredArtist.hpp"
#include "services/database/StarredRelease.hpp"
#include "services/database/Track.hpp"
#include "utils/IConfig.hpp"
#include "utils/http/IClient.hpp"
#include "utils/Logger.hpp"
#include "utils/Service.hpp"
#include "Utils.hpp"
using namespace Database;
namespace
{
bool
canBeScrobbled(Session& session, TrackId trackId, std::chrono::seconds duration)
{
auto transaction {session.createSharedTransaction()};
const Track::pointer track {Track::find(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;
}
template <typename StarredObjType>
void onStarred(Database::Session& session, typename StarredObjType::IdType id)
{
auto transaction {session.createUniqueTransaction()};
if (auto starredObj {StarredObjType::find(session, id)})
{
// maybe in the future this will be supported by ListenBrainz so set it to PendingAdd
starredObj.modify()->setScrobblingState(Database::ScrobblingState::PendingAdd);
}
}
template <typename StarredObjType>
void onUnstarred(Database::Session& session, typename StarredObjType::IdType id)
{
auto transaction {session.createUniqueTransaction()};
if (auto starredObj {StarredObjType::find(session, id)})
starredObj.remove();
}
}
namespace Scrobbling::ListenBrainz
{
Scrobbler::Scrobbler(boost::asio::io_context& ioContext, Db& db)
: _ioContext {ioContext}
, _db {db}
, _baseAPIUrl {Service<IConfig>::get()->getString("listenbrainz-api-base-url", "https://api.listenbrainz.org")}
, _client {Http::createClient(_ioContext, _baseAPIUrl)}
, _listensSynchronizer {_ioContext, db, *_client}
, _feedbacksSynchronizer {_ioContext, db, *_client}
{
LOG(INFO) << "Starting ListenBrainz scrobbler... API endpoint = '" << _baseAPIUrl << "'";
}
Scrobbler::~Scrobbler()
{
LOG(INFO) << "Stopped ListenBrainz scrobbler!";
}
void
Scrobbler::listenStarted(const Listen& listen)
{
_listensSynchronizer.enqueListenNow(listen);
}
void
Scrobbler::listenFinished(const Listen& listen, std::optional<std::chrono::seconds> duration)
{
if (duration && !canBeScrobbled(_db.getTLSSession(), listen.trackId, *duration))
return;
const TimedListen timedListen {listen, Wt::WDateTime::currentDateTime()};
_listensSynchronizer.enqueListen(timedListen);
}
void
Scrobbler::addTimedListen(const TimedListen& timedListen)
{
_listensSynchronizer.enqueListen(timedListen);
}
void
Scrobbler::onStarred(StarredArtistId starredArtistId)
{
::onStarred<StarredArtist>(_db.getTLSSession(), starredArtistId);
}
void
Scrobbler::onUnstarred(StarredArtistId starredArtistId)
{
::onUnstarred<StarredArtist>(_db.getTLSSession(), starredArtistId);
}
void
Scrobbler::onStarred(StarredReleaseId starredReleaseId)
{
::onStarred<StarredRelease>(_db.getTLSSession(), starredReleaseId);
}
void
Scrobbler::onUnstarred(StarredReleaseId starredReleaseId)
{
::onUnstarred<StarredRelease>(_db.getTLSSession(), starredReleaseId);
}
void
Scrobbler::onStarred(StarredTrackId starredTrackId)
{
_feedbacksSynchronizer.enqueFeedback(FeedbackType::Love, starredTrackId);
}
void
Scrobbler::onUnstarred(StarredTrackId starredtrackId)
{
_feedbacksSynchronizer.enqueFeedback(FeedbackType::Erase, starredtrackId);
}
} // namespace Scrobbling::ListenBrainz
@@ -1,72 +0,0 @@
/*
* 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 <string>
#include <optional>
#include <boost/asio/io_context.hpp>
#include "IScrobbler.hpp"
#include "FeedbacksSynchronizer.hpp"
#include "ListensSynchronizer.hpp"
namespace Database
{
class Db;
}
namespace Scrobbling::ListenBrainz
{
class Scrobbler final : public IScrobbler
{
public:
Scrobbler(boost::asio::io_context& ioContext, Database::Db& db);
~Scrobbler();
Scrobbler(const Scrobbler&) = delete;
Scrobbler(const Scrobbler&&) = delete;
Scrobbler& operator=(const Scrobbler&) = delete;
Scrobbler& operator=(const Scrobbler&&) = delete;
private:
void listenStarted(const Listen& listen) override;
void listenFinished(const Listen& listen, std::optional<std::chrono::seconds> duration) override;
void addTimedListen(const TimedListen& listen) override;
// Submit listens
void enqueListen(const Listen& listen, const Wt::WDateTime& timePoint);
// Star
void onStarred(Database::StarredArtistId starredArtistId) override;
void onUnstarred(Database::StarredArtistId starredArtistId) override;
void onStarred(Database::StarredReleaseId starredReleaseId) override;
void onUnstarred(Database::StarredReleaseId starredReleaseId) override;
void onStarred(Database::StarredTrackId starredTrackId) override;
void onUnstarred(Database::StarredTrackId starredTrackId) override;
boost::asio::io_context& _ioContext;
Database::Db& _db;
std::string _baseAPIUrl;
std::unique_ptr<Http::IClient> _client;
ListensSynchronizer _listensSynchronizer;
FeedbacksSynchronizer _feedbacksSynchronizer;
};
} // Scrobbling::ListenBrainz
@@ -17,9 +17,8 @@
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include "ListenBrainzScrobbler.hpp"
#include "ListensSynchronizer.hpp"
#include <tuple>
#include <boost/asio/bind_executor.hpp>
#include <Wt/Json/Array.h>
#include <Wt/Json/Object.h>
@@ -248,14 +247,14 @@ namespace Scrobbling::ListenBrainz
{
const TimedListen timedListen {listen, timePoint};
// We want the listen to be sent again later in case of failure, so we just save it as pending send
saveListen(timedListen, Database::ScrobblingState::PendingAdd);
saveListen(timedListen, Database::SyncState::PendingAdd);
request.priority = Http::ClientRequestParameters::Priority::Normal;
request.onSuccessFunc = [=](std::string_view)
{
_strand.dispatch([=]
{
if (saveListen(timedListen, Database::ScrobblingState::Synchronized))
if (saveListen(timedListen, Database::SyncState::Synchronized))
{
UserContext& context {getUserContext(listen.userId)};
if (context.listenCount)
@@ -293,14 +292,14 @@ namespace Scrobbling::ListenBrainz
}
bool
ListensSynchronizer::saveListen(const TimedListen& listen, Database::ScrobblingState scrobblingState)
ListensSynchronizer::saveListen(const TimedListen& listen, Database::SyncState scrobblingState)
{
using namespace Database;
Session& session {_db.getTLSSession()};
auto transaction {session.createUniqueTransaction()}; // TODO: unique only if needed
Database::Listen::pointer dbListen {Database::Listen::find(session, listen.userId, listen.trackId, Database::Scrobbler::ListenBrainz, listen.listenedAt)};
Database::Listen::pointer dbListen {Database::Listen::find(session, listen.userId, listen.trackId, Database::ScrobblingBackend::ListenBrainz, listen.listenedAt)};
if (!dbListen)
{
const User::pointer user {User::find(session, listen.userId)};
@@ -311,18 +310,18 @@ namespace Scrobbling::ListenBrainz
if (!track)
return false;
dbListen = session.create<Database::Listen>(user, track, Database::Scrobbler::ListenBrainz, listen.listenedAt);
dbListen.modify()->setScrobblingState(scrobblingState);
dbListen = session.create<Database::Listen>(user, track, Database::ScrobblingBackend::ListenBrainz, listen.listenedAt);
dbListen.modify()->setSyncState(scrobblingState);
LOG(DEBUG) << "LISTEN CREATED for user " << user->getLoginName() << ", track '" << track->getName() << "' AT " << listen.listenedAt.toString();
return true;
}
if (dbListen->getScrobblingState() == scrobblingState)
if (dbListen->getSyncState() == scrobblingState)
return false;
dbListen.modify()->setScrobblingState(scrobblingState);
dbListen.modify()->setSyncState(scrobblingState);
return true;
}
@@ -337,8 +336,8 @@ namespace Scrobbling::ListenBrainz
auto transaction {session.createUniqueTransaction()};
Database::Listen::FindParameters params;
params.setScrobbler(Database::Scrobbler::ListenBrainz)
.setScrobblingState(Database::ScrobblingState::PendingAdd)
params.setScrobblingBackend(Database::ScrobblingBackend::ListenBrainz)
.setSyncState(Database::SyncState::PendingAdd)
.setRange(Database::Range {0, 100}); // don't flood too much?
const Database::RangeResults results {Database::Listen::find(session, params)};
@@ -423,7 +422,7 @@ namespace Scrobbling::ListenBrainz
{
Database::Session& session {_db.getTLSSession()};
auto transaction {session.createSharedTransaction()};
userIds = Database::User::find(_db.getTLSSession(), Database::User::FindParameters{}.setScrobbler(Database::Scrobbler::ListenBrainz));
userIds = Database::User::find(_db.getTLSSession(), Database::User::FindParameters{}.setScrobblingBackend(Database::ScrobblingBackend::ListenBrainz));
}
for (const Database::UserId userId : userIds.results)
@@ -583,7 +582,7 @@ namespace Scrobbling::ListenBrainz
context.matchedListenCount++;
const Scrobbling::TimedListen listen {{context.userId, trackId}, parsedListen.listenedAt};
if (saveListen(listen, Database::ScrobblingState::Synchronized))
if (saveListen(listen, Database::SyncState::Synchronized))
context.importedListenCount++;
}
}
@@ -52,7 +52,7 @@ namespace Scrobbling::ListenBrainz
private:
void enqueListen(const Listen& listen, const Wt::WDateTime& timePoint);
bool saveListen(const TimedListen& listen, Database::ScrobblingState scrobblinState);
bool saveListen(const TimedListen& listen, Database::SyncState scrobblinState);
void enquePendingListens();
@@ -19,12 +19,11 @@
#pragma once
#include "utils/UUID.hpp"
#include "services/database/UserId.hpp"
#include "utils/Logger.hpp"
#include "utils/UUID.hpp"
#define LOG(sev) LMS_LOG(SCROBBLING, sev) << "[listenbrainz] - "
#define LOG(sev) LMS_LOG(SCROBBLING, sev) << "[listenbrainz] "
namespace Database
{
@@ -34,6 +33,5 @@ namespace Database
namespace Scrobbling::ListenBrainz::Utils
{
std::optional<UUID> getListenBrainzToken(Database::Session& session, Database::UserId userId);
std::string parseValidateToken(std::string_view msgBody);
}