diff --git a/src/Makefile.am b/src/Makefile.am index d4b3cfbd..08572294 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -46,6 +46,8 @@ lms_SOURCES = \ $(srcdir)/database/SqlQuery.hpp \ $(srcdir)/database/Track.cpp \ $(srcdir)/database/Track.hpp \ + $(srcdir)/database/TrackBookmark.cpp \ + $(srcdir)/database/TrackBookmark.hpp \ $(srcdir)/database/User.cpp \ $(srcdir)/database/User.hpp \ $(srcdir)/image/Image.cpp \ diff --git a/src/api/subsonic/SubsonicResource.cpp b/src/api/subsonic/SubsonicResource.cpp index 1797b78c..733720ba 100644 --- a/src/api/subsonic/SubsonicResource.cpp +++ b/src/api/subsonic/SubsonicResource.cpp @@ -35,6 +35,7 @@ #include "database/Release.hpp" #include "database/Session.hpp" #include "database/Track.hpp" +#include "database/TrackBookmark.hpp" #include "database/TrackList.hpp" #include "database/User.hpp" #include "similarity/SimilaritySearcher.hpp" @@ -48,6 +49,8 @@ using namespace Database; static const std::string genreClusterName {"GENRE"}; static const std::string reportedStarredDate {"2000-01-01T00:00:00"}; +static const std::string reportedCreatedBookmarkDate {"2000-01-01T00:00:00"}; +static const std::string reportedChangedBookmarkDate {"2000-01-01T00:00:00"}; template<> std::optional @@ -412,6 +415,22 @@ trackToResponseNode(const Track::pointer& track, Session& dbSession, const User: return trackResponse; } +static +Response::Node +trackBookmarkToResponseNode(const TrackBookmark::pointer& trackBookmark) +{ + Response::Node trackBookmarkNode; + + trackBookmarkNode.setAttribute("position", std::to_string(trackBookmark->getOffset().count())); + if (!trackBookmark->getComment().empty()) + trackBookmarkNode.setAttribute("comment", trackBookmark->getComment()); + trackBookmarkNode.setAttribute("created", reportedCreatedBookmarkDate); + trackBookmarkNode.setAttribute("changed", reportedChangedBookmarkDate); + trackBookmarkNode.setAttribute("username", trackBookmark->getUser()->getLoginName()); + + return trackBookmarkNode; +} + static Response::Node releaseToResponseNode(const Release::pointer& release, Session& dbSession, const User::pointer& user, bool id3) @@ -1656,6 +1675,94 @@ handleUpdatePlaylistRequest(RequestContext& context) return Response::createOkResponse(); } +static +Response +handleGetBookmarks(RequestContext& context) +{ + auto transaction {context.dbSession.createSharedTransaction()}; + + User::pointer user {User::getByLoginName(context.dbSession, context.userName)}; + if (!user) + throw UserNotAuthorizedError {}; + + const auto bookmarks {TrackBookmark::getByUser(context.dbSession, user)}; + + Response response {Response::createOkResponse()}; + Response::Node& bookmarksNode {response.createNode("bookmarks")}; + + for (const TrackBookmark::pointer& bookmark : bookmarks) + { + Response::Node bookmarkNode {trackBookmarkToResponseNode(bookmark)}; + bookmarkNode.addArrayChild("entry", trackToResponseNode(bookmark->getTrack(), context.dbSession, user)); + + bookmarksNode.addArrayChild("bookmark", std::move(bookmarkNode)); + } + + return response ; +} + +static +Response +handleCreateBookmark(RequestContext& context) +{ + // Mandatory params + Id id {getMandatoryParameterAs(context.parameters, "id")}; + if (id.type != Id::Type::Track) + throw BadParameterGenericError {"id"}; + + unsigned long position {getMandatoryParameterAs(context.parameters, "position")}; + const std::optional comment {getParameterAs(context.parameters, "comment")}; + + auto transaction {context.dbSession.createUniqueTransaction()}; + + const User::pointer user {User::getByLoginName(context.dbSession, context.userName)}; + if (!user) + throw UserNotAuthorizedError {}; + + const Track::pointer track {Track::getById(context.dbSession, id.value)}; + if (!track) + throw RequestedDataNotFoundError {}; + + // Replace any existing bookmark + auto bookmark {TrackBookmark::getByUser(context.dbSession, user, track)}; + if (!bookmark) + bookmark = TrackBookmark::create(context.dbSession, user, track); + + bookmark.modify()->setOffset(std::chrono::milliseconds {position}); + if (comment) + bookmark.modify()->setComment(*comment); + + return Response::createOkResponse(); +} + +static +Response +handleDeleteBookmark(RequestContext& context) +{ + // Mandatory params + Id id {getMandatoryParameterAs(context.parameters, "id")}; + if (id.type != Id::Type::Track) + throw BadParameterGenericError {"id"}; + + auto transaction {context.dbSession.createUniqueTransaction()}; + + const User::pointer user {User::getByLoginName(context.dbSession, context.userName)}; + if (!user) + throw UserNotAuthorizedError {}; + + const Track::pointer track {Track::getById(context.dbSession, id.value)}; + if (!track) + throw RequestedDataNotFoundError {}; + + auto bookmark {TrackBookmark::getByUser(context.dbSession, user, track)}; + if (!bookmark) + throw RequestedDataNotFoundError {}; + + bookmark.remove(); + + return Response::createOkResponse(); +} + static Response handleNotImplemented(RequestContext&) @@ -1898,9 +2005,9 @@ static std::unordered_map requestEntryPoints {"changePassword", {handleChangePassword, false}}, // Bookmarks - {"getBookmarks", {handleNotImplemented, false}}, - {"createBookmarks", {handleNotImplemented, false}}, - {"deleteBookmarks", {handleNotImplemented, false}}, + {"getBookmarks", {handleGetBookmarks, false}}, + {"createBookmark", {handleCreateBookmark, false}}, + {"deleteBookmark", {handleDeleteBookmark, false}}, {"getPlayQueue", {handleNotImplemented, false}}, {"savePlayQueue", {handleNotImplemented, false}}, diff --git a/src/api/subsonic/SubsonicResponse.cpp b/src/api/subsonic/SubsonicResponse.cpp index 6bac404c..bf1fdb12 100644 --- a/src/api/subsonic/SubsonicResponse.cpp +++ b/src/api/subsonic/SubsonicResponse.cpp @@ -54,9 +54,9 @@ Response::Node::setValue(const std::string& value) } void -Response::Node::setAttribute(const std::string& key, const std::string& value) +Response::Node::setAttribute(std::string_view key, std::string_view value) { - _attributes[key] = value; + _attributes[std::string {key}] = value; } void diff --git a/src/api/subsonic/SubsonicResponse.hpp b/src/api/subsonic/SubsonicResponse.hpp index 1b31b446..b3c0ef38 100644 --- a/src/api/subsonic/SubsonicResponse.hpp +++ b/src/api/subsonic/SubsonicResponse.hpp @@ -19,6 +19,7 @@ #include #include +#include #include @@ -178,7 +179,7 @@ class Response class Node { public: - void setAttribute(const std::string& key, const std::string& value); + void setAttribute(std::string_view key, std::string_view value); // A Node has either a value or some children void setValue(const std::string& value); diff --git a/src/database/Session.cpp b/src/database/Session.cpp index 5a3572ba..79c48c87 100644 --- a/src/database/Session.cpp +++ b/src/database/Session.cpp @@ -32,6 +32,7 @@ #include "Release.hpp" #include "ScanSettings.hpp" #include "Track.hpp" +#include "TrackBookmark.hpp" #include "TrackArtistLink.hpp" #include "TrackList.hpp" #include "TrackFeatures.hpp" @@ -39,7 +40,7 @@ namespace Database { -#define LMS_DATABASE_VERSION 9 +#define LMS_DATABASE_VERSION 10 using Version = std::size_t; @@ -125,6 +126,20 @@ Session::doDatabaseMigrationIfNeeded() // Just increment the scan version of the settings to make the next scheduled scan rescan everything ScanSettings::get(*this).modify()->incScanVersion(); } + else if (version == 9) + { + _session.execute(R"( +CREATE TABLE IF NOT EXISTS "track_bookmark" ( + "id" integer primary key autoincrement, + "version" integer not null, + "offset" integer, + "comment" text not null, + "track_id" bigint, + "user_id" bigint, + constraint "fk_track_bookmark_track" foreign key ("track_id") references "track" ("id") on delete cascade deferrable initially deferred, + constraint "fk_track_bookmark_user" foreign key ("user_id") references "user" ("id") on delete cascade deferrable initially deferred +);)"); + } else { LMS_LOG(DB, ERROR) << "Database version " << version << " cannot be handled using migration"; @@ -150,6 +165,7 @@ Session::Session(Db& db) _session.mapClass("release"); _session.mapClass("scan_settings"); _session.mapClass("track"); + _session.mapClass("track_bookmark"); _session.mapClass("track_artist_link"); _session.mapClass("track_features"); _session.mapClass("tracklist"); @@ -264,6 +280,8 @@ Session::prepareTables() _session.execute("CREATE INDEX IF NOT EXISTS track_artist_link_name_idx ON track_artist_link(name)"); _session.execute("CREATE INDEX IF NOT EXISTS track_artist_link_track_idx ON track_artist_link(track_id)"); _session.execute("CREATE INDEX IF NOT EXISTS track_artist_link_type_idx ON track_artist_link(type)"); + _session.execute("CREATE INDEX IF NOT EXISTS track_bookmark_user_idx ON track_bookmark(user_id)"); + _session.execute("CREATE INDEX IF NOT EXISTS track_bookmark_user_track_idx ON track_bookmark(user_id,track_id)"); } // Initial settings tables diff --git a/src/database/TrackBookmark.cpp b/src/database/TrackBookmark.cpp new file mode 100644 index 00000000..1307f0a7 --- /dev/null +++ b/src/database/TrackBookmark.cpp @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2020 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 "TrackBookmark.hpp" + +#include "Session.hpp" +#include "Track.hpp" +#include "User.hpp" + +namespace Database { + +TrackBookmark::TrackBookmark(Wt::Dbo::ptr user, Wt::Dbo::ptr track) +: _user {user}, +_track {track} +{ +} + + +TrackBookmark::pointer +TrackBookmark::create(Session& session, Wt::Dbo::ptr user, Wt::Dbo::ptr track) +{ + session.checkUniqueLocked(); + + TrackBookmark::pointer res {session.getDboSession().add(std::make_unique(user, track))}; + session.getDboSession().flush(); + + return res; +} + +std::vector +TrackBookmark::getAll(Session& session) +{ + session.checkSharedLocked(); + + Wt::Dbo::collection res {session.getDboSession().find()}; + + return std::vector(std::cbegin(res), std::cend(res)); +} + +std::vector +TrackBookmark::getByUser(Session& session, Wt::Dbo::ptr user) +{ + session.checkSharedLocked(); + + Wt::Dbo::collection res + { + session.getDboSession().find() + .where("user_id = ?").bind(user.id()) + }; + + return std::vector(std::cbegin(res), std::cend(res)); +} + +TrackBookmark::pointer +TrackBookmark::getByUser(Session& session, Wt::Dbo::ptr user, Wt::Dbo::ptr track) +{ + session.checkSharedLocked(); + + return session.getDboSession().find() + .where("user_id = ?").bind(user.id()) + .where("track_id = ?").bind(track.id()); +} + +TrackBookmark::pointer +TrackBookmark::getById(Session& session, IdType id) +{ + session.checkSharedLocked(); + + return session.getDboSession().find() + .where("id = ?").bind(id); +} + + +} // namespace Database + diff --git a/src/database/TrackBookmark.hpp b/src/database/TrackBookmark.hpp new file mode 100644 index 00000000..d5ff0c0c --- /dev/null +++ b/src/database/TrackBookmark.hpp @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2020 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 "Types.hpp" + +namespace Database { + +class Session; +class Track; +class User; + +class TrackBookmark : public Wt::Dbo::Dbo +{ + public: + using pointer = Wt::Dbo::ptr; + + TrackBookmark () = default; + TrackBookmark(Wt::Dbo::ptr user, Wt::Dbo::ptr track); + + // utility + static pointer create(Session& session, Wt::Dbo::ptr user, Wt::Dbo::ptr track); + + // Find utility functions + static std::vector getAll(Session& session); + static std::vector getByUser(Session& session, Wt::Dbo::ptr user); + static pointer getByUser(Session& session, Wt::Dbo::ptr user, Wt::Dbo::ptr track); + static pointer getById(Session& session, IdType id); + + // Setters + void setOffset(std::chrono::milliseconds offset) { _offset = offset; } + void setComment(std::string_view comment) { _comment = comment; } + + // Getters + std::chrono::milliseconds getOffset() const { return _offset; } + std::string_view getComment() const { return _comment; } + Wt::Dbo::ptr getTrack() const { return _track; } + Wt::Dbo::ptr getUser() const { return _user; } + + template + void persist(Action& a) + { + Wt::Dbo::field(a, _offset, "offset"); + Wt::Dbo::field(a, _comment, "comment"); + Wt::Dbo::belongsTo(a, _track, "track", Wt::Dbo::OnDeleteCascade); + Wt::Dbo::belongsTo(a, _user, "user", Wt::Dbo::OnDeleteCascade); + } + private: + + static const std::size_t _maxCommentLength = 128; + + std::chrono::duration _offset; + std::string _comment; + + Wt::Dbo::ptr _user; + Wt::Dbo::ptr _track; +}; + +} // namespace database + + diff --git a/src/database/User.hpp b/src/database/User.hpp index 2d250edc..c6283b00 100644 --- a/src/database/User.hpp +++ b/src/database/User.hpp @@ -164,6 +164,7 @@ class User : public Wt::Dbo::Dbo bool hasStarredRelease(Wt::Dbo::ptr release) const; std::vector> getStarredReleases(std::optional offset = {}, std::optional size = {}) const; + // Stars void starTrack(Wt::Dbo::ptr track); void unstarTrack(Wt::Dbo::ptr track); bool hasStarredTrack(Wt::Dbo::ptr track) const; diff --git a/test/Makefile.am b/test/Makefile.am index eefde4b8..12ea5022 100644 --- a/test/Makefile.am +++ b/test/Makefile.am @@ -24,6 +24,7 @@ test_database_SOURCES = \ $(top_srcdir)/src/database/Session.cpp \ $(top_srcdir)/src/database/SqlQuery.cpp \ $(top_srcdir)/src/database/Track.cpp \ + $(top_srcdir)/src/database/TrackBookmark.cpp \ $(top_srcdir)/src/database/User.cpp \ $(top_srcdir)/src/utils/Logger.cpp \ $(top_srcdir)/src/utils/StreamLogger.cpp \ diff --git a/test/database/DatabaseTest.cpp b/test/database/DatabaseTest.cpp index 8a19b486..005439d1 100644 --- a/test/database/DatabaseTest.cpp +++ b/test/database/DatabaseTest.cpp @@ -28,6 +28,7 @@ #include "database/Release.hpp" #include "database/Session.hpp" #include "database/Track.hpp" +#include "database/TrackBookmark.hpp" #include "database/TrackList.hpp" #include "database/User.hpp" @@ -131,6 +132,7 @@ using ScopedCluster = ScopedEntity; using ScopedClusterType = ScopedEntity; using ScopedRelease = ScopedEntity; using ScopedTrack = ScopedEntity; +using ScopedTrackBookmark = ScopedEntity; using ScopedTrackList = ScopedEntity; using ScopedUser = ScopedEntity; @@ -1266,6 +1268,42 @@ testMultipleTracksMultipleReleasesMultiClusters(Session& session) } } +static +void +testSingleTrackSingleUserSingleBookmark(Session& session) +{ + ScopedTrack track {session, "MyTrack"}; + ScopedUser user {session, "MyUser", User::PasswordHash {}}; + ScopedTrackBookmark bookmark {session, user.lockAndGet(), track.lockAndGet()}; + + { + auto transaction {session.createUniqueTransaction()}; + + bookmark.get().modify()->setComment("MyComment"); + bookmark.get().modify()->setOffset(std::chrono::milliseconds {5}); + } + + { + auto transaction {session.createSharedTransaction()}; + + CHECK(TrackBookmark::getAll(session).size() == 1); + + const auto bookmarks {TrackBookmark::getByUser(session, user.get())}; + CHECK(bookmarks.size() == 1); + CHECK(bookmarks.back() == bookmark.get()); + } + { + auto transaction {session.createSharedTransaction()}; + + auto userBookmark {TrackBookmark::getByUser(session, user.get(), track.get())}; + CHECK(userBookmark); + CHECK(userBookmark == bookmark.get()); + + CHECK(userBookmark->getOffset() == std::chrono::milliseconds {5}); + CHECK(userBookmark->getComment() == "MyComment"); + } +} + static void testDatabaseEmpty(Session& session) @@ -1277,6 +1315,7 @@ testDatabaseEmpty(Session& session) CHECK(ClusterType::getAll(session).empty()); CHECK(Release::getAll(session).empty()); CHECK(Track::getAll(session).empty()); + CHECK(TrackBookmark::getAll(session).empty()); CHECK(TrackList::getAll(session).empty()); CHECK(User::getAll(session).empty()); } @@ -1353,6 +1392,8 @@ int main() RUN_TEST(testSingleTrackListMultipleTrackMultiClusters); RUN_TEST(testMultipleTracksMultipleArtistsMultiClusters); RUN_TEST(testMultipleTracksMultipleReleasesMultiClusters); + + RUN_TEST(testSingleTrackSingleUserSingleBookmark); } } catch (std::exception& e)