Separated feedback services (stars / love for LB) from scrobbling services, to ease last.fm integration
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
* 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 "FeedbackService.hpp"
|
||||
#include "FeedbackService.impl.hpp"
|
||||
|
||||
#include "services/database/Artist.hpp"
|
||||
#include "services/database/Db.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/InternalBackend.hpp"
|
||||
#include "listenbrainz/ListenBrainzBackend.hpp"
|
||||
|
||||
namespace Feedback
|
||||
{
|
||||
std::unique_ptr<IFeedbackService> createFeedbackService(boost::asio::io_context& ioContext, Db& db)
|
||||
{
|
||||
return std::make_unique<FeedbackService>(ioContext, db);
|
||||
}
|
||||
|
||||
FeedbackService::FeedbackService(boost::asio::io_context& ioContext, Db& db)
|
||||
: _db{ db }
|
||||
{
|
||||
LMS_LOG(SCROBBLING, INFO) << "Starting service...";
|
||||
_backends.emplace(Database::FeedbackBackend::Internal, std::make_unique<InternalBackend>(_db));
|
||||
_backends.emplace(Database::FeedbackBackend::ListenBrainz, std::make_unique<ListenBrainz::ListenBrainzBackend>(ioContext, _db));
|
||||
LMS_LOG(SCROBBLING, INFO) << "Service started!";
|
||||
}
|
||||
|
||||
FeedbackService::~FeedbackService()
|
||||
{
|
||||
LMS_LOG(SCROBBLING, INFO) << "Service stopped!";
|
||||
}
|
||||
|
||||
std::optional<Database::FeedbackBackend> FeedbackService::getUserFeedbackBackend(UserId userId)
|
||||
{
|
||||
std::optional<Database::FeedbackBackend> feedbackBackend;
|
||||
|
||||
Session& session{ _db.getTLSSession() };
|
||||
auto transaction{ session.createSharedTransaction() };
|
||||
if (const User::pointer user{ User::find(session, userId) })
|
||||
feedbackBackend = user->getFeedbackBackend();
|
||||
|
||||
return feedbackBackend;
|
||||
}
|
||||
|
||||
void FeedbackService::star(UserId userId, ArtistId artistId)
|
||||
{
|
||||
star<Artist, ArtistId, StarredArtist>(userId, artistId);
|
||||
}
|
||||
|
||||
void FeedbackService::unstar(UserId userId, ArtistId artistId)
|
||||
{
|
||||
unstar<Artist, ArtistId, StarredArtist>(userId, artistId);
|
||||
}
|
||||
|
||||
bool FeedbackService::isStarred(UserId userId, ArtistId artistId)
|
||||
{
|
||||
return isStarred<Artist, ArtistId, StarredArtist>(userId, artistId);
|
||||
}
|
||||
|
||||
Wt::WDateTime FeedbackService::getStarredDateTime(UserId userId, ArtistId artistId)
|
||||
{
|
||||
return getStarredDateTime<Artist, ArtistId, StarredArtist>(userId, artistId);
|
||||
}
|
||||
|
||||
FeedbackService::ArtistContainer FeedbackService::getStarredArtists(UserId userId, const std::vector<ClusterId>& clusterIds, std::optional<TrackArtistLinkType> linkType, ArtistSortMethod sortMethod, Range range)
|
||||
{
|
||||
auto backend{ getUserFeedbackBackend(userId) };
|
||||
if (!backend)
|
||||
return {};
|
||||
|
||||
Artist::FindParameters params;
|
||||
params.setStarringUser(userId, *backend);
|
||||
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 FeedbackService::star(UserId userId, ReleaseId releaseId)
|
||||
{
|
||||
star<Release, ReleaseId, StarredRelease>(userId, releaseId);
|
||||
}
|
||||
|
||||
void FeedbackService::unstar(UserId userId, ReleaseId releaseId)
|
||||
{
|
||||
unstar<Release, ReleaseId, StarredRelease>(userId, releaseId);
|
||||
}
|
||||
|
||||
bool FeedbackService::isStarred(UserId userId, ReleaseId releaseId)
|
||||
{
|
||||
return isStarred<Release, ReleaseId, StarredRelease>(userId, releaseId);
|
||||
}
|
||||
|
||||
Wt::WDateTime FeedbackService::getStarredDateTime(UserId userId, ReleaseId releaseId)
|
||||
{
|
||||
return getStarredDateTime<Release, ReleaseId, StarredRelease>(userId, releaseId);
|
||||
}
|
||||
|
||||
FeedbackService::ReleaseContainer FeedbackService::getStarredReleases(UserId userId, const std::vector<ClusterId>& clusterIds, Range range)
|
||||
{
|
||||
auto backend{ getUserFeedbackBackend(userId) };
|
||||
if (!backend)
|
||||
return {};
|
||||
|
||||
Release::FindParameters params;
|
||||
params.setStarringUser(userId, *backend);
|
||||
params.setClusters(clusterIds);
|
||||
params.setSortMethod(ReleaseSortMethod::StarredDateDesc);
|
||||
params.setRange(range);
|
||||
|
||||
Session& session{ _db.getTLSSession() };
|
||||
auto transaction{ session.createSharedTransaction() };
|
||||
|
||||
return Release::find(session, params);
|
||||
}
|
||||
|
||||
void FeedbackService::star(UserId userId, TrackId trackId)
|
||||
{
|
||||
star<Track, TrackId, StarredTrack>(userId, trackId);
|
||||
}
|
||||
|
||||
void FeedbackService::unstar(UserId userId, TrackId trackId)
|
||||
{
|
||||
unstar<Track, TrackId, StarredTrack>(userId, trackId);
|
||||
}
|
||||
|
||||
bool FeedbackService::isStarred(UserId userId, TrackId trackId)
|
||||
{
|
||||
return isStarred<Track, TrackId, StarredTrack>(userId, trackId);
|
||||
}
|
||||
|
||||
Wt::WDateTime FeedbackService::getStarredDateTime(UserId userId, TrackId trackId)
|
||||
{
|
||||
return getStarredDateTime<Track, TrackId, StarredTrack>(userId, trackId);
|
||||
}
|
||||
|
||||
FeedbackService::TrackContainer FeedbackService::getStarredTracks(UserId userId, const std::vector<ClusterId>& clusterIds, Range range)
|
||||
{
|
||||
auto backend{ getUserFeedbackBackend(userId) };
|
||||
if (!backend)
|
||||
return {};
|
||||
|
||||
Track::FindParameters params;
|
||||
params.setStarringUser(userId, *backend);
|
||||
params.setClusters(clusterIds);
|
||||
params.setSortMethod(TrackSortMethod::StarredDateDesc);
|
||||
params.setRange(range);
|
||||
|
||||
Session& session{ _db.getTLSSession() };
|
||||
auto transaction{ session.createSharedTransaction() };
|
||||
|
||||
return Track::find(session, params);
|
||||
}
|
||||
} // ns Feedback
|
||||
|
||||
@@ -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/>.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <unordered_map>
|
||||
|
||||
#include "services/feedback/IFeedbackService.hpp"
|
||||
#include "IFeedbackBackend.hpp"
|
||||
|
||||
namespace Database
|
||||
{
|
||||
class Db;
|
||||
}
|
||||
|
||||
namespace Feedback
|
||||
{
|
||||
class FeedbackService : public IFeedbackService
|
||||
{
|
||||
public:
|
||||
FeedbackService(boost::asio::io_context& ioContext, Database::Db& db);
|
||||
~FeedbackService();
|
||||
|
||||
private:
|
||||
FeedbackService(const FeedbackService&) = delete;
|
||||
FeedbackService& operator=(const FeedbackService&) = delete;
|
||||
|
||||
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::FeedbackBackend> getUserFeedbackBackend(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);
|
||||
|
||||
Database::Db& _db;
|
||||
std::unordered_map<Database::FeedbackBackend, std::unique_ptr<IFeedbackBackend>> _backends;
|
||||
};
|
||||
|
||||
} // ns Feedback
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* 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 Feedback
|
||||
{
|
||||
using namespace Database;
|
||||
|
||||
template <typename ObjType, typename ObjIdType, typename StarredObjType>
|
||||
void FeedbackService::star(UserId userId, ObjIdType objId)
|
||||
{
|
||||
const auto backend{ getUserFeedbackBackend(userId) };
|
||||
if (!backend)
|
||||
return;
|
||||
|
||||
typename StarredObjType::IdType starredObjId;
|
||||
{
|
||||
Session& session{ _db.getTLSSession() };
|
||||
auto transaction{ session.createUniqueTransaction() };
|
||||
|
||||
typename StarredObjType::pointer starredObj{ StarredObjType::find(session, objId, userId, *backend) };
|
||||
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, *backend);
|
||||
}
|
||||
starredObj.modify()->setDateTime(Wt::WDateTime::currentDateTime());
|
||||
starredObjId = starredObj->getId();
|
||||
}
|
||||
_backends[*backend]->onStarred(starredObjId);
|
||||
}
|
||||
|
||||
template <typename ObjType, typename ObjIdType, typename StarredObjType>
|
||||
void FeedbackService::unstar(UserId userId, ObjIdType objId)
|
||||
{
|
||||
const auto backend{ getUserFeedbackBackend(userId) };
|
||||
if (!backend)
|
||||
return;
|
||||
|
||||
typename StarredObjType::IdType starredObjId;
|
||||
{
|
||||
Session& session{ _db.getTLSSession() };
|
||||
auto transaction{ session.createSharedTransaction() };
|
||||
|
||||
typename StarredObjType::pointer starredObj{ StarredObjType::find(session, objId, userId, *backend) };
|
||||
if (!starredObj)
|
||||
return;
|
||||
|
||||
starredObjId = starredObj->getId();
|
||||
}
|
||||
_backends[*backend]->onUnstarred(starredObjId);
|
||||
}
|
||||
|
||||
template <typename ObjType, typename ObjIdType, typename StarredObjType>
|
||||
bool FeedbackService::isStarred(UserId userId, ObjIdType objId)
|
||||
{
|
||||
const auto backend{ getUserFeedbackBackend(userId) };
|
||||
if (!backend)
|
||||
return false;
|
||||
|
||||
Session& session{ _db.getTLSSession() };
|
||||
auto transaction{ session.createSharedTransaction() };
|
||||
|
||||
typename StarredObjType::pointer starredObj{ StarredObjType::find(session, objId, userId, *backend) };
|
||||
return starredObj && (starredObj->getSyncState() != SyncState::PendingRemove);
|
||||
}
|
||||
|
||||
template <typename ObjType, typename ObjIdType, typename StarredObjType>
|
||||
Wt::WDateTime FeedbackService::getStarredDateTime(UserId userId, ObjIdType objId)
|
||||
{
|
||||
const auto backend{ getUserFeedbackBackend(userId) };
|
||||
if (!backend)
|
||||
return {};
|
||||
|
||||
Session& session{ _db.getTLSSession() };
|
||||
auto transaction{ session.createSharedTransaction() };
|
||||
|
||||
typename StarredObjType::pointer starredObj{ StarredObjType::find(session, objId, userId, *backend) };
|
||||
if (starredObj && (starredObj->getSyncState() != SyncState::PendingRemove))
|
||||
return starredObj->getDateTime();
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
} // ns Feedback
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright (C) 2023 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/StarredArtistId.hpp"
|
||||
#include "services/database/StarredReleaseId.hpp"
|
||||
#include "services/database/StarredTrackId.hpp"
|
||||
|
||||
namespace Feedback
|
||||
{
|
||||
class IFeedbackBackend
|
||||
{
|
||||
public:
|
||||
virtual ~IFeedbackBackend() = default;
|
||||
|
||||
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<IFeedbackBackend> createFeedbackBackend(std::string_view backendName);
|
||||
|
||||
} // ns Feedback
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* 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/Session.hpp"
|
||||
#include "services/database/StarredArtist.hpp"
|
||||
#include "services/database/StarredRelease.hpp"
|
||||
#include "services/database/StarredTrack.hpp"
|
||||
|
||||
namespace Feedback
|
||||
{
|
||||
namespace details
|
||||
{
|
||||
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()->setSyncState(Database::SyncState::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();
|
||||
}
|
||||
}
|
||||
|
||||
InternalBackend::InternalBackend(Database::Db& db)
|
||||
: _db{ db }
|
||||
{}
|
||||
|
||||
void InternalBackend::onStarred(Database::StarredArtistId starredArtistId)
|
||||
{
|
||||
details::onStarred<Database::StarredArtist>(_db.getTLSSession(), starredArtistId);
|
||||
}
|
||||
|
||||
void InternalBackend::onUnstarred(Database::StarredArtistId starredArtistId)
|
||||
{
|
||||
details::onUnstarred<Database::StarredArtist>(_db.getTLSSession(), starredArtistId);
|
||||
}
|
||||
|
||||
void InternalBackend::onStarred(Database::StarredReleaseId starredReleaseId)
|
||||
{
|
||||
details::onStarred<Database::StarredRelease>(_db.getTLSSession(), starredReleaseId);
|
||||
}
|
||||
|
||||
void InternalBackend::onUnstarred(Database::StarredReleaseId starredReleaseId)
|
||||
{
|
||||
details::onUnstarred<Database::StarredRelease>(_db.getTLSSession(), starredReleaseId);
|
||||
}
|
||||
|
||||
void InternalBackend::onStarred(Database::StarredTrackId starredTrackId)
|
||||
{
|
||||
details::onStarred<Database::StarredTrack>(_db.getTLSSession(), starredTrackId);
|
||||
}
|
||||
|
||||
void InternalBackend::onUnstarred(Database::StarredTrackId starredTrackId)
|
||||
{
|
||||
details::onUnstarred<Database::StarredTrack>(_db.getTLSSession(), starredTrackId);
|
||||
}
|
||||
} // Feedback
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* 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 "IFeedbackBackend.hpp"
|
||||
|
||||
namespace Database
|
||||
{
|
||||
class Db;
|
||||
}
|
||||
|
||||
namespace Feedback
|
||||
{
|
||||
class InternalBackend final : public IFeedbackBackend
|
||||
{
|
||||
public:
|
||||
InternalBackend(Database::Db& db);
|
||||
|
||||
private:
|
||||
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;
|
||||
};
|
||||
} // Feedback
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* 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 "services/feedback/Exception.hpp"
|
||||
|
||||
namespace Feedback::ListenBrainz
|
||||
{
|
||||
class Exception : public ::Feedback::Exception
|
||||
{
|
||||
public:
|
||||
using ::Feedback::Exception::Exception;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright (C) 2022 Emeric Poupon
|
||||
*
|
||||
* This file is part of LMS.
|
||||
*
|
||||
* LMS is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* LMS is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include "FeedbackTypes.hpp"
|
||||
|
||||
namespace Feedback::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;
|
||||
}
|
||||
} // Feedback::ListenBrainz
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright (C) 2022 Emeric Poupon
|
||||
*
|
||||
* This file is part of LMS.
|
||||
*
|
||||
* LMS is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* LMS is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <ostream>
|
||||
#include <Wt/WDateTime.h>
|
||||
#include "utils/UUID.hpp"
|
||||
|
||||
namespace Feedback::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);
|
||||
|
||||
} // Feedback::ListenBrainz
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* 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 "Exception.hpp"
|
||||
#include "Utils.hpp"
|
||||
|
||||
namespace Feedback::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;
|
||||
}
|
||||
} // Feedback::ListenBrainz
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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 Feedback::ListenBrainz
|
||||
{
|
||||
class FeedbacksParser
|
||||
{
|
||||
public:
|
||||
struct Result
|
||||
{
|
||||
std::size_t feedbackCount {}; // >= feedbacks.size()
|
||||
std::vector<Feedback> feedbacks;
|
||||
};
|
||||
|
||||
static Result parse(std::string_view msgBody);
|
||||
};
|
||||
|
||||
} // Feedback::ListenBrainz
|
||||
@@ -0,0 +1,490 @@
|
||||
/*
|
||||
* 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 "FeedbacksSynchronizer.hpp"
|
||||
|
||||
#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"
|
||||
|
||||
namespace Feedback::ListenBrainz
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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() };
|
||||
|
||||
Database::StarredTrack::pointer starredTrack{ Database::StarredTrack::find(session, starredTrackId) };
|
||||
if (!starredTrack)
|
||||
return;
|
||||
|
||||
std::optional<UUID> recordingMBID{ starredTrack->getTrack()->getRecordingMBID() };
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case FeedbackType::Love:
|
||||
if (starredTrack->getSyncState() != Database::SyncState::PendingAdd)
|
||||
starredTrack.modify()->setSyncState(Database::SyncState::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()->setSyncState(Database::SyncState::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());
|
||||
|
||||
Database::Session& session{ _db.getTLSSession() };
|
||||
auto transaction{ session.createUniqueTransaction() };
|
||||
|
||||
Database::StarredTrack::pointer starredTrack{ Database::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()->setSyncState(Database::SyncState::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()
|
||||
{
|
||||
using namespace Database;
|
||||
|
||||
auto processPendingFeedbacks{ [this](SyncState scrobblingState, FeedbackType feedbackType)
|
||||
{
|
||||
RangeResults<StarredTrackId> pendingFeedbacks;
|
||||
|
||||
{
|
||||
Database::Session& session {_db.getTLSSession()};
|
||||
|
||||
auto transaction {session.createSharedTransaction()};
|
||||
|
||||
StarredTrack::FindParameters params;
|
||||
params.setFeedbackBackend(Database::FeedbackBackend::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(SyncState::PendingAdd, FeedbackType::Love);
|
||||
processPendingFeedbacks(SyncState::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{}.setFeedbackBackend(Database::FeedbackBackend::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{ ListenBrainz::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 = ListenBrainz::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)
|
||||
{
|
||||
using namespace 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::FeedbackBackend::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::FeedbackBackend::ListenBrainz) };
|
||||
starredTrack.modify()->setSyncState(SyncState::Synchronized);
|
||||
starredTrack.modify()->setDateTime(feedback.created);
|
||||
|
||||
context.importedFeedbackCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
LOG(DEBUG) << "No need to import feedback '" << feedback << "', already imported";
|
||||
context.matchedFeedbackCount++;
|
||||
}
|
||||
}
|
||||
} // namespace Feedback::ListenBrainz
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* 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/StarredTrackId.hpp"
|
||||
#include "services/database/UserId.hpp"
|
||||
|
||||
#include "FeedbackTypes.hpp"
|
||||
|
||||
namespace Database
|
||||
{
|
||||
class Db;
|
||||
}
|
||||
|
||||
namespace Http
|
||||
{
|
||||
class IClient;
|
||||
}
|
||||
|
||||
namespace Feedback::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& operator=(const 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;
|
||||
};
|
||||
} // Feedback::ListenBrainz
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* Copyright (C) 2023 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/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"
|
||||
|
||||
namespace Feedback::ListenBrainz
|
||||
{
|
||||
namespace details
|
||||
{
|
||||
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 for all types
|
||||
starredObj.modify()->setSyncState(Database::SyncState::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();
|
||||
}
|
||||
}
|
||||
|
||||
ListenBrainzBackend::ListenBrainzBackend(boost::asio::io_context& ioContext, Database::Db& db)
|
||||
: _ioContext{ ioContext }
|
||||
, _db{ db }
|
||||
, _baseAPIUrl{ Service<IConfig>::get()->getString("listenbrainz-api-base-url", "https://api.listenbrainz.org") }
|
||||
, _client{ Http::createClient(_ioContext, _baseAPIUrl) }
|
||||
, _feedbacksSynchronizer{ _ioContext, db, *_client }
|
||||
{
|
||||
LOG(INFO) << "Starting ListenBrainz feedback backend... API endpoint = '" << _baseAPIUrl << "'";
|
||||
}
|
||||
|
||||
ListenBrainzBackend::~ListenBrainzBackend()
|
||||
{
|
||||
LOG(INFO) << "Stopped ListenBrainz feedback backend!";
|
||||
}
|
||||
|
||||
void ListenBrainzBackend::onStarred(Database::StarredArtistId starredArtistId)
|
||||
{
|
||||
details::onStarred<Database::StarredArtist>(_db.getTLSSession(), starredArtistId);
|
||||
}
|
||||
|
||||
void ListenBrainzBackend::onUnstarred(Database::StarredArtistId starredArtistId)
|
||||
{
|
||||
details::onUnstarred<Database::StarredArtist>(_db.getTLSSession(), starredArtistId);
|
||||
}
|
||||
|
||||
void ListenBrainzBackend::onStarred(Database::StarredReleaseId starredReleaseId)
|
||||
{
|
||||
details::onStarred<Database::StarredRelease>(_db.getTLSSession(), starredReleaseId);
|
||||
}
|
||||
|
||||
void ListenBrainzBackend::onUnstarred(Database::StarredReleaseId starredReleaseId)
|
||||
{
|
||||
details::onUnstarred<Database::StarredRelease>(_db.getTLSSession(), starredReleaseId);
|
||||
}
|
||||
|
||||
void ListenBrainzBackend::onStarred(Database::StarredTrackId starredTrackId)
|
||||
{
|
||||
_feedbacksSynchronizer.enqueFeedback(FeedbackType::Love, starredTrackId);
|
||||
}
|
||||
|
||||
void ListenBrainzBackend::onUnstarred(Database::StarredTrackId starredtrackId)
|
||||
{
|
||||
_feedbacksSynchronizer.enqueFeedback(FeedbackType::Erase, starredtrackId);
|
||||
}
|
||||
} // namespace Scrobbling::ListenBrainz
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright (C) 2023 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 <string>
|
||||
#include <boost/asio/io_context.hpp>
|
||||
|
||||
#include "IFeedbackBackend.hpp"
|
||||
#include "FeedbacksSynchronizer.hpp"
|
||||
|
||||
namespace Database
|
||||
{
|
||||
class Db;
|
||||
}
|
||||
|
||||
namespace Feedback::ListenBrainz
|
||||
{
|
||||
class ListenBrainzBackend final : public IFeedbackBackend
|
||||
{
|
||||
public:
|
||||
ListenBrainzBackend(boost::asio::io_context& ioContext, Database::Db& db);
|
||||
~ListenBrainzBackend() override;
|
||||
|
||||
private:
|
||||
ListenBrainzBackend(const ListenBrainzBackend&) = delete;
|
||||
ListenBrainzBackend& operator=(const ListenBrainzBackend&) = delete;
|
||||
|
||||
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;
|
||||
FeedbacksSynchronizer _feedbacksSynchronizer;
|
||||
};
|
||||
} // Feedback::ListenBrainz
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* 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 "Utils.hpp"
|
||||
|
||||
#include <Wt/Json/Object.h>
|
||||
#include <Wt/Json/Parser.h>
|
||||
|
||||
#include "services/database/Session.hpp"
|
||||
#include "services/database/User.hpp"
|
||||
|
||||
namespace Feedback::ListenBrainz::Utils
|
||||
{
|
||||
std::optional<UUID> getListenBrainzToken(Database::Session& session, Database::UserId userId)
|
||||
{
|
||||
auto transaction{ session.createSharedTransaction() };
|
||||
|
||||
const Database::User::pointer user{ Database::User::find(session, userId) };
|
||||
if (!user)
|
||||
return std::nullopt;
|
||||
|
||||
return user->getListenBrainzToken();
|
||||
}
|
||||
|
||||
std::string parseValidateToken(std::string_view msgBody)
|
||||
{
|
||||
std::string listenBrainzUserName;
|
||||
|
||||
Wt::Json::ParseError error;
|
||||
Wt::Json::Object root;
|
||||
if (!Wt::Json::parse(std::string{ msgBody }, root, error))
|
||||
{
|
||||
LOG(ERROR) << "Cannot parse 'validate-token' result: " << error.what();
|
||||
return listenBrainzUserName;
|
||||
}
|
||||
|
||||
if (!root.get("valid").orIfNull(false))
|
||||
{
|
||||
LOG(INFO) << "Invalid listenbrainz user";
|
||||
return listenBrainzUserName;
|
||||
}
|
||||
|
||||
listenBrainzUserName = root.get("user_name").orIfNull("");
|
||||
return listenBrainzUserName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (C) 2023 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/UserId.hpp"
|
||||
#include "utils/Logger.hpp"
|
||||
#include "utils/UUID.hpp"
|
||||
|
||||
#define LOG(sev) LMS_LOG(FEEDBACK, sev) << "[listenbrainz] "
|
||||
|
||||
namespace Database
|
||||
{
|
||||
class Session;
|
||||
}
|
||||
|
||||
namespace Feedback::ListenBrainz::Utils
|
||||
{
|
||||
std::optional<UUID> getListenBrainzToken(Database::Session& session, Database::UserId userId);
|
||||
std::string parseValidateToken(std::string_view msgBody);
|
||||
}
|
||||
Reference in New Issue
Block a user