diff --git a/SUBSONIC.md b/SUBSONIC.md index b2319566..34a8053a 100644 --- a/SUBSONIC.md +++ b/SUBSONIC.md @@ -25,6 +25,7 @@ The following extra fields are implemented: * `musicBrainzId` * `originalReleaseDate` * `releaseTypes` + * `userRating` * `Child` response: * `albumArtists` * `artists` diff --git a/src/libs/database/CMakeLists.txt b/src/libs/database/CMakeLists.txt index 2d3c2a16..6e8abadf 100644 --- a/src/libs/database/CMakeLists.txt +++ b/src/libs/database/CMakeLists.txt @@ -11,6 +11,9 @@ add_library(lmsdatabase SHARED impl/TrackArtistLink.cpp impl/TrackFeatures.cpp impl/TrackList.cpp + impl/RatedArtist.cpp + impl/RatedRelease.cpp + impl/RatedTrack.cpp impl/Release.cpp impl/ScanSettings.cpp impl/Session.cpp diff --git a/src/libs/database/impl/Migration.cpp b/src/libs/database/impl/Migration.cpp index 6f11ba0e..f9d8e431 100644 --- a/src/libs/database/impl/Migration.cpp +++ b/src/libs/database/impl/Migration.cpp @@ -35,7 +35,7 @@ namespace lms::db { namespace { - static constexpr Version LMS_DATABASE_VERSION{ 63 }; + static constexpr Version LMS_DATABASE_VERSION{ 64 }; } VersionInfo::VersionInfo() @@ -673,6 +673,47 @@ SELECT session.getDboSession()->execute("UPDATE scan_settings SET scan_version = scan_version + 1"); } + void migrateFromV63(Session& session) + { + // Add a rated entities + + session.getDboSession()->execute(R"(CREATE TABLE IF NOT EXISTS "rated_artist" ( + "id" integer primary key autoincrement, + "version" integer not null, + "rating" integer not null, + "last_updated" text, + "artist_id" bigint, + "user_id" bigint, + constraint "fk_rated_artist_artist" foreign key ("artist_id") references "artist" ("id") on delete cascade deferrable initially deferred, + constraint "fk_rated_artist_user" foreign key ("user_id") references "user" ("id") on delete cascade deferrable initially deferred +))"); + + session.getDboSession()->execute(R"(CREATE TABLE IF NOT EXISTS "rated_release" ( + "id" integer primary key autoincrement, + "version" integer not null, + "rating" integer not null, + "last_updated" text, + "release_id" bigint, + "user_id" bigint, + constraint "fk_rated_release_release" foreign key ("release_id") references "release" ("id") on delete cascade deferrable initially deferred, + constraint "fk_rated_release_user" foreign key ("user_id") references "user" ("id") on delete cascade deferrable initially deferred +))"); + + session.getDboSession()->execute(R"(CREATE TABLE IF NOT EXISTS "rated_track" ( + "id" integer primary key autoincrement, + "version" integer not null, + "rating" bigint not null, + "last_updated" text, + "track_id" bigint, + "user_id" bigint, + constraint "fk_rated_track_track" foreign key ("track_id") references "track" ("id") on delete cascade deferrable initially deferred, + constraint "fk_rated_track_user" foreign key ("user_id") references "user" ("id") on delete cascade deferrable initially deferred +))"); + + // Drop badly named index, will be recreated + session.getDboSession()->execute("DROP INDEX IF EXISTS listen_user_backend_date_time"); + } + bool doDbMigration(Session& session) { static const std::string outdatedMsg{ "Outdated database, please rebuild it (delete the .db file and restart)" }; @@ -712,6 +753,7 @@ SELECT { 60, migrateFromV60 }, { 61, migrateFromV61 }, { 62, migrateFromV62 }, + { 63, migrateFromV63 }, }; bool migrationPerformed{}; diff --git a/src/libs/database/impl/RatedArtist.cpp b/src/libs/database/impl/RatedArtist.cpp new file mode 100644 index 00000000..f953c27a --- /dev/null +++ b/src/libs/database/impl/RatedArtist.cpp @@ -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 . + */ + +#include "database/RatedArtist.hpp" + +#include + +#include "database/Artist.hpp" +#include "database/Session.hpp" +#include "database/User.hpp" + +#include "IdTypeTraits.hpp" +#include "Utils.hpp" + +namespace lms::db +{ + RatedArtist::RatedArtist(ObjectPtr artist, ObjectPtr user) + : _artist{ getDboPtr(artist) } + , _user{ getDboPtr(user) } + { + } + + RatedArtist::pointer RatedArtist::create(Session& session, ObjectPtr artist, ObjectPtr user) + { + return session.getDboSession()->add(std::unique_ptr{ new RatedArtist{ artist, user } }); + } + + std::size_t RatedArtist::getCount(Session& session) + { + session.checkReadTransaction(); + return utils::fetchQuerySingleResult(session.getDboSession()->query("SELECT COUNT(*) FROM rated_artist")); + } + + RatedArtist::pointer RatedArtist::find(Session& session, RatedArtistId id) + { + session.checkReadTransaction(); + return utils::fetchQuerySingleResult(session.getDboSession()->find().where("id = ?").bind(id)); + } + + RatedArtist::pointer RatedArtist::find(Session& session, ArtistId artistId, UserId userId) + { + session.checkReadTransaction(); + return utils::fetchQuerySingleResult(session.getDboSession()->find().where("artist_id = ?").bind(artistId).where("user_id = ?").bind(userId)); + } + + void RatedArtist::find(Session& session, const FindParameters& params, std::function func) + { + session.checkReadTransaction(); + + auto query{ session.getDboSession()->query>("SELECT r_a FROM rated_artist r_a") }; + + if (params.user.isValid()) + query.where("r_a.user_id = ?").bind(params.user); + + utils::forEachQueryRangeResult(query, params.range, func); + } + + void RatedArtist::setLastUpdated(const Wt::WDateTime& lastUpdated) + { + _lastUpdated = utils::normalizeDateTime(lastUpdated); + } +} // namespace lms::db diff --git a/src/libs/database/impl/RatedRelease.cpp b/src/libs/database/impl/RatedRelease.cpp new file mode 100644 index 00000000..69cc51be --- /dev/null +++ b/src/libs/database/impl/RatedRelease.cpp @@ -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 . + */ + +#include "database/RatedRelease.hpp" + +#include + +#include "database/Release.hpp" +#include "database/Session.hpp" +#include "database/User.hpp" + +#include "IdTypeTraits.hpp" +#include "Utils.hpp" + +namespace lms::db +{ + RatedRelease::RatedRelease(ObjectPtr release, ObjectPtr user) + : _release{ getDboPtr(release) } + , _user{ getDboPtr(user) } + { + } + + RatedRelease::pointer RatedRelease::create(Session& session, ObjectPtr release, ObjectPtr user) + { + return session.getDboSession()->add(std::unique_ptr{ new RatedRelease{ release, user } }); + } + + std::size_t RatedRelease::getCount(Session& session) + { + session.checkReadTransaction(); + return utils::fetchQuerySingleResult(session.getDboSession()->query("SELECT COUNT(*) FROM rated_release")); + } + + RatedRelease::pointer RatedRelease::find(Session& session, RatedReleaseId id) + { + session.checkReadTransaction(); + return utils::fetchQuerySingleResult(session.getDboSession()->find().where("id = ?").bind(id)); + } + + RatedRelease::pointer RatedRelease::find(Session& session, ReleaseId releaseId, UserId userId) + { + session.checkReadTransaction(); + return utils::fetchQuerySingleResult(session.getDboSession()->find().where("release_id = ?").bind(releaseId).where("user_id = ?").bind(userId)); + } + + void RatedRelease::find(Session& session, const FindParameters& params, std::function func) + { + session.checkReadTransaction(); + + auto query{ session.getDboSession()->query>("SELECT r_r FROM rated_release r_r") }; + + if (params.user.isValid()) + query.where("r_r.user_id = ?").bind(params.user); + + utils::forEachQueryRangeResult(query, params.range, func); + } + + void RatedRelease::setLastUpdated(const Wt::WDateTime& lastUpdated) + { + _lastUpdated = utils::normalizeDateTime(lastUpdated); + } +} // namespace lms::db diff --git a/src/libs/database/impl/RatedTrack.cpp b/src/libs/database/impl/RatedTrack.cpp new file mode 100644 index 00000000..5cf3bda5 --- /dev/null +++ b/src/libs/database/impl/RatedTrack.cpp @@ -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 . + */ + +#include "database/RatedTrack.hpp" + +#include + +#include "database/Session.hpp" +#include "database/Track.hpp" +#include "database/User.hpp" + +#include "IdTypeTraits.hpp" +#include "Utils.hpp" + +namespace lms::db +{ + RatedTrack::RatedTrack(ObjectPtr track, ObjectPtr user) + : _track{ getDboPtr(track) } + , _user{ getDboPtr(user) } + { + } + + RatedTrack::pointer RatedTrack::create(Session& session, ObjectPtr track, ObjectPtr user) + { + return session.getDboSession()->add(std::unique_ptr{ new RatedTrack{ track, user } }); + } + + std::size_t RatedTrack::getCount(Session& session) + { + session.checkReadTransaction(); + return utils::fetchQuerySingleResult(session.getDboSession()->query("SELECT COUNT(*) FROM rated_track")); + } + + RatedTrack::pointer RatedTrack::find(Session& session, RatedTrackId id) + { + session.checkReadTransaction(); + return utils::fetchQuerySingleResult(session.getDboSession()->find().where("id = ?").bind(id)); + } + + RatedTrack::pointer RatedTrack::find(Session& session, TrackId trackId, UserId userId) + { + session.checkReadTransaction(); + return utils::fetchQuerySingleResult(session.getDboSession()->find().where("track_id = ?").bind(trackId).where("user_id = ?").bind(userId)); + } + + void RatedTrack::find(Session& session, const FindParameters& params, std::function func) + { + session.checkReadTransaction(); + + auto query{ session.getDboSession()->query>("SELECT r_t FROM rated_track r_t") }; + + if (params.user.isValid()) + query.where("r_t.user_id = ?").bind(params.user); + + utils::forEachQueryRangeResult(query, params.range, func); + } + + void RatedTrack::setLastUpdated(const Wt::WDateTime& lastUpdated) + { + _lastUpdated = utils::normalizeDateTime(lastUpdated); + } +} // namespace lms::db diff --git a/src/libs/database/impl/Session.cpp b/src/libs/database/impl/Session.cpp index bcd9c03a..910c69a1 100644 --- a/src/libs/database/impl/Session.cpp +++ b/src/libs/database/impl/Session.cpp @@ -30,6 +30,9 @@ #include "database/Image.hpp" #include "database/Listen.hpp" #include "database/MediaLibrary.hpp" +#include "database/RatedArtist.hpp" +#include "database/RatedRelease.hpp" +#include "database/RatedTrack.hpp" #include "database/Release.hpp" #include "database/ScanSettings.hpp" #include "database/StarredArtist.hpp" @@ -98,6 +101,9 @@ namespace lms::db _session.mapClass("image"); _session.mapClass("listen"); _session.mapClass("media_library"); + _session.mapClass("rated_artist"); + _session.mapClass("rated_release"); + _session.mapClass("rated_track"); _session.mapClass("release"); _session.mapClass("release_type"); _session.mapClass("scan_settings"); @@ -194,10 +200,14 @@ namespace lms::db _session.execute("CREATE INDEX IF NOT EXISTS listen_backend_idx ON listen(backend)"); _session.execute("CREATE INDEX IF NOT EXISTS listen_id_idx ON listen(id)"); _session.execute("CREATE INDEX IF NOT EXISTS listen_user_backend_idx ON listen(user_id,backend)"); - _session.execute("CREATE INDEX IF NOT EXISTS listen_user_backend_date_time ON listen(user_id, backend, date_time DESC)"); + _session.execute("CREATE INDEX IF NOT EXISTS listen_user_backend_date_time_idx ON listen(user_id, backend, date_time DESC)"); _session.execute("CREATE INDEX IF NOT EXISTS listen_track_user_backend_idx ON listen(track_id,user_id,backend)"); _session.execute("CREATE INDEX IF NOT EXISTS listen_user_track_backend_date_time_idx ON listen(user_id,track_id,backend,date_time)"); + _session.execute("CREATE INDEX IF NOT EXISTS rated_artist_user_artist_idx ON rated_artist(user_id,artist_id)"); + _session.execute("CREATE INDEX IF NOT EXISTS rated_release_user_release_idx ON rated_release(user_id,release_id)"); + _session.execute("CREATE INDEX IF NOT EXISTS rated_track_user_track_idx ON rated_track(user_id,track_id)"); + _session.execute("CREATE INDEX IF NOT EXISTS release_id_idx ON release(id)"); _session.execute("CREATE INDEX IF NOT EXISTS release_name_idx ON release(name)"); _session.execute("CREATE INDEX IF NOT EXISTS release_name_nocase_idx ON release(name COLLATE NOCASE)"); diff --git a/src/libs/database/include/database/RatedArtist.hpp b/src/libs/database/include/database/RatedArtist.hpp new file mode 100644 index 00000000..7e846b67 --- /dev/null +++ b/src/libs/database/include/database/RatedArtist.hpp @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2024 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 . + */ + +#pragma once + +#include +#include + +#include "database/ArtistId.hpp" +#include "database/Object.hpp" +#include "database/RatedArtistId.hpp" +#include "database/Types.hpp" +#include "database/UserId.hpp" + +namespace lms::db +{ + class Artist; + class Session; + class User; + + class RatedArtist final : public Object + { + public: + RatedArtist() = default; + + struct FindParameters + { + UserId user; // and this user + std::optional range; + + FindParameters& setUser(UserId _user) + { + user = _user; + return *this; + } + FindParameters& setRange(std::optional _range) + { + range = _range; + return *this; + } + }; + + // Search utility + static std::size_t getCount(Session& session); + static pointer find(Session& session, RatedArtistId id); + static pointer find(Session& session, ArtistId artistId, UserId userId); + static void find(Session& session, const FindParameters& findParams, std::function func); + + // Accessors + ObjectPtr getArtist() const { return _artist; } + ObjectPtr getUser() const { return _user; } + Rating getRating() const { return _rating; } + const Wt::WDateTime& getLastUpdated() const { return _lastUpdated; } + + // Setters + void setRating(Rating rating) { _rating = rating; } + void setLastUpdated(const Wt::WDateTime& lastUpdated); + + template + void persist(Action& a) + { + Wt::Dbo::field(a, _rating, "rating"); + Wt::Dbo::field(a, _lastUpdated, "last_updated"); + + Wt::Dbo::belongsTo(a, _artist, "artist", Wt::Dbo::OnDeleteCascade); + Wt::Dbo::belongsTo(a, _user, "user", Wt::Dbo::OnDeleteCascade); + } + + private: + friend class Session; + RatedArtist(ObjectPtr artist, ObjectPtr user); + static pointer create(Session& session, ObjectPtr artist, ObjectPtr user); + + Rating _rating{}; + Wt::WDateTime _lastUpdated; // when it was rated for the last time + + Wt::Dbo::ptr _artist; + Wt::Dbo::ptr _user; + }; +} // namespace lms::db diff --git a/src/libs/database/include/database/RatedArtistId.hpp b/src/libs/database/include/database/RatedArtistId.hpp new file mode 100644 index 00000000..aa3ef104 --- /dev/null +++ b/src/libs/database/include/database/RatedArtistId.hpp @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2024 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 . + */ + +#pragma once + +#include "database/IdType.hpp" + +LMS_DECLARE_IDTYPE(RatedArtistId) diff --git a/src/libs/database/include/database/RatedRelease.hpp b/src/libs/database/include/database/RatedRelease.hpp new file mode 100644 index 00000000..54a12e1c --- /dev/null +++ b/src/libs/database/include/database/RatedRelease.hpp @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2024 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 . + */ + +#pragma once + +#include +#include + +#include "database/Object.hpp" +#include "database/RatedReleaseId.hpp" +#include "database/ReleaseId.hpp" +#include "database/Types.hpp" +#include "database/UserId.hpp" + +namespace lms::db +{ + class Release; + class Session; + class User; + + class RatedRelease final : public Object + { + public: + RatedRelease() = default; + + struct FindParameters + { + UserId user; // and this user + std::optional range; + + FindParameters& setUser(UserId _user) + { + user = _user; + return *this; + } + FindParameters& setRange(std::optional _range) + { + range = _range; + return *this; + } + }; + + // Search utility + static std::size_t getCount(Session& session); + static pointer find(Session& session, RatedReleaseId id); + static pointer find(Session& session, ReleaseId releaseId, UserId userId); + static void find(Session& session, const FindParameters& findParams, std::function func); + + // Accessors + ObjectPtr getRelease() const { return _release; } + ObjectPtr getUser() const { return _user; } + Rating getRating() const { return _rating; } + const Wt::WDateTime& getLastUpdated() const { return _lastUpdated; } + + // Setters + void setRating(Rating rating) { _rating = rating; } + void setLastUpdated(const Wt::WDateTime& lastUpdated); + + template + void persist(Action& a) + { + Wt::Dbo::field(a, _rating, "rating"); + Wt::Dbo::field(a, _lastUpdated, "last_updated"); + + Wt::Dbo::belongsTo(a, _release, "release", Wt::Dbo::OnDeleteCascade); + Wt::Dbo::belongsTo(a, _user, "user", Wt::Dbo::OnDeleteCascade); + } + + private: + friend class Session; + RatedRelease(ObjectPtr release, ObjectPtr user); + static pointer create(Session& session, ObjectPtr release, ObjectPtr user); + + Rating _rating{}; + Wt::WDateTime _lastUpdated; // when it was rated for the last time + + Wt::Dbo::ptr _release; + Wt::Dbo::ptr _user; + }; +} // namespace lms::db diff --git a/src/libs/database/include/database/RatedReleaseId.hpp b/src/libs/database/include/database/RatedReleaseId.hpp new file mode 100644 index 00000000..5ac11826 --- /dev/null +++ b/src/libs/database/include/database/RatedReleaseId.hpp @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2024 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 . + */ + +#pragma once + +#include "database/IdType.hpp" + +LMS_DECLARE_IDTYPE(RatedReleaseId) diff --git a/src/libs/database/include/database/RatedTrack.hpp b/src/libs/database/include/database/RatedTrack.hpp new file mode 100644 index 00000000..bdc2a6c6 --- /dev/null +++ b/src/libs/database/include/database/RatedTrack.hpp @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2024 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 . + */ + +#pragma once + +#include +#include + +#include "database/Object.hpp" +#include "database/RatedTrackId.hpp" +#include "database/TrackId.hpp" +#include "database/Types.hpp" +#include "database/UserId.hpp" + +namespace lms::db +{ + class Track; + class Session; + class User; + + class RatedTrack final : public Object + { + public: + RatedTrack() = default; + + struct FindParameters + { + UserId user; // and this user + std::optional range; + + FindParameters& setUser(UserId _user) + { + user = _user; + return *this; + } + FindParameters& setRange(std::optional _range) + { + range = _range; + return *this; + } + }; + + // Search utility + static std::size_t getCount(Session& session); + static pointer find(Session& session, RatedTrackId id); + static pointer find(Session& session, TrackId trackId, UserId userId); + static void find(Session& session, const FindParameters& findParams, std::function func); + + // Accessors + ObjectPtr getTrack() const { return _track; } + ObjectPtr getUser() const { return _user; } + Rating getRating() const { return _rating; } + const Wt::WDateTime& getLastUpdated() const { return _lastUpdated; } + + // Setters + void setRating(Rating rating) { _rating = rating; } + void setLastUpdated(const Wt::WDateTime& lastUpdated); + + template + void persist(Action& a) + { + Wt::Dbo::field(a, _rating, "rating"); + Wt::Dbo::field(a, _lastUpdated, "last_updated"); + + Wt::Dbo::belongsTo(a, _track, "track", Wt::Dbo::OnDeleteCascade); + Wt::Dbo::belongsTo(a, _user, "user", Wt::Dbo::OnDeleteCascade); + } + + private: + friend class Session; + RatedTrack(ObjectPtr track, ObjectPtr user); + static pointer create(Session& session, ObjectPtr track, ObjectPtr user); + + Rating _rating{}; + Wt::WDateTime _lastUpdated; // when it was rated for the last time + + Wt::Dbo::ptr _track; + Wt::Dbo::ptr _user; + }; +} // namespace lms::db diff --git a/src/libs/database/include/database/RatedTrackId.hpp b/src/libs/database/include/database/RatedTrackId.hpp new file mode 100644 index 00000000..ee3d48d9 --- /dev/null +++ b/src/libs/database/include/database/RatedTrackId.hpp @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2024 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 . + */ + +#pragma once + +#include "database/IdType.hpp" + +LMS_DECLARE_IDTYPE(RatedTrackId) diff --git a/src/libs/database/include/database/StarredTrack.hpp b/src/libs/database/include/database/StarredTrack.hpp index 8ee2e3fb..05d3490d 100644 --- a/src/libs/database/include/database/StarredTrack.hpp +++ b/src/libs/database/include/database/StarredTrack.hpp @@ -22,7 +22,6 @@ #include #include -#include "core/EnumSet.hpp" #include "database/Object.hpp" #include "database/StarredTrackId.hpp" #include "database/TrackId.hpp" diff --git a/src/libs/database/include/database/Types.hpp b/src/libs/database/include/database/Types.hpp index aadea04e..a1494e0e 100644 --- a/src/libs/database/include/database/Types.hpp +++ b/src/libs/database/include/database/Types.hpp @@ -196,6 +196,8 @@ namespace lms::db void visitAllowedAudioBitrates(std::function); bool isAudioBitrateAllowed(Bitrate bitrate); + using Rating = int; + enum class ScrobblingBackend { Internal = 0, diff --git a/src/libs/database/test/CMakeLists.txt b/src/libs/database/test/CMakeLists.txt index f6cfe3bf..0e1c4171 100644 --- a/src/libs/database/test/CMakeLists.txt +++ b/src/libs/database/test/CMakeLists.txt @@ -8,6 +8,9 @@ add_executable(test-database Image.cpp Listen.cpp Migration.cpp + RatedArtist.cpp + RatedRelease.cpp + RatedTrack.cpp Release.cpp StarredArtist.cpp StarredRelease.cpp diff --git a/src/libs/database/test/Migration.cpp b/src/libs/database/test/Migration.cpp index af119b2a..2eb7d091 100644 --- a/src/libs/database/test/Migration.cpp +++ b/src/libs/database/test/Migration.cpp @@ -23,6 +23,9 @@ #include "database/Db.hpp" #include "database/Directory.hpp" #include "database/Image.hpp" +#include "database/RatedArtist.hpp" +#include "database/RatedRelease.hpp" +#include "database/RatedTrack.hpp" #include "database/StarredArtist.hpp" #include "database/StarredRelease.hpp" #include "database/StarredTrack.hpp" @@ -335,6 +338,9 @@ VALUES EXPECT_FALSE(Directory::find(session, DirectoryId{})); EXPECT_FALSE(Image::find(session, ImageId{})); EXPECT_FALSE(Listen::find(session, ListenId{})); + EXPECT_FALSE(RatedArtist::find(session, RatedArtistId{})); + EXPECT_FALSE(RatedRelease::find(session, RatedReleaseId{})); + EXPECT_FALSE(RatedTrack::find(session, RatedTrackId{})); EXPECT_FALSE(Release::find(session, ReleaseId{})); EXPECT_FALSE(StarredArtist::find(session, StarredArtistId{})); EXPECT_FALSE(StarredRelease::find(session, StarredReleaseId{})); diff --git a/src/libs/database/test/RatedArtist.cpp b/src/libs/database/test/RatedArtist.cpp new file mode 100644 index 00000000..3cb44501 --- /dev/null +++ b/src/libs/database/test/RatedArtist.cpp @@ -0,0 +1,55 @@ +/* + * 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 . + */ + +#include "database/RatedArtist.hpp" + +#include "Common.hpp" + +namespace lms::db::tests +{ + using ScopedRatedArtist = ScopedEntity; + + TEST_F(DatabaseFixture, RatedArtist) + { + ScopedArtist artist{ session, "MyArtist" }; + ScopedUser user{ session, "MyUser" }; + ScopedUser user2{ session, "MyUser2" }; + + { + auto transaction{ session.createReadTransaction() }; + + auto starredArtist{ RatedArtist::find(session, artist->getId(), user->getId()) }; + EXPECT_FALSE(starredArtist); + EXPECT_EQ(RatedArtist::getCount(session), 0); + + auto artists{ Artist::findIds(session, Artist::FindParameters{}) }; + EXPECT_EQ(artists.results.size(), 1); + } + + ScopedRatedArtist ratedArtist{ session, artist.lockAndGet(), user.lockAndGet() }; + { + auto transaction{ session.createReadTransaction() }; + + auto gotArtist{ RatedArtist::find(session, artist->getId(), user->getId()) }; + EXPECT_EQ(gotArtist->getId(), ratedArtist->getId()); + EXPECT_EQ(gotArtist->getRating(), 0); + EXPECT_EQ(RatedArtist::getCount(session), 1); + } + } +} // namespace lms::db::tests \ No newline at end of file diff --git a/src/libs/database/test/RatedRelease.cpp b/src/libs/database/test/RatedRelease.cpp new file mode 100644 index 00000000..7a309e92 --- /dev/null +++ b/src/libs/database/test/RatedRelease.cpp @@ -0,0 +1,55 @@ +/* + * 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 . + */ + +#include "database/RatedRelease.hpp" + +#include "Common.hpp" + +namespace lms::db::tests +{ + using ScopedRatedRelease = ScopedEntity; + + TEST_F(DatabaseFixture, RatedRelease) + { + ScopedRelease release{ session, "MyRelease" }; + ScopedUser user{ session, "MyUser" }; + ScopedUser user2{ session, "MyUser2" }; + + { + auto transaction{ session.createReadTransaction() }; + + auto starredRelease{ RatedRelease::find(session, release->getId(), user->getId()) }; + EXPECT_FALSE(starredRelease); + EXPECT_EQ(RatedRelease::getCount(session), 0); + + auto releases{ Release::findIds(session, Release::FindParameters{}) }; + EXPECT_EQ(releases.results.size(), 1); + } + + ScopedRatedRelease ratedRelease{ session, release.lockAndGet(), user.lockAndGet() }; + { + auto transaction{ session.createReadTransaction() }; + + auto gotRelease{ RatedRelease::find(session, release->getId(), user->getId()) }; + EXPECT_EQ(gotRelease->getId(), ratedRelease->getId()); + EXPECT_EQ(gotRelease->getRating(), 0); + EXPECT_EQ(RatedRelease::getCount(session), 1); + } + } +} // namespace lms::db::tests \ No newline at end of file diff --git a/src/libs/database/test/RatedTrack.cpp b/src/libs/database/test/RatedTrack.cpp new file mode 100644 index 00000000..d939a6af --- /dev/null +++ b/src/libs/database/test/RatedTrack.cpp @@ -0,0 +1,55 @@ +/* + * 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 . + */ + +#include "database/RatedTrack.hpp" + +#include "Common.hpp" + +namespace lms::db::tests +{ + using ScopedRatedTrack = ScopedEntity; + + TEST_F(DatabaseFixture, RatedTrack) + { + ScopedTrack track{ session }; + ScopedUser user{ session, "MyUser" }; + ScopedUser user2{ session, "MyUser2" }; + + { + auto transaction{ session.createReadTransaction() }; + + auto starredTrack{ RatedTrack::find(session, track->getId(), user->getId()) }; + EXPECT_FALSE(starredTrack); + EXPECT_EQ(RatedTrack::getCount(session), 0); + + auto tracks{ Track::findIds(session, Track::FindParameters{}) }; + EXPECT_EQ(tracks.results.size(), 1); + } + + ScopedRatedTrack ratedTrack{ session, track.lockAndGet(), user.lockAndGet() }; + { + auto transaction{ session.createReadTransaction() }; + + auto gotTrack{ RatedTrack::find(session, track->getId(), user->getId()) }; + EXPECT_EQ(gotTrack->getId(), ratedTrack->getId()); + EXPECT_EQ(gotTrack->getRating(), 0); + EXPECT_EQ(RatedTrack::getCount(session), 1); + } + } +} // namespace lms::db::tests \ No newline at end of file diff --git a/src/libs/services/feedback/impl/FeedbackService.cpp b/src/libs/services/feedback/impl/FeedbackService.cpp index f7c0bfe6..8f14e405 100644 --- a/src/libs/services/feedback/impl/FeedbackService.cpp +++ b/src/libs/services/feedback/impl/FeedbackService.cpp @@ -23,6 +23,9 @@ #include "core/ILogger.hpp" #include "database/Artist.hpp" #include "database/Db.hpp" +#include "database/RatedArtist.hpp" +#include "database/RatedRelease.hpp" +#include "database/RatedTrack.hpp" #include "database/Release.hpp" #include "database/Session.hpp" #include "database/StarredArtist.hpp" @@ -108,6 +111,16 @@ namespace lms::feedback return Artist::findIds(session, searchParams); } + void FeedbackService::setRating(db::UserId userId, db::ArtistId artistId, std::optional rating) + { + setRating(userId, artistId, rating); + } + + std::optional FeedbackService::getRating(db::UserId userId, db::ArtistId artistId) + { + return getRating(userId, artistId); + } + void FeedbackService::star(UserId userId, ReleaseId releaseId) { star(userId, releaseId); @@ -148,6 +161,16 @@ namespace lms::feedback return Release::findIds(session, searchParams); } + void FeedbackService::setRating(db::UserId userId, db::ReleaseId releaseId, std::optional rating) + { + setRating(userId, releaseId, rating); + } + + std::optional FeedbackService::getRating(db::UserId userId, db::ReleaseId releaseId) + { + return getRating(userId, releaseId); + } + void FeedbackService::star(UserId userId, TrackId trackId) { star(userId, trackId); @@ -187,4 +210,14 @@ namespace lms::feedback return Track::findIds(session, searchParams); } + + void FeedbackService::setRating(db::UserId userId, db::TrackId trackId, std::optional rating) + { + setRating(userId, trackId, rating); + } + + std::optional FeedbackService::getRating(db::UserId userId, db::TrackId trackId) + { + return getRating(userId, trackId); + } } // namespace lms::feedback diff --git a/src/libs/services/feedback/impl/FeedbackService.hpp b/src/libs/services/feedback/impl/FeedbackService.hpp index 33d6bf27..630ce581 100644 --- a/src/libs/services/feedback/impl/FeedbackService.hpp +++ b/src/libs/services/feedback/impl/FeedbackService.hpp @@ -49,18 +49,28 @@ namespace lms::feedback Wt::WDateTime getStarredDateTime(db::UserId userId, db::ArtistId artistId) override; ArtistContainer findStarredArtists(const ArtistFindParameters& params) override; + virtual void setRating(db::UserId userId, db::ArtistId artistId, std::optional rating) override; + virtual std::optional getRating(db::UserId userId, db::ArtistId artistId) override; + void star(db::UserId userId, db::ReleaseId releaseId) override; void unstar(db::UserId userId, db::ReleaseId releaseId) override; bool isStarred(db::UserId userId, db::ReleaseId releasedId) override; Wt::WDateTime getStarredDateTime(db::UserId userId, db::ReleaseId releasedId) override; ReleaseContainer findStarredReleases(const FindParameters& params) override; + virtual void setRating(db::UserId userId, db::ReleaseId releaseId, std::optional rating) override; + virtual std::optional getRating(db::UserId userId, db::ReleaseId releaseId) override; + void star(db::UserId userId, db::TrackId trackId) override; void unstar(db::UserId userId, db::TrackId trackId) override; bool isStarred(db::UserId userId, db::TrackId trackId) override; Wt::WDateTime getStarredDateTime(db::UserId userId, db::TrackId trackId) override; TrackContainer findStarredTracks(const FindParameters& params) override; + void setRating(db::UserId userId, db::TrackId trackId, std::optional rating) override; + std::optional getRating(db::UserId userId, db::TrackId trackId) override; + + private: std::optional getUserFeedbackBackend(db::UserId userId); template @@ -72,6 +82,12 @@ namespace lms::feedback template Wt::WDateTime getStarredDateTime(db::UserId userId, ObjIdType id); + template + void setRating(db::UserId userId, ObjIdType objectId, std::optional rating); + + template + std::optional getRating(db::UserId userId, ObjIdType objectId); + db::Db& _db; std::unordered_map> _backends; }; diff --git a/src/libs/services/feedback/impl/FeedbackService.impl.hpp b/src/libs/services/feedback/impl/FeedbackService.impl.hpp index d6d22e77..bb95e07a 100644 --- a/src/libs/services/feedback/impl/FeedbackService.impl.hpp +++ b/src/libs/services/feedback/impl/FeedbackService.impl.hpp @@ -102,4 +102,46 @@ namespace lms::feedback return {}; } + template + void FeedbackService::setRating(db::UserId userId, ObjIdType objectId, std::optional rating) + { + Session& session{ _db.getTLSSession() }; + auto transaction{ session.createWriteTransaction() }; + + typename RatedObjType::pointer ratedObject{ RatedObjType::find(session, objectId, userId) }; + if (rating) + { + if (!ratedObject) + { + typename ObjType::pointer obj{ ObjType::find(session, objectId) }; + const User::pointer user{ User::find(session, userId) }; + + if (!obj || !user) + return; + + ratedObject = session.create(obj, user); + } + + ratedObject.modify()->setRating(*rating); + } + else + { + if (ratedObject) + ratedObject.remove(); + } + } + + template + std::optional FeedbackService::getRating(db::UserId userId, ObjIdType objectId) + { + Session& session{ _db.getTLSSession() }; + auto transaction{ session.createReadTransaction() }; + + const typename RatedObjType::pointer ratedObj{ RatedObjType::find(session, objectId, userId) }; + if (!ratedObj) + return std::nullopt; + + return ratedObj->getRating(); + } + } // namespace lms::feedback \ No newline at end of file diff --git a/src/libs/services/feedback/include/services/feedback/IFeedbackService.hpp b/src/libs/services/feedback/include/services/feedback/IFeedbackService.hpp index 7bab297e..eb067057 100644 --- a/src/libs/services/feedback/include/services/feedback/IFeedbackService.hpp +++ b/src/libs/services/feedback/include/services/feedback/IFeedbackService.hpp @@ -109,6 +109,9 @@ namespace lms::feedback virtual Wt::WDateTime getStarredDateTime(db::UserId userId, db::ArtistId artistId) = 0; virtual ArtistContainer findStarredArtists(const ArtistFindParameters& params) = 0; + virtual void setRating(db::UserId userId, db::ArtistId artistId, std::optional rating) = 0; + virtual std::optional getRating(db::UserId userId, db::ArtistId artistId) = 0; + // Releases virtual void star(db::UserId userId, db::ReleaseId releaseId) = 0; virtual void unstar(db::UserId userId, db::ReleaseId releaseId) = 0; @@ -116,12 +119,18 @@ namespace lms::feedback virtual Wt::WDateTime getStarredDateTime(db::UserId userId, db::ReleaseId artistId) = 0; virtual ReleaseContainer findStarredReleases(const FindParameters& params) = 0; + virtual void setRating(db::UserId userId, db::ReleaseId releaseId, std::optional rating) = 0; + virtual std::optional getRating(db::UserId userId, db::ReleaseId releaseId) = 0; + // Tracks virtual void star(db::UserId userId, db::TrackId trackId) = 0; virtual void unstar(db::UserId userId, db::TrackId trackId) = 0; virtual bool isStarred(db::UserId userId, db::TrackId artistId) = 0; virtual Wt::WDateTime getStarredDateTime(db::UserId userId, db::TrackId artistId) = 0; virtual TrackContainer findStarredTracks(const FindParameters& params) = 0; + + virtual void setRating(db::UserId userId, db::TrackId trackId, std::optional rating) = 0; + virtual std::optional getRating(db::UserId userId, db::TrackId trackId) = 0; }; std::unique_ptr createFeedbackService(boost::asio::io_service& ioService, db::Db& db); diff --git a/src/libs/subsonic/impl/SubsonicResource.cpp b/src/libs/subsonic/impl/SubsonicResource.cpp index 1a212a00..2c4b5635 100644 --- a/src/libs/subsonic/impl/SubsonicResource.cpp +++ b/src/libs/subsonic/impl/SubsonicResource.cpp @@ -206,7 +206,7 @@ namespace lms::api::subsonic // Media annotation { "/star", { handleStarRequest } }, { "/unstar", { handleUnstarRequest } }, - { "/setRating", { handleNotImplemented } }, + { "/setRating", { handleSetRating } }, { "/scrobble", { handleScrobble } }, // Sharing diff --git a/src/libs/subsonic/impl/entrypoints/MediaAnnotation.cpp b/src/libs/subsonic/impl/entrypoints/MediaAnnotation.cpp index b8a156c1..bada0099 100644 --- a/src/libs/subsonic/impl/entrypoints/MediaAnnotation.cpp +++ b/src/libs/subsonic/impl/entrypoints/MediaAnnotation.cpp @@ -19,6 +19,7 @@ #include "MediaAnnotation.hpp" +#include #include #include "core/Service.hpp" @@ -77,6 +78,37 @@ namespace lms::api::subsonic return res ? res->getId() : ReleaseId{}; } + struct RatingParameters + { + std::variant id; + std::optional rating; + }; + + RatingParameters getRatingParameters(const Wt::Http::ParameterMap& parameters) + { + RatingParameters res; + + if (const auto artistId{ getParameterAs(parameters, "id") }) + res.id = *artistId; + else if (const auto releaseId{ getParameterAs(parameters, "id") }) + res.id = *releaseId; + else if (const auto trackId{ getParameterAs(parameters, "id") }) + res.id = *trackId; + else if (const auto directoryId{ getParameterAs(parameters, "id") }) + res.id = *directoryId; + else + throw RequiredParameterMissingError{ "id" }; + + const int rating = getMandatoryParameterAs(parameters, "rating"); // The rating between 1 and 5 (inclusive), or 0 to remove the rating + if (rating < 0 || rating > 5) + throw BadParameterGenericError{ "rating must be 0 or in range 1-5" }; + + if (rating > 0) + res.rating = rating; + + return res; + } + } // namespace Response handleStarRequest(RequestContext& context) @@ -103,7 +135,7 @@ namespace lms::api::subsonic Response handleUnstarRequest(RequestContext& context) { - StarParameters params{ getStarParameters(context.parameters) }; + const StarParameters params{ getStarParameters(context.parameters) }; for (const DirectoryId id : params.directoryIds) { @@ -123,6 +155,25 @@ namespace lms::api::subsonic return Response::createOkResponse(context.serverProtocolVersion); } + Response handleSetRating(RequestContext& context) + { + const RatingParameters params{ getRatingParameters(context.parameters) }; + + if (const ArtistId * artistId{ std::get_if(¶ms.id) }) + core::Service::get()->setRating(context.user->getId(), *artistId, params.rating); + else if (const DirectoryId * directoryId{ std::get_if(¶ms.id) }) + { + if (const ReleaseId releaseId{ getReleaseFromDirectory(context.dbSession, *directoryId) }; releaseId.isValid()) + core::Service::get()->setRating(context.user->getId(), releaseId, params.rating); + } + else if (const ReleaseId * releaseId{ std::get_if(¶ms.id) }) + core::Service::get()->setRating(context.user->getId(), *releaseId, params.rating); + else if (const TrackId * trackId{ std::get_if(¶ms.id) }) + core::Service::get()->setRating(context.user->getId(), *trackId, params.rating); + + return Response::createOkResponse(context.serverProtocolVersion); + } + Response handleScrobble(RequestContext& context) { const std::vector ids{ getMandatoryMultiParametersAs(context.parameters, "id") }; diff --git a/src/libs/subsonic/impl/entrypoints/MediaAnnotation.hpp b/src/libs/subsonic/impl/entrypoints/MediaAnnotation.hpp index a7b211c8..d17e7109 100644 --- a/src/libs/subsonic/impl/entrypoints/MediaAnnotation.hpp +++ b/src/libs/subsonic/impl/entrypoints/MediaAnnotation.hpp @@ -26,5 +26,6 @@ namespace lms::api::subsonic { Response handleStarRequest(RequestContext& context); Response handleUnstarRequest(RequestContext& context); + Response handleSetRating(RequestContext& context); Response handleScrobble(RequestContext& context); } // namespace lms::api::subsonic \ No newline at end of file diff --git a/src/libs/subsonic/impl/responses/Album.cpp b/src/libs/subsonic/impl/responses/Album.cpp index 3d1fe45e..1bbacb01 100644 --- a/src/libs/subsonic/impl/responses/Album.cpp +++ b/src/libs/subsonic/impl/responses/Album.cpp @@ -116,6 +116,10 @@ namespace lms::api::subsonic if (const Wt::WDateTime dateTime{ core::Service::get()->getStarredDateTime(context.user->getId(), release->getId()) }; dateTime.isValid()) albumNode.setAttribute("starred", core::stringUtils::toISO8601String(dateTime)); + // Always report user rating, even if legacy API only specified it for directories + if (const auto rating{ core::Service::get()->getRating(context.user->getId(), release->getId()) }) + albumNode.setAttribute("userRating", *rating); + if (!context.enableOpenSubsonic) return albumNode; diff --git a/src/libs/subsonic/impl/responses/Artist.cpp b/src/libs/subsonic/impl/responses/Artist.cpp index 2bf29e0d..e7e1db67 100644 --- a/src/libs/subsonic/impl/responses/Artist.cpp +++ b/src/libs/subsonic/impl/responses/Artist.cpp @@ -103,6 +103,9 @@ namespace lms::api::subsonic if (const Wt::WDateTime dateTime{ core::Service::get()->getStarredDateTime(context.user->getId(), artist->getId()) }; dateTime.isValid()) artistNode.setAttribute("starred", core::stringUtils::toISO8601String(dateTime)); + if (const auto rating{ core::Service::get()->getRating(context.user->getId(), artist->getId()) }) + artistNode.setAttribute("userRating", *rating); + // OpenSubsonic specific fields (must always be set) if (context.enableOpenSubsonic) { diff --git a/src/libs/subsonic/impl/responses/Song.cpp b/src/libs/subsonic/impl/responses/Song.cpp index d58b84d9..546d7de7 100644 --- a/src/libs/subsonic/impl/responses/Song.cpp +++ b/src/libs/subsonic/impl/responses/Song.cpp @@ -133,6 +133,8 @@ namespace lms::api::subsonic trackResponse.setAttribute("type", "music"); trackResponse.setAttribute("created", core::stringUtils::toISO8601String(track->getLastWritten())); trackResponse.setAttribute("contentType", av::getMimeType(track->getAbsoluteFilePath().extension())); + if (const auto rating{ core::Service::get()->getRating(context.user->getId(), track->getId()) }) + trackResponse.setAttribute("userRating", *rating); if (const Wt::WDateTime dateTime{ core::Service::get()->getStarredDateTime(context.user->getId(), track->getId()) }; dateTime.isValid()) trackResponse.setAttribute("starred", core::stringUtils::toISO8601String(dateTime));