From ef648d5cb603b57e2777476deb3b8344e047b94a Mon Sep 17 00:00:00 2001 From: emeric Date: Sun, 16 Jun 2019 15:11:42 +0200 Subject: [PATCH] Subsonic API: added star support --- README.md | 1 + src/Makefile.am | 1 + src/api/subsonic/SubsonicResource.cpp | 294 ++++++++++++++++++++++---- src/database/Artist.cpp | 1 + src/database/Artist.hpp | 3 + src/database/DatabaseHandler.cpp | 76 +++++-- src/database/Release.cpp | 1 + src/database/Release.hpp | 7 +- src/database/Track.hpp | 3 + src/database/User.cpp | 82 +++++++ src/database/User.hpp | 26 ++- test/database/DatabaseTest.cpp | 171 +++++++++++++++ 12 files changed, 611 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 6b5b1cac..5f6ed2bb 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ A [demo](http://lms.demo.poupon.io) instance is available, with the following li - Custom tags (ex: "mood", "genre", "albummood", "albumgrouping", ...) - MusicBrainzID support to handle duplicated artist and release names - Playlists (only using Subsonic API for now) +- Starred Album/Artist/Tracks (only using Subsonic API for now) ## Recommendation engine LMS provides several ways to help you find the music you like: diff --git a/src/Makefile.am b/src/Makefile.am index 105296dd..0c243f71 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -94,6 +94,7 @@ lms_SOURCES = \ $(srcdir)/ui/admin/UsersView.hpp \ $(srcdir)/ui/common/Validators.cpp \ $(srcdir)/ui/common/Validators.hpp \ + $(srcdir)/ui/common/ValueStringModel.hpp \ $(srcdir)/ui/explore/ArtistInfoView.cpp \ $(srcdir)/ui/explore/ArtistInfoView.hpp \ $(srcdir)/ui/explore/ArtistLink.cpp \ diff --git a/src/api/subsonic/SubsonicResource.cpp b/src/api/subsonic/SubsonicResource.cpp index 04128ebe..6ce41398 100644 --- a/src/api/subsonic/SubsonicResource.cpp +++ b/src/api/subsonic/SubsonicResource.cpp @@ -45,7 +45,7 @@ static const std::size_t reportedBitrate {128}; static const std::string reportedFileSuffix {"mp3"}; static const Av::Encoding reportedEncoding {Av::Encoding::MP3}; static const Av::Encoding transcodeEncoding {Av::Encoding::MP3}; - +static const std::string reportedStarredDate {"2000-01-01T00:00:00"}; // Requests #define PING_URL "/rest/ping.view" @@ -73,6 +73,8 @@ static const Av::Encoding transcodeEncoding {Av::Encoding::MP3}; #define GET_SONGS_BY_GENRE_URL "/rest/getSongsByGenre.view" #define SEARCH2_URL "/rest/search2.view" #define SEARCH3_URL "/rest/search3.view" +#define STAR_URL "/rest/star.view" +#define UNSTAR_URL "/rest/unstar.view" #define UPDATE_PLAYLIST_URL "/rest/updatePlaylist.view" // MediaRetrievals @@ -141,6 +143,8 @@ static Response handleGetPlaylistsRequest(RequestContext& context); static Response handleGetSongsByGenreRequest(RequestContext& context); static Response handleSearch2Request(RequestContext& context); static Response handleSearch3Request(RequestContext& context); +static Response handleStarRequest(RequestContext& context); +static Response handleUnstarRequest(RequestContext& context); static Response handleUpdatePlaylistRequest(RequestContext& context); // MediaRetrievals @@ -179,8 +183,10 @@ static std::map requestHandlers {GET_PLAYLIST_URL, handleGetPlaylistRequest}, {GET_PLAYLISTS_URL, handleGetPlaylistsRequest}, {GET_SONGS_BY_GENRE_URL, handleGetSongsByGenreRequest}, + {STAR_URL, handleStarRequest}, {SEARCH2_URL, handleSearch2Request}, {SEARCH3_URL, handleSearch3Request}, + {UNSTAR_URL, handleUnstarRequest}, {UPDATE_PLAYLIST_URL, handleUpdatePlaylistRequest}, }; @@ -380,6 +386,8 @@ SubsonicResource::handleRequest(const Wt::Http::Request &request, Wt::Http::Resp resp.write(response.out(), format); response.setMimeType(ResponseFormatToMimeType(format)); + + LMS_LOG(API_SUBSONIC, DEBUG) << "Request '" << request.path() << "' handled!"; return; } @@ -406,15 +414,19 @@ SubsonicResource::handleRequest(const Wt::Http::Request &request, Wt::Http::Resp { auto continuation {response.createContinuation()}; continuation->setData(std::move(res.continuationData)); - return; } + + LMS_LOG(API_SUBSONIC, DEBUG) << "Request '" << request.path() << "' handled!"; + return; } LMS_LOG(API_SUBSONIC, ERROR) << "Unhandled command '" << request.path() << "'"; } catch (const Error& e) { - LMS_LOG(API_SUBSONIC, ERROR) << "Error while processing command. code = " << static_cast(e.getCode()) << ", msg = '" << e.getMessage() << "'"; + LMS_LOG(API_SUBSONIC, ERROR) << "Error while processing request '" << request.path() << "'" + << ", params = [" << parameterMapToDebugString(request.getParameterMap()) << "]" + << ", code = " << static_cast(e.getCode()) << ", msg = '" << e.getMessage() << "'"; Response resp {Response::createFailedResponse(e)}; resp.write(response.out(), format); response.setMimeType(ResponseFormatToMimeType(format)); @@ -471,7 +483,7 @@ getTrackPath(const Database::Track::pointer& track) static Response::Node -trackToResponseNode(const Database::Track::pointer& track) +trackToResponseNode(const Database::User::pointer& user, const Database::Track::pointer& track) { Response::Node trackResponse; @@ -511,6 +523,9 @@ trackToResponseNode(const Database::Track::pointer& track) trackResponse.setAttribute("contentType", Av::encodingToMimetype(reportedEncoding)); trackResponse.setAttribute("type", "music"); + if (user->hasStarredTrack(track)) + trackResponse.setAttribute("starred", reportedStarredDate); + // Report the first GENRE for this track Database::ClusterType::pointer clusterType {Database::ClusterType::getByName(*track.session(), genreClusterName)}; if (clusterType) @@ -525,7 +540,7 @@ trackToResponseNode(const Database::Track::pointer& track) static Response::Node -releaseToResponseNode(const Database::Release::pointer& release, bool id3) +releaseToResponseNode(const Database::User::pointer& user, const Database::Release::pointer& release, bool id3) { Response::Node albumNode; @@ -586,12 +601,15 @@ releaseToResponseNode(const Database::Release::pointer& release, bool id3) } } + if (user->hasStarredRelease(release)) + albumNode.setAttribute("starred", reportedStarredDate); + return albumNode; } static Response::Node -artistToResponseNode(const Database::Artist::pointer& artist, bool id3) +artistToResponseNode(const Database::User::pointer& user, const Database::Artist::pointer& artist, bool id3) { Response::Node artistNode; @@ -601,6 +619,9 @@ artistToResponseNode(const Database::Artist::pointer& artist, bool id3) if (id3) artistNode.setAttribute("albumCount", std::to_string(artist->getReleases().size())); + if (user->hasStarredArtist(artist)) + artistNode.setAttribute("starred", reportedStarredDate); + return artistNode; } @@ -730,13 +751,17 @@ handleGetRandomSongsRequest(RequestContext& context) Wt::Dbo::Transaction transaction {context.db.getSession()}; + Database::User::pointer user {context.db.getUser(context.userName)}; + if (!user) + throw Error {Error::Code::RequestedDataNotFound}; + auto tracks {Database::Track::getAllRandom(context.db.getSession(), size)}; Response response {Response::createOkResponse()}; Response::Node& randomSongsNode {response.createNode("randomSongs")}; for (const Database::Track::pointer& track : tracks) - randomSongsNode.addArrayChild("song", trackToResponseNode(track)); + randomSongsNode.addArrayChild("song", trackToResponseNode(user, track)); return response; } @@ -788,6 +813,10 @@ handleGetAlbumListRequestCommon(const RequestContext& context, bool id3) Wt::Dbo::Transaction transaction {context.db.getSession()}; + Database::User::pointer user {context.db.getUser(context.userName)}; + if (!user) + throw Error {Error::Code::RequestedDataNotFound}; + if (type == "random") { releases = getRandomAlbums(context.db.getSession(), offset, size); @@ -824,7 +853,7 @@ handleGetAlbumListRequestCommon(const RequestContext& context, bool id3) Response::Node& albumListNode {response.createNode(id3 ? "albumList2" : "albumList")}; for (const Database::Release::pointer& release : releases) - albumListNode.addArrayChild("album", releaseToResponseNode(release, id3)); + albumListNode.addArrayChild("album", releaseToResponseNode(user, release, id3)); return response; } @@ -856,12 +885,16 @@ handleGetAlbumRequest(RequestContext& context) if (!release) throw Error {Error::Code::RequestedDataNotFound}; + Database::User::pointer user {context.db.getUser(context.userName)}; + if (!user) + throw Error {Error::Code::RequestedDataNotFound}; + Response response {Response::createOkResponse()}; - Response::Node releaseNode {releaseToResponseNode(release, true /* id3 */)}; + Response::Node releaseNode {releaseToResponseNode(user, release, true /* id3 */)}; auto tracks {release->getTracks()}; for (const Database::Track::pointer& track : tracks) - releaseNode.addArrayChild("song", trackToResponseNode(track)); + releaseNode.addArrayChild("song", trackToResponseNode(user, track)); response.addNode("album", std::move(releaseNode)); @@ -880,15 +913,17 @@ handleGetArtistRequest(RequestContext& context) Wt::Dbo::Transaction transaction {context.db.getSession()}; Database::Artist::pointer artist {Database::Artist::getById(context.db.getSession(), id.value)}; - if (!artist) + Database::User::pointer user {context.db.getUser(context.userName)}; + + if (!artist || !user) throw Error {Error::Code::RequestedDataNotFound}; Response response {Response::createOkResponse()}; - Response::Node artistNode {artistToResponseNode(artist, true /* id3 */)}; + Response::Node artistNode {artistToResponseNode(user, artist, true /* id3 */)}; auto releases {artist->getReleases()}; for (const Database::Release::pointer& release : releases) - artistNode.addArrayChild("album", releaseToResponseNode(release, true /* id3 */)); + artistNode.addArrayChild("album", releaseToResponseNode(user, release, true /* id3 */)); response.addNode("artist", std::move(artistNode)); @@ -910,7 +945,9 @@ Response handleGetArtistInfoRequestCommon(RequestContext& context, bool id3) Wt::Dbo::Transaction transaction {context.db.getSession()}; Database::Artist::pointer artist {Database::Artist::getById(context.db.getSession(), id.value)}; - if (!artist) + Database::User::pointer user {context.db.getUser(context.userName)}; + + if (!artist || !user) throw Error {Error::Code::RequestedDataNotFound}; Response response {Response::createOkResponse()}; @@ -925,7 +962,7 @@ Response handleGetArtistInfoRequestCommon(RequestContext& context, bool id3) Database::Artist::pointer similarArtist {Database::Artist::getById(context.db.getSession(), similarArtistId)}; if (similarArtist) - artistInfoNode.addArrayChild("similarArtist", artistToResponseNode(similarArtist, id3)); + artistInfoNode.addArrayChild("similarArtist", artistToResponseNode(user, similarArtist, id3)); } return response; @@ -951,9 +988,13 @@ handleGetArtistsRequest(RequestContext& context) Wt::Dbo::Transaction transaction {context.db.getSession()}; + Database::User::pointer user {context.db.getUser(context.userName)}; + if (!user) + throw Error {Error::Code::RequestedDataNotFound}; + auto artists {Database::Artist::getAll(context.db.getSession())}; for (const Database::Artist::pointer& artist : artists) - indexNode.addArrayChild("artist", artistToResponseNode(artist, true /* id3 */)); + indexNode.addArrayChild("artist", artistToResponseNode(user, artist, true /* id3 */)); return response; } @@ -970,25 +1011,27 @@ handleGetMusicDirectoryRequest(RequestContext& context) directoryNode.setAttribute("id", IdToString(id)); + Wt::Dbo::Transaction transaction {context.db.getSession()}; + + Database::User::pointer user {context.db.getUser(context.userName)}; + if (!user) + throw Error {Error::Code::RequestedDataNotFound}; + switch (id.type) { case Id::Type::Root: { - Wt::Dbo::Transaction transaction {context.db.getSession()}; - directoryNode.setAttribute("name", "Music"); auto artists {Database::Artist::getAll(context.db.getSession())}; for (const Database::Artist::pointer& artist : artists) - directoryNode.addArrayChild("child", artistToResponseNode(artist, false /* no id3 */)); + directoryNode.addArrayChild("child", artistToResponseNode(user, artist, false /* no id3 */)); break; } case Id::Type::Artist: { - Wt::Dbo::Transaction transaction {context.db.getSession()}; - auto artist {Database::Artist::getById(context.db.getSession(), id.value)}; if (!artist) throw Error {Error::Code::RequestedDataNotFound}; @@ -997,15 +1040,13 @@ handleGetMusicDirectoryRequest(RequestContext& context) auto releases {artist->getReleases()}; for (const Database::Release::pointer& release : releases) - directoryNode.addArrayChild("child", releaseToResponseNode(release, false /* no id3 */)); + directoryNode.addArrayChild("child", releaseToResponseNode(user, release, false /* no id3 */)); break; } case Id::Type::Release: { - Wt::Dbo::Transaction transaction {context.db.getSession()}; - auto release {Database::Release::getById(context.db.getSession(), id.value)}; if (!release) throw Error {Error::Code::RequestedDataNotFound}; @@ -1014,7 +1055,7 @@ handleGetMusicDirectoryRequest(RequestContext& context) auto tracks {release->getTracks()}; for (const Database::Track::pointer& track : tracks) - directoryNode.addArrayChild("child", trackToResponseNode(track)); + directoryNode.addArrayChild("child", trackToResponseNode(user, track)); break; } @@ -1071,9 +1112,13 @@ handleGetIndexesRequest(RequestContext& context) Wt::Dbo::Transaction transaction {context.db.getSession()}; + Database::User::pointer user {context.db.getUser(context.userName)}; + if (!user) + throw Error {Error::Code::RequestedDataNotFound}; + auto artists {Database::Artist::getAll(context.db.getSession())}; for (const Database::Artist::pointer& artist : artists) - indexNode.addArrayChild("artist", artistToResponseNode(artist, false /* no id3 */)); + indexNode.addArrayChild("artist", artistToResponseNode(user, artist, false /* no id3 */)); return response; } @@ -1092,7 +1137,9 @@ handleGetSimilarSongsRequestCommon(RequestContext& context, bool id3) Wt::Dbo::Transaction transaction {context.db.getSession()}; Database::Artist::pointer artist {Database::Artist::getById(context.db.getSession(), id.value)}; - if (!artist) + Database::User::pointer user {context.db.getUser(context.userName)}; + + if (!user || !artist) throw Error {Error::Code::RequestedDataNotFound}; // "Returns a random collection of songs from the given artist and similar artists" @@ -1119,7 +1166,7 @@ handleGetSimilarSongsRequestCommon(RequestContext& context, bool id3) Response response {Response::createOkResponse()}; Response::Node& similarSongsNode {response.createNode(id3 ? "similarSongs2" : "similarSongs")}; for (const Database::Track::pointer& track : tracks) - similarSongsNode.addArrayChild("song", trackToResponseNode(track)); + similarSongsNode.addArrayChild("song", trackToResponseNode(user, track)); return response; } @@ -1137,16 +1184,51 @@ handleGetSimilarSongs2Request(RequestContext& context) } +static +Response +handleGetStarredRequestCommon(RequestContext& context, bool id3) +{ + Wt::Dbo::Transaction transaction {context.db.getSession()}; + + Database::User::pointer user {context.db.getUser(context.userName)}; + if (!user) + throw Error {Error::Code::RequestedDataNotFound}; + + Response response {Response::createOkResponse()}; + Response::Node& starredNode {response.createNode(id3 ? "starred2" : "starred")}; + + { + auto artists {user->getStarredArtists()}; + for (const Database::Artist::pointer& artist : artists) + starredNode.addArrayChild("artist", artistToResponseNode(user, artist, id3)); + } + + { + auto releases {user->getStarredReleases()}; + for (const Database::Release::pointer& release : releases) + starredNode.addArrayChild("album", releaseToResponseNode(user, release, id3)); + } + + { + auto tracks {user->getStarredTracks()}; + for (const Database::Track::pointer& track : tracks) + starredNode.addArrayChild("song", trackToResponseNode(user, track)); + } + + return response; + +} + Response handleGetStarredRequest(RequestContext& context) { - throw Error{Error::CustomType::NotImplemented}; + return handleGetStarredRequestCommon(context, false /* no id3 */); } Response handleGetStarred2Request(RequestContext& context) { - throw Error{Error::CustomType::NotImplemented}; + return handleGetStarredRequestCommon(context, true /* id3 */); } Response::Node @@ -1184,11 +1266,8 @@ handleGetPlaylistRequest(RequestContext& context) Wt::Dbo::Transaction transaction {context.db.getSession()}; Database::User::pointer user {context.db.getUser(context.userName)}; - if (!user) - throw Error {Error::Code::RequestedDataNotFound}; - Database::TrackList::pointer tracklist {Database::TrackList::getById(context.db.getSession(), id.value)}; - if (!tracklist) + if (!user || !tracklist) throw Error {Error::Code::RequestedDataNotFound}; Response response {Response::createOkResponse()}; @@ -1196,7 +1275,7 @@ handleGetPlaylistRequest(RequestContext& context) auto entries {tracklist->getEntries()}; for (const Database::TrackListEntry::pointer& entry : entries) - playlistNode.addArrayChild("entry", trackToResponseNode(entry->getTrack())); + playlistNode.addArrayChild("entry", trackToResponseNode(user, entry->getTrack())); response.addNode("playlist", playlistNode ); @@ -1244,17 +1323,22 @@ handleGetSongsByGenreRequest(RequestContext& context) if (!cluster) throw Error {Error::Code::RequestedDataNotFound}; + Database::User::pointer user {context.db.getUser(context.userName)}; + if (!user) + throw Error {Error::Code::RequestedDataNotFound}; + Response response {Response::createOkResponse()}; Response::Node& songsByGenreNode {response.createNode("songsByGenre")}; bool more; auto tracks {Database::Track::getByFilter(context.db.getSession(), {cluster.id()}, {}, offset, size, more)}; for (const Database::Track::pointer& track : tracks) - songsByGenreNode.addArrayChild("song", trackToResponseNode(track)); + songsByGenreNode.addArrayChild("song", trackToResponseNode(user, track)); return response; } +static Response handleSearchRequestCommon(RequestContext& context, bool id3) { @@ -1273,6 +1357,10 @@ handleSearchRequestCommon(RequestContext& context, bool id3) Wt::Dbo::Transaction transaction {context.db.getSession()}; + Database::User::pointer user {context.db.getUser(context.userName)}; + if (!user) + throw Error {Error::Code::RequestedDataNotFound}; + Response response {Response::createOkResponse()}; Response::Node& searchResult2Node {response.createNode(id3 ? "searchResult3" : "searchResult2")}; @@ -1280,24 +1368,110 @@ handleSearchRequestCommon(RequestContext& context, bool id3) { auto artists {Database::Artist::getByFilter(context.db.getSession(), {}, keywords, artistOffset, artistCount, more)}; for (const Database::Artist::pointer& artist : artists) - searchResult2Node.addArrayChild("artist", artistToResponseNode(artist, id3)); + searchResult2Node.addArrayChild("artist", artistToResponseNode(user, artist, id3)); } { auto releases {Database::Release::getByFilter(context.db.getSession(), {}, keywords, albumOffset, albumCount, more)}; for (const Database::Release::pointer& release : releases) - searchResult2Node.addArrayChild("album", releaseToResponseNode(release, id3)); + searchResult2Node.addArrayChild("album", releaseToResponseNode(user, release, id3)); } { auto tracks {Database::Track::getByFilter(context.db.getSession(), {}, keywords, songOffset, songCount, more)}; for (const Database::Track::pointer& track : tracks) - searchResult2Node.addArrayChild("song", trackToResponseNode(track)); + searchResult2Node.addArrayChild("song", trackToResponseNode(user, track)); } return response; } +struct StarParameters +{ + std::vector artistIds; + std::vector releaseIds; + std::vector trackIds; +}; + +static +StarParameters +getStarParameters(const Wt::Http::ParameterMap& parameters) +{ + StarParameters res; + + std::vector ids {getMultiParametersAs(parameters, "id")}; + res.artistIds = getMultiParametersAs(parameters, "artistId"); + res.releaseIds = getMultiParametersAs(parameters, "albumId"); + + if (!std::all_of(std::cbegin(res.releaseIds ), std::cend(res.releaseIds ), [](const Id& id) { return id.type == Id::Type::Release; })) + throw Error {Error::CustomType::BadId}; + + if (!std::all_of(std::cbegin(res.artistIds ), std::cend(res.artistIds ), [](const Id& id) { return id.type == Id::Type::Artist; })) + throw Error {Error::CustomType::BadId}; + + // Redispatch the old "id" parameter in new lists + for (const Id& id : ids) + { + switch (id.type) + { + case Id::Type::Artist: + res.artistIds.emplace_back(id); + break; + case Id::Type::Release: + res.releaseIds.emplace_back(id); + break; + case Id::Type::Track: + res.trackIds.emplace_back(id); + break; + default: + throw Error {Error::CustomType::BadId}; + } + } + + return res; +} + +Response +handleStarRequest(RequestContext& context) +{ + StarParameters params {getStarParameters(context.parameters)}; + + Wt::Dbo::Transaction transaction {context.db.getSession()}; + + Database::User::pointer user {context.db.getUser(context.userName)}; + if (!user) + throw Error {Error::Code::RequestedDataNotFound}; + + for (const Id& id : params.artistIds) + { + Database::Artist::pointer artist {Database::Artist::getById(context.db.getSession(), id.value)}; + if (!artist) + continue; + + user.modify()->starArtist(artist); + } + + for (const Id& id : params.releaseIds) + { + Database::Release::pointer release {Database::Release::getById(context.db.getSession(), id.value)}; + if (!release) + continue; + + user.modify()->starRelease(release); + } + + for (const Id& id : params.trackIds) + { + Database::Track::pointer track {Database::Track::getById(context.db.getSession(), id.value)}; + if (!track) + continue; + + user.modify()->starTrack(track); + } + + return Response::createOkResponse(); +} + Response handleSearch2Request(RequestContext& context) { @@ -1310,6 +1484,48 @@ handleSearch3Request(RequestContext& context) return handleSearchRequestCommon(context, true /* id3 */); } +Response +handleUnstarRequest(RequestContext& context) +{ + StarParameters params {getStarParameters(context.parameters)}; + + Wt::Dbo::Transaction transaction {context.db.getSession()}; + + Database::User::pointer user {context.db.getUser(context.userName)}; + if (!user) + throw Error {Error::Code::RequestedDataNotFound}; + + for (const Id& id : params.artistIds) + { + Database::Artist::pointer artist {Database::Artist::getById(context.db.getSession(), id.value)}; + if (!artist) + continue; + + user.modify()->unstarArtist(artist); + } + + for (const Id& id : params.releaseIds) + { + Database::Release::pointer release {Database::Release::getById(context.db.getSession(), id.value)}; + if (!release) + continue; + + user.modify()->unstarRelease(release); + } + + for (const Id& id : params.trackIds) + { + Database::Track::pointer track {Database::Track::getById(context.db.getSession(), id.value)}; + if (!track) + continue; + + user.modify()->unstarTrack(track); + } + + + return Response::createOkResponse(); +} + Response handleUpdatePlaylistRequest(RequestContext& context) { diff --git a/src/database/Artist.cpp b/src/database/Artist.cpp index c4a9d861..2bc7d7ad 100644 --- a/src/database/Artist.cpp +++ b/src/database/Artist.cpp @@ -26,6 +26,7 @@ #include "Release.hpp" #include "SqlQuery.hpp" #include "Track.hpp" +#include "User.hpp" namespace Database { diff --git a/src/database/Artist.hpp b/src/database/Artist.hpp index 529728d3..88eeed68 100644 --- a/src/database/Artist.hpp +++ b/src/database/Artist.hpp @@ -37,6 +37,7 @@ class Cluster; class ClusterType; class Release; class Track; +class User; class Artist : public Wt::Dbo::Dbo { @@ -93,6 +94,7 @@ class Artist : public Wt::Dbo::Dbo Wt::Dbo::field(a, _MBID, "mbid"); Wt::Dbo::hasMany(a, _trackArtistLinks, Wt::Dbo::ManyToOne, "artist"); + Wt::Dbo::hasMany(a, _starringUsers, Wt::Dbo::ManyToMany, "user_release_starred", "", Wt::Dbo::OnDeleteCascade); } private: @@ -104,6 +106,7 @@ class Artist : public Wt::Dbo::Dbo std::string _MBID; // Musicbrainz Identifier Wt::Dbo::collection> _trackArtistLinks; // Tracks involving this artist + Wt::Dbo::collection> _starringUsers; // Users that starred this artist }; } // namespace Database diff --git a/src/database/DatabaseHandler.cpp b/src/database/DatabaseHandler.cpp index b9bf0a58..d82ae2d9 100644 --- a/src/database/DatabaseHandler.cpp +++ b/src/database/DatabaseHandler.cpp @@ -46,7 +46,7 @@ namespace Database { -#define LMS_DATABASE_VERSION 3 +#define LMS_DATABASE_VERSION 4 namespace { Wt::Auth::AuthService authService; @@ -82,6 +82,67 @@ class VersionInfo int _version {LMS_DATABASE_VERSION}; }; +static +void +doDatabaseMigrationIfNeeded(Wt::Dbo::Session& session) +{ + Wt::Dbo::Transaction transaction {session}; + + static const std::string outdatedMsg {"Outdated database, please rebuild it (delete the .db file and restart)"}; + + Version version; + try + { + version = VersionInfo::get(session)->getVersion(); + if (version == LMS_DATABASE_VERSION) + return; + } + catch (std::exception& e) + { + LMS_LOG(DB, ERROR) << "Cannot get database version info: " << e.what(); + throw LmsException {outdatedMsg}; + } + + switch (version) + { + case 3: + + LMS_LOG(DB, INFO) << "Migrating database from version 3..."; + + session.execute(R"(CREATE TABLE IF NOT EXISTS "user_artist_starred" ( + "user_id" bigint, + "artist_id" bigint, + primary key ("user_id", "artist_id"), + constraint "fk_user_artist_starred_key1" foreign key ("user_id") references "user" ("id") on delete cascade deferrable initially deferred, + constraint "fk_user_artist_starred_key2" foreign key ("artist_id") references "artist" ("id") deferrable initially deferred);)"); + session.execute(R"(CREATE INDEX "user_artist_starred_user" on "user_artist_starred" ("user_id");)"); + session.execute(R"(CREATE INDEX "user_artist_starred_artist" on "user_artist_starred" ("artist_id");)"); + session.execute(R"(CREATE TABLE IF NOT EXISTS "user_release_starred" ( + "user_id" bigint, + "release_id" bigint, + primary key ("user_id", "release_id"), + constraint "fk_user_release_starred_key1" foreign key ("user_id") references "user" ("id") on delete cascade deferrable initially deferred, + constraint "fk_user_release_starred_key2" foreign key ("release_id") references "release" ("id") on delete cascade deferrable initially deferred);)"); + session.execute(R"(CREATE INDEX "user_release_starred_user" on "user_release_starred" ("user_id");)"); + session.execute(R"(CREATE INDEX "user_release_starred_release" on "user_release_starred" ("release_id");)"); + session.execute(R"(CREATE TABLE IF NOT EXISTS "user_track_starred" ( + "user_id" bigint, + "track_id" bigint, + primary key ("user_id", "track_id"), + constraint "fk_user_track_starred_key1" foreign key ("user_id") references "user" ("id") on delete cascade deferrable initially deferred, + constraint "fk_user_track_starred_key2" foreign key ("track_id") references "track" ("id") on delete cascade deferrable initially deferred);)"); + session.execute(R"(CREATE INDEX "user_track_starred_user" on "user_track_starred" ("user_id");)"); + session.execute(R"(CREATE INDEX "user_track_starred_track" on "user_track_starred" ("track_id");)"); + break; + + default: + LMS_LOG(DB, ERROR) << "Database version " << version << " cannot be handled using migration"; + throw LmsException {outdatedMsg}; + } + + VersionInfo::get(session).modify()->setVersion(LMS_DATABASE_VERSION); +} + void Handler::configureAuth(void) @@ -163,18 +224,7 @@ Handler::Handler(Wt::Dbo::SqlConnectionPool& connectionPool) LMS_LOG(DB, ERROR) << "Cannot create tables: " << e.what(); } - try - { - Wt::Dbo::Transaction transaction {_session}; - - Version version {VersionInfo::get(_session)->getVersion()}; - if (version != LMS_DATABASE_VERSION) - throw LmsException {"Outdated database, please rebuild it (database migration not yet implemented)"}; - } - catch (std::exception& e) - { - throw LmsException {"Cannot get database version, please rebuild it (database migration not yet implemented)"}; - } + doDatabaseMigrationIfNeeded(_session); { Wt::Dbo::Transaction transaction {_session}; diff --git a/src/database/Release.cpp b/src/database/Release.cpp index bec8aa1d..41731c2c 100644 --- a/src/database/Release.cpp +++ b/src/database/Release.cpp @@ -25,6 +25,7 @@ #include "Cluster.hpp" #include "SqlQuery.hpp" #include "Track.hpp" +#include "User.hpp" namespace Database { diff --git a/src/database/Release.hpp b/src/database/Release.hpp index ae9d86c3..b2675209 100644 --- a/src/database/Release.hpp +++ b/src/database/Release.hpp @@ -29,11 +29,12 @@ namespace Database { -class Track; -class Release; class Artist; class Cluster; class ClusterType; +class Release; +class Track; +class User; class Release : public Wt::Dbo::Dbo { @@ -104,6 +105,7 @@ class Release : public Wt::Dbo::Dbo Wt::Dbo::field(a, _totalTrackNumber, "total_track_number"); Wt::Dbo::hasMany(a, _tracks, Wt::Dbo::ManyToOne, "release"); + Wt::Dbo::hasMany(a, _starringUsers, Wt::Dbo::ManyToMany, "user_release_starred", "", Wt::Dbo::OnDeleteCascade); } private: @@ -115,6 +117,7 @@ class Release : public Wt::Dbo::Dbo int _totalTrackNumber {}; Wt::Dbo::collection> _tracks; // Tracks in the release + Wt::Dbo::collection> _starringUsers; // Users that starred this release }; } // namespace Database diff --git a/src/database/Track.hpp b/src/database/Track.hpp index 82686ecd..9bcaa3aa 100644 --- a/src/database/Track.hpp +++ b/src/database/Track.hpp @@ -41,6 +41,7 @@ class Release; class TrackFeatures; class TrackListEntry; class TrackStats; +class User; class Track : public Wt::Dbo::Dbo { @@ -144,6 +145,7 @@ class Track : public Wt::Dbo::Dbo Wt::Dbo::hasMany(a, _trackArtistLinks, Wt::Dbo::ManyToOne, "track"); Wt::Dbo::hasMany(a, _clusters, Wt::Dbo::ManyToMany, "track_cluster", "", Wt::Dbo::OnDeleteCascade); Wt::Dbo::hasMany(a, _playlistEntries, Wt::Dbo::ManyToOne, "track"); + Wt::Dbo::hasMany(a, _starringUsers, Wt::Dbo::ManyToMany, "user_track_starred", "", Wt::Dbo::OnDeleteCascade); Wt::Dbo::hasOne(a, _trackFeatures); } @@ -175,6 +177,7 @@ class Track : public Wt::Dbo::Dbo Wt::Dbo::collection> _trackArtistLinks; Wt::Dbo::collection> _clusters; Wt::Dbo::collection> _playlistEntries; + Wt::Dbo::collection> _starringUsers; Wt::Dbo::weak_ptr _trackFeatures; }; diff --git a/src/database/User.cpp b/src/database/User.cpp index cf31677b..7911df91 100644 --- a/src/database/User.cpp +++ b/src/database/User.cpp @@ -19,6 +19,9 @@ #include "User.hpp" +#include "Artist.hpp" +#include "Release.hpp" +#include "Track.hpp" #include "TrackList.hpp" namespace Database { @@ -123,6 +126,85 @@ User::getQueuedTrackList() const return res; } +void +User::starArtist(Wt::Dbo::ptr artist) +{ + if (_starredArtists.count(artist) == 0) + _starredArtists.insert(artist); +} + +void +User::unstarArtist(Wt::Dbo::ptr artist) +{ + if (_starredArtists.count(artist) != 0) + _starredArtists.erase(artist); +} + +bool +User::hasStarredArtist(Wt::Dbo::ptr artist) const +{ + return _starredArtists.count(artist) != 0; +} + +std::vector> +User::getStarredArtists() const +{ + return std::vector>(_starredArtists.begin(), _starredArtists.end()); +} + +void +User::starRelease(Wt::Dbo::ptr release) +{ + if (_starredReleases.count(release) == 0) + _starredReleases.insert(release); +} + +void +User::unstarRelease(Wt::Dbo::ptr release) +{ + if (_starredReleases.count(release) != 0) + _starredReleases.erase(release); +} + +bool +User::hasStarredRelease(Wt::Dbo::ptr release) const +{ + return _starredReleases.count(release) != 0; +} + +std::vector> +User::getStarredReleases() const +{ + return std::vector>(_starredReleases.begin(), _starredReleases.end()); +} + + +void +User::starTrack(Wt::Dbo::ptr track) +{ + if (_starredTracks.count(track) == 0) + _starredTracks.insert(track); +} + +void +User::unstarTrack(Wt::Dbo::ptr track) +{ + if (_starredTracks.count(track) != 0) + _starredTracks.erase(track); +} + +bool +User::hasStarredTrack(Wt::Dbo::ptr track) const +{ + return _starredTracks.count(track) != 0; +} + +std::vector> +User::getStarredTracks() const +{ + return std::vector>(_starredTracks.begin(), _starredTracks.end()); +} + } // namespace Database diff --git a/src/database/User.hpp b/src/database/User.hpp index 9a990615..a9690162 100644 --- a/src/database/User.hpp +++ b/src/database/User.hpp @@ -31,7 +31,10 @@ namespace Database { class User; using AuthInfo = Wt::Auth::Dbo::AuthInfo; +class Artist; +class Release; class TrackList; +class Track; // User selectable audio formats // Do not change values @@ -97,6 +100,21 @@ class User : public Wt::Dbo::Dbo Wt::Dbo::ptr getQueuedTrackList() const; Wt::Dbo::ptr getPlayedTrackList() const; + void starArtist(Wt::Dbo::ptr artist); + void unstarArtist(Wt::Dbo::ptr artist); + bool hasStarredArtist(Wt::Dbo::ptr artist) const; + std::vector> getStarredArtists() const; + + void starRelease(Wt::Dbo::ptr release); + void unstarRelease(Wt::Dbo::ptr release); + bool hasStarredRelease(Wt::Dbo::ptr release) const; + std::vector> getStarredReleases() const; + + void starTrack(Wt::Dbo::ptr track); + void unstarTrack(Wt::Dbo::ptr track); + bool hasStarredTrack(Wt::Dbo::ptr track) const; + std::vector> getStarredTracks() const; + template void persist(Action& a) { @@ -110,6 +128,9 @@ class User : public Wt::Dbo::Dbo Wt::Dbo::field(a, _repeatAll, "repeat_all"); Wt::Dbo::field(a, _radio, "radio"); Wt::Dbo::hasMany(a, _tracklists, Wt::Dbo::ManyToOne, "user"); + Wt::Dbo::hasMany(a, _starredArtists, Wt::Dbo::ManyToMany, "user_artist_starred", "", Wt::Dbo::OnDeleteCascade); + Wt::Dbo::hasMany(a, _starredReleases, Wt::Dbo::ManyToMany, "user_release_starred", "", Wt::Dbo::OnDeleteCascade); + Wt::Dbo::hasMany(a, _starredTracks, Wt::Dbo::ManyToMany, "user_track_starred", "", Wt::Dbo::OnDeleteCascade); } private: @@ -132,7 +153,10 @@ class User : public Wt::Dbo::Dbo bool _repeatAll {}; bool _radio {}; - Wt::Dbo::collection< Wt::Dbo::ptr > _tracklists; + Wt::Dbo::collection> _tracklists; + Wt::Dbo::collection> _starredArtists; + Wt::Dbo::collection> _starredReleases; + Wt::Dbo::collection> _starredTracks; }; diff --git a/test/database/DatabaseTest.cpp b/test/database/DatabaseTest.cpp index 3709329b..3ec1989c 100644 --- a/test/database/DatabaseTest.cpp +++ b/test/database/DatabaseTest.cpp @@ -964,6 +964,174 @@ testSingleTrackSingleReleaseSingleArtistMultiClusters(Wt::Dbo::Session& session) } } +static +void +testSingleStarredArtist(Wt::Dbo::Session& session) +{ + IdType artistId {}; + IdType userId {}; + { + Wt::Dbo::Transaction transaction {session}; + + auto artist {Artist::create(session, "MyArtist")}; + CHECK(artist); + auto user {User::create(session)}; + CHECK(user); + + session.flush(); + artistId = artist.id(); + userId = user.id(); + } + + { + Wt::Dbo::Transaction transaction {session}; + + auto user {User::getById(session, userId)}; + CHECK(user); + auto artist {Artist::getById(session, artistId)}; + CHECK(artist); + + user.modify()->starArtist(artist); + } + + { + Wt::Dbo::Transaction transaction {session}; + + auto user {User::getById(session, userId)}; + CHECK(user); + auto artists {user->getStarredArtists()}; + CHECK(artists.size() == 1); + CHECK(artists.front().id() == artistId); + + auto artist {Artist::getById(session, artistId)}; + CHECK(user->hasStarredArtist(artist)); + } + + { + Wt::Dbo::Transaction transaction {session}; + + auto user {User::getById(session, userId)}; + CHECK(user); + auto artist {Artist::getById(session, artistId)}; + CHECK(artist); + + user.remove(); + artist.remove(); + } +} + +static +void +testSingleStarredRelease(Wt::Dbo::Session& session) +{ + IdType releaseId {}; + IdType userId {}; + { + Wt::Dbo::Transaction transaction {session}; + + auto release {Release::create(session, "MyRelease")}; + CHECK(release); + auto user {User::create(session)}; + CHECK(user); + + session.flush(); + releaseId = release.id(); + userId = user.id(); + } + + { + Wt::Dbo::Transaction transaction {session}; + + auto user {User::getById(session, userId)}; + CHECK(user); + auto release {Release::getById(session, releaseId)}; + CHECK(release); + + user.modify()->starRelease(release); + } + + { + Wt::Dbo::Transaction transaction {session}; + + auto user {User::getById(session, userId)}; + CHECK(user); + auto releases {user->getStarredReleases()}; + CHECK(releases.size() == 1); + CHECK(releases.front().id() == releaseId); + + auto release {Release::getById(session, releaseId)}; + CHECK(user->hasStarredRelease(release)); + } + + { + Wt::Dbo::Transaction transaction {session}; + + auto user {User::getById(session, userId)}; + CHECK(user); + auto release {Release::getById(session, releaseId)}; + CHECK(release); + + user.remove(); + release.remove(); + } +} + +static +void +testSingleStarredTrack(Wt::Dbo::Session& session) +{ + IdType trackId {}; + IdType userId {}; + { + Wt::Dbo::Transaction transaction {session}; + + auto track {Track::create(session, "MyTrackFile")}; + CHECK(track); + auto user {User::create(session)}; + CHECK(user); + + session.flush(); + trackId = track.id(); + userId = user.id(); + } + + { + Wt::Dbo::Transaction transaction {session}; + + auto user {User::getById(session, userId)}; + CHECK(user); + auto track {Track::getById(session, trackId)}; + CHECK(track); + + user.modify()->starTrack(track); + } + + { + Wt::Dbo::Transaction transaction {session}; + + auto user {User::getById(session, userId)}; + CHECK(user); + auto tracks {user->getStarredTracks()}; + CHECK(tracks.size() == 1); + CHECK(tracks.front().id() == trackId); + + auto track {Track::getById(session, trackId)}; + CHECK(user->hasStarredTrack(track)); + } + + { + Wt::Dbo::Transaction transaction {session}; + + auto user {User::getById(session, userId)}; + CHECK(user); + auto track {Track::getById(session, trackId)}; + CHECK(track); + + user.remove(); + track.remove(); + } +} + static void testDatabaseEmpty(Wt::Dbo::Session& session) @@ -1025,6 +1193,9 @@ int main(int argc, char* argv[]) RUN_TEST(testSingleTrackSingleReleaseSingleArtistSingleCluster); RUN_TEST(testSingleTrackSingleReleaseSingleArtistMultiClusters); + RUN_TEST(testSingleStarredArtist); + RUN_TEST(testSingleStarredRelease); + RUN_TEST(testSingleStarredTrack); } catch (std::exception& e) {